From e953dad8783e332f94d26622a67654fbe8690c41 Mon Sep 17 00:00:00 2001 From: Marc Kelechava Date: Tue, 9 Dec 2025 16:10:49 -0800 Subject: [PATCH] Additional changes for proper templating and masked storage of new Secret Credential type (#4253) --- .../forge/sdk/api/custom_credential_client.py | 33 ++++++- skyvern/forge/sdk/services/bitwarden.py | 86 ++++++++++++++++++- skyvern/forge/sdk/workflow/models/block.py | 13 +-- 3 files changed, 122 insertions(+), 10 deletions(-) diff --git a/skyvern/forge/sdk/api/custom_credential_client.py b/skyvern/forge/sdk/api/custom_credential_client.py index 9bab3354..1036d3c1 100644 --- a/skyvern/forge/sdk/api/custom_credential_client.py +++ b/skyvern/forge/sdk/api/custom_credential_client.py @@ -9,6 +9,7 @@ from skyvern.forge.sdk.schemas.credentials import ( CredentialType, CreditCardCredential, PasswordCredential, + SecretCredential, ) LOG = structlog.get_logger() @@ -35,7 +36,9 @@ class CustomCredentialAPIClient: "Content-Type": "application/json", } - def _credential_to_api_payload(self, credential: PasswordCredential | CreditCardCredential) -> dict[str, Any]: + def _credential_to_api_payload( + self, credential: PasswordCredential | CreditCardCredential | SecretCredential + ) -> dict[str, Any]: """Convert Skyvern credential to API payload format.""" if isinstance(credential, PasswordCredential): return { @@ -55,6 +58,14 @@ class CustomCredentialAPIClient: "card_cvv": credential.card_cvv, "card_brand": credential.card_brand, } + elif isinstance(credential, SecretCredential): + payload = { + "type": "secret", + "secret_value": credential.secret_value, + } + if credential.secret_label is not None: + payload["secret_label"] = credential.secret_label + return payload else: raise TypeError(f"Unsupported credential type: {type(credential)}") @@ -107,10 +118,28 @@ class CustomCredentialAPIClient: name=name, credential_type=CredentialType.CREDIT_CARD, ) + elif credential_type == "secret": + required_fields = ["secret_value"] + missing = [f for f in required_fields if f not in credential_data] + if missing: + raise ValueError(f"Missing required secret fields from API: {missing}") + + credential = SecretCredential( + secret_value=credential_data["secret_value"], + secret_label=credential_data.get("secret_label"), + ) + return CredentialItem( + item_id=item_id, + credential=credential, + name=name, + credential_type=CredentialType.SECRET, + ) else: raise ValueError(f"Unsupported credential type from API: {credential_type}") - async def create_credential(self, name: str, credential: PasswordCredential | CreditCardCredential) -> str: + async def create_credential( + self, name: str, credential: PasswordCredential | CreditCardCredential | SecretCredential + ) -> str: """ Create a credential using the custom API. diff --git a/skyvern/forge/sdk/services/bitwarden.py b/skyvern/forge/sdk/services/bitwarden.py index 63557813..faf48812 100644 --- a/skyvern/forge/sdk/services/bitwarden.py +++ b/skyvern/forge/sdk/services/bitwarden.py @@ -30,6 +30,7 @@ from skyvern.forge.sdk.schemas.credentials import ( CredentialType, CreditCardCredential, PasswordCredential, + SecretCredential, ) from skyvern.forge.sdk.services.credentials import parse_totp_secret @@ -84,6 +85,26 @@ def get_list_response_item_from_bitwarden_item(item: dict) -> CredentialItem: name=item["name"], credential_type=CredentialType.CREDIT_CARD, ) + elif item["type"] == BitwardenItemType.SECURE_NOTE: + notes = item.get("notes") or "" + secret_value = "" + secret_label = None + try: + parsed_notes = json.loads(notes) + if isinstance(parsed_notes, dict): + secret_value = parsed_notes.get("secret_value", "") or "" + secret_label = parsed_notes.get("secret_label") + else: + secret_value = notes + except Exception: + secret_value = notes + + return CredentialItem( + item_id=item["id"], + credential=SecretCredential(secret_value=secret_value, secret_label=secret_label), + name=item["name"], + credential_type=CredentialType.SECRET, + ) else: raise BitwardenGetItemError(f"Unsupported item type: {item['type']}") @@ -807,7 +828,7 @@ class BitwardenService: async def create_credential_item( collection_id: str, name: str, - credential: PasswordCredential | CreditCardCredential, + credential: PasswordCredential | CreditCardCredential | SecretCredential, ) -> str: try: master_password, bw_organization_id, _, _ = await BitwardenService._get_skyvern_auth_secrets() @@ -820,16 +841,56 @@ class BitwardenService: name=name, credential=credential, ) - else: + elif isinstance(credential, CreditCardCredential): return await BitwardenService._create_credit_card_item_using_server( bw_organization_id=bw_organization_id, collection_id=collection_id, name=name, credential=credential, ) + else: + return await BitwardenService._create_secret_item_using_server( + bw_organization_id=bw_organization_id, + collection_id=collection_id, + name=name, + credential=credential, + ) except Exception as e: raise e + @staticmethod + async def _create_secret_item_using_server( + bw_organization_id: str, + collection_id: str, + name: str, + credential: SecretCredential, + ) -> str: + item_template = await aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/object/template/item", timeout=120) + secure_note_template = await aiohttp_get_json( + f"{BITWARDEN_SERVER_BASE_URL}/object/template/item.securenote", timeout=120 + ) + + item_template = item_template["data"]["template"] + secure_note_template = secure_note_template["data"]["template"] + + item_template["type"] = get_bitwarden_item_type_code(BitwardenItemType.SECURE_NOTE) + item_template["name"] = name + item_template["collectionIds"] = [collection_id] + item_template["organizationId"] = bw_organization_id + item_template["secureNote"] = secure_note_template + item_template["notes"] = json.dumps( + { + "secret_value": credential.secret_value, + "secret_label": credential.secret_label, + } + ) + + response = await aiohttp_post(f"{BITWARDEN_SERVER_BASE_URL}/object/item", data=item_template, timeout=120) + if not response or response.get("success") is False: + raise BitwardenCreateLoginItemError("Failed to create secret item") + + return response["data"]["id"] + @staticmethod async def _get_skyvern_auth_master_password() -> str: master_password = settings.SKYVERN_AUTH_BITWARDEN_MASTER_PASSWORD @@ -1011,6 +1072,27 @@ class BitwardenService: card_brand=credit_card_item["brand"], ), ) + elif response["data"]["type"] == BitwardenItemType.SECURE_NOTE: + name = response["data"]["name"] + notes = response["data"].get("notes") or "" + secret_value = "" + secret_label = None + try: + parsed_notes = json.loads(notes) + if isinstance(parsed_notes, dict): + secret_value = parsed_notes.get("secret_value", "") or "" + secret_label = parsed_notes.get("secret_label") + else: + secret_value = notes + except Exception: + secret_value = notes + + return CredentialItem( + item_id=item_id, + credential_type=CredentialType.SECRET, + name=name, + credential=SecretCredential(secret_value=secret_value, secret_label=secret_label), + ) else: raise BitwardenGetItemError(f"Unsupported item type: {response['data']['type']}") diff --git a/skyvern/forge/sdk/workflow/models/block.py b/skyvern/forge/sdk/workflow/models/block.py index 7a879fcd..9f023849 100644 --- a/skyvern/forge/sdk/workflow/models/block.py +++ b/skyvern/forge/sdk/workflow/models/block.py @@ -253,12 +253,13 @@ class Block(BaseModel, abc.ABC): # First collect all credential parameters to avoid modifying dict during iteration credential_params = [] for key, value in list(template_data.items()): - if isinstance(value, dict) and "context" in value and "username" in value and "password" in value: - credential_params.append((key, value)) - elif is_safe_block_for_secrets and isinstance(value, str): - secret_value = workflow_run_context.get_original_secret_value_or_none(value) - if secret_value is not None: - template_data[key] = secret_value + if isinstance(value, dict) and "context" in value: + # PASSWORD credential: has username and password + if "username" in value and "password" in value: + credential_params.append((key, value)) + # SECRET credential: has secret_value + elif "secret_value" in value: + credential_params.append((key, value)) # Now add the real_username/real_password entries for key, value in credential_params: