From 6136e18bf74d9df9bd98347740a6acfca64aea06 Mon Sep 17 00:00:00 2001 From: Arash Date: Tue, 29 Oct 2024 20:03:04 +0100 Subject: [PATCH 001/116] init add secrets to tools --- lib/galaxy/tool_util/deps/requirements.py | 55 ++++++++++++++++++++--- lib/galaxy/tools/__init__.py | 22 +++++++++ 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/lib/galaxy/tool_util/deps/requirements.py b/lib/galaxy/tool_util/deps/requirements.py index bfb02e0ea606..57ea63ca24c0 100644 --- a/lib/galaxy/tool_util/deps/requirements.py +++ b/lib/galaxy/tool_util/deps/requirements.py @@ -20,6 +20,7 @@ from galaxy.util import ( asbool, + string_as_bool, xml_text, ) from galaxy.util.oset import OrderedSet @@ -41,17 +42,30 @@ def __init__( type: Optional[str] = None, version: Optional[str] = None, specs: Optional[Iterable["RequirementSpecification"]] = None, + inject_as_env: Optional[str] = None, + interfaces: Optional[List[Dict[str, Any]]] = None, ) -> None: if specs is None: specs = [] + if interfaces is None: + interfaces = [] self.name = name self.type = type self.version = version self.specs = specs + self.inject_as_env = inject_as_env + self.interfaces = interfaces def to_dict(self) -> Dict[str, Any]: specs = [s.to_dict() for s in self.specs] - return dict(name=self.name, type=self.type, version=self.version, specs=specs) + return dict( + name=self.name, + type=self.type, + version=self.version, + specs=specs, + inject_as_env=self.inject_as_env, + interfaces=self.interfaces, + ) def copy(self) -> "ToolRequirement": return copy.deepcopy(self) @@ -62,7 +76,11 @@ def from_dict(cls, d: Dict[str, Any]) -> "ToolRequirement": name = d["name"] type = d.get("type") specs = [RequirementSpecification.from_dict(s) for s in d.get("specs", [])] - return cls(name=name, type=type, version=version, specs=specs) + inject_as_env = d.get("inject_as_env") + interfaces = d.get("interfaces", []) + return cls( + name=name, type=type, version=version, specs=specs, inject_as_env=inject_as_env, interfaces=interfaces + ) def __eq__(self, other: Any) -> bool: return ( @@ -70,13 +88,24 @@ def __eq__(self, other: Any) -> bool: and self.type == other.type and self.version == other.version and self.specs == other.specs + and self.inject_as_env == other.inject_as_env + and self.interfaces == other.interfaces ) def __hash__(self) -> int: - return hash((self.name, self.type, self.version, frozenset(self.specs))) + return hash( + ( + self.name, + self.type, + self.version, + frozenset(self.specs), + self.inject_as_env, + frozenset(tuple(i.items()) for i in self.interfaces), + ) + ) def __str__(self) -> str: - return f"ToolRequirement[{self.name},version={self.version},type={self.type},specs={self.specs}]" + return f"ToolRequirement[{self.name},version={self.version},type={self.type},specs={self.specs},inject_as_env={self.inject_as_env},interfaces={self.interfaces}]" __repr__ = __str__ @@ -352,7 +381,11 @@ def parse_requirements_from_xml(xml_root, parse_resources: bool = False): name = xml_text(requirement_elem) type = requirement_elem.get("type", DEFAULT_REQUIREMENT_TYPE) version = requirement_elem.get("version", DEFAULT_REQUIREMENT_VERSION) - requirement = ToolRequirement(name=name, type=type, version=version) + inject_as_env = requirement_elem.get("inject_as_env") + interfaces = parse_interfaces(requirement_elem) + requirement = ToolRequirement( + name=name, type=type, version=version, inject_as_env=inject_as_env, interfaces=interfaces + ) requirements.append(requirement) container_elems = [] @@ -368,6 +401,18 @@ def parse_requirements_from_xml(xml_root, parse_resources: bool = False): return requirements, containers +def parse_interfaces(requirement_elem): + interfaces = [] + for interface_elem in requirement_elem.findall("interface"): + interface = { + "name": interface_elem.get("name"), + "label": interface_elem.get("label"), + "required": string_as_bool(interface_elem.get("required", "false")), + } + interfaces.append(interface) + return interfaces + + def resource_from_element(resource_elem) -> ResourceRequirement: value_or_expression = xml_text(resource_elem) resource_type = resource_elem.get("type") diff --git a/lib/galaxy/tools/__init__.py b/lib/galaxy/tools/__init__.py index 90ccde24679e..4c113472be09 100644 --- a/lib/galaxy/tools/__init__.py +++ b/lib/galaxy/tools/__init__.py @@ -817,6 +817,8 @@ def __init__( self.display_interface = True self.require_login = False self.rerun = False + self.inject_as_env: Optional[str] = None + self.interfaces: List[Dict[str, Any]] = [] # This will be non-None for tools loaded from the database (DynamicTool objects). self.dynamic_tool = None # Define a place to keep track of all input These @@ -1223,6 +1225,26 @@ def parse(self, tool_source: ToolSource, guid: Optional[str] = None, dynamic: bo self.requirements = requirements self.containers = containers self.resource_requirements = resource_requirements + for requirement in self.requirements: + if requirement.type == "secret": + self.inject_as_env = requirement.inject_as_env + self.interfaces = requirement.interfaces + for interface in self.interfaces: + preferences = self.app.config.user_preferences_extra["preferences"] + interface_name = interface.get("name", "") + main_key, input_key = interface_name.split("|") + preferences_inputs = preferences.get(main_key, {}).get("inputs", []) + required = interface.get("required", False) + for input_item in preferences_inputs: + if any(input_item.get("name") == input_key): + input_item["required"] = required + # now this should add it the environment variables to the job as the name self.inject_as_env + # value should be get from vault (trans.user_vault.read_secret(vault_key)) + + self.environment_variables.append({"name": self.inject_as_env, "value": None}) + break + else: + raise exceptions.ConfigurationError(f"Interface {interface_name} not found in user preferences") required_files = tool_source.parse_required_files() if required_files is None: From dfd226c1126ebebdbc94434320912aef56ba8218 Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 30 Oct 2024 13:58:23 +0100 Subject: [PATCH 002/116] add secret requirement in tools schema --- lib/galaxy/tool_util/xsd/galaxy.xsd | 70 ++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 15 deletions(-) diff --git a/lib/galaxy/tool_util/xsd/galaxy.xsd b/lib/galaxy/tool_util/xsd/galaxy.xsd index 98931067e796..0e4f2d94dde4 100644 --- a/lib/galaxy/tool_util/xsd/galaxy.xsd +++ b/lib/galaxy/tool_util/xsd/galaxy.xsd @@ -614,7 +614,7 @@ serve as complete descriptions of the runtime of a tool. - + ``` +This is possible to add secrets into tools directly by adding a requirement of +type ``secret``. This will inject the value of the requirement into the +environment of the tool. The value of the requirement is the value of the +environment variable to inject the secret into. + +```xml + + + + + +``` + + ]]> - - - - - Valid values are ``package``, ``set_environment``, ``python-module`` (deprecated), ``binary`` (deprecated) - - - - - For requirements of type ``package`` this value defines a specific version of the tool dependency. - - - - + + + + + + + + Valid values are ``package``, ``set_environment``, ``secret``, ``python-module`` (deprecated), ``binary`` (deprecated) + + + + + For requirements of type ``package`` this value defines a specific version of the tool dependency. + + + + + For requirements of type ``secret`` this value defines the name of the environment variable to inject the value of the requirement into. + + + + + + + + The name of the interface. + + + + + The label of the interface. + + + + + Whether the interface is required. + + + + From 58fe28dee73c72ce816021cbca2ee2a837361307 Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 30 Oct 2024 13:59:05 +0100 Subject: [PATCH 003/116] check the required field with user_preferences_extra --- lib/galaxy/tools/__init__.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/galaxy/tools/__init__.py b/lib/galaxy/tools/__init__.py index 4c113472be09..9b18df0f0f6c 100644 --- a/lib/galaxy/tools/__init__.py +++ b/lib/galaxy/tools/__init__.py @@ -1237,11 +1237,10 @@ def parse(self, tool_source: ToolSource, guid: Optional[str] = None, dynamic: bo required = interface.get("required", False) for input_item in preferences_inputs: if any(input_item.get("name") == input_key): - input_item["required"] = required - # now this should add it the environment variables to the job as the name self.inject_as_env - # value should be get from vault (trans.user_vault.read_secret(vault_key)) - - self.environment_variables.append({"name": self.inject_as_env, "value": None}) + if input_item["required"] != required: + raise exceptions.ConfigurationError( + f"Interface {interface_name} required mismatch between tool and user preferences" + ) break else: raise exceptions.ConfigurationError(f"Interface {interface_name} not found in user preferences") From 71cda212c88d1d1ed2cd195f9daaa26eec49bf30 Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 30 Oct 2024 14:02:20 +0100 Subject: [PATCH 004/116] validate secret type and store for tool interface in user preferences --- lib/galaxy/tools/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/tools/__init__.py b/lib/galaxy/tools/__init__.py index 9b18df0f0f6c..4fba3e1d4a42 100644 --- a/lib/galaxy/tools/__init__.py +++ b/lib/galaxy/tools/__init__.py @@ -1237,10 +1237,14 @@ def parse(self, tool_source: ToolSource, guid: Optional[str] = None, dynamic: bo required = interface.get("required", False) for input_item in preferences_inputs: if any(input_item.get("name") == input_key): - if input_item["required"] != required: + if input_item.get("required") != required: raise exceptions.ConfigurationError( f"Interface {interface_name} required mismatch between tool and user preferences" ) + if input_item.get("type") != "secret" or input_item.get("store") != "vault": + raise exceptions.ConfigurationError( + f"Interface {interface_name} type should be 'secret' and store should be 'vault'." + ) break else: raise exceptions.ConfigurationError(f"Interface {interface_name} not found in user preferences") From c2c8796332b4a2102c93f4b770929d96819e64ff Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 30 Oct 2024 17:52:43 +0100 Subject: [PATCH 005/116] Add secrets into tools --- lib/galaxy/tool_util/deps/requirements.py | 135 ++++++++++++---------- lib/galaxy/tool_util/parser/interface.py | 7 +- lib/galaxy/tool_util/parser/xml.py | 2 +- lib/galaxy/tool_util/xsd/galaxy.xsd | 120 ++++++++++--------- lib/galaxy/tools/__init__.py | 34 ++---- lib/galaxy/tools/evaluation.py | 17 ++- 6 files changed, 170 insertions(+), 145 deletions(-) diff --git a/lib/galaxy/tool_util/deps/requirements.py b/lib/galaxy/tool_util/deps/requirements.py index 57ea63ca24c0..3c1a51171f4d 100644 --- a/lib/galaxy/tool_util/deps/requirements.py +++ b/lib/galaxy/tool_util/deps/requirements.py @@ -42,30 +42,17 @@ def __init__( type: Optional[str] = None, version: Optional[str] = None, specs: Optional[Iterable["RequirementSpecification"]] = None, - inject_as_env: Optional[str] = None, - interfaces: Optional[List[Dict[str, Any]]] = None, ) -> None: if specs is None: specs = [] - if interfaces is None: - interfaces = [] self.name = name self.type = type self.version = version self.specs = specs - self.inject_as_env = inject_as_env - self.interfaces = interfaces def to_dict(self) -> Dict[str, Any]: specs = [s.to_dict() for s in self.specs] - return dict( - name=self.name, - type=self.type, - version=self.version, - specs=specs, - inject_as_env=self.inject_as_env, - interfaces=self.interfaces, - ) + return dict(name=self.name, type=self.type, version=self.version, specs=specs) def copy(self) -> "ToolRequirement": return copy.deepcopy(self) @@ -76,11 +63,7 @@ def from_dict(cls, d: Dict[str, Any]) -> "ToolRequirement": name = d["name"] type = d.get("type") specs = [RequirementSpecification.from_dict(s) for s in d.get("specs", [])] - inject_as_env = d.get("inject_as_env") - interfaces = d.get("interfaces", []) - return cls( - name=name, type=type, version=version, specs=specs, inject_as_env=inject_as_env, interfaces=interfaces - ) + return cls(name=name, type=type, version=version, specs=specs) def __eq__(self, other: Any) -> bool: return ( @@ -88,24 +71,13 @@ def __eq__(self, other: Any) -> bool: and self.type == other.type and self.version == other.version and self.specs == other.specs - and self.inject_as_env == other.inject_as_env - and self.interfaces == other.interfaces ) def __hash__(self) -> int: - return hash( - ( - self.name, - self.type, - self.version, - frozenset(self.specs), - self.inject_as_env, - frozenset(tuple(i.items()) for i in self.interfaces), - ) - ) + return hash((self.name, self.type, self.version, frozenset(self.specs))) def __str__(self) -> str: - return f"ToolRequirement[{self.name},version={self.version},type={self.type},specs={self.specs},inject_as_env={self.inject_as_env},interfaces={self.interfaces}]" + return f"ToolRequirement[{self.name},version={self.version},type={self.type},specs={self.specs}]" __repr__ = __str__ @@ -334,6 +306,54 @@ def resource_requirements_from_list(requirements: Iterable[Dict[str, Any]]) -> L return rr +class SecretsRequirement: + def __init__( + self, + type: str, + user_preferences_key: str, + inject_as_env: str, + label: Optional[str] = "", + required: Optional[bool] = False, + ) -> None: + self.type = type + self.user_preferences_key = user_preferences_key + self.inject_as_env = inject_as_env + self.label = label + self.required = required + if not self.user_preferences_key: + raise ValueError("Missing user_preferences_key") + seperated_key = user_preferences_key.split("/") + if len(seperated_key) != 2 or not seperated_key[0] or not seperated_key[1]: + raise ValueError("Invalid user_preferences_key") + if self.type not in {"vault"}: + raise ValueError(f"Invalid secret type '{self.type}'") + if not self.inject_as_env: + raise ValueError("Missing inject_as_env") + + def to_dict(self) -> Dict[str, Any]: + return { + "type": self.type, + "user_preferences_key": self.user_preferences_key, + "inject_as_env": self.inject_as_env, + "label": self.label, + "required": self.required, + } + + def from_dict(self, dict: Dict[str, Any]) -> "SecretsRequirement": + type = dict["type"] + user_preferences_key = dict["user_preferences_key"] + inject_as_env = dict["inject_as_env"] + label = dict.get("label", "") + required = dict.get("required", False) + return SecretsRequirement( + type=type, + user_preferences_key=user_preferences_key, + inject_as_env=inject_as_env, + label=label, + required=required, + ) + + def parse_requirements_from_lists( software_requirements: List[Union[ToolRequirement, Dict[str, Any]]], containers: Iterable[Dict[str, Any]], @@ -346,15 +366,15 @@ def parse_requirements_from_lists( ) -def parse_requirements_from_xml(xml_root, parse_resources: bool = False): +def parse_requirements_from_xml(xml_root, parse_resources_and_secrets: bool = False): """ Parses requirements, containers and optionally resource requirements from Xml tree. >>> from galaxy.util import parse_xml_string - >>> def load_requirements(contents, parse_resources=False): + >>> def load_requirements(contents, parse_resources_and_secrets=False): ... contents_document = '''%s''' ... root = parse_xml_string(contents_document % contents) - ... return parse_requirements_from_xml(root, parse_resources=parse_resources) + ... return parse_requirements_from_xml(root, parse_resources_and_secrets=parse_resources_and_secrets) >>> reqs, containers = load_requirements('''bwa''') >>> reqs[0].name 'bwa' @@ -373,46 +393,30 @@ def parse_requirements_from_xml(xml_root, parse_resources: bool = False): requirements_elem = xml_root.find("requirements") requirement_elems = [] + container_elems = [] if requirements_elem is not None: requirement_elems = requirements_elem.findall("requirement") + container_elems = requirements_elem.findall("container") requirements = ToolRequirements() for requirement_elem in requirement_elems: name = xml_text(requirement_elem) type = requirement_elem.get("type", DEFAULT_REQUIREMENT_TYPE) version = requirement_elem.get("version", DEFAULT_REQUIREMENT_VERSION) - inject_as_env = requirement_elem.get("inject_as_env") - interfaces = parse_interfaces(requirement_elem) - requirement = ToolRequirement( - name=name, type=type, version=version, inject_as_env=inject_as_env, interfaces=interfaces - ) + requirement = ToolRequirement(name=name, type=type, version=version) requirements.append(requirement) - container_elems = [] - if requirements_elem is not None: - container_elems = requirements_elem.findall("container") - containers = [container_from_element(c) for c in container_elems] - if parse_resources: + if parse_resources_and_secrets: resource_elems = requirements_elem.findall("resource") if requirements_elem is not None else [] resources = [resource_from_element(r) for r in resource_elems] - return requirements, containers, resources + secret_elems = requirements_elem.findall("secret") if requirements_elem is not None else [] + secrets = [secret_from_element(s) for s in secret_elems] + return requirements, containers, resources, secrets return requirements, containers -def parse_interfaces(requirement_elem): - interfaces = [] - for interface_elem in requirement_elem.findall("interface"): - interface = { - "name": interface_elem.get("name"), - "label": interface_elem.get("label"), - "required": string_as_bool(interface_elem.get("required", "false")), - } - interfaces.append(interface) - return interfaces - - def resource_from_element(resource_elem) -> ResourceRequirement: value_or_expression = xml_text(resource_elem) resource_type = resource_elem.get("type") @@ -431,3 +435,18 @@ def container_from_element(container_elem) -> ContainerDescription: shell=shell, ) return container + + +def secret_from_element(secret_elem) -> SecretsRequirement: + type = secret_elem.get("type") + user_preferences_key = secret_elem.get("user_preferences_key") + inject_as_env = secret_elem.get("inject_as_env") + label = secret_elem.get("label", "") + required = string_as_bool(secret_elem.get("required", "false")) + return SecretsRequirement( + type=type, + user_preferences_key=user_preferences_key, + inject_as_env=inject_as_env, + label=label, + required=required, + ) diff --git a/lib/galaxy/tool_util/parser/interface.py b/lib/galaxy/tool_util/parser/interface.py index 21db34d203f8..286c41ee9655 100644 --- a/lib/galaxy/tool_util/parser/interface.py +++ b/lib/galaxy/tool_util/parser/interface.py @@ -35,6 +35,7 @@ from galaxy.tool_util.deps.requirements import ( ContainerDescription, ResourceRequirement, + SecretsRequirement, ToolRequirements, ) from galaxy.tool_util.parser.output_objects import ( @@ -313,8 +314,10 @@ def parse_required_files(self) -> Optional["RequiredFiles"]: @abstractmethod def parse_requirements_and_containers( self, - ) -> Tuple["ToolRequirements", List["ContainerDescription"], List["ResourceRequirement"]]: - """Return triple of ToolRequirement, ContainerDescription and ResourceRequirement lists.""" + ) -> Tuple[ + "ToolRequirements", List["ContainerDescription"], List["ResourceRequirement"], List["SecretsRequirement"] + ]: + """Return triple of ToolRequirement, ContainerDescription, ResourceRequirement, and SecretsRequirement objects.""" @abstractmethod def parse_input_pages(self) -> "PagesSource": diff --git a/lib/galaxy/tool_util/parser/xml.py b/lib/galaxy/tool_util/parser/xml.py index 863a316e45df..240a60b71d44 100644 --- a/lib/galaxy/tool_util/parser/xml.py +++ b/lib/galaxy/tool_util/parser/xml.py @@ -413,7 +413,7 @@ def parse_include_exclude_list(tag_name): return RequiredFiles.from_dict(as_dict) def parse_requirements_and_containers(self): - return requirements.parse_requirements_from_xml(self.root, parse_resources=True) + return requirements.parse_requirements_from_xml(self.root, parse_resources_and_secrets=True) def parse_input_pages(self) -> "XmlPagesSource": return XmlPagesSource(self.root) diff --git a/lib/galaxy/tool_util/xsd/galaxy.xsd b/lib/galaxy/tool_util/xsd/galaxy.xsd index 0e4f2d94dde4..74edd0758d86 100644 --- a/lib/galaxy/tool_util/xsd/galaxy.xsd +++ b/lib/galaxy/tool_util/xsd/galaxy.xsd @@ -612,9 +612,10 @@ serve as complete descriptions of the runtime of a tool. + - + ``` -This is possible to add secrets into tools directly by adding a requirement of -type ``secret``. This will inject the value of the requirement into the -environment of the tool. The value of the requirement is the value of the -environment variable to inject the secret into. - -```xml - - - - - -``` - - ]]> - - - - - - - - Valid values are ``package``, ``set_environment``, ``secret``, ``python-module`` (deprecated), ``binary`` (deprecated) - - - - - For requirements of type ``package`` this value defines a specific version of the tool dependency. - - - - - For requirements of type ``secret`` this value defines the name of the environment variable to inject the value of the requirement into. - - - - - - - - The name of the interface. - - - - - The label of the interface. - - - - - Whether the interface is required. - - + + + + + Valid values are ``package``, ``set_environment``, ``python-module`` (deprecated), ``binary`` (deprecated) + + + + + For requirements of type ``package`` this value defines a specific version of the tool dependency. + + + + - + + + + + +``` +]]> + + + + The type of secret to inject. Valid value is ``vault`` for now. + + + + + The name of the user preference key to store the secret in. + + + + + The name of the environment variable to inject the secret into. + + + + + The label of the secret. + + + + + Whether the secret is required to run the tool. + + + Document type of tool help @@ -7810,7 +7813,6 @@ and ``bibtex`` are the only supported options. - @@ -7889,6 +7891,14 @@ and ``bibtex`` are the only supported options. + + + Type of secret for tool execution. + + + + + Documentation for ToolTypeType diff --git a/lib/galaxy/tools/__init__.py b/lib/galaxy/tools/__init__.py index 4fba3e1d4a42..3cfb5a3ffe7a 100644 --- a/lib/galaxy/tools/__init__.py +++ b/lib/galaxy/tools/__init__.py @@ -817,8 +817,6 @@ def __init__( self.display_interface = True self.require_login = False self.rerun = False - self.inject_as_env: Optional[str] = None - self.interfaces: List[Dict[str, Any]] = [] # This will be non-None for tools loaded from the database (DynamicTool objects). self.dynamic_tool = None # Define a place to keep track of all input These @@ -1221,33 +1219,17 @@ def parse(self, tool_source: ToolSource, guid: Optional[str] = None, dynamic: bo raise Exception(message) # Requirements (dependencies) - requirements, containers, resource_requirements = tool_source.parse_requirements_and_containers() + requirements, containers, resource_requirements, secrets = tool_source.parse_requirements_and_containers() self.requirements = requirements self.containers = containers self.resource_requirements = resource_requirements - for requirement in self.requirements: - if requirement.type == "secret": - self.inject_as_env = requirement.inject_as_env - self.interfaces = requirement.interfaces - for interface in self.interfaces: - preferences = self.app.config.user_preferences_extra["preferences"] - interface_name = interface.get("name", "") - main_key, input_key = interface_name.split("|") - preferences_inputs = preferences.get(main_key, {}).get("inputs", []) - required = interface.get("required", False) - for input_item in preferences_inputs: - if any(input_item.get("name") == input_key): - if input_item.get("required") != required: - raise exceptions.ConfigurationError( - f"Interface {interface_name} required mismatch between tool and user preferences" - ) - if input_item.get("type") != "secret" or input_item.get("store") != "vault": - raise exceptions.ConfigurationError( - f"Interface {interface_name} type should be 'secret' and store should be 'vault'." - ) - break - else: - raise exceptions.ConfigurationError(f"Interface {interface_name} not found in user preferences") + self.secrets = secrets + for secret in self.secrets: + preferences = self.app.config.user_preferences_extra["preferences"] + main_key, input_key = secret.user_preferences_key.split("/") + preferences_input = preferences.get(main_key, {}).get("inputs", []) + if not any(input_item.get("name") == input_key for input_item in preferences_input): + raise exceptions.ConfigurationError(f"User preferences key {secret.user_preferences_key} not found") required_files = tool_source.parse_required_files() if required_files is None: diff --git a/lib/galaxy/tools/evaluation.py b/lib/galaxy/tools/evaluation.py index 1d1a11914fc1..9455c4f543d7 100644 --- a/lib/galaxy/tools/evaluation.py +++ b/lib/galaxy/tools/evaluation.py @@ -28,9 +28,10 @@ ) from galaxy.model.none_like import NoneDataset from galaxy.security.object_wrapper import wrap_with_safe_string +from galaxy.security.vault import UserVaultWrapper from galaxy.structured_app import ( BasicSharedApp, - MinimalToolApp, + StructuredApp, ) from galaxy.tool_util.data import TabularToolDataTable from galaxy.tools.actions import determine_output_format @@ -128,11 +129,11 @@ class ToolEvaluator: tool inputs in an isolated, testable manner. """ - app: MinimalToolApp + app: StructuredApp job: model.Job materialize_datasets: bool = True - def __init__(self, app: MinimalToolApp, tool: "Tool", job, local_working_directory): + def __init__(self, app: StructuredApp, tool: "Tool", job, local_working_directory): self.app = app self.job = job self.tool = tool @@ -193,6 +194,16 @@ def set_compute_environment(self, compute_environment: ComputeEnvironment, get_s self.execute_tool_hooks(inp_data=inp_data, out_data=out_data, incoming=incoming) + if self.tool.secrets: + user_vault = UserVaultWrapper(self.app.vault, self._user) + for secret in self.tool.secrets: + vault_key = secret.user_preferences_key + secret_value = user_vault.read_secret("preferences/" + vault_key) + if secret_value is not None: + self.environment_variables.append({"name": secret.inject_as_env, "value": secret_value}) + else: + log.warning(f"Failed to read secret from vault with key {vault_key}") + def execute_tool_hooks(self, inp_data, out_data, incoming): # Certain tools require tasks to be completed prior to job execution # ( this used to be performed in the "exec_before_job" hook, but hooks are deprecated ). From 99ff6427904f405f716596081dc58798f81d6530 Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 30 Oct 2024 18:59:32 +0100 Subject: [PATCH 006/116] Add tests for secrets in tools --- test/functional/tools/secret_tool.xml | 15 ++++++++++ test/integration/test_vault_extra_prefs.py | 32 ++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 test/functional/tools/secret_tool.xml diff --git a/test/functional/tools/secret_tool.xml b/test/functional/tools/secret_tool.xml new file mode 100644 index 000000000000..bbcf9b7f91a3 --- /dev/null +++ b/test/functional/tools/secret_tool.xml @@ -0,0 +1,15 @@ + + + + + '$output' + ]]> + + + + + + + + diff --git a/test/integration/test_vault_extra_prefs.py b/test/integration/test_vault_extra_prefs.py index 5072ac69d270..64fbb7a6ea40 100644 --- a/test/integration/test_vault_extra_prefs.py +++ b/test/integration/test_vault_extra_prefs.py @@ -11,6 +11,11 @@ ) from galaxy.model.db.user import get_user_by_email +from galaxy_test.api.test_tools import TestsTools +from galaxy_test.base.populators import ( + DatasetPopulator, + skip_without_tool, +) from galaxy_test.driver import integration_util TEST_USER_EMAIL = "vault_test_user@bx.psu.edu" @@ -134,3 +139,30 @@ def __url(self, action, user): def _get_dbuser(self, app, user): return get_user_by_email(app.model.session, user["email"]) + + +class TestSecretsInExtraUserPreferences( + integration_util.IntegrationTestCase, integration_util.ConfiguresDatabaseVault, TestsTools +): + dataset_populator: DatasetPopulator + + @classmethod + def handle_galaxy_config_kwds(cls, config): + super().handle_galaxy_config_kwds(config) + cls._configure_database_vault(config) + config["user_preferences_extra_conf_path"] = os.path.join( + os.path.dirname(__file__), "user_preferences_extra_conf.yml" + ) + + def setUp(self): + super().setUp() + self.dataset_populator = DatasetPopulator(self.galaxy_interactor) + + @skip_without_tool("secret_tool") + def test_secrets_tool(self, history_id): + user = self._setup_user(TEST_USER_EMAIL) + url = self._api_url(f"users/{user['id']}/information/inputs", params=dict(key=self.master_api_key)) + put(url, data=json.dumps({"secret_tool|api_key": "test"})) + run_response = self._run("secret", history_id, assert_ok=True) + outputs = run_response["outputs"] + assert outputs[0]["extra_files"][0]["value"] == "test" From fcbe6a2150bee2cfa7bc497003c8df506284ab2f Mon Sep 17 00:00:00 2001 From: Arash Date: Thu, 31 Oct 2024 12:34:37 +0100 Subject: [PATCH 007/116] Avoid log vault_key --- lib/galaxy/tools/evaluation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/tools/evaluation.py b/lib/galaxy/tools/evaluation.py index 9455c4f543d7..1dc43674372d 100644 --- a/lib/galaxy/tools/evaluation.py +++ b/lib/galaxy/tools/evaluation.py @@ -202,7 +202,7 @@ def set_compute_environment(self, compute_environment: ComputeEnvironment, get_s if secret_value is not None: self.environment_variables.append({"name": secret.inject_as_env, "value": secret_value}) else: - log.warning(f"Failed to read secret from vault with key {vault_key}") + log.warning("Failed to read secret from vault") def execute_tool_hooks(self, inp_data, out_data, incoming): # Certain tools require tasks to be completed prior to job execution From 3695368d832bee56afc13c2c32f634de2fe9371a Mon Sep 17 00:00:00 2001 From: Arash Date: Thu, 31 Oct 2024 13:03:45 +0100 Subject: [PATCH 008/116] fix typo --- lib/galaxy/tool_util/xsd/galaxy.xsd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/tool_util/xsd/galaxy.xsd b/lib/galaxy/tool_util/xsd/galaxy.xsd index 74edd0758d86..8e571f39b38d 100644 --- a/lib/galaxy/tool_util/xsd/galaxy.xsd +++ b/lib/galaxy/tool_util/xsd/galaxy.xsd @@ -736,7 +736,7 @@ environment variable to inject the secret into. ```xml - + ``` ]]> From f7207a18d0c1afdea779b73e19e39915c403cbf1 Mon Sep 17 00:00:00 2001 From: Arash Date: Thu, 31 Oct 2024 13:13:26 +0100 Subject: [PATCH 009/116] cast app for using vault into StructuredApp --- lib/galaxy/tools/evaluation.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/galaxy/tools/evaluation.py b/lib/galaxy/tools/evaluation.py index 1dc43674372d..5f4a49d896ff 100644 --- a/lib/galaxy/tools/evaluation.py +++ b/lib/galaxy/tools/evaluation.py @@ -9,6 +9,7 @@ from typing import ( Any, Callable, + cast, Dict, List, Optional, @@ -31,6 +32,7 @@ from galaxy.security.vault import UserVaultWrapper from galaxy.structured_app import ( BasicSharedApp, + MinimalToolApp, StructuredApp, ) from galaxy.tool_util.data import TabularToolDataTable @@ -129,11 +131,11 @@ class ToolEvaluator: tool inputs in an isolated, testable manner. """ - app: StructuredApp + app: MinimalToolApp job: model.Job materialize_datasets: bool = True - def __init__(self, app: StructuredApp, tool: "Tool", job, local_working_directory): + def __init__(self, app: MinimalToolApp, tool: "Tool", job, local_working_directory): self.app = app self.job = job self.tool = tool @@ -195,7 +197,8 @@ def set_compute_environment(self, compute_environment: ComputeEnvironment, get_s self.execute_tool_hooks(inp_data=inp_data, out_data=out_data, incoming=incoming) if self.tool.secrets: - user_vault = UserVaultWrapper(self.app.vault, self._user) + app = cast(StructuredApp, self.app) + user_vault = UserVaultWrapper(app.vault, self._user) for secret in self.tool.secrets: vault_key = secret.user_preferences_key secret_value = user_vault.read_secret("preferences/" + vault_key) From d3913aaed15b589ff699e429a46b45b3fb9fa841 Mon Sep 17 00:00:00 2001 From: Arash Date: Thu, 31 Oct 2024 13:13:41 +0100 Subject: [PATCH 010/116] Add secrets parameter to parse_requirements_and_containers method in linters --- lib/galaxy/tool_util/linters/general.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/galaxy/tool_util/linters/general.py b/lib/galaxy/tool_util/linters/general.py index eb3a98fd3b82..2e13abd0ea53 100644 --- a/lib/galaxy/tool_util/linters/general.py +++ b/lib/galaxy/tool_util/linters/general.py @@ -183,7 +183,7 @@ class RequirementNameMissing(Linter): @classmethod def lint(cls, tool_source: "ToolSource", lint_ctx: "LintContext"): _, tool_node = _tool_xml_and_root(tool_source) - requirements, containers, resource_requirements = tool_source.parse_requirements_and_containers() + requirements, containers, resource_requirements, secrets = tool_source.parse_requirements_and_containers() for r in requirements: if r.type != "package": continue @@ -195,7 +195,7 @@ class RequirementVersionMissing(Linter): @classmethod def lint(cls, tool_source: "ToolSource", lint_ctx: "LintContext"): _, tool_node = _tool_xml_and_root(tool_source) - requirements, containers, resource_requirements = tool_source.parse_requirements_and_containers() + requirements, containers, resource_requirements, secrets = tool_source.parse_requirements_and_containers() for r in requirements: if r.type != "package": continue @@ -207,7 +207,7 @@ class RequirementVersionWhitespace(Linter): @classmethod def lint(cls, tool_source: "ToolSource", lint_ctx: "LintContext"): _, tool_node = _tool_xml_and_root(tool_source) - requirements, containers, resource_requirements = tool_source.parse_requirements_and_containers() + requirements, containers, resource_requirements, secrets = tool_source.parse_requirements_and_containers() for r in requirements: if r.type != "package": continue @@ -223,7 +223,7 @@ class ResourceRequirementExpression(Linter): @classmethod def lint(cls, tool_source: "ToolSource", lint_ctx: "LintContext"): _, tool_node = _tool_xml_and_root(tool_source) - requirements, containers, resource_requirements = tool_source.parse_requirements_and_containers() + requirements, containers, resource_requirements, secrets = tool_source.parse_requirements_and_containers() for rr in resource_requirements: if rr.runtime_required: lint_ctx.warn( From dafe1e28ff4b6a706d520a9e68f663f2f2ab13e7 Mon Sep 17 00:00:00 2001 From: Arash Date: Thu, 31 Oct 2024 15:00:23 +0100 Subject: [PATCH 011/116] add secrets into cwl and yml --- lib/galaxy/tool_util/cwl/parser.py | 3 +++ lib/galaxy/tool_util/deps/requirements.py | 9 ++++++--- lib/galaxy/tool_util/parser/cwl.py | 2 ++ lib/galaxy/tool_util/parser/yaml.py | 1 + 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/galaxy/tool_util/cwl/parser.py b/lib/galaxy/tool_util/cwl/parser.py index 30f5da9e47a2..aa53e8060e27 100644 --- a/lib/galaxy/tool_util/cwl/parser.py +++ b/lib/galaxy/tool_util/cwl/parser.py @@ -242,6 +242,9 @@ def software_requirements(self) -> List: def resource_requirements(self) -> List: return self.hints_or_requirements_of_class("ResourceRequirement") + def secrets(self) -> List: + return self.hints_or_requirements_of_class("Secrets") + class CommandLineToolProxy(ToolProxy): _class = "CommandLineTool" diff --git a/lib/galaxy/tool_util/deps/requirements.py b/lib/galaxy/tool_util/deps/requirements.py index 3c1a51171f4d..7b74ec2575be 100644 --- a/lib/galaxy/tool_util/deps/requirements.py +++ b/lib/galaxy/tool_util/deps/requirements.py @@ -339,13 +339,14 @@ def to_dict(self) -> Dict[str, Any]: "required": self.required, } - def from_dict(self, dict: Dict[str, Any]) -> "SecretsRequirement": + @classmethod + def from_dict(cls, dict: Dict[str, Any]) -> "SecretsRequirement": type = dict["type"] user_preferences_key = dict["user_preferences_key"] inject_as_env = dict["inject_as_env"] label = dict.get("label", "") required = dict.get("required", False) - return SecretsRequirement( + return cls( type=type, user_preferences_key=user_preferences_key, inject_as_env=inject_as_env, @@ -358,11 +359,13 @@ def parse_requirements_from_lists( software_requirements: List[Union[ToolRequirement, Dict[str, Any]]], containers: Iterable[Dict[str, Any]], resource_requirements: Iterable[Dict[str, Any]], -) -> Tuple[ToolRequirements, List[ContainerDescription], List[ResourceRequirement]]: + secrets: Iterable[Dict[str, Any]], +) -> Tuple[ToolRequirements, List[ContainerDescription], List[ResourceRequirement], List[SecretsRequirement]]: return ( ToolRequirements.from_list(software_requirements), [ContainerDescription.from_dict(c) for c in containers], resource_requirements_from_list(resource_requirements), + [SecretsRequirement.from_dict(s) for s in secrets], ) diff --git a/lib/galaxy/tool_util/parser/cwl.py b/lib/galaxy/tool_util/parser/cwl.py index ec3c925fc116..1f9cd8616c04 100644 --- a/lib/galaxy/tool_util/parser/cwl.py +++ b/lib/galaxy/tool_util/parser/cwl.py @@ -173,10 +173,12 @@ def parse_requirements_and_containers(self): software_requirements = self.tool_proxy.software_requirements() resource_requirements = self.tool_proxy.resource_requirements() + secrets = self.tool_proxy.secrets() return requirements.parse_requirements_from_lists( software_requirements=[{"name": r[0], "version": r[1], "type": "package"} for r in software_requirements], containers=containers, resource_requirements=resource_requirements, + secrets=secrets, ) def parse_profile(self): diff --git a/lib/galaxy/tool_util/parser/yaml.py b/lib/galaxy/tool_util/parser/yaml.py index 88be9c72846a..4de318b44ddd 100644 --- a/lib/galaxy/tool_util/parser/yaml.py +++ b/lib/galaxy/tool_util/parser/yaml.py @@ -115,6 +115,7 @@ def parse_requirements_and_containers(self): software_requirements=[r for r in mixed_requirements if r.get("type") != "resource"], containers=self.root_dict.get("containers", []), resource_requirements=[r for r in mixed_requirements if r.get("type") == "resource"], + secrets=self.root_dict.get("secrets", []), ) def parse_input_pages(self) -> PagesSource: From b8dba5dc219c984cf9e6a6d77121487bef94a8bf Mon Sep 17 00:00:00 2001 From: Arash Date: Thu, 31 Oct 2024 15:00:45 +0100 Subject: [PATCH 012/116] Fix tool parsing test to get secrets --- test/unit/tool_util/test_parsing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/tool_util/test_parsing.py b/test/unit/tool_util/test_parsing.py index 42e5bbfd1b1b..974de9643ed9 100644 --- a/test/unit/tool_util/test_parsing.py +++ b/test/unit/tool_util/test_parsing.py @@ -347,7 +347,7 @@ def test_action(self): assert self._tool_source.parse_action_module() is None def test_requirements(self): - requirements, containers, resource_requirements = self._tool_source.parse_requirements_and_containers() + requirements, containers, resource_requirements, secrets = self._tool_source.parse_requirements_and_containers() assert requirements[0].type == "package" assert list(containers)[0].identifier == "mycool/bwa" assert resource_requirements[0].resource_type == "cores_min" From 8425f2c84d89ef441eec8fcaaf56dd5d221e0c2e Mon Sep 17 00:00:00 2001 From: Arash Date: Thu, 31 Oct 2024 15:05:34 +0100 Subject: [PATCH 013/116] Fix tool tests to include secrets --- test/unit/tool_util/test_cwl.py | 2 +- test/unit/tool_util/test_parsing.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/test/unit/tool_util/test_cwl.py b/test/unit/tool_util/test_cwl.py index c84240fb1bed..49e35bba9817 100644 --- a/test/unit/tool_util/test_cwl.py +++ b/test/unit/tool_util/test_cwl.py @@ -281,7 +281,7 @@ def test_load_proxy_simple(): outputs, output_collections = tool_source.parse_outputs(None) assert len(outputs) == 1 - software_requirements, containers, resource_requirements = tool_source.parse_requirements_and_containers() + software_requirements, containers, resource_requirements, secrets = tool_source.parse_requirements_and_containers() assert software_requirements.to_dict() == [] assert len(containers) == 1 assert containers[0].to_dict() == { diff --git a/test/unit/tool_util/test_parsing.py b/test/unit/tool_util/test_parsing.py index 974de9643ed9..d7d916299b2c 100644 --- a/test/unit/tool_util/test_parsing.py +++ b/test/unit/tool_util/test_parsing.py @@ -533,7 +533,9 @@ def test_action(self): assert self._tool_source.parse_action_module() is None def test_requirements(self): - software_requirements, containers, resource_requirements = self._tool_source.parse_requirements_and_containers() + software_requirements, containers, resource_requirements, secrets = ( + self._tool_source.parse_requirements_and_containers() + ) assert software_requirements.to_dict() == [{"name": "bwa", "type": "package", "version": "1.0.1", "specs": []}] assert len(containers) == 1 assert containers[0].to_dict() == { From 9cfc9db0c7edba1214f72ddc582da654e849dbc9 Mon Sep 17 00:00:00 2001 From: Arash Date: Thu, 28 Nov 2024 17:05:09 +0100 Subject: [PATCH 014/116] Rename 'secrets' to 'credentials' in tool parsing and related methods and use the discussed XML format #19196 --- lib/galaxy/tool_util/cwl/parser.py | 4 +- lib/galaxy/tool_util/deps/requirements.py | 133 +++++++++++++++------- lib/galaxy/tool_util/linters/general.py | 8 +- lib/galaxy/tool_util/parser/cwl.py | 4 +- lib/galaxy/tool_util/parser/interface.py | 6 +- lib/galaxy/tool_util/parser/xml.py | 2 +- lib/galaxy/tool_util/parser/yaml.py | 2 +- lib/galaxy/tool_util/xsd/galaxy.xsd | 113 +++++++++++++----- lib/galaxy/tools/__init__.py | 17 +-- lib/galaxy/tools/evaluation.py | 37 +++--- test/unit/tool_util/test_cwl.py | 2 +- test/unit/tool_util/test_parsing.py | 4 +- 12 files changed, 224 insertions(+), 108 deletions(-) diff --git a/lib/galaxy/tool_util/cwl/parser.py b/lib/galaxy/tool_util/cwl/parser.py index aa53e8060e27..deede49359b9 100644 --- a/lib/galaxy/tool_util/cwl/parser.py +++ b/lib/galaxy/tool_util/cwl/parser.py @@ -242,8 +242,8 @@ def software_requirements(self) -> List: def resource_requirements(self) -> List: return self.hints_or_requirements_of_class("ResourceRequirement") - def secrets(self) -> List: - return self.hints_or_requirements_of_class("Secrets") + def credentials(self) -> List: + return self.hints_or_requirements_of_class("Credentials") class CommandLineToolProxy(ToolProxy): diff --git a/lib/galaxy/tool_util/deps/requirements.py b/lib/galaxy/tool_util/deps/requirements.py index 7b74ec2575be..7865bdb59435 100644 --- a/lib/galaxy/tool_util/deps/requirements.py +++ b/lib/galaxy/tool_util/deps/requirements.py @@ -306,52 +306,99 @@ def resource_requirements_from_list(requirements: Iterable[Dict[str, Any]]) -> L return rr -class SecretsRequirement: +class SecretOrVariable: def __init__( self, type: str, - user_preferences_key: str, + name: str, inject_as_env: str, - label: Optional[str] = "", - required: Optional[bool] = False, + label: str = "", + description: str = "", ) -> None: self.type = type - self.user_preferences_key = user_preferences_key + self.name = name self.inject_as_env = inject_as_env self.label = label - self.required = required - if not self.user_preferences_key: - raise ValueError("Missing user_preferences_key") - seperated_key = user_preferences_key.split("/") - if len(seperated_key) != 2 or not seperated_key[0] or not seperated_key[1]: - raise ValueError("Invalid user_preferences_key") - if self.type not in {"vault"}: - raise ValueError(f"Invalid secret type '{self.type}'") + self.description = description + if self.type not in {"secret", "variable"}: + raise ValueError(f"Invalid credential type '{self.type}'") if not self.inject_as_env: raise ValueError("Missing inject_as_env") def to_dict(self) -> Dict[str, Any]: return { "type": self.type, - "user_preferences_key": self.user_preferences_key, + "name": self.name, "inject_as_env": self.inject_as_env, "label": self.label, - "required": self.required, + "description": self.description, } @classmethod - def from_dict(cls, dict: Dict[str, Any]) -> "SecretsRequirement": + def from_element(cls, elem) -> "SecretOrVariable": + return cls( + type=elem.tag, + name=elem.get("name"), + inject_as_env=elem.get("inject_as_env"), + label=elem.get("label", ""), + description=elem.get("description", ""), + ) + + @classmethod + def from_dict(cls, dict: Dict[str, Any]) -> "SecretOrVariable": type = dict["type"] - user_preferences_key = dict["user_preferences_key"] + name = dict["name"] inject_as_env = dict["inject_as_env"] label = dict.get("label", "") + description = dict.get("description", "") + return cls(type=type, name=name, inject_as_env=inject_as_env, label=label, description=description) + + +class CredentialsRequirement: + def __init__( + self, + name: str, + reference: str, + required: bool = False, + label: str = "", + description: str = "", + secrets_and_variables: Optional[List[SecretOrVariable]] = None, + ) -> None: + self.name = name + self.reference = reference + self.required = required + self.label = label + self.description = description + self.secrets_and_variables = secrets_and_variables if secrets_and_variables is not None else [] + + if not self.reference: + raise ValueError("Missing reference") + + def to_dict(self) -> Dict[str, Any]: + return { + "name": self.name, + "reference": self.reference, + "required": self.required, + "label": self.label, + "description": self.description, + "secrets_and_variables": [s.to_dict() for s in self.secrets_and_variables], + } + + @classmethod + def from_dict(cls, dict: Dict[str, Any]) -> "CredentialsRequirement": + name = dict["name"] + reference = dict["reference"] required = dict.get("required", False) + label = dict.get("label", "") + description = dict.get("description", "") + secrets_and_variables = [SecretOrVariable.from_dict(s) for s in dict.get("secrets_and_variables", [])] return cls( - type=type, - user_preferences_key=user_preferences_key, - inject_as_env=inject_as_env, - label=label, + name=name, + reference=reference, required=required, + label=label, + description=description, + secrets_and_variables=secrets_and_variables, ) @@ -359,25 +406,25 @@ def parse_requirements_from_lists( software_requirements: List[Union[ToolRequirement, Dict[str, Any]]], containers: Iterable[Dict[str, Any]], resource_requirements: Iterable[Dict[str, Any]], - secrets: Iterable[Dict[str, Any]], -) -> Tuple[ToolRequirements, List[ContainerDescription], List[ResourceRequirement], List[SecretsRequirement]]: + credentials: Iterable[Dict[str, Any]], +) -> Tuple[ToolRequirements, List[ContainerDescription], List[ResourceRequirement], List[CredentialsRequirement]]: return ( ToolRequirements.from_list(software_requirements), [ContainerDescription.from_dict(c) for c in containers], resource_requirements_from_list(resource_requirements), - [SecretsRequirement.from_dict(s) for s in secrets], + [CredentialsRequirement.from_dict(s) for s in credentials], ) -def parse_requirements_from_xml(xml_root, parse_resources_and_secrets: bool = False): +def parse_requirements_from_xml(xml_root, parse_resources_and_credentials: bool = False): """ Parses requirements, containers and optionally resource requirements from Xml tree. >>> from galaxy.util import parse_xml_string - >>> def load_requirements(contents, parse_resources_and_secrets=False): + >>> def load_requirements(contents, parse_resources_and_credentials=False): ... contents_document = '''%s''' ... root = parse_xml_string(contents_document % contents) - ... return parse_requirements_from_xml(root, parse_resources_and_secrets=parse_resources_and_secrets) + ... return parse_requirements_from_xml(root, parse_resources_and_credentials=parse_resources_and_credentials) >>> reqs, containers = load_requirements('''bwa''') >>> reqs[0].name 'bwa' @@ -410,12 +457,12 @@ def parse_requirements_from_xml(xml_root, parse_resources_and_secrets: bool = Fa requirements.append(requirement) containers = [container_from_element(c) for c in container_elems] - if parse_resources_and_secrets: + if parse_resources_and_credentials: resource_elems = requirements_elem.findall("resource") if requirements_elem is not None else [] resources = [resource_from_element(r) for r in resource_elems] - secret_elems = requirements_elem.findall("secret") if requirements_elem is not None else [] - secrets = [secret_from_element(s) for s in secret_elems] - return requirements, containers, resources, secrets + credentials_elems = requirements_elem.findall("credentials") if requirements_elem is not None else [] + credentials = [credentials_from_element(s) for s in credentials_elems] + return requirements, containers, resources, credentials return requirements, containers @@ -440,16 +487,18 @@ def container_from_element(container_elem) -> ContainerDescription: return container -def secret_from_element(secret_elem) -> SecretsRequirement: - type = secret_elem.get("type") - user_preferences_key = secret_elem.get("user_preferences_key") - inject_as_env = secret_elem.get("inject_as_env") - label = secret_elem.get("label", "") - required = string_as_bool(secret_elem.get("required", "false")) - return SecretsRequirement( - type=type, - user_preferences_key=user_preferences_key, - inject_as_env=inject_as_env, - label=label, +def credentials_from_element(credentials_elem) -> CredentialsRequirement: + name = credentials_elem.get("name") + reference = credentials_elem.get("reference") + required = string_as_bool(credentials_elem.get("required", "false")) + label = credentials_elem.get("label", "") + description = credentials_elem.get("description", "") + secrets_and_variables = [SecretOrVariable.from_element(elem) for elem in credentials_elem.findall("*")] + return CredentialsRequirement( + name=name, + reference=reference, required=required, + label=label, + description=description, + secrets_and_variables=secrets_and_variables, ) diff --git a/lib/galaxy/tool_util/linters/general.py b/lib/galaxy/tool_util/linters/general.py index 2e13abd0ea53..ddc2c4e6621c 100644 --- a/lib/galaxy/tool_util/linters/general.py +++ b/lib/galaxy/tool_util/linters/general.py @@ -183,7 +183,7 @@ class RequirementNameMissing(Linter): @classmethod def lint(cls, tool_source: "ToolSource", lint_ctx: "LintContext"): _, tool_node = _tool_xml_and_root(tool_source) - requirements, containers, resource_requirements, secrets = tool_source.parse_requirements_and_containers() + requirements, *_ = tool_source.parse_requirements_and_containers() for r in requirements: if r.type != "package": continue @@ -195,7 +195,7 @@ class RequirementVersionMissing(Linter): @classmethod def lint(cls, tool_source: "ToolSource", lint_ctx: "LintContext"): _, tool_node = _tool_xml_and_root(tool_source) - requirements, containers, resource_requirements, secrets = tool_source.parse_requirements_and_containers() + requirements, *_ = tool_source.parse_requirements_and_containers() for r in requirements: if r.type != "package": continue @@ -207,7 +207,7 @@ class RequirementVersionWhitespace(Linter): @classmethod def lint(cls, tool_source: "ToolSource", lint_ctx: "LintContext"): _, tool_node = _tool_xml_and_root(tool_source) - requirements, containers, resource_requirements, secrets = tool_source.parse_requirements_and_containers() + requirements, *_ = tool_source.parse_requirements_and_containers() for r in requirements: if r.type != "package": continue @@ -223,7 +223,7 @@ class ResourceRequirementExpression(Linter): @classmethod def lint(cls, tool_source: "ToolSource", lint_ctx: "LintContext"): _, tool_node = _tool_xml_and_root(tool_source) - requirements, containers, resource_requirements, secrets = tool_source.parse_requirements_and_containers() + *_, resource_requirements, _ = tool_source.parse_requirements_and_containers() for rr in resource_requirements: if rr.runtime_required: lint_ctx.warn( diff --git a/lib/galaxy/tool_util/parser/cwl.py b/lib/galaxy/tool_util/parser/cwl.py index 1f9cd8616c04..5f466778d9c8 100644 --- a/lib/galaxy/tool_util/parser/cwl.py +++ b/lib/galaxy/tool_util/parser/cwl.py @@ -173,12 +173,12 @@ def parse_requirements_and_containers(self): software_requirements = self.tool_proxy.software_requirements() resource_requirements = self.tool_proxy.resource_requirements() - secrets = self.tool_proxy.secrets() + credentials = self.tool_proxy.credentials() return requirements.parse_requirements_from_lists( software_requirements=[{"name": r[0], "version": r[1], "type": "package"} for r in software_requirements], containers=containers, resource_requirements=resource_requirements, - secrets=secrets, + credentials=credentials, ) def parse_profile(self): diff --git a/lib/galaxy/tool_util/parser/interface.py b/lib/galaxy/tool_util/parser/interface.py index 286c41ee9655..1219b19c9fe5 100644 --- a/lib/galaxy/tool_util/parser/interface.py +++ b/lib/galaxy/tool_util/parser/interface.py @@ -34,8 +34,8 @@ if TYPE_CHECKING: from galaxy.tool_util.deps.requirements import ( ContainerDescription, + CredentialsRequirement, ResourceRequirement, - SecretsRequirement, ToolRequirements, ) from galaxy.tool_util.parser.output_objects import ( @@ -315,9 +315,9 @@ def parse_required_files(self) -> Optional["RequiredFiles"]: def parse_requirements_and_containers( self, ) -> Tuple[ - "ToolRequirements", List["ContainerDescription"], List["ResourceRequirement"], List["SecretsRequirement"] + "ToolRequirements", List["ContainerDescription"], List["ResourceRequirement"], List["CredentialsRequirement"] ]: - """Return triple of ToolRequirement, ContainerDescription, ResourceRequirement, and SecretsRequirement objects.""" + """Return triple of ToolRequirement, ContainerDescription, ResourceRequirement, and CredentialsRequirement objects.""" @abstractmethod def parse_input_pages(self) -> "PagesSource": diff --git a/lib/galaxy/tool_util/parser/xml.py b/lib/galaxy/tool_util/parser/xml.py index 240a60b71d44..255fe9482519 100644 --- a/lib/galaxy/tool_util/parser/xml.py +++ b/lib/galaxy/tool_util/parser/xml.py @@ -413,7 +413,7 @@ def parse_include_exclude_list(tag_name): return RequiredFiles.from_dict(as_dict) def parse_requirements_and_containers(self): - return requirements.parse_requirements_from_xml(self.root, parse_resources_and_secrets=True) + return requirements.parse_requirements_from_xml(self.root, parse_resources_and_credentials=True) def parse_input_pages(self) -> "XmlPagesSource": return XmlPagesSource(self.root) diff --git a/lib/galaxy/tool_util/parser/yaml.py b/lib/galaxy/tool_util/parser/yaml.py index 4de318b44ddd..673e5a2cb418 100644 --- a/lib/galaxy/tool_util/parser/yaml.py +++ b/lib/galaxy/tool_util/parser/yaml.py @@ -115,7 +115,7 @@ def parse_requirements_and_containers(self): software_requirements=[r for r in mixed_requirements if r.get("type") != "resource"], containers=self.root_dict.get("containers", []), resource_requirements=[r for r in mixed_requirements if r.get("type") == "resource"], - secrets=self.root_dict.get("secrets", []), + credentials=self.root_dict.get("credentials", []), ) def parse_input_pages(self) -> PagesSource: diff --git a/lib/galaxy/tool_util/xsd/galaxy.xsd b/lib/galaxy/tool_util/xsd/galaxy.xsd index 8e571f39b38d..b834752628e0 100644 --- a/lib/galaxy/tool_util/xsd/galaxy.xsd +++ b/lib/galaxy/tool_util/xsd/galaxy.xsd @@ -600,10 +600,10 @@ practice. @@ -612,7 +612,7 @@ serve as complete descriptions of the runtime of a tool. - + @@ -726,44 +726,111 @@ Read more about configuring Galaxy to run Docker jobs - + - + + + + + ``` ]]> - + + + + + + + The name of the credential set. + + + + + A reference to the source of the credentials. + + + + + The label of the credential set. + + + - The type of secret to inject. Valid value is ``vault`` for now. + The description of the credential set. - + - The name of the user preference key to store the secret in. + Whether the credentials are required for the tool to run. + + + + + + + + + + The name of the variable. - The name of the environment variable to inject the secret into. + The environment variable name to inject the value as. - The label of the secret. + The label for the variable. - + - Whether the secret is required to run the tool. + The description for the variable. + + + + + + + + + + The name of the secret. + + + + + The environment variable name to inject the value as. + + + + + The label for the secret. + + + + + The description for the secret. @@ -7891,14 +7958,6 @@ and ``bibtex`` are the only supported options. - - - Type of secret for tool execution. - - - - - Documentation for ToolTypeType diff --git a/lib/galaxy/tools/__init__.py b/lib/galaxy/tools/__init__.py index 3cfb5a3ffe7a..874faf5e1448 100644 --- a/lib/galaxy/tools/__init__.py +++ b/lib/galaxy/tools/__init__.py @@ -1219,17 +1219,18 @@ def parse(self, tool_source: ToolSource, guid: Optional[str] = None, dynamic: bo raise Exception(message) # Requirements (dependencies) - requirements, containers, resource_requirements, secrets = tool_source.parse_requirements_and_containers() + requirements, containers, resource_requirements, credentials = tool_source.parse_requirements_and_containers() self.requirements = requirements self.containers = containers self.resource_requirements = resource_requirements - self.secrets = secrets - for secret in self.secrets: - preferences = self.app.config.user_preferences_extra["preferences"] - main_key, input_key = secret.user_preferences_key.split("/") - preferences_input = preferences.get(main_key, {}).get("inputs", []) - if not any(input_item.get("name") == input_key for input_item in preferences_input): - raise exceptions.ConfigurationError(f"User preferences key {secret.user_preferences_key} not found") + self.credentials = credentials + # for credential in self.credentials: + # pass + # preferences = self.app.config.user_preferences_extra["preferences"] + # main_key, input_key = credential.user_preferences_key.split("/") + # preferences_input = preferences.get(main_key, {}).get("inputs", []) + # if not any(input_item.get("name") == input_key for input_item in preferences_input): + # raise exceptions.ConfigurationError(f"User preferences key {credential.user_preferences_key} not found") required_files = tool_source.parse_required_files() if required_files is None: diff --git a/lib/galaxy/tools/evaluation.py b/lib/galaxy/tools/evaluation.py index 5f4a49d896ff..d85408851c63 100644 --- a/lib/galaxy/tools/evaluation.py +++ b/lib/galaxy/tools/evaluation.py @@ -6,10 +6,9 @@ import string import tempfile from datetime import datetime -from typing import ( +from typing import ( # cast, Any, Callable, - cast, Dict, List, Optional, @@ -29,11 +28,11 @@ ) from galaxy.model.none_like import NoneDataset from galaxy.security.object_wrapper import wrap_with_safe_string -from galaxy.security.vault import UserVaultWrapper -from galaxy.structured_app import ( + +# from galaxy.security.vault import UserVaultWrapper +from galaxy.structured_app import ( # StructuredApp, BasicSharedApp, MinimalToolApp, - StructuredApp, ) from galaxy.tool_util.data import TabularToolDataTable from galaxy.tools.actions import determine_output_format @@ -196,16 +195,24 @@ def set_compute_environment(self, compute_environment: ComputeEnvironment, get_s self.execute_tool_hooks(inp_data=inp_data, out_data=out_data, incoming=incoming) - if self.tool.secrets: - app = cast(StructuredApp, self.app) - user_vault = UserVaultWrapper(app.vault, self._user) - for secret in self.tool.secrets: - vault_key = secret.user_preferences_key - secret_value = user_vault.read_secret("preferences/" + vault_key) - if secret_value is not None: - self.environment_variables.append({"name": secret.inject_as_env, "value": secret_value}) - else: - log.warning("Failed to read secret from vault") + if self.tool.credentials: + # app = cast(StructuredApp, self.app) + # user_vault = UserVaultWrapper(app.vault, self._user) + for credentials in self.tool.credentials: + reference = credentials.reference + for secret_or_variable in credentials.secrets_and_variables: + if secret_or_variable.type == "variable": + # variable_value = self.param_dict.get(f"{reference}/{secret_or_variable.name}") + variable_value = f"A variable: {reference}/{secret_or_variable.name}" + self.environment_variables.append( + {"name": secret_or_variable.inject_as_env, "value": variable_value} + ) + elif secret_or_variable.type == "secret": + # secret_value = user_vault.read_secret(f"{reference}/{secret_or_variable.name}") + secret_value = f"A secret: {reference}/{secret_or_variable.name}" + self.environment_variables.append( + {"name": secret_or_variable.inject_as_env, "value": secret_value} + ) def execute_tool_hooks(self, inp_data, out_data, incoming): # Certain tools require tasks to be completed prior to job execution diff --git a/test/unit/tool_util/test_cwl.py b/test/unit/tool_util/test_cwl.py index 49e35bba9817..849469a92b32 100644 --- a/test/unit/tool_util/test_cwl.py +++ b/test/unit/tool_util/test_cwl.py @@ -281,7 +281,7 @@ def test_load_proxy_simple(): outputs, output_collections = tool_source.parse_outputs(None) assert len(outputs) == 1 - software_requirements, containers, resource_requirements, secrets = tool_source.parse_requirements_and_containers() + software_requirements, containers, resource_requirements, _ = tool_source.parse_requirements_and_containers() assert software_requirements.to_dict() == [] assert len(containers) == 1 assert containers[0].to_dict() == { diff --git a/test/unit/tool_util/test_parsing.py b/test/unit/tool_util/test_parsing.py index d7d916299b2c..c9836c97c213 100644 --- a/test/unit/tool_util/test_parsing.py +++ b/test/unit/tool_util/test_parsing.py @@ -347,7 +347,7 @@ def test_action(self): assert self._tool_source.parse_action_module() is None def test_requirements(self): - requirements, containers, resource_requirements, secrets = self._tool_source.parse_requirements_and_containers() + requirements, containers, resource_requirements, _ = self._tool_source.parse_requirements_and_containers() assert requirements[0].type == "package" assert list(containers)[0].identifier == "mycool/bwa" assert resource_requirements[0].resource_type == "cores_min" @@ -533,7 +533,7 @@ def test_action(self): assert self._tool_source.parse_action_module() is None def test_requirements(self): - software_requirements, containers, resource_requirements, secrets = ( + software_requirements, containers, resource_requirements, _ = ( self._tool_source.parse_requirements_and_containers() ) assert software_requirements.to_dict() == [{"name": "bwa", "type": "package", "version": "1.0.1", "specs": []}] From 218ec2452aef968ea96f3f71adf1b35cdf73d09c Mon Sep 17 00:00:00 2001 From: Arash Date: Mon, 2 Dec 2024 15:24:20 +0100 Subject: [PATCH 015/116] Refactor test cases to remove unused TestSecretsInExtraUserPreferences class and add credentials parsing test --- test/integration/test_vault_extra_prefs.py | 50 +++++++++++----------- test/unit/tool_util/test_parsing.py | 15 +++++++ 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/test/integration/test_vault_extra_prefs.py b/test/integration/test_vault_extra_prefs.py index 64fbb7a6ea40..2fde51a0201a 100644 --- a/test/integration/test_vault_extra_prefs.py +++ b/test/integration/test_vault_extra_prefs.py @@ -141,28 +141,28 @@ def _get_dbuser(self, app, user): return get_user_by_email(app.model.session, user["email"]) -class TestSecretsInExtraUserPreferences( - integration_util.IntegrationTestCase, integration_util.ConfiguresDatabaseVault, TestsTools -): - dataset_populator: DatasetPopulator - - @classmethod - def handle_galaxy_config_kwds(cls, config): - super().handle_galaxy_config_kwds(config) - cls._configure_database_vault(config) - config["user_preferences_extra_conf_path"] = os.path.join( - os.path.dirname(__file__), "user_preferences_extra_conf.yml" - ) - - def setUp(self): - super().setUp() - self.dataset_populator = DatasetPopulator(self.galaxy_interactor) - - @skip_without_tool("secret_tool") - def test_secrets_tool(self, history_id): - user = self._setup_user(TEST_USER_EMAIL) - url = self._api_url(f"users/{user['id']}/information/inputs", params=dict(key=self.master_api_key)) - put(url, data=json.dumps({"secret_tool|api_key": "test"})) - run_response = self._run("secret", history_id, assert_ok=True) - outputs = run_response["outputs"] - assert outputs[0]["extra_files"][0]["value"] == "test" +# class TestSecretsInExtraUserPreferences( +# integration_util.IntegrationTestCase, integration_util.ConfiguresDatabaseVault, TestsTools +# ): +# dataset_populator: DatasetPopulator + +# @classmethod +# def handle_galaxy_config_kwds(cls, config): +# super().handle_galaxy_config_kwds(config) +# cls._configure_database_vault(config) +# config["user_preferences_extra_conf_path"] = os.path.join( +# os.path.dirname(__file__), "user_preferences_extra_conf.yml" +# ) + +# def setUp(self): +# super().setUp() +# self.dataset_populator = DatasetPopulator(self.galaxy_interactor) + +# @skip_without_tool("secret_tool") +# def test_secrets_tool(self, history_id): +# user = self._setup_user(TEST_USER_EMAIL) +# url = self._api_url(f"users/{user['id']}/information/inputs", params=dict(key=self.master_api_key)) +# put(url, data=json.dumps({"secret_tool|api_key": "test"})) +# run_response = self._run("secret", history_id, assert_ok=True) +# outputs = run_response["outputs"] +# assert outputs[0]["extra_files"][0]["value"] == "test" diff --git a/test/unit/tool_util/test_parsing.py b/test/unit/tool_util/test_parsing.py index c9836c97c213..b2dd2be8fcf7 100644 --- a/test/unit/tool_util/test_parsing.py +++ b/test/unit/tool_util/test_parsing.py @@ -50,6 +50,11 @@ 1 2 67108864 + + + + + @@ -359,6 +364,16 @@ def test_requirements(self): assert resource_requirements[6].resource_type == "shm_size" assert not resource_requirements[0].runtime_required + def test_credentials(self): + *_, credentials = self._tool_source.parse_requirements_and_containers() + assert credentials[0].name == "Apollo" + assert credentials[0].reference == "gmod.org/apollo" + assert credentials[0].required + assert len(credentials[0].secrets_and_variables) == 3 + assert credentials[0].secrets_and_variables[0].type == "variable" + assert credentials[0].secrets_and_variables[1].type == "secret" + assert credentials[0].secrets_and_variables[2].type == "secret" + def test_outputs(self): outputs, output_collections = self._tool_source.parse_outputs(object()) assert len(outputs) == 1, outputs From 489d8b5b5d034545842d963da42013071c6566bd Mon Sep 17 00:00:00 2001 From: Arash Date: Tue, 3 Dec 2024 16:08:24 +0100 Subject: [PATCH 016/116] updating the credentials to the new format --- lib/galaxy/tool_util/deps/requirements.py | 98 ++++++++++++++++------ lib/galaxy/tool_util/xsd/galaxy.xsd | 19 +++-- lib/galaxy/tools/__init__.py | 22 +++++ lib/galaxy/tools/evaluation.py | 19 ++--- test/functional/tools/secret_tool.xml | 12 +-- test/integration/test_vault_extra_prefs.py | 11 +-- test/unit/tool_util/test_parsing.py | 16 ++-- 7 files changed, 132 insertions(+), 65 deletions(-) diff --git a/lib/galaxy/tool_util/deps/requirements.py b/lib/galaxy/tool_util/deps/requirements.py index 7865bdb59435..b77566cc3cd9 100644 --- a/lib/galaxy/tool_util/deps/requirements.py +++ b/lib/galaxy/tool_util/deps/requirements.py @@ -306,28 +306,64 @@ def resource_requirements_from_list(requirements: Iterable[Dict[str, Any]]) -> L return rr -class SecretOrVariable: +class Secret: + def __init__( + self, + name: str, + inject_as_env: str, + label: str = "", + description: str = "", + ) -> None: + self.name = name + self.inject_as_env = inject_as_env + self.label = label + self.description = description + if not self.inject_as_env: + raise ValueError("Missing inject_as_env") + + def to_dict(self) -> Dict[str, Any]: + return { + "name": self.name, + "inject_as_env": self.inject_as_env, + "label": self.label, + "description": self.description, + } + + @classmethod + def from_element(cls, elem) -> "Secret": + return cls( + name=elem.get("name"), + inject_as_env=elem.get("inject_as_env"), + label=elem.get("label", ""), + description=elem.get("description", ""), + ) + + @classmethod + def from_dict(cls, dict: Dict[str, Any]) -> "Secret": + name = dict["name"] + inject_as_env = dict["inject_as_env"] + label = dict.get("label", "") + description = dict.get("description", "") + return cls(name=name, inject_as_env=inject_as_env, label=label, description=description) + + +class Variable: def __init__( self, - type: str, name: str, inject_as_env: str, label: str = "", description: str = "", ) -> None: - self.type = type self.name = name self.inject_as_env = inject_as_env self.label = label self.description = description - if self.type not in {"secret", "variable"}: - raise ValueError(f"Invalid credential type '{self.type}'") if not self.inject_as_env: raise ValueError("Missing inject_as_env") def to_dict(self) -> Dict[str, Any]: return { - "type": self.type, "name": self.name, "inject_as_env": self.inject_as_env, "label": self.label, @@ -335,9 +371,8 @@ def to_dict(self) -> Dict[str, Any]: } @classmethod - def from_element(cls, elem) -> "SecretOrVariable": + def from_element(cls, elem) -> "Variable": return cls( - type=elem.tag, name=elem.get("name"), inject_as_env=elem.get("inject_as_env"), label=elem.get("label", ""), @@ -345,13 +380,12 @@ def from_element(cls, elem) -> "SecretOrVariable": ) @classmethod - def from_dict(cls, dict: Dict[str, Any]) -> "SecretOrVariable": - type = dict["type"] + def from_dict(cls, dict: Dict[str, Any]) -> "Variable": name = dict["name"] inject_as_env = dict["inject_as_env"] label = dict.get("label", "") description = dict.get("description", "") - return cls(type=type, name=name, inject_as_env=inject_as_env, label=label, description=description) + return cls(name=name, inject_as_env=inject_as_env, label=label, description=description) class CredentialsRequirement: @@ -359,17 +393,21 @@ def __init__( self, name: str, reference: str, - required: bool = False, + optional: bool = True, + multiple: bool = False, label: str = "", description: str = "", - secrets_and_variables: Optional[List[SecretOrVariable]] = None, + secrets: Optional[List[Secret]] = None, + variables: Optional[List[Variable]] = None, ) -> None: self.name = name self.reference = reference - self.required = required + self.optional = optional + self.multiple = multiple self.label = label self.description = description - self.secrets_and_variables = secrets_and_variables if secrets_and_variables is not None else [] + self.secrets = secrets if secrets is not None else [] + self.variables = variables if variables is not None else [] if not self.reference: raise ValueError("Missing reference") @@ -378,27 +416,33 @@ def to_dict(self) -> Dict[str, Any]: return { "name": self.name, "reference": self.reference, - "required": self.required, + "optional": self.optional, + "multiple": self.multiple, "label": self.label, "description": self.description, - "secrets_and_variables": [s.to_dict() for s in self.secrets_and_variables], + "secrets": [s.to_dict() for s in self.secrets], + "variables": [v.to_dict() for v in self.variables], } @classmethod def from_dict(cls, dict: Dict[str, Any]) -> "CredentialsRequirement": name = dict["name"] reference = dict["reference"] - required = dict.get("required", False) + optional = dict.get("optional", True) + multiple = dict.get("multiple", False) label = dict.get("label", "") description = dict.get("description", "") - secrets_and_variables = [SecretOrVariable.from_dict(s) for s in dict.get("secrets_and_variables", [])] + secrets = [Secret.from_dict(s) for s in dict.get("secrets", [])] + variables = [Variable.from_dict(v) for v in dict.get("variables", [])] return cls( name=name, reference=reference, - required=required, + optional=optional, + multiple=multiple, label=label, description=description, - secrets_and_variables=secrets_and_variables, + secrets=secrets, + variables=variables, ) @@ -490,15 +534,19 @@ def container_from_element(container_elem) -> ContainerDescription: def credentials_from_element(credentials_elem) -> CredentialsRequirement: name = credentials_elem.get("name") reference = credentials_elem.get("reference") - required = string_as_bool(credentials_elem.get("required", "false")) + optional = string_as_bool(credentials_elem.get("optional", "true")) + multiple = string_as_bool(credentials_elem.get("multiple", "false")) label = credentials_elem.get("label", "") description = credentials_elem.get("description", "") - secrets_and_variables = [SecretOrVariable.from_element(elem) for elem in credentials_elem.findall("*")] + secrets = [Secret.from_element(elem) for elem in credentials_elem.findall("secret")] + variables = [Variable.from_element(elem) for elem in credentials_elem.findall("variable")] return CredentialsRequirement( name=name, reference=reference, - required=required, + optional=optional, + multiple=multiple, label=label, description=description, - secrets_and_variables=secrets_and_variables, + secrets=secrets, + variables=variables, ) diff --git a/lib/galaxy/tool_util/xsd/galaxy.xsd b/lib/galaxy/tool_util/xsd/galaxy.xsd index b834752628e0..7f1df64a862d 100644 --- a/lib/galaxy/tool_util/xsd/galaxy.xsd +++ b/lib/galaxy/tool_util/xsd/galaxy.xsd @@ -739,11 +739,11 @@ It can contain multiple ``variable`` and ``secret`` tags. ```xml - - - - - + + + + + ``` ]]> @@ -772,9 +772,14 @@ It can contain multiple ``variable`` and ``secret`` tags. The description of the credential set. - + - Whether the credentials are required for the tool to run. + Whether the credentials are optional for the tool to run. + + + + + Indicates multiple sets of credentials can be provided. diff --git a/lib/galaxy/tools/__init__.py b/lib/galaxy/tools/__init__.py index 874faf5e1448..fa1b12f3dc65 100644 --- a/lib/galaxy/tools/__init__.py +++ b/lib/galaxy/tools/__init__.py @@ -2674,6 +2674,27 @@ def to_json(self, trans, kwd=None, job=None, workflow_building_mode=False, histo state_inputs_json: ToolStateDumpedToJsonT = params_to_json(self.inputs, state_inputs, self.app) + credentials = [] + for credential in self.credentials: + credential_dict = credential.to_dict() + credential_dict["variables"] = [ + { + "name": variable.name, + "label": variable.label, + "description": variable.description, + } + for variable in credential.variables + ] + credential_dict["secrets"] = [ + { + "name": secret.name, + "label": secret.label, + "description": secret.description, + } + for secret in credential.secrets + ] + credentials.append(credential_dict) + # update tool model tool_model.update( { @@ -2686,6 +2707,7 @@ def to_json(self, trans, kwd=None, job=None, workflow_building_mode=False, histo "warnings": tool_warnings, "versions": self.tool_versions, "requirements": [{"name": r.name, "version": r.version} for r in self.requirements], + "credentials": credentials, "errors": state_errors, "tool_errors": self.tool_errors, "state_inputs": state_inputs_json, diff --git a/lib/galaxy/tools/evaluation.py b/lib/galaxy/tools/evaluation.py index d85408851c63..b90925a67659 100644 --- a/lib/galaxy/tools/evaluation.py +++ b/lib/galaxy/tools/evaluation.py @@ -200,19 +200,12 @@ def set_compute_environment(self, compute_environment: ComputeEnvironment, get_s # user_vault = UserVaultWrapper(app.vault, self._user) for credentials in self.tool.credentials: reference = credentials.reference - for secret_or_variable in credentials.secrets_and_variables: - if secret_or_variable.type == "variable": - # variable_value = self.param_dict.get(f"{reference}/{secret_or_variable.name}") - variable_value = f"A variable: {reference}/{secret_or_variable.name}" - self.environment_variables.append( - {"name": secret_or_variable.inject_as_env, "value": variable_value} - ) - elif secret_or_variable.type == "secret": - # secret_value = user_vault.read_secret(f"{reference}/{secret_or_variable.name}") - secret_value = f"A secret: {reference}/{secret_or_variable.name}" - self.environment_variables.append( - {"name": secret_or_variable.inject_as_env, "value": secret_value} - ) + for secret in credentials.secret: + secret_value = f"{reference}/{secret.name}" + self.environment_variables.append({"name": secret.inject_as_env, "value": secret_value}) + for variable in credentials.variable: + variable_value = f"{reference}/{variable.name}" + self.environment_variables.append({"name": variable.inject_as_env, "value": variable_value}) def execute_tool_hooks(self, inp_data, out_data, incoming): # Certain tools require tasks to be completed prior to job execution diff --git a/test/functional/tools/secret_tool.xml b/test/functional/tools/secret_tool.xml index bbcf9b7f91a3..998bf14dcb1a 100644 --- a/test/functional/tools/secret_tool.xml +++ b/test/functional/tools/secret_tool.xml @@ -1,15 +1,15 @@ - + + + + + '$output' +echo \$service1_url > '$output' && echo \$service1_user >> '$output' && echo \$service1_pass >> '$output' ]]> - - - - diff --git a/test/integration/test_vault_extra_prefs.py b/test/integration/test_vault_extra_prefs.py index 2fde51a0201a..d9166a553b8a 100644 --- a/test/integration/test_vault_extra_prefs.py +++ b/test/integration/test_vault_extra_prefs.py @@ -11,11 +11,12 @@ ) from galaxy.model.db.user import get_user_by_email -from galaxy_test.api.test_tools import TestsTools -from galaxy_test.base.populators import ( - DatasetPopulator, - skip_without_tool, -) + +# from galaxy_test.api.test_tools import TestsTools +# from galaxy_test.base.populators import ( +# DatasetPopulator, +# skip_without_tool, +# ) from galaxy_test.driver import integration_util TEST_USER_EMAIL = "vault_test_user@bx.psu.edu" diff --git a/test/unit/tool_util/test_parsing.py b/test/unit/tool_util/test_parsing.py index b2dd2be8fcf7..a5f1dd07c4c7 100644 --- a/test/unit/tool_util/test_parsing.py +++ b/test/unit/tool_util/test_parsing.py @@ -50,10 +50,10 @@ 1 2 67108864 - - - - + + + + @@ -368,11 +368,9 @@ def test_credentials(self): *_, credentials = self._tool_source.parse_requirements_and_containers() assert credentials[0].name == "Apollo" assert credentials[0].reference == "gmod.org/apollo" - assert credentials[0].required - assert len(credentials[0].secrets_and_variables) == 3 - assert credentials[0].secrets_and_variables[0].type == "variable" - assert credentials[0].secrets_and_variables[1].type == "secret" - assert credentials[0].secrets_and_variables[2].type == "secret" + assert credentials[0].optional + assert len(credentials[0].secrets) == 2 + assert len(credentials[0].variables) == 1 def test_outputs(self): outputs, output_collections = self._tool_source.parse_outputs(object()) From 82275c7daaca35b591f9b94f5666862db4457082 Mon Sep 17 00:00:00 2001 From: Arash Date: Tue, 3 Dec 2024 17:33:38 +0100 Subject: [PATCH 017/116] Refactor credential classes (Variable and Secret) to inherit from BaseCredential class --- lib/galaxy/tool_util/deps/requirements.py | 48 +++-------------------- 1 file changed, 6 insertions(+), 42 deletions(-) diff --git a/lib/galaxy/tool_util/deps/requirements.py b/lib/galaxy/tool_util/deps/requirements.py index b77566cc3cd9..62d32dbfb6ca 100644 --- a/lib/galaxy/tool_util/deps/requirements.py +++ b/lib/galaxy/tool_util/deps/requirements.py @@ -306,7 +306,7 @@ def resource_requirements_from_list(requirements: Iterable[Dict[str, Any]]) -> L return rr -class Secret: +class BaseCredential: def __init__( self, name: str, @@ -329,6 +329,8 @@ def to_dict(self) -> Dict[str, Any]: "description": self.description, } + +class Secret(BaseCredential): @classmethod def from_element(cls, elem) -> "Secret": return cls( @@ -338,38 +340,8 @@ def from_element(cls, elem) -> "Secret": description=elem.get("description", ""), ) - @classmethod - def from_dict(cls, dict: Dict[str, Any]) -> "Secret": - name = dict["name"] - inject_as_env = dict["inject_as_env"] - label = dict.get("label", "") - description = dict.get("description", "") - return cls(name=name, inject_as_env=inject_as_env, label=label, description=description) - - -class Variable: - def __init__( - self, - name: str, - inject_as_env: str, - label: str = "", - description: str = "", - ) -> None: - self.name = name - self.inject_as_env = inject_as_env - self.label = label - self.description = description - if not self.inject_as_env: - raise ValueError("Missing inject_as_env") - - def to_dict(self) -> Dict[str, Any]: - return { - "name": self.name, - "inject_as_env": self.inject_as_env, - "label": self.label, - "description": self.description, - } +class Variable(BaseCredential): @classmethod def from_element(cls, elem) -> "Variable": return cls( @@ -379,14 +351,6 @@ def from_element(cls, elem) -> "Variable": description=elem.get("description", ""), ) - @classmethod - def from_dict(cls, dict: Dict[str, Any]) -> "Variable": - name = dict["name"] - inject_as_env = dict["inject_as_env"] - label = dict.get("label", "") - description = dict.get("description", "") - return cls(name=name, inject_as_env=inject_as_env, label=label, description=description) - class CredentialsRequirement: def __init__( @@ -432,8 +396,8 @@ def from_dict(cls, dict: Dict[str, Any]) -> "CredentialsRequirement": multiple = dict.get("multiple", False) label = dict.get("label", "") description = dict.get("description", "") - secrets = [Secret.from_dict(s) for s in dict.get("secrets", [])] - variables = [Variable.from_dict(v) for v in dict.get("variables", [])] + secrets = [Secret.from_element(s) for s in dict.get("secrets", [])] + variables = [Variable.from_element(v) for v in dict.get("variables", [])] return cls( name=name, reference=reference, From 908048324a63db19ccf0e1bcbc6e3342df35828a Mon Sep 17 00:00:00 2001 From: Arash Date: Fri, 6 Dec 2024 15:19:04 +0100 Subject: [PATCH 018/116] user credential model --- lib/galaxy/model/__init__.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index da20ff941aa6..597f85f2848b 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -11558,6 +11558,37 @@ def __repr__(self): ) +class UserCredentials(Base): + """ + Represents a credential associated with a user for a specific service. + """ + + __tablename__ = "user_credentials" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("galaxy_user.id"), index=True, nullable=False) + service_reference: Mapped[str] = mapped_column(nullable=False) # tool|tool_id|refrence|set + create_time: Mapped[Optional[datetime]] = mapped_column(default=now) + update_time: Mapped[Optional[datetime]] = mapped_column(default=now, onupdate=now) + + +class Credentials(Base): + """ + Represents a credential associated with a user for a specific + service. + """ + + __tablename__ = "credentials" + + id: Mapped[int] = mapped_column(primary_key=True) + user_credentials_id: Mapped[int] = mapped_column(ForeignKey("user_credentials.id"), index=True, nullable=False) + name: Mapped[str] = mapped_column(nullable=False) + type: Mapped[str] = mapped_column(nullable=False) + value: Mapped[str] = mapped_column(nullable=False) + create_time: Mapped[Optional[datetime]] = mapped_column(default=now) + update_time: Mapped[Optional[datetime]] = mapped_column(default=now, onupdate=now) + + # The following models (HDA, LDDA) are mapped imperatively (for details see discussion in PR #12064) # TLDR: there are issues ('metadata' property, Galaxy object wrapping) that need to be addressed separately # before these models can be mapped declaratively. Keeping them in the mapping module breaks the auth package From 16ebd123291d81902836588327cabfb775afc395 Mon Sep 17 00:00:00 2001 From: Arash Date: Fri, 6 Dec 2024 15:19:13 +0100 Subject: [PATCH 019/116] Add API and schema for user credentials management --- lib/galaxy/schema/credentials.py | 106 ++++++++ lib/galaxy/webapps/galaxy/api/credentials.py | 128 +++++++++ .../webapps/galaxy/services/credentials.py | 253 ++++++++++++++++++ 3 files changed, 487 insertions(+) create mode 100644 lib/galaxy/schema/credentials.py create mode 100644 lib/galaxy/webapps/galaxy/api/credentials.py create mode 100644 lib/galaxy/webapps/galaxy/services/credentials.py diff --git a/lib/galaxy/schema/credentials.py b/lib/galaxy/schema/credentials.py new file mode 100644 index 000000000000..99d9821187fe --- /dev/null +++ b/lib/galaxy/schema/credentials.py @@ -0,0 +1,106 @@ +from enum import Enum +from typing import List + +from pydantic import ( + Field, + RootModel, +) + +from galaxy.schema.fields import EncodedDatabaseIdField +from galaxy.schema.schema import Model + + +class CredentialType(str, Enum): + secret = "secret" + variable = "variable" + + +class CredentialResponse(Model): + id: EncodedDatabaseIdField = Field( + ..., + title="ID", + description="ID of the credential", + ) + name: str = Field( + ..., + title="Credential Name", + description="Name of the credential", + ) + type: CredentialType = Field( + ..., + title="Type", + description="Type of the credential", + ) + + +class CredentialsListResponse(Model): + service_reference: str = Field( + ..., + title="Service Reference", + description="Reference to the service", + ) + user_credentials_id: EncodedDatabaseIdField = Field( + ..., + title="User Credentials ID", + description="ID of the user credentials", + ) + credentials: List[CredentialResponse] = Field( + ..., + title="Credentials", + description="List of credentials", + ) + + +class UserCredentialsListResponse(RootModel): + root: List[CredentialsListResponse] = Field( + ..., + title="User Credentials", + description="List of user credentials", + ) + + +class CredentialPayload(Model): + name: str = Field( + ..., + title="Credential Name", + description="Name of the credential", + ) + type: CredentialType = Field( + ..., + title="Type", + description="Type of the credential(secret/variable)", + ) + value: str = Field( + ..., + title="Credential Value", + description="Value of the credential", + ) + + +class CredentialsPayload(Model): + service_reference: str = Field( + ..., + title="Service Reference", + description="Reference to the service", + ) + credentials: List[CredentialPayload] = Field( + ..., + title="Credentials", + description="List of credentials", + ) + + +class VerifyCredentialsResponse(Model): + exists: bool = Field( + ..., + title="Exists", + description="Indicates if the credentials exist", + ) + + +class DeleteCredentialsResponse(Model): + deleted: bool = Field( + ..., + title="Deleted", + description="Indicates if the credentials were deleted", + ) diff --git a/lib/galaxy/webapps/galaxy/api/credentials.py b/lib/galaxy/webapps/galaxy/api/credentials.py new file mode 100644 index 000000000000..a2567b16d74c --- /dev/null +++ b/lib/galaxy/webapps/galaxy/api/credentials.py @@ -0,0 +1,128 @@ +""" +API operations on credentials (credentials and variables). +""" + +import logging +from typing import Optional + +from fastapi import Query + +from galaxy.managers.context import ProvidesUserContext +from galaxy.schema.credentials import ( + CredentialsListResponse, + CredentialsPayload, + DeleteCredentialsResponse, + UserCredentialsListResponse, + VerifyCredentialsResponse, +) +from galaxy.schema.fields import DecodedDatabaseIdField +from galaxy.webapps.galaxy.api import ( + depends, + DependsOnTrans, + Router, +) +from galaxy.webapps.galaxy.api.common import UserIdPathParam +from galaxy.webapps.galaxy.services.credentials import CredentialsService + +log = logging.getLogger(__name__) + +router = Router(tags=["users"]) + + +@router.cbv +class FastAPICredentials: + service: CredentialsService = depends(CredentialsService) + + @router.get( + "/api/users/{user_id}/credentials", + summary="Lists all credentials the user has provided", + ) + def list_user_credentials( + self, + user_id: UserIdPathParam, + trans: ProvidesUserContext = DependsOnTrans, + source_type: Optional[str] = Query( + None, + description="The type of source to filter by.", + ), + source_id: Optional[str] = Query( + None, + description="The ID of the source to filter by.", + ), + ) -> UserCredentialsListResponse: + return self.service.list_user_credentials(trans, user_id, source_type, source_id) + + @router.get( + "/api/users/{user_id}/credentials/{user_credentials_id}", + summary="Verifies if credentials have been provided for a specific service", + ) + def verify_service_credentials( + self, + user_id: UserIdPathParam, + user_credentials_id: DecodedDatabaseIdField, + trans: ProvidesUserContext = DependsOnTrans, + ) -> VerifyCredentialsResponse: + return self.service.verify_service_credentials(trans, user_id, user_credentials_id) + + @router.get( + "/api/users/{user_id}/credentials/{user_credentials_id}/{credentials_id}", + summary="Verifies if a credential have been provided", + ) + def verify_credentials( + self, + user_id: UserIdPathParam, + user_credentials_id: DecodedDatabaseIdField, + credentials_id: DecodedDatabaseIdField, + trans: ProvidesUserContext = DependsOnTrans, + ) -> VerifyCredentialsResponse: + return self.service.verify_credentials(trans, user_credentials_id, credentials_id) + + @router.post( + "/api/users/{user_id}/credentials", + summary="Allows users to provide credentials for a secret/variable", + ) + def provide_credential( + self, + user_id: UserIdPathParam, + payload: CredentialsPayload, + trans: ProvidesUserContext = DependsOnTrans, + ) -> CredentialsListResponse: + return self.service.provide_credential(trans, user_id, payload) + + @router.put( + "/api/users/{user_id}/credentials/{credentials_id}", + summary="Updates credentials for a specific secret/variable", + ) + def update_credential( + self, + user_id: UserIdPathParam, + credentials_id: DecodedDatabaseIdField, + payload: CredentialsPayload, + trans: ProvidesUserContext = DependsOnTrans, + ) -> CredentialsListResponse: + return self.service.update_credential(trans, user_id, credentials_id, payload) + + @router.delete( + "/api/users/{user_id}/credentials/{user_credentials_id}", + summary="Deletes all credentials for a specific service", + ) + def delete_service_credentials( + self, + user_id: UserIdPathParam, + user_credentials_id: DecodedDatabaseIdField, + trans: ProvidesUserContext = DependsOnTrans, + ) -> DeleteCredentialsResponse: + return self.service.delete_service_credentials(trans, user_id, user_credentials_id) + + @router.delete( + "/api/users/{user_id}/credentials/{user_credentials_id}/{credentials_id}", + summary="Deletes a specific credential", + ) + def delete_credentials( + self, + user_id: UserIdPathParam, + user_credentials_id: DecodedDatabaseIdField, + credentials_id: DecodedDatabaseIdField, + trans: ProvidesUserContext = DependsOnTrans, + ) -> DeleteCredentialsResponse: + return self.service.delete_credentials(trans, user_id, user_credentials_id, credentials_id) diff --git a/lib/galaxy/webapps/galaxy/services/credentials.py b/lib/galaxy/webapps/galaxy/services/credentials.py new file mode 100644 index 000000000000..769d8e2f52db --- /dev/null +++ b/lib/galaxy/webapps/galaxy/services/credentials.py @@ -0,0 +1,253 @@ +from typing import ( + Dict, + List, + Optional, + Tuple, + Union, +) + +from galaxy import exceptions +from galaxy.managers.context import ProvidesUserContext +from galaxy.model import ( + Credentials, + UserCredentials, +) +from galaxy.model.base import transaction +from galaxy.schema.credentials import ( + CredentialResponse, + CredentialsListResponse, + CredentialsPayload, + DeleteCredentialsResponse, + UserCredentialsListResponse, + VerifyCredentialsResponse, +) +from galaxy.schema.fields import DecodedDatabaseIdField +from galaxy.security.vault import UserVaultWrapper +from galaxy.structured_app import StructuredApp +from galaxy.webapps.galaxy.api.common import UserIdPathParam + + +class CredentialsService: + """Interface/service object shared by controllers for interacting with credentials.""" + + def __init__(self, app: StructuredApp) -> None: + self._app = app + + def list_user_credentials( + self, + trans: ProvidesUserContext, + user_id: UserIdPathParam, + source_type: Optional[str] = None, + source_id: Optional[str] = None, + ) -> UserCredentialsListResponse: + """Lists all credentials the user has provided (credentials themselves are not included).""" + service_reference = f"{source_type}|{source_id}".strip("|") if source_type else None + user_credentials, credentials_dict = self._user_credentials( + trans, user_id=user_id, service_reference=service_reference + ) + user_credentials_list = [ + CredentialsListResponse( + service_reference=sref, + user_credentials_id=next( + (uc.id for uc in user_credentials if uc.service_reference == sref), + None, + ), + credentials=self._credentials_response(creds), + ) + for sref, creds in credentials_dict.items() + ] + return UserCredentialsListResponse(root=user_credentials_list) + + def verify_service_credentials( + self, + trans: ProvidesUserContext, + user_id: UserIdPathParam, + user_credentials_id: DecodedDatabaseIdField, + ) -> VerifyCredentialsResponse: + """Verifies if credentials have been provided for a specific service (no credential data returned).""" + _, credentials_dict = self._user_credentials(trans, user_id=user_id, user_credentials_id=user_credentials_id) + return VerifyCredentialsResponse(exists=bool(credentials_dict)) + + def verify_credentials( + self, + trans: ProvidesUserContext, + user_credentials_id: DecodedDatabaseIdField, + credentials_id: DecodedDatabaseIdField, + ) -> VerifyCredentialsResponse: + """Verifies if a credential have been provided (no credential data returned).""" + credentials = self._credentials(trans, user_credentials_id=user_credentials_id, id=credentials_id) + return VerifyCredentialsResponse(exists=bool(credentials)) + + def provide_credential( + self, + trans: ProvidesUserContext, + user_id: UserIdPathParam, + payload: CredentialsPayload, + ) -> CredentialsListResponse: + """Allows users to provide credentials for a secret/variable.""" + return self._create_user_credential(trans, user_id, payload) + + def update_credential( + self, + trans: ProvidesUserContext, + user_id: UserIdPathParam, + credentials_id: DecodedDatabaseIdField, + payload: CredentialsPayload, + ) -> CredentialsListResponse: + """Updates credentials for a specific secret/variable.""" + return self._create_user_credential(trans, user_id, payload, credentials_id) + + def delete_service_credentials( + self, + trans: ProvidesUserContext, + user_id: UserIdPathParam, + user_credentials_id: DecodedDatabaseIdField, + ) -> DeleteCredentialsResponse: + """Deletes all credentials for a specific service.""" + user_credentials, credentials_dict = self._user_credentials( + trans, user_id=user_id, user_credentials_id=user_credentials_id + ) + session = trans.sa_session + for credentials in credentials_dict.values(): + for credential in credentials: + session.delete(credential) + for user_credential in user_credentials: + session.delete(user_credential) + with transaction(session): + session.commit() + return DeleteCredentialsResponse(deleted=True) + + def delete_credentials( + self, + trans: ProvidesUserContext, + user_id: UserIdPathParam, + user_credentials_id: DecodedDatabaseIdField, + credentials_id: DecodedDatabaseIdField, + ) -> DeleteCredentialsResponse: + """Deletes a specific credential.""" + credentials = self._credentials(trans, user_credentials_id=user_credentials_id, id=credentials_id) + session = trans.sa_session + for credential in credentials: + session.delete(credential) + with transaction(session): + session.commit() + return DeleteCredentialsResponse(deleted=True) + + def _user_credentials( + self, + trans: ProvidesUserContext, + user_id: UserIdPathParam, + service_reference: Optional[str] = None, + user_credentials_id: Optional[DecodedDatabaseIdField] = None, + ) -> Tuple[List[UserCredentials], Dict[str, List[Credentials]]]: + if not trans.user_is_admin and (not trans.user or trans.user != user_id): + raise exceptions.ItemOwnershipException( + "Only admins and the user can manage their own credentials.", type="error" + ) + query = trans.sa_session.query(UserCredentials).filter(UserCredentials.user_id == user_id) + if service_reference: + query = query.filter(UserCredentials.service_reference.startswith(service_reference)) + if user_credentials_id: + query = query.filter(UserCredentials.id == user_credentials_id) + user_credentials_list = query.all() + credentials_dict = {} + for user_credential in user_credentials_list: + credentials_list = self._credentials(trans, user_credentials_id=user_credential.id) + credentials_dict[user_credential.service_reference] = credentials_list + return user_credentials_list, credentials_dict + + def _credentials( + self, + trans: ProvidesUserContext, + user_credentials_id: Optional[DecodedDatabaseIdField] = None, + id: Optional[DecodedDatabaseIdField] = None, + name: Optional[str] = None, + type: Optional[str] = None, + ) -> List[Credentials]: + query = trans.sa_session.query(Credentials) + if user_credentials_id: + query = query.filter(Credentials.user_credentials_id == user_credentials_id) + if id: + query = query.filter(Credentials.id == id) + if name: + query = query.filter(Credentials.name == name) + if type: + query = query.filter(Credentials.type == type) + return query.all() + + def _credentials_response(self, credentials_list: List[Credentials]) -> List[CredentialResponse]: + return [ + CredentialResponse( + id=credential.id, + name=credential.name, + type=credential.type, + ) + for credential in credentials_list + ] + + def _create_user_credential( + self, + trans: ProvidesUserContext, + user_id: UserIdPathParam, + payload: CredentialsPayload, + credentials_id: Optional[DecodedDatabaseIdField] = None, + ) -> CredentialsListResponse: + service_reference = payload.service_reference + user_credentials_list, credentials_dict = self._user_credentials( + trans, user_id, service_reference=service_reference + ) + user_credential = user_credentials_list[0] if user_credentials_list else None + + session = trans.sa_session + + if not user_credential: + user_credential = UserCredentials( + user_id=user_id, + service_reference=service_reference, + ) + session.add(user_credential) + session.flush() + + user_credential_id = user_credential.id + + provided_credentials_list: List[Credentials] = [] + for credential_payload in payload.credentials: + credential_name = credential_payload.name + credential_type = credential_payload.type + credential_value = credential_payload.value + + credential = next( + ( + cred + for sref, creds in credentials_dict.items() + if sref == service_reference + for cred in creds + if cred.name == credential_name and cred.type == credential_type + ), + None, + ) + + if not credential: + credential = Credentials( + user_credentials_id=user_credential_id, + name=credential_name, + type=credential_type, + ) + elif not credentials_id: + raise exceptions.RequestParameterInvalidException( + f"Credential {service_reference}|{credential_name} already exists.", type="error" + ) + if credential_type == "secret": + user_vault = UserVaultWrapper(self._app.vault, trans.user) + user_vault.write_secret(f"{service_reference}|{credential_name}", credential_value) + elif credential_type == "variable": + credential.value = credential_value + provided_credentials_list.append(credential) + session.add(credential) + with transaction(session): + session.commit() + return CredentialsListResponse( + service_reference=service_reference, + user_credentials_id=user_credential_id, + credentials=self._credentials_response(provided_credentials_list), + ) From 3420daabcf29c448278dbd84102332cb82a39f19 Mon Sep 17 00:00:00 2001 From: Arash Date: Fri, 6 Dec 2024 15:24:32 +0100 Subject: [PATCH 020/116] Remove unused Union import from credentials service --- lib/galaxy/webapps/galaxy/services/credentials.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/galaxy/webapps/galaxy/services/credentials.py b/lib/galaxy/webapps/galaxy/services/credentials.py index 769d8e2f52db..6843b765e007 100644 --- a/lib/galaxy/webapps/galaxy/services/credentials.py +++ b/lib/galaxy/webapps/galaxy/services/credentials.py @@ -3,7 +3,6 @@ List, Optional, Tuple, - Union, ) from galaxy import exceptions From 1909f09ed2e0f0bee252adc254bbc83376f107fb Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 5 Dec 2024 12:44:57 +0100 Subject: [PATCH 021/116] Add basic ToolCredentials component and related interfaces for managing tool credentials --- client/src/api/users.ts | 34 ++++ client/src/components/Tool/ToolCard.vue | 7 + .../src/components/Tool/ToolCredentials.vue | 166 ++++++++++++++++++ .../User/Credentials/CredentialsInput.vue | 38 ++++ .../Credentials/ManageToolCredentials.vue | 56 ++++++ 5 files changed, 301 insertions(+) create mode 100644 client/src/components/Tool/ToolCredentials.vue create mode 100644 client/src/components/User/Credentials/CredentialsInput.vue create mode 100644 client/src/components/User/Credentials/ManageToolCredentials.vue diff --git a/client/src/api/users.ts b/client/src/api/users.ts index a6086985d7a9..8ef110ef24a3 100644 --- a/client/src/api/users.ts +++ b/client/src/api/users.ts @@ -35,3 +35,37 @@ export async function fetchCurrentUserQuotaSourceUsage(quotaSourceLabel?: string return toQuotaUsage(data); } + +// TODO: Temporarily using these interfaces until the new API is implemented +export interface CredentialsDefinition { + name: string; + reference: string; + optional: boolean; + multiple: boolean; + label?: string; + description?: string; +} +export interface UserCredentials extends CredentialsDefinition { + variables: Variable[]; + secrets: Secret[]; +} + +export interface ToolCredentials extends CredentialsDefinition { + variables: CredentialDetail[]; + secrets: CredentialDetail[]; +} + +export interface CredentialDetail { + name: string; + label?: string; + description?: string; +} + +export interface Secret extends CredentialDetail { + alreadySet: boolean; + value?: string; +} + +export interface Variable extends CredentialDetail { + value?: string; +} diff --git a/client/src/components/Tool/ToolCard.vue b/client/src/components/Tool/ToolCard.vue index f76955803d2b..599e51e3fab9 100644 --- a/client/src/components/Tool/ToolCard.vue +++ b/client/src/components/Tool/ToolCard.vue @@ -14,6 +14,7 @@ import { useUserStore } from "@/stores/userStore"; import ToolSelectPreferredObjectStore from "./ToolSelectPreferredObjectStore"; import ToolTargetPreferredObjectStorePopover from "./ToolTargetPreferredObjectStorePopover"; +import ToolCredentials from "./ToolCredentials.vue"; import ToolHelpForum from "./ToolHelpForum.vue"; import ToolTutorialRecommendations from "./ToolTutorialRecommendations.vue"; import ToolFavoriteButton from "components/Tool/Buttons/ToolFavoriteButton.vue"; @@ -174,6 +175,12 @@ const showHelpForum = computed(() => isConfigLoaded.value && config.value.enable + +
diff --git a/client/src/components/Tool/ToolCredentials.vue b/client/src/components/Tool/ToolCredentials.vue new file mode 100644 index 000000000000..8554aef08b64 --- /dev/null +++ b/client/src/components/Tool/ToolCredentials.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/client/src/components/User/Credentials/CredentialsInput.vue b/client/src/components/User/Credentials/CredentialsInput.vue new file mode 100644 index 000000000000..9eea61a64dc6 --- /dev/null +++ b/client/src/components/User/Credentials/CredentialsInput.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/client/src/components/User/Credentials/ManageToolCredentials.vue b/client/src/components/User/Credentials/ManageToolCredentials.vue new file mode 100644 index 000000000000..f82461a99b8b --- /dev/null +++ b/client/src/components/User/Credentials/ManageToolCredentials.vue @@ -0,0 +1,56 @@ + + + + + From 2748f2e8ee6bd139a79b584aa16e09a0354a93e3 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 5 Dec 2024 12:56:04 +0100 Subject: [PATCH 022/116] Refactor ToolCredentials component To improve loading state management and user feedback --- .../src/components/Tool/ToolCredentials.vue | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/client/src/components/Tool/ToolCredentials.vue b/client/src/components/Tool/ToolCredentials.vue index 8554aef08b64..d1ee8e682c94 100644 --- a/client/src/components/Tool/ToolCredentials.vue +++ b/client/src/components/Tool/ToolCredentials.vue @@ -18,7 +18,8 @@ const props = defineProps(); const userStore = useUserStore(); -const checkingUserCredentials = ref(true); +const isBusy = ref(true); +const busyMessage = ref(""); const userCredentials = ref(undefined); const hasUserProvidedCredentials = computed(() => { @@ -42,7 +43,7 @@ const provideCredentialsButtonTitle = computed(() => { }); const bannerVariant = computed(() => { - if (checkingUserCredentials.value) { + if (isBusy.value) { return "info"; } return hasUserProvidedCredentials.value ? "success" : "warning"; @@ -54,7 +55,8 @@ const showModal = ref(false); * Check if the user has credentials for the tool. */ async function checkUserCredentials(providedCredentials?: UserCredentials[]) { - checkingUserCredentials.value = true; + busyMessage.value = "Checking your credentials..."; + isBusy.value = true; try { if (userStore.isAnonymous) { return; @@ -81,7 +83,7 @@ async function checkUserCredentials(providedCredentials?: UserCredentials[]) { // TODO: Implement error handling. console.error("Error checking user credentials", error); } finally { - checkingUserCredentials.value = false; + isBusy.value = false; } } @@ -91,10 +93,19 @@ function provideCredentials() { async function onSavedCredentials(providedCredentials: UserCredentials[]) { showModal.value = false; - await saveCredentials(providedCredentials); + busyMessage.value = "Saving your credentials..."; + try { + isBusy.value = true; + userCredentials.value = await saveCredentials(providedCredentials); + } catch (error) { + // TODO: Implement error handling. + console.error("Error saving user credentials", error); + } finally { + isBusy.value = false; + } } -async function saveCredentials(providedCredentials: UserCredentials[]) { +async function saveCredentials(providedCredentials: UserCredentials[]): Promise { // TODO: Implement store and real API request to save the provided credentials. console.log("SAVING CREDENTIALS", providedCredentials); await new Promise((resolve) => setTimeout(resolve, 1000)); @@ -103,6 +114,7 @@ async function saveCredentials(providedCredentials: UserCredentials[]) { secret.alreadySet = true; } } + return providedCredentials; } checkUserCredentials(); @@ -111,7 +123,7 @@ checkUserCredentials(); From 4dd7483670cb24ce4c5e61863708e1b3689402a7 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 14 Jan 2025 17:32:10 +0100 Subject: [PATCH 070/116] Fix new group not selected on creation by default --- client/src/components/User/Credentials/ServiceCredentials.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/components/User/Credentials/ServiceCredentials.vue b/client/src/components/User/Credentials/ServiceCredentials.vue index 32ebd5a94113..a6516b438e7b 100644 --- a/client/src/components/User/Credentials/ServiceCredentials.vue +++ b/client/src/components/User/Credentials/ServiceCredentials.vue @@ -41,6 +41,7 @@ function onCreateNewSet() { }; emit("new-credentials-set", props.credentialPayload, newSet); selectedSet.value = newSet; + onCurrentSetChange(newSet); } function onCurrentSetChange(selectedSet: ServiceGroupPayload) { From 1f8a202a858c08b7482a9d709c7ee84a084ff71c Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 14 Jan 2025 18:25:32 +0100 Subject: [PATCH 071/116] Enhance manage new credential sets --- .../User/Credentials/ServiceCredentials.vue | 55 ++++++++++++++++--- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/client/src/components/User/Credentials/ServiceCredentials.vue b/client/src/components/User/Credentials/ServiceCredentials.vue index a6516b438e7b..31130c877437 100644 --- a/client/src/components/User/Credentials/ServiceCredentials.vue +++ b/client/src/components/User/Credentials/ServiceCredentials.vue @@ -26,13 +26,28 @@ const selectedSet = ref( ); const availableSets = computed(() => Object.values(props.credentialPayload.groups)); const newSetName = ref(""); -const canCreateNewSet = computed(() => newSetName.value.trim() !== ""); +const canCreateNewSet = computed( + () => newSetName.value.trim() !== "" && !availableSets.value.some((set) => set.name === newSetName.value) +); +const canShowSetSelector = computed( + () => props.credentialDefinition.multiple && availableSets.value.length > 1 +); +const isAddingNewSet = ref(false); const emit = defineEmits<{ (e: "new-credentials-set", credential: ServiceCredentialPayload, newSet: ServiceGroupPayload): void; (e: "update-current-set", credential: ServiceCredentialPayload, newSet: ServiceGroupPayload): void; }>(); +function onAddingNewSet() { + isAddingNewSet.value = true; +} + +function onCancelAddingNewSet() { + isAddingNewSet.value = false; + newSetName.value = ""; +} + function onCreateNewSet() { const newSet: ServiceGroupPayload = { name: generateUniqueName(newSetName.value, props.credentialPayload.groups), @@ -42,6 +57,8 @@ function onCreateNewSet() { emit("new-credentials-set", props.credentialPayload, newSet); selectedSet.value = newSet; onCurrentSetChange(newSet); + isAddingNewSet.value = false; + newSetName.value = ""; } function onCurrentSetChange(selectedSet: ServiceGroupPayload) { @@ -99,14 +116,14 @@ function getVariableDescription(name: string, type: CredentialType): string | un + title="You can provide multiple sets of credentials for this tool. But only one set can be active at a time."> Multiple

{{ credentialDefinition.description }}

-

- Select a set of credentials: +
+ Using set: - - - - -

+
@@ -142,6 +155,25 @@ function getVariableDescription(name: string, type: CredentialType): string | un :optional="credentialDefinition.optional" :help="getVariableDescription(secret.name, 'secret')" />
+ +
+ +
+ + + +
+
@@ -155,4 +187,9 @@ function getVariableDescription(name: string, type: CredentialType): string | un color: green; margin-left: 0.5em; } +.set-management-bar { + display: flex; + gap: 1em; + align-items: center; +} From c446284dc87d5f684d1c1fabffc2413054f37958 Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 15 Jan 2025 10:44:18 +0100 Subject: [PATCH 072/116] Fix bug when updating user credentials to get the updated one --- lib/galaxy/webapps/galaxy/services/credentials.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/webapps/galaxy/services/credentials.py b/lib/galaxy/webapps/galaxy/services/credentials.py index a21dd945873b..81ced1c70fe5 100644 --- a/lib/galaxy/webapps/galaxy/services/credentials.py +++ b/lib/galaxy/webapps/galaxy/services/credentials.py @@ -307,7 +307,7 @@ def _create_user_credential( with transaction(session): session.commit() - new_user_credentials = db_user_credentials or self._user_credentials( + new_user_credentials = self._user_credentials( trans, user_id=user_id, source_type=source_type, From e86127fe2275c286c89f69fe7b46b8f89a26d5b2 Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 15 Jan 2025 10:44:25 +0100 Subject: [PATCH 073/116] Add integration test for adding a new group to user credentials --- test/integration/test_credentials.py | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/integration/test_credentials.py b/test/integration/test_credentials.py index c142b0a67abe..1cb52a437f6a 100644 --- a/test/integration/test_credentials.py +++ b/test/integration/test_credentials.py @@ -56,6 +56,46 @@ def test_delete_not_existing_credentials(self): response = self._delete("/api/users/current/credentials/f2db41e1fa331b3e/f2db41e1fa331b3e") self._assert_status_code_is(response, 400) + def test_add_group_to_credentials(self): + self._populate_user_credentials() + new_group_name = "new_group" + payload = { + "source_type": "tool", + "source_id": "test_tool", + "credentials": [ + { + "reference": "test_service", + "current_group": new_group_name, + "groups": [ + { + "name": "default", + "variables": [{"name": "server", "value": "http://localhost:8080"}], + "secrets": [ + {"name": "username", "value": "user"}, + {"name": "password", "value": "pass"}, + {"name": "token", "value": "key"}, + ], + }, + { + "name": new_group_name, + "variables": [{"name": "server", "value": "http://localhost:8080/new"}], + "secrets": [ + {"name": "username", "value": "user_new"}, + {"name": "password", "value": "pass_new"}, + {"name": "token", "value": "key_new"}, + ], + }, + ], + }, + ], + } + response = self._post("/api/users/current/credentials", data=payload, json=True) + self._assert_status_code_is(response, 200) + updated_user_credentials = response.json() + assert len(updated_user_credentials) == 1 + assert updated_user_credentials[0]["current_group_name"] == new_group_name + assert len(updated_user_credentials[0]["groups"]) == 2 + def _populate_user_credentials(self): payload = { "source_type": "tool", From 2d27a7d0bdb1166f6b6801d9f5e35e2705371cdb Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 15 Jan 2025 15:03:23 +0100 Subject: [PATCH 074/116] Refactoring CredentialsService and Add CredentialsManager for seperating user credential management --- lib/galaxy/managers/credentials.py | 191 ++++++++++ lib/galaxy/webapps/galaxy/api/credentials.py | 2 +- .../webapps/galaxy/services/credentials.py | 328 ++++-------------- 3 files changed, 260 insertions(+), 261 deletions(-) create mode 100644 lib/galaxy/managers/credentials.py diff --git a/lib/galaxy/managers/credentials.py b/lib/galaxy/managers/credentials.py new file mode 100644 index 000000000000..45e25ef31348 --- /dev/null +++ b/lib/galaxy/managers/credentials.py @@ -0,0 +1,191 @@ +from typing import ( + Any, + Dict, + List, + Optional, + Tuple, +) + +from sqlalchemy import select +from sqlalchemy.orm import aliased + +from galaxy.exceptions import ( + AuthenticationRequired, + ItemOwnershipException, + RequestParameterInvalidException, +) +from galaxy.managers.context import ProvidesUserContext +from galaxy.model import ( + CredentialsGroup, + Secret, + UserCredentials, + Variable, +) +from galaxy.model.base import transaction +from galaxy.model.scoped_session import galaxy_scoped_session +from galaxy.schema.credentials import ( + CreateSourceCredentialsPayload, + SOURCE_TYPE, +) +from galaxy.schema.fields import DecodedDatabaseIdField +from galaxy.schema.schema import FlexibleUserIdType +from galaxy.security.vault import UserVaultWrapper +from galaxy.structured_app import StructuredApp + + +class CredentialsManager: + """Manager object shared by controllers for interacting with credentials.""" + + def __init__(self, app: StructuredApp) -> None: + self._app = app + + def get_user_credentials( + self, + trans: ProvidesUserContext, + user_id: FlexibleUserIdType, + source_type: Optional[SOURCE_TYPE] = None, + source_id: Optional[str] = None, + group_name: Optional[str] = None, + user_credentials_id: Optional[DecodedDatabaseIdField] = None, + group_id: Optional[DecodedDatabaseIdField] = None, + ) -> List[Tuple[UserCredentials, CredentialsGroup]]: + if trans.anonymous: + raise AuthenticationRequired("You need to be logged in to access your credentials.") + if user_id == "current": + user_id = trans.user.id + elif trans.user.id != user_id: + raise ItemOwnershipException("You can only access your own credentials.") + user_cred_alias, group_alias = aliased(UserCredentials), aliased(CredentialsGroup) + stmt = ( + select(user_cred_alias, group_alias) + .join(group_alias, group_alias.user_credentials_id == user_cred_alias.id) + .where(user_cred_alias.user_id == user_id) + ) + if source_type: + stmt = stmt.where(user_cred_alias.source_type == source_type) + if source_id: + if not source_type: + raise RequestParameterInvalidException("Source type is required when source ID is provided.") + stmt = stmt.where(user_cred_alias.source_id == source_id) + if group_name: + stmt = stmt.where(group_alias.name == group_name) + if user_credentials_id: + stmt = stmt.where(user_cred_alias.id == user_credentials_id) + if group_id: + stmt = stmt.where(group_alias.id == group_id) + + result = trans.sa_session.execute(stmt).all() + return [(row[0], row[1]) for row in result] + + def fetch_credentials( + self, + session: galaxy_scoped_session, + group_id: DecodedDatabaseIdField, + ) -> Tuple[List[Variable], List[Secret]]: + variables = list( + session.execute(select(Variable).where(Variable.user_credential_group_id == group_id)).scalars().all() + ) + + secrets = list( + session.execute(select(Secret).where(Secret.user_credential_group_id == group_id)).scalars().all() + ) + + return variables, secrets + + def create_or_update_credentials( + self, + trans: ProvidesUserContext, + payload: CreateSourceCredentialsPayload, + db_user_credentials: List[Tuple[UserCredentials, CredentialsGroup]], + credentials_dict: Dict[int, Dict[str, Any]], + ) -> None: + session = trans.sa_session + existing_groups = { + cred["reference"]: {group["name"]: group["id"] for group in cred["groups"].values()} + for cred in credentials_dict.values() + } + for service_payload in payload.credentials: + reference = service_payload.reference + current_group_name = service_payload.current_group + current_group_id = existing_groups.get(reference, {}).get(current_group_name) + + user_credentials = next((uc[0] for uc in db_user_credentials if uc[0].reference == reference), None) + if not user_credentials: + user_credentials = UserCredentials( + user_id=trans.user.id, + reference=reference, + source_type=payload.source_type, + source_id=payload.source_id, + ) + session.add(user_credentials) + session.flush() + user_credentials_id = user_credentials.id + + for group in service_payload.groups: + group_name = group.name + credentials_group = next( + (uc[1] for uc in db_user_credentials if uc[1].name == group_name and uc[0].reference == reference), + None, + ) + if not credentials_group: + credentials_group = CredentialsGroup(name=group_name, user_credentials_id=user_credentials_id) + session.add(credentials_group) + session.flush() + user_credential_group_id = credentials_group.id + if current_group_name == group_name: + current_group_id = user_credential_group_id + variables, secrets = self.fetch_credentials(trans.sa_session, user_credential_group_id) + user_vault = UserVaultWrapper(self._app.vault, trans.user) + for variable_payload in group.variables: + variable_name, variable_value = variable_payload.name, variable_payload.value + if variable_value is None: + continue + variable = next( + (var for var in variables if var.name == variable_name), + None, + ) + if variable: + variable.value = variable_value + else: + variable = Variable( + user_credential_group_id=user_credential_group_id, + name=variable_name, + value=variable_value, + ) + session.add(variable) + for secret_payload in group.secrets: + secret_name, secret_value = secret_payload.name, secret_payload.value + if secret_value is None: + continue + secret = next( + (sec for sec in secrets if sec.name == secret_name), + None, + ) + if secret: + secret.already_set = True if secret_value else False + else: + secret = Secret( + user_credential_group_id=user_credential_group_id, + name=secret_name, + already_set=True if secret_value else False, + ) + session.add(secret) + vault_ref = f"{payload.source_type}|{payload.source_id}|{reference}|{group_name}|{secret_name}" + user_vault.write_secret(vault_ref, secret_value) + if not current_group_id: + raise RequestParameterInvalidException("No current group selected.") + user_credentials.current_group_id = current_group_id + session.add(user_credentials) + + with transaction(session): + session.commit() + + def delete_rows( + self, + session: galaxy_scoped_session, + rows_to_delete: List, + ) -> None: + for row in rows_to_delete: + session.delete(row) + with transaction(session): + session.commit() diff --git a/lib/galaxy/webapps/galaxy/api/credentials.py b/lib/galaxy/webapps/galaxy/api/credentials.py index e5128298c3cd..203e61befb25 100644 --- a/lib/galaxy/webapps/galaxy/api/credentials.py +++ b/lib/galaxy/webapps/galaxy/api/credentials.py @@ -80,7 +80,7 @@ def delete_service_credentials( user_credentials_id: DecodedDatabaseIdField, trans: ProvidesUserContext = DependsOnTrans, ): - self.service.delete_service_credentials(trans, user_id, user_credentials_id) + self.service.delete_credentials(trans, user_id, user_credentials_id) return Response(status_code=status.HTTP_204_NO_CONTENT) @router.delete( diff --git a/lib/galaxy/webapps/galaxy/services/credentials.py b/lib/galaxy/webapps/galaxy/services/credentials.py index 81ced1c70fe5..4123c3abc9a0 100644 --- a/lib/galaxy/webapps/galaxy/services/credentials.py +++ b/lib/galaxy/webapps/galaxy/services/credentials.py @@ -7,19 +7,15 @@ Union, ) -from sqlalchemy import select -from sqlalchemy.orm import aliased - -from galaxy import exceptions +from galaxy.exceptions import ObjectNotFound from galaxy.managers.context import ProvidesUserContext +from galaxy.managers.credentials import CredentialsManager from galaxy.model import ( CredentialsGroup, Secret, UserCredentials, Variable, ) -from galaxy.model.base import transaction -from galaxy.model.scoped_session import galaxy_scoped_session from galaxy.schema.credentials import ( CreateSourceCredentialsPayload, SOURCE_TYPE, @@ -28,15 +24,16 @@ ) from galaxy.schema.fields import DecodedDatabaseIdField from galaxy.schema.schema import FlexibleUserIdType -from galaxy.security.vault import UserVaultWrapper -from galaxy.structured_app import StructuredApp class CredentialsService: - """Interface/service object shared by controllers for interacting with credentials.""" + """Service object shared by controllers for interacting with credentials.""" - def __init__(self, app: StructuredApp) -> None: - self._app = app + def __init__( + self, + credentials_manager: CredentialsManager, + ) -> None: + self._credentials_manager = credentials_manager def list_user_credentials( self, @@ -47,16 +44,7 @@ def list_user_credentials( group_name: Optional[str] = None, ) -> UserCredentialsListResponse: """Lists all credentials the user has provided (credentials themselves are not included).""" - db_user_credentials = self._user_credentials( - trans, user_id=user_id, source_type=source_type, source_id=source_id, group_name=group_name - ) - credentials_dict = self._user_credentials_to_dict(db_user_credentials) - for user_credentials, credentials_group in db_user_credentials: - variables, secrets = self._credentials(trans, group_id=credentials_group.id) - group = credentials_dict.get(user_credentials.id, {}).get("groups", {}).get(credentials_group.name, {}) - self._add_credential_to_group(group, variables, secrets) - - return UserCredentialsListResponse(root=[UserCredentialsResponse(**cred) for cred in credentials_dict.values()]) + return self._list_user_credentials(trans, user_id, source_type, source_id, group_name) def provide_credential( self, @@ -65,267 +53,87 @@ def provide_credential( payload: CreateSourceCredentialsPayload, ) -> UserCredentialsListResponse: """Allows users to provide credentials for a group of secrets and variables.""" - return self._create_user_credential(trans, user_id, payload) - - def delete_service_credentials( - self, - trans: ProvidesUserContext, - user_id: FlexibleUserIdType, - user_credentials_id: DecodedDatabaseIdField, - ): - """Deletes all credentials for a specific service.""" - db_user_credentials = self._user_credentials(trans, user_id=user_id, user_credentials_id=user_credentials_id) - rows_to_be_deleted: List[Union[UserCredentials, CredentialsGroup, Variable, Secret]] = [] - for uc, credentials_group in db_user_credentials: - variables, secrets = self._credentials(trans, group_id=credentials_group.id) - rows_to_be_deleted.extend([uc, credentials_group, *variables, *secrets]) - self._delete_credentials(trans.sa_session, rows_to_be_deleted) + source_type, source_id = payload.source_type, payload.source_id + db_user_credentials = self._credentials_manager.get_user_credentials(trans, user_id, source_type, source_id) + credentials_dict = self._map_user_credentials(db_user_credentials) + self._credentials_manager.create_or_update_credentials(trans, payload, db_user_credentials, credentials_dict) + return self._list_user_credentials(trans, user_id, source_type, source_id) def delete_credentials( self, trans: ProvidesUserContext, user_id: FlexibleUserIdType, - group_id: DecodedDatabaseIdField, - ): - """Deletes a specific credential group.""" - user_credentials = self._user_credentials(trans, user_id=user_id, group_id=group_id) - rows_to_be_deleted: List[Union[CredentialsGroup, Variable, Secret]] = [] - for _, credentials_group in user_credentials: - variables, secrets = self._credentials(trans, group_id=credentials_group.id) - rows_to_be_deleted.extend([credentials_group, *variables, *secrets]) - self._delete_credentials(trans.sa_session, rows_to_be_deleted) + group_id: Optional[DecodedDatabaseIdField] = None, + user_credentials_id: Optional[DecodedDatabaseIdField] = None, + ) -> None: + """Deletes a specific credential group or all credentials for a specific service.""" + db_user_credentials = self._credentials_manager.get_user_credentials( + trans, user_id, group_id=group_id, user_credentials_id=user_credentials_id + ) + if not db_user_credentials: + raise ObjectNotFound("No credentials found.") + rows_to_delete: List[Union[UserCredentials, CredentialsGroup, Variable, Secret]] = [] + for uc, credentials_group in db_user_credentials: + if not group_id: + rows_to_delete.append(uc) + variables, secrets = self._credentials_manager.fetch_credentials(trans.sa_session, credentials_group.id) + rows_to_delete.extend([credentials_group, *variables, *secrets]) + self._credentials_manager.delete_rows(trans.sa_session, rows_to_delete) - def _user_credentials( + def _list_user_credentials( self, trans: ProvidesUserContext, user_id: FlexibleUserIdType, source_type: Optional[SOURCE_TYPE] = None, source_id: Optional[str] = None, - reference: Optional[str] = None, group_name: Optional[str] = None, - user_credentials_id: Optional[DecodedDatabaseIdField] = None, - group_id: Optional[DecodedDatabaseIdField] = None, - ) -> List[Tuple[UserCredentials, CredentialsGroup]]: - if trans.anonymous: - raise exceptions.AuthenticationRequired("You need to be logged in to access your credentials.") - if user_id == "current": - user_id = trans.user.id - if trans.user and trans.user.id != user_id: - raise exceptions.ItemOwnershipException("You can only access your own credentials.") - user_cred_alias = aliased(UserCredentials) - group_alias = aliased(CredentialsGroup) - stmt = ( - select(user_cred_alias, group_alias) - .join(group_alias, group_alias.user_credentials_id == user_cred_alias.id) - .where(user_cred_alias.user_id == user_id) + ) -> UserCredentialsListResponse: + db_user_credentials = self._credentials_manager.get_user_credentials( + trans, user_id, source_type, source_id, group_name ) - if source_type: - stmt = stmt.where(user_cred_alias.source_type == source_type) - if source_id: - if not source_type: - raise exceptions.RequestParameterInvalidException( - "Source type is required when source ID is provided.", type="error" - ) - stmt = stmt.where(user_cred_alias.source_id == source_id) - if group_name: - if not source_type or not source_id: - raise exceptions.RequestParameterInvalidException( - "Source type and source ID are required when group name is provided.", type="error" - ) - stmt = stmt.where(group_alias.name == group_name) - - if reference: - stmt = stmt.where(user_cred_alias.reference == reference) - - if user_credentials_id: - stmt = stmt.where(user_cred_alias.id == user_credentials_id) - - if group_id: - stmt = stmt.where(group_alias.id == group_id) - - result = trans.sa_session.execute(stmt).all() - return [(row[0], row[1]) for row in result] - - def _credentials( - self, - trans: ProvidesUserContext, - group_id: DecodedDatabaseIdField, - ) -> Tuple[List[Variable], List[Secret]]: - variables_stmt = select(Variable).where(Variable.user_credential_group_id == group_id) - secrets_stmt = select(Secret).where(Secret.user_credential_group_id == group_id) - - variables_result = list(trans.sa_session.execute(variables_stmt).scalars().all()) - secrets_result = list(trans.sa_session.execute(secrets_stmt).scalars().all()) + credentials_dict = self._map_user_credentials(db_user_credentials) + for user_credentials, credentials_group in db_user_credentials: + variables, secrets = self._credentials_manager.fetch_credentials(trans.sa_session, credentials_group.id) + group = credentials_dict[user_credentials.id]["groups"].get(credentials_group.name, {}) + group["variables"].extend( + {"id": variable.id, "name": variable.name, "value": variable.value} for variable in variables + ) + group["secrets"].extend( + {"id": secret.id, "name": secret.name, "already_set": secret.already_set} for secret in secrets + ) - return variables_result, secrets_result + return UserCredentialsListResponse(root=[UserCredentialsResponse(**cred) for cred in credentials_dict.values()]) - def _user_credentials_to_dict( + def _map_user_credentials( self, db_user_credentials: List[Tuple[UserCredentials, CredentialsGroup]], ) -> Dict[int, Dict[str, Any]]: - grouped_data: Dict[int, Dict[str, Any]] = {} + user_credentials_dict: Dict[int, Dict[str, Any]] = {} group_name = {group.id: group.name for _, group in db_user_credentials} for user_credentials, credentials_group in db_user_credentials: - grouped_data.setdefault( - user_credentials.id, - dict( - user_id=user_credentials.user_id, - id=user_credentials.id, - reference=user_credentials.reference, - source_type=user_credentials.source_type, - source_id=user_credentials.source_id, - current_group_id=user_credentials.current_group_id, - current_group_name=group_name[user_credentials.current_group_id], - groups={}, - ), + cred_id = user_credentials.id + user_credentials_dict.setdefault( + cred_id, + { + "user_id": user_credentials.user_id, + "id": cred_id, + "reference": user_credentials.reference, + "source_type": user_credentials.source_type, + "source_id": user_credentials.source_id, + "current_group_id": user_credentials.current_group_id, + "current_group_name": group_name[user_credentials.current_group_id], + "groups": {}, + }, ) - grouped_data[user_credentials.id]["groups"].setdefault( + user_credentials_dict[cred_id]["groups"].setdefault( credentials_group.name, - dict( - id=credentials_group.id, - name=credentials_group.name, - variables=[], - secrets=[], - ), - ) - - return grouped_data - - def _add_credential_to_group( - self, - group: Dict[str, Any], - variables: List[Variable], - secrets: List[Secret], - ) -> None: - for variable in variables: - group["variables"].append({"id": variable.id, "name": variable.name, "value": variable.value}) - for secret in secrets: - group["secrets"].append({"id": secret.id, "name": secret.name, "already_set": secret.already_set}) - - def _create_user_credential( - self, - trans: ProvidesUserContext, - user_id: FlexibleUserIdType, - payload: CreateSourceCredentialsPayload, - ) -> UserCredentialsListResponse: - session = trans.sa_session - - source_type, source_id = payload.source_type, payload.source_id - - db_user_credentials = self._user_credentials( - trans, - user_id=user_id, - source_type=source_type, - source_id=source_id, - ) - credentials_dict = self._user_credentials_to_dict(db_user_credentials) - existing_groups = { - cred["reference"]: {group["name"]: group["id"] for group in cred["groups"].values()} - for cred in credentials_dict.values() - } - - for service_payload in payload.credentials: - reference = service_payload.reference - current_group_name = service_payload.current_group - current_group_id = existing_groups.get(reference, {}).get(current_group_name) - - user_credentials = next((uc[0] for uc in db_user_credentials if uc[0].reference == reference), None) - if not user_credentials: - if user_id == "current": - user_id = trans.user.id - user_credentials = UserCredentials( - user_id=user_id, - reference=reference, - source_type=source_type, - source_id=source_id, - ) - session.add(user_credentials) - session.flush() - user_credentials_id = user_credentials.id - - for group in service_payload.groups: - group_name = group.name - credentials_group = next( - (uc[1] for uc in db_user_credentials if uc[1].name == group_name and uc[0].reference == reference), - None, - ) - if not credentials_group: - credentials_group = CredentialsGroup(name=group_name, user_credentials_id=user_credentials_id) - session.add(credentials_group) - session.flush() - user_credential_group_id = credentials_group.id - - if current_group_name == group_name: - current_group_id = user_credential_group_id - - variables, secrets = self._credentials(trans, group_id=user_credential_group_id) - user_vault = UserVaultWrapper(self._app.vault, trans.user) - for variable_payload in group.variables: - variable_name, variable_value = variable_payload.name, variable_payload.value - if variable_value is None: - continue - variable = next( - (var for var in variables if var.name == variable_name), - None, - ) - if variable: - variable.value = variable_value - else: - variable = Variable( - user_credential_group_id=user_credential_group_id, - name=variable_name, - value=variable_value, - ) - session.add(variable) - for secret_payload in group.secrets: - secret_name, secret_value = secret_payload.name, secret_payload.value - if secret_value is None: - continue - secret = next( - (sec for sec in secrets if sec.name == secret_name), - None, - ) - if secret: - secret.already_set = True if secret_value else False - else: - secret = Secret( - user_credential_group_id=user_credential_group_id, - name=secret_name, - already_set=True if secret_value else False, - ) - session.add(secret) - vault_ref = f"{source_type}|{source_id}|{reference}|{group_name}|{secret_name}" - user_vault.write_secret(vault_ref, secret_value) - if not current_group_id: - raise exceptions.RequestParameterInvalidException( - "No group was selected as the current group.", type="error" - ) - user_credentials.current_group_id = current_group_id - session.add(user_credentials) - - with transaction(session): - session.commit() - - new_user_credentials = self._user_credentials( - trans, - user_id=user_id, - source_type=source_type, - source_id=source_id, - ) - credentials_dict = self._user_credentials_to_dict(new_user_credentials) - for new_user_credentials_list, new_credentials_group in new_user_credentials: - variables, secrets = self._credentials(trans, group_id=new_credentials_group.id) - db_group = ( - credentials_dict.get(new_user_credentials_list.id, {}) - .get("groups", {}) - .get(new_credentials_group.name, {}) + { + "id": credentials_group.id, + "name": credentials_group.name, + "variables": [], + "secrets": [], + }, ) - self._add_credential_to_group(db_group, variables, secrets) - return UserCredentialsListResponse(root=[UserCredentialsResponse(**cred) for cred in credentials_dict.values()]) - def _delete_credentials(self, sa_session: galaxy_scoped_session, rows_to_be_deleted: List): - for row in rows_to_be_deleted: - sa_session.delete(row) - with transaction(sa_session): - sa_session.commit() + return user_credentials_dict From e1777e1e51986325e71c0a58c4627388174345be Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 15 Jan 2025 15:26:05 +0100 Subject: [PATCH 075/116] Fix parameter naming in delete_credentials method calls for clarity --- lib/galaxy/webapps/galaxy/api/credentials.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/webapps/galaxy/api/credentials.py b/lib/galaxy/webapps/galaxy/api/credentials.py index 203e61befb25..dd5afa86543c 100644 --- a/lib/galaxy/webapps/galaxy/api/credentials.py +++ b/lib/galaxy/webapps/galaxy/api/credentials.py @@ -80,7 +80,7 @@ def delete_service_credentials( user_credentials_id: DecodedDatabaseIdField, trans: ProvidesUserContext = DependsOnTrans, ): - self.service.delete_credentials(trans, user_id, user_credentials_id) + self.service.delete_credentials(trans, user_id, user_credentials_id=user_credentials_id) return Response(status_code=status.HTTP_204_NO_CONTENT) @router.delete( @@ -94,5 +94,5 @@ def delete_credentials( group_id: DecodedDatabaseIdField, trans: ProvidesUserContext = DependsOnTrans, ): - self.service.delete_credentials(trans, user_id, group_id) + self.service.delete_credentials(trans, user_id, group_id=group_id) return Response(status_code=status.HTTP_204_NO_CONTENT) From 61c5aa67dfbfda375864b4f3ccbe5d3496571166 Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 15 Jan 2025 15:39:22 +0100 Subject: [PATCH 076/116] Remove group_name parameter from user credentials API and related methods for simplification --- client/src/api/schema/schema.ts | 2 -- lib/galaxy/managers/credentials.py | 3 --- lib/galaxy/webapps/galaxy/api/credentials.py | 6 +----- lib/galaxy/webapps/galaxy/services/credentials.py | 12 ++++-------- 4 files changed, 5 insertions(+), 18 deletions(-) diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 8c7f7e5151fc..2758bf2c4dff 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -33976,8 +33976,6 @@ export interface operations { source_type?: "tool" | null; /** @description The ID of the source to filter by. */ source_id?: string | null; - /** @description The name of the group to filter by. */ - group_name?: string | null; }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ diff --git a/lib/galaxy/managers/credentials.py b/lib/galaxy/managers/credentials.py index 45e25ef31348..782b20cf519b 100644 --- a/lib/galaxy/managers/credentials.py +++ b/lib/galaxy/managers/credentials.py @@ -45,7 +45,6 @@ def get_user_credentials( user_id: FlexibleUserIdType, source_type: Optional[SOURCE_TYPE] = None, source_id: Optional[str] = None, - group_name: Optional[str] = None, user_credentials_id: Optional[DecodedDatabaseIdField] = None, group_id: Optional[DecodedDatabaseIdField] = None, ) -> List[Tuple[UserCredentials, CredentialsGroup]]: @@ -67,8 +66,6 @@ def get_user_credentials( if not source_type: raise RequestParameterInvalidException("Source type is required when source ID is provided.") stmt = stmt.where(user_cred_alias.source_id == source_id) - if group_name: - stmt = stmt.where(group_alias.name == group_name) if user_credentials_id: stmt = stmt.where(user_cred_alias.id == user_credentials_id) if group_id: diff --git a/lib/galaxy/webapps/galaxy/api/credentials.py b/lib/galaxy/webapps/galaxy/api/credentials.py index dd5afa86543c..b88df2681360 100644 --- a/lib/galaxy/webapps/galaxy/api/credentials.py +++ b/lib/galaxy/webapps/galaxy/api/credentials.py @@ -51,12 +51,8 @@ def list_user_credentials( None, description="The ID of the source to filter by.", ), - group_name: Optional[str] = Query( - None, - description="The name of the group to filter by.", - ), ) -> UserCredentialsListResponse: - return self.service.list_user_credentials(trans, user_id, source_type, source_id, group_name) + return self.service.list_user_credentials(trans, user_id, source_type, source_id) @router.post( "/api/users/{user_id}/credentials", diff --git a/lib/galaxy/webapps/galaxy/services/credentials.py b/lib/galaxy/webapps/galaxy/services/credentials.py index 4123c3abc9a0..52afaaa0de82 100644 --- a/lib/galaxy/webapps/galaxy/services/credentials.py +++ b/lib/galaxy/webapps/galaxy/services/credentials.py @@ -41,10 +41,9 @@ def list_user_credentials( user_id: FlexibleUserIdType, source_type: Optional[SOURCE_TYPE] = None, source_id: Optional[str] = None, - group_name: Optional[str] = None, ) -> UserCredentialsListResponse: """Lists all credentials the user has provided (credentials themselves are not included).""" - return self._list_user_credentials(trans, user_id, source_type, source_id, group_name) + return self._list_user_credentials(trans, user_id, source_type, source_id) def provide_credential( self, @@ -63,12 +62,12 @@ def delete_credentials( self, trans: ProvidesUserContext, user_id: FlexibleUserIdType, - group_id: Optional[DecodedDatabaseIdField] = None, user_credentials_id: Optional[DecodedDatabaseIdField] = None, + group_id: Optional[DecodedDatabaseIdField] = None, ) -> None: """Deletes a specific credential group or all credentials for a specific service.""" db_user_credentials = self._credentials_manager.get_user_credentials( - trans, user_id, group_id=group_id, user_credentials_id=user_credentials_id + trans, user_id, user_credentials_id=user_credentials_id, group_id=group_id ) if not db_user_credentials: raise ObjectNotFound("No credentials found.") @@ -86,11 +85,8 @@ def _list_user_credentials( user_id: FlexibleUserIdType, source_type: Optional[SOURCE_TYPE] = None, source_id: Optional[str] = None, - group_name: Optional[str] = None, ) -> UserCredentialsListResponse: - db_user_credentials = self._credentials_manager.get_user_credentials( - trans, user_id, source_type, source_id, group_name - ) + db_user_credentials = self._credentials_manager.get_user_credentials(trans, user_id, source_type, source_id) credentials_dict = self._map_user_credentials(db_user_credentials) for user_credentials, credentials_group in db_user_credentials: variables, secrets = self._credentials_manager.fetch_credentials(trans.sa_session, credentials_group.id) From 8604232e8b288c9efc77a9220078bad52c99da78 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Wed, 15 Jan 2025 13:00:48 +0100 Subject: [PATCH 077/116] Enhance credential management interface --- .../Credentials/ManageToolCredentials.vue | 3 +- .../User/Credentials/ServiceCredentials.vue | 148 +++++++++--------- 2 files changed, 79 insertions(+), 72 deletions(-) diff --git a/client/src/components/User/Credentials/ManageToolCredentials.vue b/client/src/components/User/Credentials/ManageToolCredentials.vue index 2e896305fbbf..0eb4c70c4039 100644 --- a/client/src/components/User/Credentials/ManageToolCredentials.vue +++ b/client/src/components/User/Credentials/ManageToolCredentials.vue @@ -124,13 +124,14 @@ function getServiceCredentialsDefinition(reference: string): ServiceCredentialsD

Here you can manage your credentials for the tool {{ toolId }} version {{ toolVersion }}. + >. After you make any changes, don't forget to use the Save Credentials button to save them.

diff --git a/client/src/components/User/Credentials/ServiceCredentials.vue b/client/src/components/User/Credentials/ServiceCredentials.vue index 31130c877437..25e662bfcae9 100644 --- a/client/src/components/User/Credentials/ServiceCredentials.vue +++ b/client/src/components/User/Credentials/ServiceCredentials.vue @@ -96,85 +96,91 @@ function getVariableDescription(name: string, type: CredentialType): string | un From 167912fe9ae337b7b67cc2cf6a0538eac0b7a37f Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Wed, 15 Jan 2025 16:10:18 +0100 Subject: [PATCH 078/116] Add functionality to delete credential groups in UI --- .../src/components/Tool/ToolCredentials.vue | 14 ++++++ .../Credentials/ManageToolCredentials.vue | 10 +++++ .../User/Credentials/ServiceCredentials.vue | 43 +++++++++++++++++-- client/src/stores/userCredentials.ts | 38 ++++++++++++++++ 4 files changed, 102 insertions(+), 3 deletions(-) diff --git a/client/src/components/Tool/ToolCredentials.vue b/client/src/components/Tool/ToolCredentials.vue index b19cb56ed689..68e425894693 100644 --- a/client/src/components/Tool/ToolCredentials.vue +++ b/client/src/components/Tool/ToolCredentials.vue @@ -143,6 +143,19 @@ async function onSavedCredentials(providedCredentials: CreateSourceCredentialsPa } } +async function onDeleteCredentialsGroup(reference: string, groupName: string) { + busyMessage.value = "Updating your credentials..."; + isBusy.value = true; + try { + userCredentialsStore.deleteCredentialsGroupForTool(props.toolId, reference, groupName); + } catch (error) { + // TODO: Implement error handling. + console.error("Error deleting user credentials group", error); + } finally { + isBusy.value = false; + } +} + checkUserCredentials(); @@ -197,6 +210,7 @@ checkUserCredentials(); :tool-version="props.toolVersion" :tool-credentials-definition="credentialsDefinition" :user-tool-credentials="userCredentials" + @delete-credentials-group="onDeleteCredentialsGroup" @save-credentials="onSavedCredentials" /> diff --git a/client/src/components/User/Credentials/ManageToolCredentials.vue b/client/src/components/User/Credentials/ManageToolCredentials.vue index 0eb4c70c4039..51b1c639e2b7 100644 --- a/client/src/components/User/Credentials/ManageToolCredentials.vue +++ b/client/src/components/User/Credentials/ManageToolCredentials.vue @@ -28,6 +28,7 @@ const providedCredentials = ref(initializeCreden const emit = defineEmits<{ (e: "save-credentials", credentials: CreateSourceCredentialsPayload): void; + (e: "delete-credentials-group", reference: string, groupName: string): void; }>(); function saveCredentials() { @@ -103,6 +104,14 @@ function onNewCredentialsSet(credential: ServiceCredentialPayload, newSet: Servi } } +function onDeleteCredentialsGroup(reference: string, groupName: string) { + const credentialFound = providedCredentials.value.credentials.find((c) => c.reference === reference); + if (credentialFound) { + credentialFound.groups = credentialFound.groups.filter((g) => g.name !== groupName); + emit("delete-credentials-group", reference, groupName); + } +} + function onCurrentSetChange(credential: ServiceCredentialPayload, newSet: ServiceGroupPayload) { const credentialFound = providedCredentials.value.credentials.find((c) => c.reference === credential.reference); if (credentialFound) { @@ -133,6 +142,7 @@ function getServiceCredentialsDefinition(reference: string): ServiceCredentialsD :credential-payload="credential" class="mb-2" @new-credentials-set="onNewCredentialsSet" + @delete-credentials-group="onDeleteCredentialsGroup" @update-current-set="onCurrentSetChange" /> diff --git a/client/src/components/User/Credentials/ServiceCredentials.vue b/client/src/components/User/Credentials/ServiceCredentials.vue index 25e662bfcae9..6528d8becebf 100644 --- a/client/src/components/User/Credentials/ServiceCredentials.vue +++ b/client/src/components/User/Credentials/ServiceCredentials.vue @@ -1,4 +1,6 @@