From 8a1b0f3797adc82f565ff04b40693000a8e8867c Mon Sep 17 00:00:00 2001 From: Shuchang Zheng Date: Mon, 3 Mar 2025 11:45:50 -0500 Subject: [PATCH] Add bitwarden_item_id to bitwarden_login_credential (#1871) Co-authored-by: Muhammed Salih Altun --- ...513_add_bitwarden_item_id_and_make_url_.py | 37 +++++++++++++++++++ skyvern/forge/sdk/db/client.py | 4 +- skyvern/forge/sdk/db/models.py | 3 +- skyvern/forge/sdk/db/utils.py | 1 + skyvern/forge/sdk/services/bitwarden.py | 29 +++++++++++++-- skyvern/forge/sdk/workflow/context_manager.py | 20 +++++++++- .../forge/sdk/workflow/models/parameter.py | 4 +- skyvern/forge/sdk/workflow/models/yaml.py | 4 +- skyvern/forge/sdk/workflow/service.py | 19 +++++++--- 9 files changed, 107 insertions(+), 14 deletions(-) create mode 100644 alembic/versions/2025_03_03_1642-268dcc995513_add_bitwarden_item_id_and_make_url_.py diff --git a/alembic/versions/2025_03_03_1642-268dcc995513_add_bitwarden_item_id_and_make_url_.py b/alembic/versions/2025_03_03_1642-268dcc995513_add_bitwarden_item_id_and_make_url_.py new file mode 100644 index 00000000..91dec345 --- /dev/null +++ b/alembic/versions/2025_03_03_1642-268dcc995513_add_bitwarden_item_id_and_make_url_.py @@ -0,0 +1,37 @@ +"""add bitwarden_item_id and make url_parameter_key optional in bitwarden_login_credential + +Revision ID: 268dcc995513 +Revises: a21b9f4f51d2 +Create Date: 2025-03-03 16:42:11.868247+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "268dcc995513" +down_revision: Union[str, None] = "a21b9f4f51d2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("bitwarden_login_credential_parameters", sa.Column("bitwarden_item_id", sa.String(), nullable=True)) + op.alter_column( + "bitwarden_login_credential_parameters", "url_parameter_key", existing_type=sa.VARCHAR(), nullable=True + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "bitwarden_login_credential_parameters", "url_parameter_key", existing_type=sa.VARCHAR(), nullable=False + ) + op.drop_column("bitwarden_login_credential_parameters", "bitwarden_item_id") + # ### end Alembic commands ### diff --git a/skyvern/forge/sdk/db/client.py b/skyvern/forge/sdk/db/client.py index 067a1c22..1acb361d 100644 --- a/skyvern/forge/sdk/db/client.py +++ b/skyvern/forge/sdk/db/client.py @@ -1586,10 +1586,11 @@ class AgentDB: bitwarden_client_id_aws_secret_key: str, bitwarden_client_secret_aws_secret_key: str, bitwarden_master_password_aws_secret_key: str, - url_parameter_key: str, key: str, + url_parameter_key: str | None = None, description: str | None = None, bitwarden_collection_id: str | None = None, + bitwarden_item_id: str | None = None, ) -> BitwardenLoginCredentialParameter: async with self.Session() as session: bitwarden_login_credential_parameter = BitwardenLoginCredentialParameterModel( @@ -1601,6 +1602,7 @@ class AgentDB: key=key, description=description, bitwarden_collection_id=bitwarden_collection_id, + bitwarden_item_id=bitwarden_item_id, ) session.add(bitwarden_login_credential_parameter) await session.commit() diff --git a/skyvern/forge/sdk/db/models.py b/skyvern/forge/sdk/db/models.py index cf8fbc86..1611d02f 100644 --- a/skyvern/forge/sdk/db/models.py +++ b/skyvern/forge/sdk/db/models.py @@ -326,7 +326,8 @@ class BitwardenLoginCredentialParameterModel(Base): bitwarden_client_secret_aws_secret_key = Column(String, nullable=False) bitwarden_master_password_aws_secret_key = Column(String, nullable=False) bitwarden_collection_id = Column(String, nullable=True, default=None) - url_parameter_key = Column(String, nullable=False) + bitwarden_item_id = Column(String, nullable=True, default=None) + url_parameter_key = Column(String, nullable=True, default=None) created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) modified_at = Column( DateTime, diff --git a/skyvern/forge/sdk/db/utils.py b/skyvern/forge/sdk/db/utils.py index 23aa4081..61173e87 100644 --- a/skyvern/forge/sdk/db/utils.py +++ b/skyvern/forge/sdk/db/utils.py @@ -286,6 +286,7 @@ def convert_to_bitwarden_login_credential_parameter( bitwarden_client_secret_aws_secret_key=bitwarden_login_credential_parameter_model.bitwarden_client_secret_aws_secret_key, bitwarden_master_password_aws_secret_key=bitwarden_login_credential_parameter_model.bitwarden_master_password_aws_secret_key, bitwarden_collection_id=bitwarden_login_credential_parameter_model.bitwarden_collection_id, + bitwarden_item_id=bitwarden_login_credential_parameter_model.bitwarden_item_id, url_parameter_key=bitwarden_login_credential_parameter_model.url_parameter_key, created_at=bitwarden_login_credential_parameter_model.created_at, modified_at=bitwarden_login_credential_parameter_model.modified_at, diff --git a/skyvern/forge/sdk/services/bitwarden.py b/skyvern/forge/sdk/services/bitwarden.py index 5cbf5529..6ad940b0 100644 --- a/skyvern/forge/sdk/services/bitwarden.py +++ b/skyvern/forge/sdk/services/bitwarden.py @@ -106,7 +106,7 @@ class BitwardenConstants(StrEnum): URL = "BW_URL" BW_COLLECTION_ID = "BW_COLLECTION_ID" IDENTITY_KEY = "BW_IDENTITY_KEY" - ITEM_ID = "BW_ITEM_ID" + BW_ITEM_ID = "BW_ITEM_ID" USERNAME = "BW_USERNAME" PASSWORD = "BW_PASSWORD" @@ -190,8 +190,9 @@ class BitwardenService: master_password: str, bw_organization_id: str | None, bw_collection_ids: list[str] | None, - url: str, + url: str | None = None, collection_id: str | None = None, + item_id: str | None = None, max_retries: int = settings.BITWARDEN_MAX_RETRIES, timeout: int = settings.BITWARDEN_TIMEOUT_SECONDS, ) -> dict[str, str]: @@ -215,6 +216,7 @@ class BitwardenService: bw_collection_ids=bw_collection_ids, url=url, collection_id=collection_id, + item_id=item_id, timeout=timeout, ) except BitwardenAccessDeniedError as e: @@ -271,8 +273,9 @@ class BitwardenService: master_password: str, bw_organization_id: str | None, bw_collection_ids: list[str] | None, - url: str, + url: str | None = None, collection_id: str | None = None, + item_id: str | None = None, timeout: int = 60, ) -> dict[str, str]: """ @@ -283,6 +286,26 @@ class BitwardenService: await BitwardenService.sync() session_key = await BitwardenService.unlock(master_password) + if item_id: # if item_id provided, get single item by item id + command = ["bw", "get", "item", item_id, "--session", session_key] + item_result = await BitwardenService.run_command(command) + if item_result.stderr: + raise BitwardenGetItemError( + f"Failed to get the bitwarden item {item_id}. Error: {item_result.stderr}" + ) + try: + item = json.loads(item_result.stdout) + except json.JSONDecodeError: + raise BitwardenGetItemError(f"Failed to parse item JSON for item ID: {item_id}") + return { + BitwardenConstants.USERNAME: item["login"]["username"], + BitwardenConstants.PASSWORD: item["login"]["password"], + BitwardenConstants.TOTP: item["login"]["totp"], + } + elif not url: + # if item_id is not provided, we need a url to search for items + raise BitwardenGetItemError("No url or item ID provided") + # Extract the domain from the URL and search for items in Bitwarden with that domain extract_url = tldextract.extract(url) domain = extract_url.domain diff --git a/skyvern/forge/sdk/workflow/context_manager.py b/skyvern/forge/sdk/workflow/context_manager.py index 82e529e0..05ed1044 100644 --- a/skyvern/forge/sdk/workflow/context_manager.py +++ b/skyvern/forge/sdk/workflow/context_manager.py @@ -165,6 +165,7 @@ class WorkflowRunContext: bw_organization_id=self.secrets[BitwardenConstants.BW_ORGANIZATION_ID], bw_collection_ids=self.secrets[BitwardenConstants.BW_COLLECTION_IDS], collection_id=self.secrets[BitwardenConstants.BW_COLLECTION_ID], + item_id=self.secrets[BitwardenConstants.BW_ITEM_ID], ) return secret_credentials @@ -241,11 +242,17 @@ class WorkflowRunContext: LOG.error(f"Failed to get Bitwarden login credentials from AWS secrets. Error: {e}") raise e - if self.has_parameter(parameter.url_parameter_key) and self.has_value(parameter.url_parameter_key): + if ( + parameter.url_parameter_key + and self.has_parameter(parameter.url_parameter_key) + and self.has_value(parameter.url_parameter_key) + ): url = self.values[parameter.url_parameter_key] elif parameter.url_parameter_key: # If a key can't be found within the parameter values dict, assume it's a URL (and not a URL Parameter) url = parameter.url_parameter_key + elif parameter.bitwarden_item_id: + url = None else: LOG.error(f"URL parameter {parameter.url_parameter_key} not found or has no value") raise SkyvernException("URL parameter for Bitwarden login credentials not found or has no value") @@ -259,6 +266,13 @@ class WorkflowRunContext: else: collection_id = parameter.bitwarden_collection_id + item_id = None + if parameter.bitwarden_item_id: + if self.has_parameter(parameter.bitwarden_item_id) and self.has_value(parameter.bitwarden_item_id): + item_id = self.values[parameter.bitwarden_item_id] + else: + item_id = parameter.bitwarden_item_id + try: secret_credentials = await BitwardenService.get_secret_value_from_url( client_id, @@ -268,6 +282,7 @@ class WorkflowRunContext: organization.bw_collection_ids, url, collection_id=collection_id, + item_id=item_id, ) if secret_credentials: self.secrets[BitwardenConstants.BW_ORGANIZATION_ID] = organization.bw_organization_id @@ -277,6 +292,7 @@ class WorkflowRunContext: self.secrets[BitwardenConstants.CLIENT_ID] = client_id self.secrets[BitwardenConstants.MASTER_PASSWORD] = master_password self.secrets[BitwardenConstants.BW_COLLECTION_ID] = parameter.bitwarden_collection_id + self.secrets[BitwardenConstants.BW_ITEM_ID] = item_id random_secret_id = self.generate_random_secret_id() # username secret @@ -410,7 +426,7 @@ class WorkflowRunContext: self.secrets[BitwardenConstants.CLIENT_ID] = client_id self.secrets[BitwardenConstants.CLIENT_SECRET] = client_secret self.secrets[BitwardenConstants.MASTER_PASSWORD] = master_password - self.secrets[BitwardenConstants.ITEM_ID] = item_id + self.secrets[BitwardenConstants.BW_ITEM_ID] = item_id fields_to_obfuscate = { BitwardenConstants.CREDIT_CARD_NUMBER: "card_number", diff --git a/skyvern/forge/sdk/workflow/models/parameter.py b/skyvern/forge/sdk/workflow/models/parameter.py index a7cb8fc3..9e4df855 100644 --- a/skyvern/forge/sdk/workflow/models/parameter.py +++ b/skyvern/forge/sdk/workflow/models/parameter.py @@ -56,10 +56,12 @@ class BitwardenLoginCredentialParameter(Parameter): bitwarden_client_secret_aws_secret_key: str bitwarden_master_password_aws_secret_key: str # url to request the login credentials from bitwarden - url_parameter_key: str + url_parameter_key: str | None = None # bitwarden collection id to filter the login credentials from, # if not provided, no filtering will be done bitwarden_collection_id: str | None = None + # bitwarden item id to request the login credential + bitwarden_item_id: str | None = None created_at: datetime modified_at: datetime diff --git a/skyvern/forge/sdk/workflow/models/yaml.py b/skyvern/forge/sdk/workflow/models/yaml.py index 95103bc6..fb8085a4 100644 --- a/skyvern/forge/sdk/workflow/models/yaml.py +++ b/skyvern/forge/sdk/workflow/models/yaml.py @@ -37,10 +37,12 @@ class BitwardenLoginCredentialParameterYAML(ParameterYAML): bitwarden_client_secret_aws_secret_key: str bitwarden_master_password_aws_secret_key: str # parameter key for the url to request the login credentials from bitwarden - url_parameter_key: str + url_parameter_key: str | None = None # bitwarden collection id to filter the login credentials from, # if not provided, no filtering will be done bitwarden_collection_id: str | None = None + # bitwarden item id to request the login credential + bitwarden_item_id: str | None = None class CredentialParameterYAML(ParameterYAML): diff --git a/skyvern/forge/sdk/workflow/service.py b/skyvern/forge/sdk/workflow/service.py index e1400d8e..77e50b91 100644 --- a/skyvern/forge/sdk/workflow/service.py +++ b/skyvern/forge/sdk/workflow/service.py @@ -804,20 +804,22 @@ class WorkflowService: bitwarden_client_id_aws_secret_key: str, bitwarden_client_secret_aws_secret_key: str, bitwarden_master_password_aws_secret_key: str, - url_parameter_key: str, key: str, + url_parameter_key: str | None = None, description: str | None = None, bitwarden_collection_id: str | None = None, + bitwarden_item_id: str | None = None, ) -> Parameter: return await app.DATABASE.create_bitwarden_login_credential_parameter( workflow_id=workflow_id, bitwarden_client_id_aws_secret_key=bitwarden_client_id_aws_secret_key, bitwarden_client_secret_aws_secret_key=bitwarden_client_secret_aws_secret_key, bitwarden_master_password_aws_secret_key=bitwarden_master_password_aws_secret_key, - url_parameter_key=url_parameter_key, key=key, + url_parameter_key=url_parameter_key, description=description, bitwarden_collection_id=bitwarden_collection_id, + bitwarden_item_id=bitwarden_item_id, ) async def create_credential_parameter( @@ -1397,11 +1399,17 @@ class WorkflowService: credential_id=parameter.credential_id, ) elif parameter.parameter_type == ParameterType.BITWARDEN_LOGIN_CREDENTIAL: - if not parameter.bitwarden_collection_id: + if not parameter.bitwarden_collection_id and not parameter.bitwarden_item_id: raise WorkflowParameterMissingRequiredValue( workflow_parameter_type=ParameterType.BITWARDEN_LOGIN_CREDENTIAL, workflow_parameter_key=parameter.key, - required_value="bitwarden_collection_id", + required_value="bitwarden_collection_id or bitwarden_item_id", + ) + if parameter.bitwarden_collection_id and not parameter.url_parameter_key: + raise WorkflowParameterMissingRequiredValue( + workflow_parameter_type=ParameterType.BITWARDEN_LOGIN_CREDENTIAL, + workflow_parameter_key=parameter.key, + required_value="url_parameter_key", ) parameters[parameter.key] = await self.create_bitwarden_login_credential_parameter( workflow_id=workflow.workflow_id, @@ -1412,6 +1420,7 @@ class WorkflowService: key=parameter.key, description=parameter.description, bitwarden_collection_id=parameter.bitwarden_collection_id, + bitwarden_item_id=parameter.bitwarden_item_id, ) elif parameter.parameter_type == ParameterType.BITWARDEN_SENSITIVE_INFORMATION: parameters[parameter.key] = await self.create_bitwarden_sensitive_information_parameter( @@ -1434,7 +1443,7 @@ class WorkflowService: bitwarden_master_password_aws_secret_key=parameter.bitwarden_master_password_aws_secret_key, # TODO: remove "# type: ignore" after ensuring bitwarden_collection_id is always set bitwarden_collection_id=parameter.bitwarden_collection_id, # type: ignore - bitwarden_item_id=parameter.bitwarden_item_id, + bitwarden_item_id=parameter.bitwarden_item_id, # type: ignore key=parameter.key, description=parameter.description, )