diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 1e9ab944ad2e..adc8898a860e 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -4692,6 +4692,61 @@ 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; + }; + /** Verifies if credentials have been provided for a specific service */ + get: operations["verify_service_credentials_api_users__user_id__credentials__user_credentials_id__get"]; + /** Updates credentials for a specific secret/variable */ + put: operations["update_credential_api_users__user_id__credentials__user_credentials_id__put"]; + 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}/{credentials_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Verifies if a credential have been provided */ + get: operations["verify_credentials_api_users__user_id__credentials__user_credentials_id___credentials_id__get"]; + put?: never; + post?: never; + /** Deletes a specific credential */ + delete: operations["delete_credentials_api_users__user_id__credentials__user_credentials_id___credentials_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/users/{user_id}/custom_builds": { parameters: { query?: never; @@ -7392,6 +7447,80 @@ export interface components { */ username: string; }; + /** CredentialPayload */ + CredentialPayload: { + /** + * Credential Name + * @description Name of the credential + */ + name: string; + /** + * Type + * @description Type of the credential(secret/variable) + */ + type: components["schemas"]["CredentialType"]; + /** + * Credential Value + * @description Value of the credential + */ + value: string; + }; + /** CredentialResponse */ + CredentialResponse: { + /** + * ID + * @description ID of the credential + * @example 0123456789ABCDEF + */ + id: string; + /** + * Credential Name + * @description Name of the credential + */ + name: string; + /** + * Type + * @description Type of the credential + */ + type: components["schemas"]["CredentialType"]; + }; + /** + * CredentialType + * @enum {string} + */ + CredentialType: "secret" | "variable"; + /** CredentialsListResponse */ + CredentialsListResponse: { + /** + * Credentials + * @description List of credentials + */ + credentials: components["schemas"]["CredentialResponse"][]; + /** + * Service Reference + * @description Reference to the service + */ + service_reference: string; + /** + * User Credentials ID + * @description ID of the user credentials + * @example 0123456789ABCDEF + */ + user_credentials_id: string; + }; + /** CredentialsPayload */ + CredentialsPayload: { + /** + * Credentials + * @description List of credentials + */ + credentials: components["schemas"]["CredentialPayload"][]; + /** + * Service Reference + * @description Reference to the service + */ + service_reference: string; + }; /** CustomArchivedHistoryView */ CustomArchivedHistoryView: { /** @@ -8342,6 +8471,14 @@ export interface components { * @enum {string} */ DefaultQuotaValues: "unregistered" | "registered" | "no"; + /** DeleteCredentialsResponse */ + DeleteCredentialsResponse: { + /** + * Deleted + * @description Indicates if the credentials were deleted + */ + deleted: boolean; + }; /** DeleteDatasetBatchPayload */ DeleteDatasetBatchPayload: { /** @@ -17051,6 +17188,28 @@ export interface components { /** Creator */ creator?: unknown; }; + /** UpdateCredentialPayload */ + UpdateCredentialPayload: { + /** + * ID + * @description ID of the credential + * @example 0123456789ABCDEF + */ + id: string; + /** + * Value + * @description Value of the credential + */ + value: string; + }; + /** UpdateCredentialsPayload */ + UpdateCredentialsPayload: { + /** + * Update Credentials + * @description List of credentials to update + */ + root: components["schemas"]["UpdateCredentialPayload"][]; + }; /** UpdateDatasetPermissionsPayload */ UpdateDatasetPermissionsPayload: { /** Access Ids[] */ @@ -17573,6 +17732,11 @@ export interface components { */ username: string; }; + /** + * UserCredentialsListResponse + * @description List of user credentials + */ + UserCredentialsListResponse: components["schemas"]["CredentialsListResponse"][]; /** UserDeletionPayload */ UserDeletionPayload: { /** @@ -17843,6 +18007,14 @@ export interface components { */ username?: string | null; }; + /** VerifyCredentialsResponse */ + VerifyCredentialsResponse: { + /** + * Exists + * @description Indicates if the credentials exist + */ + exists: boolean; + }; /** Visualization */ Visualization: Record; /** VisualizationCreatePayload */ @@ -33798,6 +33970,334 @@ export interface operations { }; }; }; + list_user_credentials_api_users__user_id__credentials_get: { + parameters: { + query?: { + /** @description The type of source to filter by. */ + source_type?: string | null; + /** @description The ID of the source to filter by. */ + source_id?: 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: { + /** @description The ID of the user. */ + user_id: string; + }; + 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: { + /** @description The ID of the user. */ + user_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CredentialsPayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CredentialsListResponse"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + verify_service_credentials_api_users__user_id__credentials__user_credentials_id__get: { + 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: { + /** @description The ID of the user. */ + user_id: string; + user_credentials_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VerifyCredentialsResponse"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + update_credential_api_users__user_id__credentials__user_credentials_id__put: { + 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: { + /** @description The ID of the user. */ + user_id: string; + user_credentials_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateCredentialsPayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CredentialsListResponse"]; + }; + }; + /** @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: { + /** @description The ID of the user. */ + user_id: string; + user_credentials_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeleteCredentialsResponse"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + verify_credentials_api_users__user_id__credentials__user_credentials_id___credentials_id__get: { + 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: { + /** @description The ID of the user. */ + user_id: string; + user_credentials_id: string; + credentials_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VerifyCredentialsResponse"]; + }; + }; + /** @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___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: { + /** @description The ID of the user. */ + user_id: string; + user_credentials_id: string; + credentials_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeleteCredentialsResponse"]; + }; + }; + /** @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..f3a9204a1ae8 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 ToolCredentialsDefinition 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 f09527328008..bd366ea11b39 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..cb0c68679f75 --- /dev/null +++ b/client/src/components/Tool/ToolCredentials.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/client/src/components/User/Credentials/CredentialsInput.vue b/client/src/components/User/Credentials/CredentialsInput.vue new file mode 100644 index 000000000000..178c16b88382 --- /dev/null +++ b/client/src/components/User/Credentials/CredentialsInput.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/client/src/components/User/Credentials/ManageToolCredentials.vue b/client/src/components/User/Credentials/ManageToolCredentials.vue new file mode 100644 index 000000000000..d4902cc5b12d --- /dev/null +++ b/client/src/components/User/Credentials/ManageToolCredentials.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/client/src/stores/userCredentials.ts b/client/src/stores/userCredentials.ts new file mode 100644 index 000000000000..149c6489b69e --- /dev/null +++ b/client/src/stores/userCredentials.ts @@ -0,0 +1,81 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; + +import { isRegisteredUser } from "@/api"; +import type { ToolCredentialsDefinition, UserCredentials } from "@/api/users"; +import { useUserStore } from "@/stores/userStore"; + +const SECRET_PLACEHOLDER = "************"; + +export const useUserCredentialsStore = defineStore("userCredentialsStore", () => { + const userCredentialsForTools = ref>({}); + + const userStore = useUserStore(); + + function getAllUserCredentialsForTool(toolId: string): UserCredentials[] | undefined { + ensureUserIsRegistered(); + return userCredentialsForTools.value[toolId]; + } + + async function fetchAllUserCredentialsForTool( + toolId: string, + toolCredentialsDefinitions: ToolCredentialsDefinition[] + ): Promise { + ensureUserIsRegistered(); + + //TODO: Implement this. Simulate for now + await new Promise((resolve) => setTimeout(resolve, 1000)); + const simulatedUserCredentials = []; + for (const credentials of toolCredentialsDefinitions) { + const fetchedCredentials = { + ...credentials, + secrets: credentials.secrets.map((secret) => ({ + ...secret, + alreadySet: false, + value: SECRET_PLACEHOLDER, //This value is never set for real + })), + variables: credentials.variables.map((variable) => ({ ...variable })), + }; + simulatedUserCredentials.push(fetchedCredentials); + } + userCredentialsForTools.value[toolId] = simulatedUserCredentials; + return simulatedUserCredentials; + } + + async function saveUserCredentialsForTool( + toolId: string, + userCredentials: UserCredentials[] + ): Promise { + ensureUserIsRegistered(); + //TODO: Implement this. Simulate for now + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const savedUserCredentials: UserCredentials[] = []; + for (const credentials of userCredentials) { + const savedCredentials = { + ...credentials, + secrets: credentials.secrets.map((secret) => ({ + ...secret, + alreadySet: !!secret.value && secret.value !== SECRET_PLACEHOLDER, + value: SECRET_PLACEHOLDER, + })), + variables: credentials.variables.map((variable) => ({ ...variable })), + }; + savedUserCredentials.push(savedCredentials); + } + userCredentialsForTools.value[toolId] = savedUserCredentials; + return savedUserCredentials; + } + + function ensureUserIsRegistered() { + if (!isRegisteredUser(userStore.currentUser)) { + throw new Error("Only registered users can have tool credentials"); + } + } + + return { + getAllUserCredentialsForTool, + fetchAllUserCredentialsForTool, + saveUserCredentialsForTool, + }; +}); diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index bd5d59384efd..87e95182f6b9 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -11604,6 +11604,70 @@ 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) + create_time: Mapped[Optional[datetime]] = mapped_column(default=now) + + +class CredentialsSet(Base): + """ + Represents a set of credentials associated with a user for a specific + service. + """ + + __tablename__ = "user_credentials_set" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(nullable=False) + user_credentials_id: Mapped[int] = mapped_column(ForeignKey("user_credentials.id"), index=True, nullable=False) + create_time: Mapped[Optional[datetime]] = mapped_column(default=now) + update_time: Mapped[Optional[datetime]] = mapped_column(default=now, onupdate=now) + + +class Credential(Base): + """ + Represents a credential associated with a user for a specific + service. + """ + + __tablename__ = "credential" + + id: Mapped[int] = mapped_column(primary_key=True) + user_credential_set_id: Mapped[int] = mapped_column( + ForeignKey("user_credentials_set.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) + + +class UserToolCredentials(Base): + """ + Represents a credential associated with a tool. + """ + + __tablename__ = "user_tool_credentials" + + id: Mapped[int] = mapped_column(primary_key=True) + user_credential_set_id: Mapped[int] = mapped_column( + ForeignKey("user_credentials_set.id"), index=True, nullable=False + ) + tool_id: Mapped[str] = mapped_column(nullable=False) + tool_version: 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 diff --git a/lib/galaxy/model/migrations/alembic/versions_gxy/8812a3a0aaf5_add_user_credentials_table.py b/lib/galaxy/model/migrations/alembic/versions_gxy/8812a3a0aaf5_add_user_credentials_table.py new file mode 100644 index 000000000000..1b1ed6013f21 --- /dev/null +++ b/lib/galaxy/model/migrations/alembic/versions_gxy/8812a3a0aaf5_add_user_credentials_table.py @@ -0,0 +1,54 @@ +"""add user credentials table + +Revision ID: 8812a3a0aaf5 +Revises: 75348cfb3715 +Create Date: 2024-12-04 15:15:02.919598 + +""" + +from sqlalchemy import ( + Column, + DateTime, + Integer, +) + +from galaxy.model.custom_types import TrimmedString +from galaxy.model.migrations.util import ( + create_table, + drop_table, +) + +# revision identifiers, used by Alembic. +revision = "8812a3a0aaf5" +down_revision = "75348cfb3715" +branch_labels = None +depends_on = None + +table_name_1 = "user_credentials" +table_name_2 = "credentials" + + +def upgrade(): + create_table( + table_name_1, + Column("id", Integer, primary_key=True), + Column("user_id", Integer, index=True), + Column("service_reference", TrimmedString(255)), + Column("create_time", DateTime), + Column("update_time", DateTime), + ) + create_table( + table_name_2, + Column("id", Integer, primary_key=True), + Column("user_credentials_id", Integer, index=True), + Column("name", TrimmedString(255)), + Column("type", TrimmedString(255)), + Column("value", TrimmedString(255)), + Column("create_time", DateTime), + Column("update_time", DateTime), + ) + + +def downgrade(): + drop_table(table_name_1) + drop_table(table_name_2) diff --git a/lib/galaxy/schema/credentials.py b/lib/galaxy/schema/credentials.py new file mode 100644 index 000000000000..aa6420bc866b --- /dev/null +++ b/lib/galaxy/schema/credentials.py @@ -0,0 +1,131 @@ +from enum import Enum +from typing import List + +from pydantic import ( + BaseModel, + Field, + RootModel, +) + +from galaxy.schema.fields import ( + DecodedDatabaseIdField, + 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 UpdateCredentialPayload(Model): + id: DecodedDatabaseIdField = Field( + ..., + title="ID", + description="ID of the credential", + ) + value: str = Field( + ..., + title="Value", + description="Value of the credential", + ) + + +class UpdateCredentialsPayload(BaseModel): + root: List[UpdateCredentialPayload] = Field( + ..., + title="Update Credentials", + description="List of credentials to update", + ) + + +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/tool_util/cwl/parser.py b/lib/galaxy/tool_util/cwl/parser.py index e4a4ff83fb20..cc0b9944483a 100644 --- a/lib/galaxy/tool_util/cwl/parser.py +++ b/lib/galaxy/tool_util/cwl/parser.py @@ -219,6 +219,9 @@ def software_requirements(self) -> List: def resource_requirements(self) -> List: return self.hints_or_requirements_of_class("ResourceRequirement") + def credentials(self) -> List: + return self.hints_or_requirements_of_class("Credentials") + class CommandLineToolProxy(ToolProxy): _class = "CommandLineTool" diff --git a/lib/galaxy/tool_util/deps/requirements.py b/lib/galaxy/tool_util/deps/requirements.py index bfb02e0ea606..62d32dbfb6ca 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,133 @@ def resource_requirements_from_list(requirements: Iterable[Dict[str, Any]]) -> L return rr +class BaseCredential: + 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 Secret(BaseCredential): + @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", ""), + ) + + +class Variable(BaseCredential): + @classmethod + def from_element(cls, elem) -> "Variable": + return cls( + name=elem.get("name"), + inject_as_env=elem.get("inject_as_env"), + label=elem.get("label", ""), + description=elem.get("description", ""), + ) + + +class CredentialsRequirement: + def __init__( + self, + name: str, + reference: str, + optional: bool = True, + multiple: bool = False, + label: str = "", + description: str = "", + secrets: Optional[List[Secret]] = None, + variables: Optional[List[Variable]] = None, + ) -> None: + self.name = name + self.reference = reference + self.optional = optional + self.multiple = multiple + 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.reference: + raise ValueError("Missing reference") + + def to_dict(self) -> Dict[str, Any]: + return { + "name": self.name, + "reference": self.reference, + "optional": self.optional, + "multiple": self.multiple, + "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"] + reference = dict["reference"] + optional = dict.get("optional", True) + multiple = dict.get("multiple", False) + 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, + reference=reference, + optional=optional, + multiple=multiple, + 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 +451,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 +464,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 +493,24 @@ def container_from_element(container_elem) -> ContainerDescription: shell=shell, ) return container + + +def credentials_from_element(credentials_elem) -> CredentialsRequirement: + name = credentials_elem.get("name") + reference = credentials_elem.get("reference") + 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 = [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, + optional=optional, + multiple=multiple, + label=label, + description=description, + secrets=secrets, + variables=variables, + ) diff --git a/lib/galaxy/tool_util/linters/general.py b/lib/galaxy/tool_util/linters/general.py index eb3a98fd3b82..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 = 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 = 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 = 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 = 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 26d2fdfdc16c..540f8edcfbdd 100644 --- a/lib/galaxy/tool_util/parser/cwl.py +++ b/lib/galaxy/tool_util/parser/cwl.py @@ -162,10 +162,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() 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 af72bf4a4825..0a8f3c2978c7 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, ) @@ -307,8 +308,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["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 aba5a6874248..ba405cdfca6f 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_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..673e5a2cb418 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"], + 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 1c23f88066fe..6700dbd18a22 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. + + + + + A reference to the source of the credentials. + + + + + The label of the credential set. + + + + + The description of the credential set. + + + + + Whether the credentials are optional for the tool to run. + + + + + Indicates multiple sets of credentials can be provided. + + + + + + + + + + The name of the variable. + + + + + The environment variable name to inject the value as. + + + + + The label for the variable. + + + + + 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. + + + Document type of tool help diff --git a/lib/galaxy/tools/__init__.py b/lib/galaxy/tools/__init__.py index 2e352e7c0f39..77d23113d6c7 100644 --- a/lib/galaxy/tools/__init__.py +++ b/lib/galaxy/tools/__init__.py @@ -1216,10 +1216,18 @@ 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, credentials = tool_source.parse_requirements_and_containers() self.requirements = requirements self.containers = containers self.resource_requirements = resource_requirements + 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: @@ -2659,6 +2667,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( { @@ -2671,6 +2700,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 582bd65be06e..bbafe7383828 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, @@ -28,9 +29,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 ( BasicSharedApp, MinimalToolApp, + StructuredApp, ) from galaxy.tool_util.data import TabularToolDataTable from galaxy.tools.parameters import ( @@ -188,6 +191,35 @@ 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.credentials: + app = cast(StructuredApp, self.app) + user_vault = UserVaultWrapper(app.vault, self._user) + for credentials in self.tool.credentials: + reference = credentials.reference + for secret in credentials.secret: + vault_value = user_vault.read_secret(f"{reference}|{secret.name}") or "" + self.environment_variables.append({"name": secret.inject_as_env, "value": vault_value}) + for variable in credentials.variable: + service_refrence = f"{reference}|{variable.name}" + model = app.model + query = ( + model.context.query(model.UserCredential) + .filter_by(user_id=self._user.id, service_reference=service_refrence) + .first() + ) + if query: + credential_id = query.id + credential = ( + model.context.query(model.Credential) + .filter_by(user_credential_id=credential_id, name=variable.name) + .first() + ) + if credential: + variable_value = credential.value + self.environment_variables.append({"name": variable.inject_as_env, "value": variable_value}) + else: + log.warning(f"Variable {variable.name} not found in credentials") + 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..647ff5801103 --- /dev/null +++ b/lib/galaxy/webapps/galaxy/api/credentials.py @@ -0,0 +1,129 @@ +""" +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, + UpdateCredentialsPayload, + 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/{user_credentials_id}", + summary="Updates credentials for a specific secret/variable", + ) + def update_credential( + self, + user_id: UserIdPathParam, + user_credentials_id: DecodedDatabaseIdField, + payload: UpdateCredentialsPayload, + trans: ProvidesUserContext = DependsOnTrans, + ) -> CredentialsListResponse: + return self.service.update_credential(trans, user_id, user_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..eb82583e4753 --- /dev/null +++ b/lib/galaxy/webapps/galaxy/services/credentials.py @@ -0,0 +1,285 @@ +from typing import ( + Dict, + List, + Optional, + Tuple, +) + +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, + UpdateCredentialsPayload, + 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, + user_credentials_id: DecodedDatabaseIdField, + payload: UpdateCredentialsPayload, + ) -> CredentialsListResponse: + """Updates credentials for a specific secret/variable.""" + return self._update_user_credential(trans, user_id, user_credentials_id, payload) + + 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 _update_user_credential( + self, + trans: ProvidesUserContext, + user_id: UserIdPathParam, + user_credential_id: DecodedDatabaseIdField, + payload: UpdateCredentialsPayload, + ) -> CredentialsListResponse: + user_credentials, credentials_dict = self._user_credentials( + trans, user_id, user_credentials_id=user_credential_id + ) + user_credential = user_credentials[0] if user_credentials else None + if not user_credential: + raise exceptions.ObjectNotFound(f"User credential {user_credential_id} not found.", type="error") + db_credentials = sum(credentials_dict.values(), []) + session = trans.sa_session + for credential in payload.root: + existing_credential = next( + (cred for cred in db_credentials if cred.id == credential.id), + None, + ) + if not existing_credential: + raise exceptions.ObjectNotFound(f"Credential {credential.id} not found.", type="error") + + if existing_credential.type == "secret": + user_vault = UserVaultWrapper(self._app.vault, trans.user) + user_vault.write_secret( + f"{user_credential.service_reference}|{existing_credential.name}", credential.value + ) + elif existing_credential.type == "variable": + existing_credential.value = credential.value + session.add(existing_credential) + with transaction(session): + session.commit() + return CredentialsListResponse( + service_reference=user_credential.service_reference, + user_credentials_id=user_credential_id, + credentials=self._credentials_response(db_credentials), + ) + + def _create_user_credential( + self, + trans: ProvidesUserContext, + user_id: UserIdPathParam, + payload: CredentialsPayload, + ) -> 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 + db_credentials = credentials_dict.get(service_reference, []) + 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 + + existing_credential = next( + (cred for cred in db_credentials if cred.name == credential_name and cred.type == credential_type), + None, + ) + if existing_credential: + raise exceptions.RequestParameterInvalidException( + f"Credential {service_reference}|{credential_name} already exists.", type="error" + ) + + credential = Credentials( + user_credentials_id=user_credential_id, + name=credential_name, + type=credential_type, + ) + + 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), + ) diff --git a/test/functional/tools/secret_tool.xml b/test/functional/tools/secret_tool.xml new file mode 100644 index 000000000000..998bf14dcb1a --- /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_vault_extra_prefs.py b/test/integration/test_vault_extra_prefs.py index 5072ac69d270..d9166a553b8a 100644 --- a/test/integration/test_vault_extra_prefs.py +++ b/test/integration/test_vault_extra_prefs.py @@ -11,6 +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.driver import integration_util TEST_USER_EMAIL = "vault_test_user@bx.psu.edu" @@ -134,3 +140,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" diff --git a/test/unit/tool_util/test_cwl.py b/test/unit/tool_util/test_cwl.py index a3bae657b132..8e95793cc8a9 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, _ = 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 21ec92387694..ea4ddb1f334f 100644 --- a/test/unit/tool_util/test_parsing.py +++ b/test/unit/tool_util/test_parsing.py @@ -50,6 +50,11 @@ 1 2 67108864 + + + + + @@ -347,7 +352,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, _ = 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" @@ -359,6 +364,14 @@ 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].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()) assert len(outputs) == 1, outputs @@ -533,7 +546,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, _ = ( + 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() == {