diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 1ec1d6fc39d1..cfe6bcef8cee 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -4713,6 +4713,58 @@ export interface paths { patch?: never; trace?: never; }; + "/api/users/{user_id}/credentials": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Lists all credentials the user has provided */ + get: operations["list_user_credentials_api_users__user_id__credentials_get"]; + put?: never; + /** Allows users to provide credentials for a secret/variable */ + post: operations["provide_credential_api_users__user_id__credentials_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/{user_id}/credentials/{user_credentials_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Deletes all credentials for a specific service */ + delete: operations["delete_service_credentials_api_users__user_id__credentials__user_credentials_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/{user_id}/credentials/{user_credentials_id}/{group_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Deletes a specific credential */ + delete: operations["delete_credentials_api_users__user_id__credentials__user_credentials_id___group_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/users/{user_id}/custom_builds": { parameters: { query?: never; @@ -7309,6 +7361,20 @@ export interface components { */ url: string; }; + /** CreateSourceCredentialsPayload */ + CreateSourceCredentialsPayload: { + /** Credentials */ + credentials: components["schemas"]["ServiceCredentialPayload"][]; + /** Source Id */ + source_id: string; + /** + * Source Type + * @constant + */ + source_type: "tool"; + /** Source Version */ + source_version: string; + }; /** * CreateType * @enum {string} @@ -7404,6 +7470,59 @@ export interface components { */ username: string; }; + /** CredentialDefinitionResponse */ + CredentialDefinitionResponse: { + /** Description */ + description: string; + /** Label */ + label: string; + /** Name */ + name: string; + /** Optional */ + optional: boolean; + }; + /** CredentialDefinitionsResponse */ + CredentialDefinitionsResponse: { + /** Secrets */ + secrets: components["schemas"]["CredentialDefinitionResponse"][]; + /** Variables */ + variables: components["schemas"]["CredentialDefinitionResponse"][]; + }; + /** CredentialGroupResponse */ + CredentialGroupResponse: { + /** + * Id + * @example 0123456789ABCDEF + */ + id: string; + /** Name */ + name: string; + /** Secrets */ + secrets: components["schemas"]["CredentialResponse"][]; + /** Variables */ + variables: components["schemas"]["CredentialResponse"][]; + }; + /** CredentialPayload */ + CredentialPayload: { + /** Name */ + name: string; + /** Value */ + value: string | null; + }; + /** CredentialResponse */ + CredentialResponse: { + /** + * Id + * @example 0123456789ABCDEF + */ + id: string; + /** Is Set */ + is_set: boolean; + /** Name */ + name: string; + /** Value */ + value: string | null; + }; /** CustomArchivedHistoryView */ CustomArchivedHistoryView: { /** @@ -15838,6 +15957,29 @@ export interface components { */ version: string; }; + /** ServiceCredentialPayload */ + ServiceCredentialPayload: { + /** + * Current Group + * @default default + */ + current_group: string | null; + /** Groups */ + groups: components["schemas"]["ServiceGroupPayload"][]; + /** Name */ + name: string; + /** Version */ + version: string; + }; + /** ServiceGroupPayload */ + ServiceGroupPayload: { + /** Name */ + name: string; + /** Secrets */ + secrets: components["schemas"]["CredentialPayload"][]; + /** Variables */ + variables: components["schemas"]["CredentialPayload"][]; + }; /** ServiceType */ ServiceType: { /** @@ -17553,6 +17695,45 @@ export interface components { */ username: string; }; + /** UserCredentialsListResponse */ + UserCredentialsListResponse: components["schemas"]["UserCredentialsResponse"][]; + /** UserCredentialsResponse */ + UserCredentialsResponse: { + credential_definitions: components["schemas"]["CredentialDefinitionsResponse"]; + /** Current Group Name */ + current_group_name: string; + /** Description */ + description: string; + /** Groups */ + groups: { + [key: string]: components["schemas"]["CredentialGroupResponse"]; + }; + /** + * Id + * @example 0123456789ABCDEF + */ + id: string; + /** Label */ + label: string; + /** Name */ + name: string; + /** Source Id */ + source_id: string; + /** + * Source Type + * @constant + */ + source_type: "tool"; + /** Source Version */ + source_version: string; + /** + * User Id + * @example 0123456789ABCDEF + */ + user_id: string; + /** Version */ + version: string; + }; /** UserDeletionPayload */ UserDeletionPayload: { /** @@ -33809,6 +33990,192 @@ export interface operations { }; }; }; + list_user_credentials_api_users__user_id__credentials_get: { + parameters: { + query?: { + /** @description The type of source to filter by. */ + source_type?: "tool" | null; + /** @description The ID of the source to filter by. */ + source_id?: string | null; + /** @description The version of the source to filter by. By default it is the latest version. */ + source_version?: 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. */ + "run-as"?: string | null; + }; + path: { + user_id: string | "current"; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserCredentialsListResponse"]; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + provide_credential_api_users__user_id__credentials_post: { + parameters: { + query?: never; + 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. */ + "run-as"?: string | null; + }; + path: { + user_id: string | "current"; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateSourceCredentialsPayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserCredentialsListResponse"]; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + delete_service_credentials_api_users__user_id__credentials__user_credentials_id__delete: { + parameters: { + query?: never; + 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. */ + "run-as"?: string | null; + }; + path: { + user_id: string | "current"; + user_credentials_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + delete_credentials_api_users__user_id__credentials__user_credentials_id___group_id__delete: { + parameters: { + query?: never; + 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. */ + "run-as"?: string | null; + }; + path: { + user_id: string | "current"; + user_credentials_id: string; + group_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; get_custom_builds_api_users__user_id__custom_builds_get: { parameters: { query?: never; diff --git a/client/src/api/users.ts b/client/src/api/users.ts index a6086985d7a9..c22ee98d1896 100644 --- a/client/src/api/users.ts +++ b/client/src/api/users.ts @@ -1,4 +1,4 @@ -import { GalaxyApi } from "@/api"; +import { type components, GalaxyApi } from "@/api"; import { toQuotaUsage } from "@/components/User/DiskUsage/Quota/model"; import { rethrowSimple } from "@/utils/simple-error"; @@ -35,3 +35,55 @@ export async function fetchCurrentUserQuotaSourceUsage(quotaSourceLabel?: string return toQuotaUsage(data); } + +export type CreateSourceCredentialsPayload = components["schemas"]["CreateSourceCredentialsPayload"]; +export type ServiceCredentialPayload = components["schemas"]["ServiceCredentialPayload"]; +export type ServiceGroupPayload = components["schemas"]["ServiceGroupPayload"]; +export type UserCredentials = components["schemas"]["UserCredentialsResponse"]; +export type ServiceVariableDefinition = components["schemas"]["CredentialDefinitionResponse"]; + +export function transformToSourceCredentials( + toolId: string, + toolCredentialsDefinition: ServiceCredentialsDefinition[] +): SourceCredentialsDefinition { + const services = new Map( + toolCredentialsDefinition.map((service) => [getKeyFromCredentialsIdentifier(service), service]) + ); + return { + sourceType: "tool", + sourceId: toolId, + services, + }; +} + +export interface ServiceCredentialsIdentifier { + name: string; + version: string; +} + +export function getKeyFromCredentialsIdentifier(credentialsIdentifier: ServiceCredentialsIdentifier): string { + return `${credentialsIdentifier.name}-${credentialsIdentifier.version}`; +} + +/** + * Represents the definition of credentials for a particular service. + */ +export interface ServiceCredentialsDefinition extends ServiceCredentialsIdentifier { + label?: string; + description?: string; + secrets: ServiceVariableDefinition[]; + variables: ServiceVariableDefinition[]; +} + +/** + * Represents the definition of credentials for a particular source. + * A source can be a tool, a workflow, etc.Base interface for credentials definitions. + * A source may accept multiple services, each with its own credentials. + * + * The `services` map is indexed by the service name and version using the `getKeyFromCredentialsIdentifier` function. + */ +export interface SourceCredentialsDefinition { + sourceType: string; + sourceId: string; + services: Map; +} diff --git a/client/src/components/Tool/ToolCard.vue b/client/src/components/Tool/ToolCard.vue index f76955803d2b..a09887a58ee3 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..445101e7f751 --- /dev/null +++ b/client/src/components/Tool/ToolCredentials.vue @@ -0,0 +1,249 @@ + + + + + diff --git a/client/src/components/User/Credentials/ManageToolCredentials.vue b/client/src/components/User/Credentials/ManageToolCredentials.vue new file mode 100644 index 000000000000..b2b6aafd2512 --- /dev/null +++ b/client/src/components/User/Credentials/ManageToolCredentials.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/client/src/components/User/Credentials/ServiceCredentials.vue b/client/src/components/User/Credentials/ServiceCredentials.vue new file mode 100644 index 000000000000..041dfd98db02 --- /dev/null +++ b/client/src/components/User/Credentials/ServiceCredentials.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/client/src/stores/userCredentials.ts b/client/src/stores/userCredentials.ts new file mode 100644 index 000000000000..d8d3effc72ab --- /dev/null +++ b/client/src/stores/userCredentials.ts @@ -0,0 +1,138 @@ +import { ref, set } from "vue"; + +import { GalaxyApi } from "@/api"; +import type { CreateSourceCredentialsPayload, ServiceCredentialsIdentifier, UserCredentials } from "@/api/users"; + +import { defineScopedStore } from "./scopedStore"; + +export const SECRET_PLACEHOLDER = "********"; + +export const useUserCredentialsStore = defineScopedStore("userCredentialsStore", (currentUserId: string) => { + const userCredentialsForTools = ref>({}); + + function getKey(toolId: string): string { + const userId = ensureUserIsRegistered(); + return `${userId}-${toolId}`; + } + + function getAllUserCredentialsForTool(toolId: string): UserCredentials[] | undefined { + ensureUserIsRegistered(); + return userCredentialsForTools.value[toolId]; + } + + async function fetchAllUserCredentialsForTool(toolId: string): Promise { + const userId = ensureUserIsRegistered(); + + const { data, error } = await GalaxyApi().GET("/api/users/{user_id}/credentials", { + params: { + path: { user_id: userId }, + query: { + source_type: "tool", + source_id: toolId, + }, + }, + }); + + if (error) { + throw Error(`Failed to fetch user credentials for tool ${toolId}: ${error.err_msg}`); + } + + const key = getKey(toolId); + set(userCredentialsForTools.value, key, data); + return data; + } + + async function saveUserCredentialsForTool( + providedCredentials: CreateSourceCredentialsPayload + ): Promise { + const userId = ensureUserIsRegistered(); + const toolId = providedCredentials.source_id; + + removeSecretPlaceholders(providedCredentials); + + const { data, error } = await GalaxyApi().POST("/api/users/{user_id}/credentials", { + params: { + path: { user_id: userId }, + }, + body: providedCredentials, + }); + + if (error) { + throw Error(`Failed to save user credentials for tool ${toolId}: ${error.err_msg}`); + } + + const key = getKey(toolId); + set(userCredentialsForTools.value, key, data); + return data; + } + + async function deleteCredentialsGroupForTool( + toolId: string, + serviceIdentifier: ServiceCredentialsIdentifier, + groupName: string + ): Promise { + const userId = ensureUserIsRegistered(); + const key = getKey(toolId); + const credentials = userCredentialsForTools.value[key]; + + if (credentials) { + const serviceCredentials = credentials.find( + (credential) => + credential.name === serviceIdentifier.name && credential.version === serviceIdentifier.version + ); + if (!serviceCredentials) { + throw new Error(`No credentials found for service reference ${serviceIdentifier}`); + } + const group = serviceCredentials.groups[groupName]; + if (!group) { + // Group does not exist, nothing to delete + return; + } + const { error } = await GalaxyApi().DELETE( + "/api/users/{user_id}/credentials/{user_credentials_id}/{group_id}", + { + params: { + path: { user_id: userId, user_credentials_id: serviceCredentials.id, group_id: group.id }, + }, + } + ); + + if (error) { + throw Error(`Failed to delete user credentials group for tool ${toolId}: ${error.err_msg}`); + } + // Remove the group from the credentials + const updatedCredentials = credentials.map((credential) => { + if (credential.name === serviceIdentifier.name && credential.version === serviceIdentifier.version) { + delete credential.groups[groupName]; + } + }); + set(userCredentialsForTools.value, key, updatedCredentials); + } + } + + function ensureUserIsRegistered(): string { + if (currentUserId === "anonymous") { + throw new Error("Only registered users can have tool credentials"); + } + return currentUserId; + } + + function removeSecretPlaceholders(providedCredentials: CreateSourceCredentialsPayload) { + providedCredentials.credentials.forEach((credential) => { + credential.groups.forEach((group) => { + group.secrets.forEach((secret) => { + if (secret.value === SECRET_PLACEHOLDER) { + secret.value = null; + } + }); + }); + }); + } + + return { + getAllUserCredentialsForTool, + fetchAllUserCredentialsForTool, + saveUserCredentialsForTool, + deleteCredentialsGroupForTool, + }; +}); diff --git a/lib/galaxy/app_unittest_utils/galaxy_mock.py b/lib/galaxy/app_unittest_utils/galaxy_mock.py index 7a819334d575..d74e005079b3 100644 --- a/lib/galaxy/app_unittest_utils/galaxy_mock.py +++ b/lib/galaxy/app_unittest_utils/galaxy_mock.py @@ -111,6 +111,7 @@ class MockApp(di.Container, GalaxyDataTestApp): workflow_manager: WorkflowsManager history_manager: HistoryManager job_metrics: JobMetrics + vault: Optional[Vault] = None stop: bool is_webapp: bool = True diff --git a/lib/galaxy/managers/credentials.py b/lib/galaxy/managers/credentials.py new file mode 100644 index 000000000000..298d177a2647 --- /dev/null +++ b/lib/galaxy/managers/credentials.py @@ -0,0 +1,181 @@ +from typing import ( + List, + Optional, + Tuple, + Union, +) + +from sqlalchemy import select +from sqlalchemy.orm import aliased + +from galaxy.exceptions import RequestParameterInvalidException +from galaxy.model import ( + Credential, + CredentialsGroup, + UserCredentials, +) +from galaxy.model.scoped_session import galaxy_scoped_session +from galaxy.schema.credentials import SOURCE_TYPE +from galaxy.schema.fields import DecodedDatabaseIdField + +CredentialsModelsList = List[Union[UserCredentials, CredentialsGroup, Credential]] + + +class CredentialsManager: + """Manager object shared by controllers for interacting with credentials.""" + + def __init__(self, session: galaxy_scoped_session) -> None: + self.session = session + + def get_user_credentials( + self, + user_id: DecodedDatabaseIdField, + source_type: Optional[SOURCE_TYPE] = None, + source_id: Optional[str] = None, + source_version: Optional[str] = None, + user_credentials_id: Optional[DecodedDatabaseIdField] = None, + group_id: Optional[DecodedDatabaseIdField] = None, + ) -> List[Tuple[UserCredentials, CredentialsGroup, Credential]]: + if source_id and not source_type: + raise RequestParameterInvalidException("Source type is required when source ID is provided.") + + user_cred_alias, group_alias, cred_alias = ( + aliased(UserCredentials), + aliased(CredentialsGroup), + aliased(Credential), + ) + stmt = ( + select(user_cred_alias, group_alias, cred_alias) + .join(group_alias, group_alias.user_credentials_id == user_cred_alias.id) + .outerjoin(cred_alias, cred_alias.group_id == group_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: + stmt = stmt.where(user_cred_alias.source_id == source_id) + if source_version: + stmt = stmt.where(user_cred_alias.source_version == source_version) + 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 = self.session.execute(stmt).tuples().all() + return list(result) + + def add_user_credentials( + self, + existing_user_credentials: List[Tuple[UserCredentials, CredentialsGroup, Credential]], + user_id: DecodedDatabaseIdField, + source_type: SOURCE_TYPE, + source_id: str, + source_version: str, + name: str, + version: str, + ) -> DecodedDatabaseIdField: + user_credentials = next( + ( + uc + for uc, *_ in existing_user_credentials + if uc.user_id == user_id + and uc.source_type == source_type + and uc.source_id == source_id + and uc.source_version == source_version + and uc.name == name + and uc.version == version + ), + None, + ) + + if not user_credentials: + user_credentials = UserCredentials( + user_id=user_id, + source_type=source_type, + source_id=source_id, + source_version=source_version, + name=name, + version=version, + ) + self.session.add(user_credentials) + self.session.flush() + + return user_credentials.id + + def add_group( + self, + existing_user_credentials: List[Tuple[UserCredentials, CredentialsGroup, Credential]], + user_credentials_id: DecodedDatabaseIdField, + group_name: str, + ) -> DecodedDatabaseIdField: + credentials_group = next( + ( + creds_group + for _, creds_group, _ in existing_user_credentials + if creds_group.name == group_name and creds_group.user_credentials_id == user_credentials_id + ), + None, + ) + + if not credentials_group: + credentials_group = CredentialsGroup(name=group_name, user_credentials_id=user_credentials_id) + self.session.add(credentials_group) + self.session.flush() + + return credentials_group.id + + def add_or_update_credential( + self, + existing_user_credentials: List[Tuple[UserCredentials, CredentialsGroup, Credential]], + group_id: DecodedDatabaseIdField, + name: str, + value: Optional[str], + is_secret: bool = False, + ) -> None: + credential = next( + ( + cred + for *_, cred in existing_user_credentials + if cred and cred.name == name and cred.group_id == group_id and cred.is_secret == is_secret + ), + None, + ) + if credential: + if value is not None: + credential.is_set = True + if not is_secret: + credential.value = value + self.session.add(credential) + else: + credential = Credential( + group_id=group_id, + name=name, + is_secret=is_secret, + is_set=bool(value), + value=value if not is_secret else None, + ) + self.session.add(credential) + + def update_current_group( + self, + user_id: DecodedDatabaseIdField, + user_credentials_id: DecodedDatabaseIdField, + group_name: Optional[str] = None, + ) -> None: + group_name = group_name or "default" + existing_user_credentials = self.get_user_credentials(user_id, user_credentials_id=user_credentials_id) + for user_credentials, credentials_group, _ in existing_user_credentials: + if credentials_group.name == group_name: + user_credentials.current_group_id = credentials_group.id + self.session.add(user_credentials) + break + else: + raise RequestParameterInvalidException("Group not found to set as current.") + + def delete_rows( + self, + rows_to_delete: CredentialsModelsList, + ) -> None: + for row in rows_to_delete: + self.session.delete(row) + self.session.commit() diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index da20ff941aa6..7721bc6d2170 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -11558,6 +11558,59 @@ def __repr__(self): ) +class UserCredentials(Base): + """ + Represents a credential associated with a user for a specific service. + """ + + __tablename__ = "user_credentials" + __table_args__ = (UniqueConstraint("user_id", "source_type", "source_id", "source_version", "name", "version"),) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("galaxy_user.id"), index=True) + source_type: Mapped[str] = mapped_column() + source_id: Mapped[str] = mapped_column() + source_version: Mapped[str] = mapped_column() + name: Mapped[str] = mapped_column() + version: Mapped[str] = mapped_column() + current_group_id: Mapped[int] = mapped_column( + ForeignKey("credentials_group.id", ondelete="CASCADE"), index=True, nullable=True + ) + create_time: Mapped[datetime] = mapped_column(default=now) + update_time: Mapped[datetime] = mapped_column(default=now, onupdate=now) + + +class CredentialsGroup(Base): + """ + Represents a group of credentials associated with a user for a specific service. + """ + + __tablename__ = "credentials_group" + + id: Mapped[int] = mapped_column(primary_key=True) + user_credentials_id: Mapped[int] = mapped_column(ForeignKey("user_credentials.id", ondelete="CASCADE"), index=True) + name: Mapped[str] = mapped_column() + create_time: Mapped[datetime] = mapped_column(default=now) + update_time: Mapped[datetime] = mapped_column(default=now, onupdate=now) + + +class Credential(Base): + """ + Represents a credential (variable or secret) associated with a user for a specific service. + """ + + __tablename__ = "credential" + + id: Mapped[int] = mapped_column(primary_key=True) + group_id: Mapped[int] = mapped_column(ForeignKey("credentials_group.id", ondelete="CASCADE"), index=True) + name: Mapped[str] = mapped_column() + is_secret: Mapped[bool] = mapped_column(Boolean) + is_set: Mapped[bool] = mapped_column(Boolean) + value: Mapped[Optional[str]] = mapped_column(nullable=True) + create_time: Mapped[datetime] = mapped_column(default=now) + update_time: Mapped[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 diff --git a/lib/galaxy/model/migrations/alembic/versions_gxy/b112afff85da_add_user_credentials_table.py b/lib/galaxy/model/migrations/alembic/versions_gxy/b112afff85da_add_user_credentials_table.py new file mode 100644 index 000000000000..0b88fb0a9c7f --- /dev/null +++ b/lib/galaxy/model/migrations/alembic/versions_gxy/b112afff85da_add_user_credentials_table.py @@ -0,0 +1,103 @@ +"""add user credentials table + +Revision ID: b112afff85da +Revises: a4c3ef999ab5 +Create Date: 2025-02-11 11:08:33.635329 + +""" + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + ForeignKey, + Integer, +) + +from galaxy.model.custom_types import TrimmedString +from galaxy.model.database_object_names import build_foreign_key_name +from galaxy.model.migrations.util import ( + add_column, + create_foreign_key, + create_table, + create_unique_constraint, + drop_constraint, + drop_table, + transaction, +) + +# revision identifiers, used by Alembic. +revision = "b112afff85da" +down_revision = "a4c3ef999ab5" +branch_labels = None +depends_on = None + +user_credentials_table = "user_credentials" +credentials_group_table = "credentials_group" +credential_table = "credential" +current_group_id_column_name = "current_group_id" +current_group_id_fk_name = build_foreign_key_name(user_credentials_table, current_group_id_column_name) +user_credentials_unique_constraint_name = "uq_user_credentials" + + +def upgrade(): + with transaction(): + create_table( + user_credentials_table, + Column("id", Integer, primary_key=True), + Column("user_id", Integer, ForeignKey("galaxy_user.id"), index=True), + Column("source_type", TrimmedString(255)), + Column("source_id", TrimmedString(255)), + Column("source_version", TrimmedString(255)), + Column("name", TrimmedString(255)), + Column("version", TrimmedString(255)), + Column("create_time", DateTime), + Column("update_time", DateTime), + ) + create_unique_constraint( + user_credentials_unique_constraint_name, + user_credentials_table, + ["user_id", "source_type", "source_id", "source_version", "name", "version"], + ) + create_table( + credentials_group_table, + Column("id", Integer, primary_key=True), + Column( + "user_credentials_id", + Integer, + ForeignKey(f"{user_credentials_table}.id", ondelete="CASCADE"), + index=True, + ), + Column("name", TrimmedString(255)), + Column("create_time", DateTime), + Column("update_time", DateTime), + ) + add_column(user_credentials_table, Column(current_group_id_column_name, Integer, index=True, nullable=True)) + create_foreign_key( + current_group_id_fk_name, + user_credentials_table, + credentials_group_table, + [current_group_id_column_name], + ["id"], + ondelete="CASCADE", + ) + create_table( + credential_table, + Column("id", Integer, primary_key=True), + Column("group_id", Integer, ForeignKey(f"{credentials_group_table}.id", ondelete="CASCADE"), index=True), + Column("name", TrimmedString(255)), + Column("is_secret", Boolean), + Column("is_set", Boolean), + Column("value", TrimmedString(255), nullable=True), + Column("create_time", DateTime), + Column("update_time", DateTime), + ) + + +def downgrade(): + with transaction(): + drop_constraint(current_group_id_fk_name, user_credentials_table) + + drop_table(credential_table) + drop_table(credentials_group_table) + drop_table(user_credentials_table) diff --git a/lib/galaxy/schema/credentials.py b/lib/galaxy/schema/credentials.py new file mode 100644 index 000000000000..1aae536c42bf --- /dev/null +++ b/lib/galaxy/schema/credentials.py @@ -0,0 +1,83 @@ +from typing import ( + Dict, + List, + Optional, +) + +from pydantic import RootModel +from typing_extensions import Literal + +from galaxy.schema.fields import EncodedDatabaseIdField +from galaxy.schema.schema import Model + +SOURCE_TYPE = Literal["tool"] + + +class CredentialResponse(Model): + id: EncodedDatabaseIdField + name: str + is_set: bool + value: Optional[str] + + +class CredentialGroupResponse(Model): + id: EncodedDatabaseIdField + name: str + variables: List[CredentialResponse] + secrets: List[CredentialResponse] + + +class CredentialDefinitionResponse(Model): + name: str + label: str + description: str + optional: bool + + +class CredentialDefinitionsResponse(Model): + variables: List[CredentialDefinitionResponse] + secrets: List[CredentialDefinitionResponse] + + +class UserCredentialsResponse(Model): + user_id: EncodedDatabaseIdField + id: EncodedDatabaseIdField + source_type: SOURCE_TYPE + source_id: str + source_version: str + name: str + version: str + label: str + description: str + current_group_name: str + credential_definitions: CredentialDefinitionsResponse + groups: Dict[str, CredentialGroupResponse] + + +class UserCredentialsListResponse(RootModel): + root: List[UserCredentialsResponse] + + +class CredentialPayload(Model): + name: str + value: Optional[str] + + +class ServiceGroupPayload(Model): + name: str + variables: List[CredentialPayload] + secrets: List[CredentialPayload] + + +class ServiceCredentialPayload(Model): + name: str + version: str + current_group: Optional[str] = "default" # The selected group, the one that would be used when running the service + groups: List[ServiceGroupPayload] # All provided groups, including the selected one + + +class CreateSourceCredentialsPayload(Model): + source_type: SOURCE_TYPE + source_id: str + source_version: str + credentials: List[ServiceCredentialPayload] # The credentials to create for each service diff --git a/lib/galaxy/tool_util/cwl/parser.py b/lib/galaxy/tool_util/cwl/parser.py index 30f5da9e47a2..b66f5c7c4338 100644 --- a/lib/galaxy/tool_util/cwl/parser.py +++ b/lib/galaxy/tool_util/cwl/parser.py @@ -92,6 +92,7 @@ "SubworkflowFeatureRequirement", "StepInputExpressionRequirement", "MultipleInputFeatureRequirement", + "CredentialsRequirement", ] @@ -242,6 +243,9 @@ def software_requirements(self) -> List: def resource_requirements(self) -> List: return self.hints_or_requirements_of_class("ResourceRequirement") + def credentials_requirements(self) -> List: + return self.hints_or_requirements_of_class("CredentialsRequirement") + class CommandLineToolProxy(ToolProxy): _class = "CommandLineTool" diff --git a/lib/galaxy/tool_util/deps/mulled/mulled_build_tool.py b/lib/galaxy/tool_util/deps/mulled/mulled_build_tool.py index 6be1a2de4a5e..966a541bb036 100644 --- a/lib/galaxy/tool_util/deps/mulled/mulled_build_tool.py +++ b/lib/galaxy/tool_util/deps/mulled/mulled_build_tool.py @@ -29,7 +29,7 @@ def _mulled_build_tool(tool, args): tool_source = get_tool_source(tool) - requirements, *_ = tool_source.parse_requirements_and_containers() + requirements, *_ = tool_source.parse_requirements() targets = requirements_to_mulled_targets(requirements) kwds = args_to_mull_targets_kwds(args) mull_targets(targets, **kwds) diff --git a/lib/galaxy/tool_util/deps/requirements.py b/lib/galaxy/tool_util/deps/requirements.py index bfb02e0ea606..2f0241caed17 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 @@ -305,27 +306,132 @@ def resource_requirements_from_list(requirements: Iterable[Dict[str, Any]]) -> L return rr +class BaseCredential: + def __init__( + self, + name: str, + inject_as_env: str, + optional: bool = False, + label: str = "", + description: str = "", + ) -> None: + self.name = name + self.inject_as_env = inject_as_env + self.optional = optional + self.label = label + self.description = description + + if not self.name: + raise ValueError("Missing credential (secret/variable) name") + if not self.inject_as_env: + raise ValueError("Missing inject_as_env") + + def to_dict(self) -> Dict[str, Any]: + return { + "name": self.name, + "optional": self.optional, + "label": self.label, + "description": self.description, + } + + +class Secret(BaseCredential): + @classmethod + def from_element(cls, elem) -> "Secret": + return cls( + name=elem.get("name"), + inject_as_env=elem.get("inject_as_env"), + optional=string_as_bool(elem.get("optional", "false")), + label=elem.get("label", ""), + description=elem.get("description", ""), + ) + + +class Variable(BaseCredential): + @classmethod + def from_element(cls, elem) -> "Variable": + return cls( + name=elem.get("name"), + inject_as_env=elem.get("inject_as_env"), + optional=string_as_bool(elem.get("optional", "false")), + label=elem.get("label", ""), + description=elem.get("description", ""), + ) + + +class CredentialsRequirement: + def __init__( + self, + name: str, + version: str, + label: str = "", + description: str = "", + secrets: Optional[List[Secret]] = None, + variables: Optional[List[Variable]] = None, + ) -> None: + self.name = name + self.version = version + self.label = label + self.description = description + self.secrets = secrets if secrets is not None else [] + self.variables = variables if variables is not None else [] + + if not self.name: + raise ValueError("Missing user credentials name") + if not self.version: + raise ValueError("Missing version") + + def to_dict(self) -> Dict[str, Any]: + return { + "name": self.name, + "version": self.version, + "label": self.label, + "description": self.description, + "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"] + version = dict["version"] + label = dict.get("label", "") + description = dict.get("description", "") + 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, + version=version, + label=label, + description=description, + secrets=secrets, + variables=variables, + ) + + 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]]: + 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), + [CredentialsRequirement.from_dict(s) for s in credentials], ) -def parse_requirements_from_xml(xml_root, parse_resources: 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=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=parse_resources) + ... return parse_requirements_from_xml(root, parse_resources_and_credentials=parse_resources_and_credentials) >>> reqs, containers = load_requirements('''bwa''') >>> reqs[0].name 'bwa' @@ -344,8 +450,10 @@ 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: @@ -355,15 +463,13 @@ def parse_requirements_from_xml(xml_root, parse_resources: bool = False): 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_credentials: 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 + 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 @@ -386,3 +492,20 @@ def container_from_element(container_elem) -> ContainerDescription: shell=shell, ) return container + + +def credentials_from_element(credentials_elem) -> CredentialsRequirement: + name = credentials_elem.get("name") + version = credentials_elem.get("version") + label = credentials_elem.get("label", "") + description = credentials_elem.get("description", "") + 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, + version=version, + label=label, + description=description, + secrets=secrets, + variables=variables, + ) diff --git a/lib/galaxy/tool_util/linters/cwl.py b/lib/galaxy/tool_util/linters/cwl.py index c72bd433f0aa..6e761dee2fa5 100644 --- a/lib/galaxy/tool_util/linters/cwl.py +++ b/lib/galaxy/tool_util/linters/cwl.py @@ -63,7 +63,7 @@ def lint(cls, tool_source: "ToolSource", lint_ctx: "LintContext"): class CWLDockerMissing(Linter): @classmethod def lint(cls, tool_source: "ToolSource", lint_ctx: "LintContext"): - _, containers, *_ = tool_source.parse_requirements_and_containers() + _, containers, *_ = tool_source.parse_requirements() if len(containers) == 0: lint_ctx.warn("Tool does not specify a DockerPull source.") @@ -71,7 +71,7 @@ def lint(cls, tool_source: "ToolSource", lint_ctx: "LintContext"): class CWLDockerGood(Linter): @classmethod def lint(cls, tool_source: "ToolSource", lint_ctx: "LintContext"): - _, containers, *_ = tool_source.parse_requirements_and_containers() + _, containers, *_ = tool_source.parse_requirements() if len(containers) > 0: identifier = containers[0].identifier lint_ctx.info(f"Tool will run in Docker image [{identifier}].") diff --git a/lib/galaxy/tool_util/linters/general.py b/lib/galaxy/tool_util/linters/general.py index eb3a98fd3b82..3676dc6a651f 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, *_ = tool_source.parse_requirements() 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, *_ = tool_source.parse_requirements() 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, *_ = tool_source.parse_requirements() 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() + *_, resource_requirements, _ = tool_source.parse_requirements() 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 ec3c925fc116..f2fc9167d211 100644 --- a/lib/galaxy/tool_util/parser/cwl.py +++ b/lib/galaxy/tool_util/parser/cwl.py @@ -165,7 +165,7 @@ def _parse_output(self, tool: Optional["Tool"], output_instance: "OutputInstance output.actions = ToolOutputActionGroup(output, None) return output - def parse_requirements_and_containers(self): + def parse_requirements(self): containers = [] docker_identifier = self.tool_proxy.docker_identifier() if docker_identifier: @@ -173,10 +173,12 @@ def parse_requirements_and_containers(self): software_requirements = self.tool_proxy.software_requirements() resource_requirements = self.tool_proxy.resource_requirements() + credentials = self.tool_proxy.credentials_requirements() 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, + 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 21db34d203f8..536fa2137ab3 100644 --- a/lib/galaxy/tool_util/parser/interface.py +++ b/lib/galaxy/tool_util/parser/interface.py @@ -34,6 +34,7 @@ if TYPE_CHECKING: from galaxy.tool_util.deps.requirements import ( ContainerDescription, + CredentialsRequirement, ResourceRequirement, ToolRequirements, ) @@ -311,10 +312,12 @@ def parse_required_files(self) -> Optional["RequiredFiles"]: return None @abstractmethod - def parse_requirements_and_containers( + def parse_requirements( self, - ) -> Tuple["ToolRequirements", List["ContainerDescription"], List["ResourceRequirement"]]: - """Return triple of ToolRequirement, ContainerDescription and ResourceRequirement lists.""" + ) -> Tuple[ + "ToolRequirements", List["ContainerDescription"], List["ResourceRequirement"], List["CredentialsRequirement"] + ]: + """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 863a316e45df..7465ff7f1325 100644 --- a/lib/galaxy/tool_util/parser/xml.py +++ b/lib/galaxy/tool_util/parser/xml.py @@ -412,8 +412,8 @@ def parse_include_exclude_list(tag_name): as_dict["excludes"] = parse_include_exclude_list("exclude") return RequiredFiles.from_dict(as_dict) - def parse_requirements_and_containers(self): - return requirements.parse_requirements_from_xml(self.root, parse_resources=True) + def parse_requirements(self): + 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 88be9c72846a..a64a14e2bc2a 100644 --- a/lib/galaxy/tool_util/parser/yaml.py +++ b/lib/galaxy/tool_util/parser/yaml.py @@ -109,12 +109,13 @@ def parse_version_command(self): def parse_version_command_interpreter(self): return self.root_dict.get("runtime_version", {}).get("interpreter", None) - def parse_requirements_and_containers(self): + def parse_requirements(self): mixed_requirements = self.root_dict.get("requirements", []) return requirements.parse_requirements_from_lists( 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"], + 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 98931067e796..5ee3ab7b07a7 100644 --- a/lib/galaxy/tool_util/xsd/galaxy.xsd +++ b/lib/galaxy/tool_util/xsd/galaxy.xsd @@ -600,10 +600,10 @@ practice. @@ -612,6 +612,7 @@ serve as complete descriptions of the runtime of a tool. + @@ -725,6 +726,119 @@ Read more about configuring Galaxy to run Docker jobs + + + + + + + + + +``` +]]> + + + + + + + + The name of the credential set. + + + + + The version of the credential set. + + + + + The label of the credential set. + + + + + The description of the credential set. + + + + + + + + + + The name of the variable. + + + + + The environment variable name to inject the value as. + + + + + Whether the variable is optional for the tool to run. + + + + + The label for the variable. + + + + + The description for the variable. + + + + + + + + + + The name of the secret. + + + + + The environment variable name to inject the value as. + + + + + Whether the secret is optional for the tool to run. + + + + + The label for the secret. + + + + + The description for the secret. + + + Document type of tool help diff --git a/lib/galaxy/tools/__init__.py b/lib/galaxy/tools/__init__.py index 90ccde24679e..dd9f891f9052 100644 --- a/lib/galaxy/tools/__init__.py +++ b/lib/galaxy/tools/__init__.py @@ -1219,10 +1219,9 @@ 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() - self.requirements = requirements - self.containers = containers - self.resource_requirements = resource_requirements + self.requirements, self.containers, self.resource_requirements, self.credentials = ( + tool_source.parse_requirements() + ) required_files = tool_source.parse_required_files() if required_files is None: @@ -2274,7 +2273,7 @@ def installed_tool_dependencies(self): @property def tool_requirements(self): """ - Return all requiremens of type package + Return all requirements of type package """ return self.requirements.packages @@ -2678,6 +2677,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": [credential.to_dict() for credential in self.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 1d1a11914fc1..e1c6238446ca 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, @@ -17,6 +18,8 @@ ) from packaging.version import Version +from sqlalchemy import select +from sqlalchemy.orm import aliased from galaxy import model from galaxy.authnz.util import provider_name_to_backend @@ -27,12 +30,19 @@ materializer_factory, ) from galaxy.model.none_like import NoneDataset +from galaxy.model.scoped_session import galaxy_scoped_session from galaxy.security.object_wrapper import wrap_with_safe_string +from galaxy.security.vault import ( + UserVaultWrapper, + Vault, +) from galaxy.structured_app import ( BasicSharedApp, MinimalToolApp, + StructuredApp, ) from galaxy.tool_util.data import TabularToolDataTable +from galaxy.tool_util.deps.requirements import CredentialsRequirement from galaxy.tools.actions import determine_output_format from galaxy.tools.parameters import ( visit_input_values, @@ -123,6 +133,56 @@ def global_tool_logs(func, config_file: Optional[StrPath], action_str: str, tool ] +class UserCredentialsConfigurator: + def __init__( + self, + vault: Vault, + session: galaxy_scoped_session, + user: model.User, + environment_variables: List[Dict[str, str]], + ): + self.vault = vault + self.session = session + self.user = user + self.environment_variables = environment_variables + + def set_environment_variables(self, source_type: str, source_id: str, credentials: List[CredentialsRequirement]): + user_vault = UserVaultWrapper(self.vault, self.user) + user_id = self.user.id + if not user_id: + raise ValueError("User does not exist.") + for credential in credentials: + service_name = credential.name + service_version = credential.version + user_cred_alias = aliased(model.UserCredentials) + group_alias = aliased(model.CredentialsGroup) + cred_alias = aliased(model.Credential) + stmt = ( + select(user_cred_alias, group_alias, cred_alias) + .join(group_alias, group_alias.user_credentials_id == user_cred_alias.id) + .outerjoin(cred_alias, cred_alias.group_id == group_alias.id) + .where(user_cred_alias.current_group_id == group_alias.id) + .where(user_cred_alias.user_id == user_id) + .where(user_cred_alias.source_type == source_type) + .where(user_cred_alias.source_id == source_id) + .where(user_cred_alias.name == service_name) + .where(user_cred_alias.version == service_version) + ) + result = self.session.execute(stmt).tuples().all() + if not result: + raise ValueError( + f"Credentials not found for {source_type}|{source_id}|{service_name}|{service_version}." + ) + current_group = result[0][1].name + for secret in credential.secrets: + vault_ref = f"{source_type}|{source_id}|{service_name}|{service_version}|{current_group}|{secret.name}" + vault_value = user_vault.read_secret(vault_ref) or "" + self.environment_variables.append({"name": secret.inject_as_env, "value": vault_value}) + for variable in credential.variables: + variable_value = str(next((c.value for _, _, c in result if c.name == variable.name), "")) + self.environment_variables.append({"name": variable.inject_as_env, "value": variable_value}) + + class ToolEvaluator: """An abstraction linking together a tool and a job runtime to evaluate tool inputs in an isolated, testable manner. @@ -193,6 +253,32 @@ def set_compute_environment(self, compute_environment: ComputeEnvironment, get_s self.execute_tool_hooks(inp_data=inp_data, out_data=out_data, incoming=incoming) + def tool_uses_credentials() -> bool: + return hasattr(self.tool, "credentials") and bool(self.tool.credentials) + + if isinstance(self.app, StructuredApp): + structured_app = cast(StructuredApp, self.app) + + if ( + tool_uses_credentials() + and self.tool.id is not None + and self.job.user is not None + and bool(structured_app.vault) + and bool(structured_app.model.session) + ): + user_credentials_configurator = UserCredentialsConfigurator( + structured_app.vault, structured_app.model.session, self.job.user, self.environment_variables + ) + user_credentials_configurator.set_environment_variables("tool", self.tool.id, self.tool.credentials) + else: + if tool_uses_credentials(): + pass + # for credential in credentials: + # for secret in credential.secrets: + # self.environment_variables.append({"name": secret.name, "value": secret.value}) + # for variable in credential.variables: + # self.environment_variables.append({"name": variable.name, "value": variable.value}) + 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 ). diff --git a/lib/galaxy/webapps/galaxy/api/credentials.py b/lib/galaxy/webapps/galaxy/api/credentials.py new file mode 100644 index 000000000000..a07de390bb6a --- /dev/null +++ b/lib/galaxy/webapps/galaxy/api/credentials.py @@ -0,0 +1,98 @@ +""" +API operations on credentials (credentials and variables). +""" + +import logging +from typing import Optional + +from fastapi import ( + Query, + Response, + status, +) + +from galaxy.managers.context import ProvidesUserContext +from galaxy.schema.credentials import ( + CreateSourceCredentialsPayload, + SOURCE_TYPE, + UserCredentialsListResponse, +) +from galaxy.schema.fields import DecodedDatabaseIdField +from galaxy.schema.schema import FlexibleUserIdType +from galaxy.webapps.galaxy.api import ( + depends, + DependsOnTrans, + Router, +) +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: FlexibleUserIdType, + trans: ProvidesUserContext = DependsOnTrans, + source_type: Optional[SOURCE_TYPE] = 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.", + ), + source_version: Optional[str] = Query( + None, + description="The version of the source to filter by. By default it is the latest version.", + ), + ) -> UserCredentialsListResponse: + return self.service.list_user_credentials(trans, user_id, source_type, source_id, source_version) + + @router.post( + "/api/users/{user_id}/credentials", + summary="Allows users to provide credentials for a secret/variable", + ) + def provide_credential( + self, + user_id: FlexibleUserIdType, + payload: CreateSourceCredentialsPayload, + trans: ProvidesUserContext = DependsOnTrans, + ) -> UserCredentialsListResponse: + return self.service.provide_credential(trans, user_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: FlexibleUserIdType, + user_credentials_id: DecodedDatabaseIdField, + trans: ProvidesUserContext = DependsOnTrans, + ): + self.service.delete_credentials(trans, user_id, user_credentials_id=user_credentials_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + @router.delete( + "/api/users/{user_id}/credentials/{user_credentials_id}/{group_id}", + summary="Deletes a specific credential", + ) + def delete_credentials( + self, + user_id: FlexibleUserIdType, + user_credentials_id: DecodedDatabaseIdField, + group_id: DecodedDatabaseIdField, + trans: ProvidesUserContext = DependsOnTrans, + ): + self.service.delete_credentials(trans, user_id, user_credentials_id=user_credentials_id, group_id=group_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/lib/galaxy/webapps/galaxy/services/credentials.py b/lib/galaxy/webapps/galaxy/services/credentials.py new file mode 100644 index 000000000000..49a4462fc97a --- /dev/null +++ b/lib/galaxy/webapps/galaxy/services/credentials.py @@ -0,0 +1,267 @@ +from typing import ( + Any, + Callable, + cast, + Dict, + Optional, +) + +from galaxy.exceptions import ( + AuthenticationFailed, + AuthenticationRequired, + ItemOwnershipException, + ObjectNotFound, + RequestParameterInvalidException, + ToolMetaParameterException, +) +from galaxy.managers.context import ProvidesUserContext +from galaxy.managers.credentials import ( + CredentialsManager, + CredentialsModelsList, +) +from galaxy.model import User +from galaxy.model.scoped_session import galaxy_scoped_session +from galaxy.schema.credentials import ( + CreateSourceCredentialsPayload, + SOURCE_TYPE, + UserCredentialsListResponse, + UserCredentialsResponse, +) +from galaxy.schema.fields import DecodedDatabaseIdField +from galaxy.schema.schema import FlexibleUserIdType +from galaxy.security.vault import UserVaultWrapper +from galaxy.structured_app import StructuredApp +from galaxy.tool_util.deps.requirements import CredentialsRequirement +from galaxy.tools import Tool + +GetToolCredentialsDefinition = Callable[[User, str, str, str, str], Optional[CredentialsRequirement]] + + +class CredentialsService: + """Service object shared by controllers for interacting with credentials.""" + + def __init__( + self, + app: StructuredApp, + credentials_manager: CredentialsManager, + ) -> None: + self.app = app + self.credentials_manager = credentials_manager + self.source_type_credentials: Dict[SOURCE_TYPE, GetToolCredentialsDefinition] = { + "tool": self._get_tool_credentials_definition, + } + + def list_user_credentials( + self, + trans: ProvidesUserContext, + user_id: FlexibleUserIdType, + source_type: Optional[SOURCE_TYPE] = None, + source_id: Optional[str] = None, + source_version: Optional[str] = None, + ) -> UserCredentialsListResponse: + """Lists all credentials the user has provided (credentials themselves are not included).""" + user = self._ensure_user_access(trans, user_id) + return self._list_user_credentials(user, source_type, source_id, source_version) + + def provide_credential( + self, + trans: ProvidesUserContext, + user_id: FlexibleUserIdType, + payload: CreateSourceCredentialsPayload, + ) -> UserCredentialsListResponse: + """Allows users to provide credentials for a group of secrets and variables.""" + user = self._ensure_user_access(trans, user_id) + self._create_or_update_credentials(trans.sa_session, user, payload) + return self._list_user_credentials(user, payload.source_type, payload.source_id, payload.source_version) + + def delete_credentials( + self, + trans: ProvidesUserContext, + user_id: FlexibleUserIdType, + user_credentials_id: Optional[DecodedDatabaseIdField] = None, + group_id: Optional[DecodedDatabaseIdField] = None, + ) -> None: + """Deletes a specific credential group or all credentials for a specific service.""" + user = self._ensure_user_access(trans, user_id) + existing_user_credentials = self.credentials_manager.get_user_credentials( + user.id, user_credentials_id=user_credentials_id, group_id=group_id + ) + if not existing_user_credentials: + raise ObjectNotFound("No credentials found.") + rows_to_delete: CredentialsModelsList = [] + for user_credentials, credentials_group, credential in existing_user_credentials: + if group_id: + if credentials_group.name == "default": + raise RequestParameterInvalidException("Cannot delete the default group.") + if credentials_group.id == user_credentials.current_group_id: + self.credentials_manager.update_current_group(user.id, user_credentials.id, "default") + else: + rows_to_delete.append(user_credentials) + rows_to_delete.extend([credentials_group, credential]) + self.credentials_manager.delete_rows(rows_to_delete) + + def _get_tool_credentials_definition( + self, + user: User, + tool_id: str, + tool_version: str, + service_name: str, + service_version: str, + ) -> Optional[CredentialsRequirement]: + tool: Tool = self.app.toolbox.get_tool(tool_id, tool_version) + if not tool: + raise ObjectNotFound(f"Could not find tool with id '{tool_id}'.") + if not tool.allow_user_access(user): + raise AuthenticationFailed(f"Access denied, please login for tool with id '{tool_id}'.") + # even if the tool is found, the version might not be the same + if tool.version != tool_version: + raise ObjectNotFound(f"Could not find tool {tool_id} with version '{tool_version}'.") + if not tool.credentials: + raise ToolMetaParameterException(f"Tool '{tool_id}' does not require any credentials.") + for credentials_service in tool.credentials: + if credentials_service.name == service_name and credentials_service.version == service_version: + return credentials_service + return None + + def _list_user_credentials( + self, + user: User, + source_type: Optional[SOURCE_TYPE] = None, + source_id: Optional[str] = None, + source_version: Optional[str] = None, + ) -> UserCredentialsListResponse: + existing_user_credentials = self.credentials_manager.get_user_credentials( + user.id, source_type, source_id, source_version + ) + user_credentials_dict: Dict[int, Dict[str, Any]] = {} + for user_credentials, credentials_group, credential in existing_user_credentials: + cred_id = user_credentials.id + definition = self.source_type_credentials[cast(SOURCE_TYPE, user_credentials.source_type)]( + user, + user_credentials.source_id, + user_credentials.source_version, + user_credentials.name, + user_credentials.version, + ) + if definition is None: + continue + user_credentials_dict.setdefault( + cred_id, + { + "user_id": user_credentials.user_id, + "id": cred_id, + "source_type": user_credentials.source_type, + "source_id": user_credentials.source_id, + "source_version": user_credentials.source_version, + "name": user_credentials.name, + "version": user_credentials.version, + "label": definition.label, + "description": definition.description, + "credential_definitions": { + "variables": [v.to_dict() for v in definition.variables], + "secrets": [s.to_dict() for s in definition.secrets], + }, + "groups": {}, + }, + ) + + user_credentials_dict[cred_id]["groups"].setdefault( + credentials_group.name, + { + "id": credentials_group.id, + "name": credentials_group.name, + "variables": [], + "secrets": [], + }, + ) + + target_list = "secrets" if credential.is_secret else "variables" + user_credentials_dict[cred_id]["groups"][credentials_group.name][target_list].append( + { + "id": credential.id, + "name": credential.name, + "is_set": credential.is_set, + "value": credential.value, + } + ) + + if credentials_group.id == user_credentials.current_group_id: + user_credentials_dict[cred_id]["current_group_name"] = credentials_group.name + + user_credentials_list = [UserCredentialsResponse(**cred) for cred in user_credentials_dict.values()] + return UserCredentialsListResponse(root=user_credentials_list) + + def _create_or_update_credentials( + self, + session: galaxy_scoped_session, + user: User, + payload: CreateSourceCredentialsPayload, + ) -> None: + user_vault = UserVaultWrapper(self.app.vault, user) + source_type, source_id, source_version = payload.source_type, payload.source_id, payload.source_version + existing_user_credentials = self.credentials_manager.get_user_credentials( + user.id, source_type, source_id, source_version + ) + for service_payload in payload.credentials: + service_name = service_payload.name + service_version = service_payload.version + source_credentials = self.source_type_credentials[source_type]( + user, source_id, source_version, service_name, service_version + ) + if source_credentials is None: + raise ObjectNotFound( + f"Service '{service_name}' with version '{service_version}' is not defined" + f"in {source_type} with id {source_id} and version {source_version}." + ) + user_credentials_id = self.credentials_manager.add_user_credentials( + existing_user_credentials, + user.id, + source_type, + source_id, + source_version, + service_name, + service_version, + ) + for group in service_payload.groups: + user_credential_group_id = self.credentials_manager.add_group( + existing_user_credentials, user_credentials_id, group.name + ) + for variable_payload in group.variables: + if not any(v.name == variable_payload.name for v in source_credentials.variables): + raise RequestParameterInvalidException( + f"Variable '{variable_payload.name}' is not defined for service '{service_name}'." + ) + self.credentials_manager.add_or_update_credential( + existing_user_credentials, + user_credential_group_id, + variable_payload.name, + variable_payload.value, + ) + for secret_payload in group.secrets: + if not any(s.name == secret_payload.name for s in source_credentials.secrets): + raise RequestParameterInvalidException( + f"Secret '{secret_payload.name}' is not defined for service '{service_name}'." + ) + if secret_payload.value is not None: + vault_ref = f"{source_type}|{source_id}|{service_name}|{service_version}|{group.name}|{secret_payload.name}" + user_vault.write_secret(vault_ref, secret_payload.value) + self.credentials_manager.add_or_update_credential( + existing_user_credentials, + user_credential_group_id, + secret_payload.name, + secret_payload.value, + is_secret=True, + ) + self.credentials_manager.update_current_group(user.id, user_credentials_id, service_payload.current_group) + session.commit() + + def _ensure_user_access( + self, + trans: ProvidesUserContext, + user_id: FlexibleUserIdType, + ) -> User: + if trans.anonymous: + raise AuthenticationRequired("You need to be logged in to access your credentials.") + if user_id != "current" and user_id != trans.user.id: + raise ItemOwnershipException("You can only access your own credentials.") + return trans.user diff --git a/test/functional/tools/sample_tool_conf.xml b/test/functional/tools/sample_tool_conf.xml index 0918d7dc92b4..143ec23fa078 100644 --- a/test/functional/tools/sample_tool_conf.xml +++ b/test/functional/tools/sample_tool_conf.xml @@ -292,6 +292,7 @@ + @@ -319,5 +320,4 @@ - diff --git a/test/functional/tools/secret_tool.xml b/test/functional/tools/secret_tool.xml new file mode 100644 index 000000000000..710b230527fd --- /dev/null +++ b/test/functional/tools/secret_tool.xml @@ -0,0 +1,15 @@ + + + + + + + + + '$output' && echo \$service1_user >> '$output' && echo \$service1_pass >> '$output' + ]]> + + + + diff --git a/test/integration/test_credentials.py b/test/integration/test_credentials.py new file mode 100644 index 000000000000..e86ddd44fecb --- /dev/null +++ b/test/integration/test_credentials.py @@ -0,0 +1,228 @@ +from galaxy_test.base.populators import skip_without_tool +from galaxy_test.driver import integration_util + +CREDENTIALS_TEST_TOOL = "secret_tool" + + +class TestCredentialsApi(integration_util.IntegrationTestCase, integration_util.ConfiguresDatabaseVault): + @classmethod + def handle_galaxy_config_kwds(cls, config): + super().handle_galaxy_config_kwds(config) + cls._configure_database_vault(config) + + @skip_without_tool(CREDENTIALS_TEST_TOOL) + def test_provide_credential(self): + created_user_credentials = self._provide_user_credentials() + assert len(created_user_credentials) == 1 + assert created_user_credentials[0]["current_group_name"] == "default" + assert len(created_user_credentials[0]["groups"]["default"]["variables"]) == 1 + assert len(created_user_credentials[0]["groups"]["default"]["secrets"]) == 2 + + @skip_without_tool(CREDENTIALS_TEST_TOOL) + def test_anon_users_cannot_provide_credentials(self): + payload = self._build_credentials_payload() + response = self._post("/api/users/current/credentials", data=payload, json=True, anon=True) + self._assert_status_code_is(response, 403) + + @skip_without_tool(CREDENTIALS_TEST_TOOL) + def test_list_user_credentials(self): + self._provide_user_credentials() + + # Check there is at least one credential + response = self._get("/api/users/current/credentials") + self._assert_status_code_is(response, 200) + list_user_credentials = response.json() + assert len(list_user_credentials) > 0 + + # Check the specific credential exists + self._check_credentials_exist() + + @skip_without_tool(CREDENTIALS_TEST_TOOL) + def test_other_users_cannot_list_credentials(self): + self._provide_user_credentials() + + self._check_credentials_exist() + + with self._different_user(): + self._check_credentials_exist(num_credentials=0) + + def test_list_by_source_id_requires_source_type(self): + response = self._get("/api/users/current/credentials?source_id={CREDENTIALS_TEST_TOOL}") + self._assert_status_code_is(response, 400) + + def test_list_unsupported_source_type(self): + response = self._get("/api/users/current/credentials?source_type=invalid") + self._assert_status_code_is(response, 400) + + @skip_without_tool(CREDENTIALS_TEST_TOOL) + def test_add_group_to_credentials(self): + payload = self._build_credentials_payload() + user_credentials = self._provide_user_credentials(payload) + assert len(user_credentials) == 1 + assert len(user_credentials[0]["groups"]) == 1 + + # Add a new group + new_group_name = "new_group" + payload = self._add_group_and_set_as_current(payload, new_group_name) + updated_user_credentials = self._provide_user_credentials(payload) + 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 + + @skip_without_tool(CREDENTIALS_TEST_TOOL) + def test_delete_service_credentials(self): + # Create credentials + created_user_credentials = self._provide_user_credentials() + user_credentials_id = created_user_credentials[0]["id"] + + # Check credentials exist + self._check_credentials_exist() + + # Delete credentials + response = self._delete(f"/api/users/current/credentials/{user_credentials_id}") + self._assert_status_code_is(response, 204) + + # Check credentials are deleted + self._check_credentials_exist(num_credentials=0) + + @skip_without_tool(CREDENTIALS_TEST_TOOL) + def test_delete_credentials_group(self): + target_group_name = "new_group" + payload = self._build_credentials_payload() + payload = self._add_group_and_set_as_current(payload, target_group_name) + user_credentials = self._provide_user_credentials(payload) + + # Check credentials exist with the new group + list_user_credentials = self._check_credentials_exist() + assert list_user_credentials[0]["current_group_name"] == target_group_name + + # Delete the group + user_credentials_id = user_credentials[0]["id"] + target_group = user_credentials[0]["groups"][target_group_name] + group_id = target_group["id"] + response = self._delete(f"/api/users/current/credentials/{user_credentials_id}/{group_id}") + self._assert_status_code_is(response, 204) + + # Check group is deleted + list_user_credentials = self._check_credentials_exist() + assert len(list_user_credentials[0]["groups"]) == 1 + assert list_user_credentials[0]["current_group_name"] == "default" + + @skip_without_tool(CREDENTIALS_TEST_TOOL) + def test_provide_credential_invalid_group(self): + payload = self._build_credentials_payload() + payload["credentials"][0]["current_group"] = "invalid_group_name" + self._provide_user_credentials(payload, status_code=400) + + def test_invalid_source_type(self): + payload = self._build_credentials_payload(source_type="invalid_source_type") + self._provide_user_credentials(payload, status_code=400) + + def test_not_existing_tool(self): + payload = self._build_credentials_payload(source_id="nonexistent_tool") + self._provide_user_credentials(payload, status_code=404) + + @skip_without_tool(CREDENTIALS_TEST_TOOL) + def test_not_existing_tool_version(self): + payload = self._build_credentials_payload(source_version="nonexistent_tool_version") + self._provide_user_credentials(payload, status_code=404) + + def test_not_existing_service_name(self): + payload = self._build_credentials_payload(service_name="nonexistent_service") + self._provide_user_credentials(payload, status_code=404) + + @skip_without_tool(CREDENTIALS_TEST_TOOL) + def test_not_existing_service_version(self): + payload = self._build_credentials_payload(service_version="nonexistent_service_version") + self._provide_user_credentials(payload, status_code=404) + + @skip_without_tool(CREDENTIALS_TEST_TOOL) + def test_invalid_credential_name(self): + for key in ["variables", "secrets"]: + payload = self._build_credentials_payload() + payload["credentials"][0]["groups"][0][key][0]["name"] = "invalid_name" + self._provide_user_credentials(payload, status_code=400) + + def test_delete_nonexistent_service_credentials(self): + response = self._delete("/api/users/current/credentials/f2db41e1fa331b3e") + self._assert_status_code_is(response, 400) + + def test_delete_nonexistent_credentials_group(self): + response = self._delete("/api/users/current/credentials/f2db41e1fa331b3e/f2db41e1fa331b3e") + self._assert_status_code_is(response, 400) + + @skip_without_tool(CREDENTIALS_TEST_TOOL) + def test_cannot_delete_default_credential_group(self): + created_user_credentials = self._provide_user_credentials() + user_credentials_id = created_user_credentials[0]["id"] + default_group = created_user_credentials[0]["groups"]["default"] + group_id = default_group["id"] + response = self._delete(f"/api/users/current/credentials/{user_credentials_id}/{group_id}") + self._assert_status_code_is(response, 400) + + def _provide_user_credentials(self, payload=None, status_code=200): + payload = payload or self._build_credentials_payload() + response = self._post("/api/users/current/credentials", data=payload, json=True) + self._assert_status_code_is(response, status_code) + if status_code == 200: + return response.json() + return [] + + def _build_credentials_payload( + self, + source_type: str = "tool", + source_id: str = CREDENTIALS_TEST_TOOL, + source_version: str = "test", + service_name: str = "service1", + service_version: str = "1", + ): + return { + "source_type": source_type, + "source_id": source_id, + "source_version": source_version, + "credentials": [ + { + "name": service_name, + "version": service_version, + "current_group": "default", + "groups": [ + { + "name": "default", + "variables": [{"name": "server", "value": "http://localhost:8080"}], + "secrets": [ + {"name": "username", "value": "user"}, + {"name": "password", "value": "pass"}, + ], + } + ], + }, + ], + } + + def _add_group_and_set_as_current(self, payload: dict, new_group_name: str): + service_credentials = payload["credentials"][0] + service_credentials["current_group"] = new_group_name + service_credentials_groups = service_credentials["groups"] + assert isinstance(service_credentials_groups, list) + service_credentials_groups.append( + { + "name": new_group_name, + "variables": [{"name": "server", "value": "http://localhost:8080"}], + "secrets": [ + {"name": "username", "value": "user"}, + {"name": "password", "value": "pass"}, + ], + } + ) + assert len(payload["credentials"][0]["groups"]) == 2 + return payload + + def _check_credentials_exist(self, source_id: str = CREDENTIALS_TEST_TOOL, num_credentials: int = 1): + response = self._get(f"/api/users/current/credentials?source_type=tool&source_id={source_id}") + self._assert_status_code_is(response, 200) + list_user_credentials = response.json() + assert len(list_user_credentials) == num_credentials + if num_credentials > 0: + assert list_user_credentials[0]["source_id"] == source_id + + return list_user_credentials diff --git a/test/unit/app/managers/test_CredentialsManager.py b/test/unit/app/managers/test_CredentialsManager.py new file mode 100644 index 000000000000..880bd3704b1a --- /dev/null +++ b/test/unit/app/managers/test_CredentialsManager.py @@ -0,0 +1,91 @@ +from galaxy.managers.credentials import ( + CredentialsManager, + CredentialsModelsList, +) +from galaxy.model import User +from galaxy.schema.credentials import SOURCE_TYPE +from .base import BaseTestCase + + +class TestCredentialsManager(BaseTestCase): + + def set_up_managers(self): + super().set_up_managers() + self.credentials_manager = CredentialsManager(self.trans.sa_session) + + def test_user_credentials(self): + user = self._create_test_user() + user_id = user.id + name = "ServiceA" + version = "1.0" + source_type: SOURCE_TYPE = "tool" + source_id = "tool_id" + source_version = "tool_version" + user_credentials = self.credentials_manager.get_user_credentials( + user_id, source_type, source_id, source_version + ) + + user_credentials_id = self.credentials_manager.add_user_credentials( + user_credentials, user_id, source_type, source_id, source_version, name, version + ) + + group_name = "group1" + + user_credential_group_id = self.credentials_manager.add_group(user_credentials, user_credentials_id, group_name) + + variable_name = "var1" + variable_value = "value1" + self.credentials_manager.add_or_update_credential( + user_credentials, user_credential_group_id, variable_name, variable_value + ) + + secret_name = "secret1" + secret_value = "value1" + self.credentials_manager.add_or_update_credential( + user_credentials, user_credential_group_id, secret_name, secret_value, is_secret=True + ) + + self.credentials_manager.update_current_group(user_id, user_credentials_id, group_name) + self.trans.sa_session.commit() + + user_credentials = self.credentials_manager.get_user_credentials( + user_id, source_type, source_id, source_version, user_credential_group_id + ) + for result_user_credentials, result_credentials_group, _ in user_credentials: + assert result_user_credentials.id == user_credentials_id + assert result_user_credentials.user_id == user_id + assert result_user_credentials.name == name + assert result_user_credentials.version == version + assert result_user_credentials.source_type == source_type + assert result_user_credentials.source_id == source_id + assert result_user_credentials.source_version == source_version + assert result_user_credentials.current_group_id == user_credential_group_id + assert result_credentials_group.id == user_credential_group_id + assert result_credentials_group.name == group_name + assert result_credentials_group.user_credentials_id == user_credentials_id + + assert user_credentials[0][2].group_id == user_credential_group_id + assert user_credentials[0][2].name == variable_name + assert user_credentials[0][2].value == variable_value + assert not user_credentials[0][2].is_secret + assert user_credentials[0][2].is_set + + assert user_credentials[1][2].group_id == user_credential_group_id + assert user_credentials[1][2].name == secret_name + assert user_credentials[1][2].value is None + assert user_credentials[1][2].is_secret + assert user_credentials[1][2].is_set + + rows_to_delete: CredentialsModelsList = [ + result_user_credentials, + result_credentials_group, + user_credentials[0][2], + user_credentials[1][2], + ] + self.credentials_manager.delete_rows(rows_to_delete) + self.trans.sa_session.commit() + + def _create_test_user(self, username="user1") -> User: + user_data = dict(email=f"{username}@user.email", username=username, password="password") + user = self.user_manager.create(**user_data) + return user diff --git a/test/unit/app/tools/test_evaluation.py b/test/unit/app/tools/test_evaluation.py index 27e4b4a147b5..6237e7556c5f 100644 --- a/test/unit/app/tools/test_evaluation.py +++ b/test/unit/app/tools/test_evaluation.py @@ -306,6 +306,8 @@ def __init__(self, app): self.options = Bunch(sanitize=False) self.check_values = True self.version_string_cmd = "" + self.credentials = [] + self.id = "tool_id" def test_thresh_param(self): elem = XML('') diff --git a/test/unit/tool_util/test_cwl.py b/test/unit/tool_util/test_cwl.py index c84240fb1bed..57826280e702 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, credentials = tool_source.parse_requirements() assert software_requirements.to_dict() == [] assert len(containers) == 1 assert containers[0].to_dict() == { @@ -292,6 +292,7 @@ def test_load_proxy_simple(): } assert len(resource_requirements) == 1 assert resource_requirements[0].to_dict() == {"resource_type": "ram_min", "value_or_expression": 8} + assert len(credentials) == 0 def test_representation_id(): diff --git a/test/unit/tool_util/test_parsing.py b/test/unit/tool_util/test_parsing.py index 42e5bbfd1b1b..af69af0530c8 100644 --- a/test/unit/tool_util/test_parsing.py +++ b/test/unit/tool_util/test_parsing.py @@ -50,6 +50,11 @@ 1 2 67108864 + + + + + @@ -159,6 +164,26 @@ containers: - type: docker identifier: "awesome/bowtie" +credentials: + - name: Apollo + version: gmod.org/apollo + secrets: + - name: username + label: Your Apollo username + description: Username for Apollo + inject_as_env: apollo_user + optional: true + - name: password + label: Your Apollo password + description: Password for Apollo + inject_as_env: apollo_pass + optional: true + variables: + - name: server + label: Your Apollo server + description: URL of your Apollo server + inject_as_env: apollo_url + optional: true outputs: out1: format: bam @@ -347,7 +372,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, credentials = self._tool_source.parse_requirements() assert requirements[0].type == "package" assert list(containers)[0].identifier == "mycool/bwa" assert resource_requirements[0].resource_type == "cores_min" @@ -358,6 +383,10 @@ def test_requirements(self): assert resource_requirements[5].resource_type == "cuda_device_count_max" assert resource_requirements[6].resource_type == "shm_size" assert not resource_requirements[0].runtime_required + assert credentials[0].name == "Apollo" + assert credentials[0].version == "gmod.org/apollo" + 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()) @@ -533,7 +562,7 @@ 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, credentials = self._tool_source.parse_requirements() assert software_requirements.to_dict() == [{"name": "bwa", "type": "package", "version": "1.0.1", "specs": []}] assert len(containers) == 1 assert containers[0].to_dict() == { @@ -562,6 +591,9 @@ def test_requirements(self): "resource_type": "shm_size", "value_or_expression": 67108864, } + assert len(credentials) == 1 + 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()) diff --git a/tools/chatgpt/chatgpt.xml b/tools/chatgpt/chatgpt.xml new file mode 100644 index 000000000000..64861a058294 --- /dev/null +++ b/tools/chatgpt/chatgpt.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + '$output' + ]]> + + + + + + + + + + + + +