diff --git a/alembic/versions/2025_02_20_2146-b4bb0b98912a_change_credentials_table_add_.py b/alembic/versions/2025_02_20_2146-b4bb0b98912a_change_credentials_table_add_.py new file mode 100644 index 00000000..2986dd30 --- /dev/null +++ b/alembic/versions/2025_02_20_2146-b4bb0b98912a_change_credentials_table_add_.py @@ -0,0 +1,52 @@ +"""change credentials table, add organization_collections table + +Revision ID: b4bb0b98912a +Revises: 26c5ed737819 +Create Date: 2025-02-20 21:46:23.732969+00:00 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "b4bb0b98912a" +down_revision: Union[str, None] = "26c5ed737819" +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.create_table( + "organization_bitwarden_collections", + sa.Column("organization_bitwarden_collection_id", sa.String(), nullable=False), + sa.Column("organization_id", sa.String(), nullable=False), + sa.Column("collection_id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("modified_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("organization_bitwarden_collection_id"), + ) + op.create_index( + op.f("ix_organization_bitwarden_collections_organization_id"), + "organization_bitwarden_collections", + ["organization_id"], + unique=False, + ) + op.add_column("credentials", sa.Column("item_id", sa.String(), nullable=True)) + op.drop_column("credentials", "website_url") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("credentials", sa.Column("website_url", sa.VARCHAR(), autoincrement=False, nullable=True)) + op.drop_column("credentials", "item_id") + op.drop_index( + op.f("ix_organization_bitwarden_collections_organization_id"), table_name="organization_bitwarden_collections" + ) + op.drop_table("organization_bitwarden_collections") + # ### end Alembic commands ### diff --git a/skyvern/config.py b/skyvern/config.py index 2ce3a555..a4d1e6f6 100644 --- a/skyvern/config.py +++ b/skyvern/config.py @@ -148,6 +148,14 @@ class Settings(BaseSettings): BITWARDEN_CLIENT_SECRET: str | None = None BITWARDEN_MASTER_PASSWORD: str | None = None + # Skyvern Auth Bitwarden Settings + SKYVERN_AUTH_BITWARDEN_CLIENT_ID: str | None = None + SKYVERN_AUTH_BITWARDEN_CLIENT_SECRET: str | None = None + SKYVERN_AUTH_BITWARDEN_MASTER_PASSWORD: str | None = None + SKYVERN_AUTH_BITWARDEN_ORGANIZATION_ID: str | None = None + + BITWARDEN_SERVER_PORT: int = 8002 + SVG_MAX_LENGTH: int = 100000 ENABLE_LOG_ARTIFACTS: bool = False diff --git a/skyvern/exceptions.py b/skyvern/exceptions.py index 779a6cb4..d67c9d94 100644 --- a/skyvern/exceptions.py +++ b/skyvern/exceptions.py @@ -266,6 +266,11 @@ class NoFileDownloadTriggered(SkyvernException): super().__init__(f"Clicking on element doesn't trigger the file download. element_id={element_id}") +class BitwardenSecretError(SkyvernException): + def __init__(self, message: str) -> None: + super().__init__(f"Bitwarden secret error: {message}") + + class BitwardenBaseError(SkyvernException): def __init__(self, message: str) -> None: super().__init__(f"Bitwarden error: {message}") @@ -281,6 +286,31 @@ class BitwardenUnlockError(BitwardenBaseError): super().__init__(f"Error unlocking Bitwarden: {message}") +class BitwardenCreateCollectionError(BitwardenBaseError): + def __init__(self, message: str) -> None: + super().__init__(f"Error creating collection in Bitwarden: {message}") + + +class BitwardenCreateLoginItemError(BitwardenBaseError): + def __init__(self, message: str) -> None: + super().__init__(f"Error creating login item in Bitwarden: {message}") + + +class BitwardenCreateCreditCardItemError(BitwardenBaseError): + def __init__(self, message: str) -> None: + super().__init__(f"Error creating credit card item in Bitwarden: {message}") + + +class BitwardenCreateFolderError(BitwardenBaseError): + def __init__(self, message: str) -> None: + super().__init__(f"Error creating folder in Bitwarden: {message}") + + +class BitwardenGetItemError(BitwardenBaseError): + def __init__(self, message: str) -> None: + super().__init__(f"Error getting item in Bitwarden: {message}") + + class BitwardenListItemsError(BitwardenBaseError): def __init__(self, message: str) -> None: super().__init__(f"Error listing items in Bitwarden: {message}") diff --git a/skyvern/forge/sdk/core/aiohttp_helper.py b/skyvern/forge/sdk/core/aiohttp_helper.py index 7ed62f49..d6e38f00 100644 --- a/skyvern/forge/sdk/core/aiohttp_helper.py +++ b/skyvern/forge/sdk/core/aiohttp_helper.py @@ -114,3 +114,36 @@ async def aiohttp_post( await asyncio.sleep(retry_timeout) count += 1 raise Exception(f"Failed post request url={url}") + + +async def aiohttp_delete( + url: str, + headers: dict[str, str] | None = None, + cookies: dict[str, str] | None = None, + retry: int = 0, + proxy: str | None = None, + timeout: int = DEFAULT_REQUEST_TIMEOUT, + raise_exception: bool = True, + retry_timeout: float = 0, +) -> dict[str, Any]: + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session: + count = 0 + while count <= retry: + try: + async with session.delete( + url, + headers=headers, + cookies=cookies, + proxy=proxy, + ) as response: + if response.status == 200: + return await response.json() + if raise_exception: + raise HttpException(response.status, url) + LOG.error(f"Failed to delete data from {url}", status_code=response.status) + return {} + except Exception: + if retry_timeout > 0: + await asyncio.sleep(retry_timeout) + count += 1 + raise Exception(f"Failed to delete data from {url}") diff --git a/skyvern/forge/sdk/db/client.py b/skyvern/forge/sdk/db/client.py index 224ba1cb..78bc0016 100644 --- a/skyvern/forge/sdk/db/client.py +++ b/skyvern/forge/sdk/db/client.py @@ -25,6 +25,7 @@ from skyvern.forge.sdk.db.models import ( ObserverCruiseModel, ObserverThoughtModel, OrganizationAuthTokenModel, + OrganizationBitwardenCollectionModel, OrganizationModel, OutputParameterModel, PersistentBrowserSessionModel, @@ -63,6 +64,7 @@ from skyvern.forge.sdk.models import Step, StepStatus from skyvern.forge.sdk.schemas.ai_suggestions import AISuggestion from skyvern.forge.sdk.schemas.credentials import Credential, CredentialType from skyvern.forge.sdk.schemas.observers import ObserverTask, ObserverTaskStatus, ObserverThought, ObserverThoughtType +from skyvern.forge.sdk.schemas.organization_bitwarden_collections import OrganizationBitwardenCollection from skyvern.forge.sdk.schemas.organizations import Organization, OrganizationAuthToken from skyvern.forge.sdk.schemas.persistent_browser_sessions import PersistentBrowserSession from skyvern.forge.sdk.schemas.task_generations import TaskGeneration @@ -2745,14 +2747,18 @@ class AgentDB: return TaskRun.model_validate(task_run) async def create_credential( - self, name: str, website_url: str | None, credential_type: CredentialType, organization_id: str + self, + name: str, + credential_type: CredentialType, + organization_id: str, + item_id: str, ) -> Credential: async with self.Session() as session: credential = CredentialModel( organization_id=organization_id, name=name, - website_url=website_url, credential_type=credential_type, + item_id=item_id, ) session.add(credential) await session.commit() @@ -2773,7 +2779,7 @@ class AgentDB: return Credential.model_validate(credential) raise NotFoundError(f"Credential {credential_id} not found") - async def get_credentials(self, organization_id: str) -> list[Credential]: + async def get_credentials(self, organization_id: str, page: int = 1, page_size: int = 10) -> list[Credential]: async with self.Session() as session: credentials = ( await session.scalars( @@ -2781,6 +2787,8 @@ class AgentDB: .filter_by(organization_id=organization_id) .filter(CredentialModel.deleted_at.is_(None)) .order_by(CredentialModel.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) ) ).all() return [Credential.model_validate(credential) for credential in credentials] @@ -2822,6 +2830,34 @@ class AgentDB: await session.refresh(credential) return None + async def create_organization_bitwarden_collection( + self, + organization_id: str, + collection_id: str, + ) -> OrganizationBitwardenCollection: + async with self.Session() as session: + organization_bitwarden_collection = OrganizationBitwardenCollectionModel( + organization_id=organization_id, collection_id=collection_id + ) + session.add(organization_bitwarden_collection) + await session.commit() + await session.refresh(organization_bitwarden_collection) + return OrganizationBitwardenCollection.model_validate(organization_bitwarden_collection) + + async def get_organization_bitwarden_collection( + self, + organization_id: str, + ) -> OrganizationBitwardenCollection | None: + async with self.Session() as session: + organization_bitwarden_collection = ( + await session.scalars( + select(OrganizationBitwardenCollectionModel).filter_by(organization_id=organization_id) + ) + ).first() + if organization_bitwarden_collection: + return OrganizationBitwardenCollection.model_validate(organization_bitwarden_collection) + return None + async def cache_task_run(self, run_id: str, organization_id: str | None = None) -> TaskRun: async with self.Session() as session: task_run = ( diff --git a/skyvern/forge/sdk/db/id.py b/skyvern/forge/sdk/db/id.py index 06a91719..54864d45 100644 --- a/skyvern/forge/sdk/db/id.py +++ b/skyvern/forge/sdk/db/id.py @@ -35,6 +35,8 @@ BITWARDEN_CREDIT_CARD_DATA_PARAMETER_PREFIX = "bccd" BITWARDEN_LOGIN_CREDENTIAL_PARAMETER_PREFIX = "blc" BITWARDEN_SENSITIVE_INFORMATION_PARAMETER_PREFIX = "bsi" CREDENTIAL_PARAMETER_PREFIX = "cp" +CREDENTIAL_PREFIX = "cred" +ORGANIZATION_BITWARDEN_COLLECTION_PREFIX = "obc" OBSERVER_CRUISE_ID = "oc" OBSERVER_THOUGHT_ID = "ot" ORGANIZATION_AUTH_TOKEN_PREFIX = "oat" @@ -52,7 +54,6 @@ WORKFLOW_PERMANENT_ID_PREFIX = "wpid" WORKFLOW_PREFIX = "w" WORKFLOW_RUN_BLOCK_PREFIX = "wrb" WORKFLOW_RUN_PREFIX = "wr" -CREDENTIAL_PREFIX = "cred" def generate_workflow_id() -> str: @@ -175,14 +176,19 @@ def generate_task_run_id() -> str: return f"{TASK_RUN_PREFIX}_{int_id}" +def generate_credential_parameter_id() -> str: + int_id = generate_id() + return f"{CREDENTIAL_PARAMETER_PREFIX}_{int_id}" + + def generate_credential_id() -> str: int_id = generate_id() return f"{CREDENTIAL_PREFIX}_{int_id}" -def generate_credential_parameter_id() -> str: +def generate_organization_bitwarden_collection_id() -> str: int_id = generate_id() - return f"{CREDENTIAL_PARAMETER_PREFIX}_{int_id}" + return f"{ORGANIZATION_BITWARDEN_COLLECTION_PREFIX}_{int_id}" def generate_id() -> int: diff --git a/skyvern/forge/sdk/db/models.py b/skyvern/forge/sdk/db/models.py index 51a0590c..8882dc2b 100644 --- a/skyvern/forge/sdk/db/models.py +++ b/skyvern/forge/sdk/db/models.py @@ -33,6 +33,7 @@ from skyvern.forge.sdk.db.id import ( generate_observer_thought_id, generate_org_id, generate_organization_auth_token_id, + generate_organization_bitwarden_collection_id, generate_output_parameter_id, generate_persistent_browser_session_id, generate_step_id, @@ -648,15 +649,29 @@ class TaskRunModel(Base): modified_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow, nullable=False) +class OrganizationBitwardenCollectionModel(Base): + __tablename__ = "organization_bitwarden_collections" + + organization_bitwarden_collection_id = Column( + String, primary_key=True, default=generate_organization_bitwarden_collection_id + ) + + organization_id = Column(String, nullable=False, index=True) + collection_id = Column(String, nullable=False) + + created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) + modified_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow, nullable=False) + + class CredentialModel(Base): __tablename__ = "credentials" credential_id = Column(String, primary_key=True, default=generate_credential_id) organization_id = Column(String, nullable=False) + item_id = Column(String, nullable=True) - credential_type = Column(String, nullable=False) name = Column(String, nullable=False) - website_url = Column(String, nullable=True) + credential_type = Column(String, nullable=False) created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) modified_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow, nullable=False) diff --git a/skyvern/forge/sdk/schemas/credentials.py b/skyvern/forge/sdk/schemas/credentials.py index 7e107a9e..b9262f1b 100644 --- a/skyvern/forge/sdk/schemas/credentials.py +++ b/skyvern/forge/sdk/schemas/credentials.py @@ -9,6 +9,15 @@ class CredentialType(StrEnum): CREDIT_CARD = "credit_card" +class PasswordCredentialResponse(BaseModel): + username: str + + +class CreditCardCredentialResponse(BaseModel): + last_four: str + brand: str + + class PasswordCredential(BaseModel): password: str username: str @@ -23,27 +32,36 @@ class CreditCardCredential(BaseModel): card_holder_name: str -class UpdateCredentialRequest(BaseModel): - name: str | None = None - website_url: str | None = None +class CredentialItem(BaseModel): + item_id: str + name: str + credential_type: CredentialType + credential: PasswordCredential | CreditCardCredential class CreateCredentialRequest(BaseModel): name: str - website_url: str | None = None credential_type: CredentialType credential: PasswordCredential | CreditCardCredential +class CredentialResponse(BaseModel): + credential_id: str + credential: PasswordCredentialResponse | CreditCardCredentialResponse + credential_type: CredentialType + name: str + + class Credential(BaseModel): model_config = ConfigDict(from_attributes=True) credential_id: str organization_id: str name: str - website_url: str | None = None credential_type: CredentialType + item_id: str + created_at: datetime modified_at: datetime deleted_at: datetime | None = None diff --git a/skyvern/forge/sdk/schemas/organization_bitwarden_collections.py b/skyvern/forge/sdk/schemas/organization_bitwarden_collections.py new file mode 100644 index 00000000..565098f4 --- /dev/null +++ b/skyvern/forge/sdk/schemas/organization_bitwarden_collections.py @@ -0,0 +1,13 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class OrganizationBitwardenCollection(BaseModel): + model_config = ConfigDict(from_attributes=True) + + organization_bitwarden_collection_id: str + organization_id: str + collection_id: str + created_at: datetime + modified_at: datetime diff --git a/skyvern/forge/sdk/services/bitwarden.py b/skyvern/forge/sdk/services/bitwarden.py index 9c227ed2..fd439cf4 100644 --- a/skyvern/forge/sdk/services/bitwarden.py +++ b/skyvern/forge/sdk/services/bitwarden.py @@ -3,7 +3,8 @@ import json import os import re import urllib.parse -from enum import StrEnum +from enum import IntEnum, StrEnum +from typing import Tuple import structlog import tldextract @@ -12,15 +13,80 @@ from pydantic import BaseModel from skyvern.config import settings from skyvern.exceptions import ( BitwardenAccessDeniedError, + BitwardenCreateCollectionError, + BitwardenCreateCreditCardItemError, + BitwardenCreateLoginItemError, + BitwardenGetItemError, BitwardenListItemsError, BitwardenLoginError, BitwardenLogoutError, + BitwardenSecretError, BitwardenSyncError, BitwardenUnlockError, ) +from skyvern.forge.sdk.api.aws import aws_client +from skyvern.forge.sdk.core.aiohttp_helper import aiohttp_delete, aiohttp_get_json, aiohttp_post +from skyvern.forge.sdk.schemas.credentials import ( + CredentialItem, + CredentialType, + CreditCardCredential, + PasswordCredential, +) + + +class BitwardenItemType(IntEnum): + LOGIN = 1 + SECURE_NOTE = 2 + CREDIT_CARD = 3 + IDENTITY = 4 + + +def get_bitwarden_item_type_code(item_type: BitwardenItemType) -> int: + if item_type == BitwardenItemType.LOGIN: + return 1 + elif item_type == BitwardenItemType.SECURE_NOTE: + return 2 + elif item_type == BitwardenItemType.CREDIT_CARD: + return 3 + elif item_type == BitwardenItemType.IDENTITY: + return 4 + + +def get_list_response_item_from_bitwarden_item(item: dict) -> CredentialItem: + if item["type"] == BitwardenItemType.LOGIN: + login = item["login"] + return CredentialItem( + item_id=item["id"], + credential=PasswordCredential( + username=login["username"], + password=login["password"], + ), + name=item["name"], + credential_type=CredentialType.PASSWORD, + ) + elif item["type"] == BitwardenItemType.CREDIT_CARD: + card = item["card"] + return CredentialItem( + item_id=item["id"], + credential=CreditCardCredential( + card_holder_name=card["cardholderName"], + card_number=card["number"], + card_exp_month=card["expMonth"], + card_exp_year=card["expYear"], + card_cvv=card["code"], + card_brand=card["brand"], + ), + name=item["name"], + credential_type=CredentialType.CREDIT_CARD, + ) + else: + raise BitwardenGetItemError(f"Unsupported item type: {item['type']}") + LOG = structlog.get_logger() +BITWARDEN_SERVER_BASE_URL = f"http://localhost:{settings.BITWARDEN_SERVER_PORT or 8002}" + def is_valid_email(email: str | None) -> bool: if not email: @@ -52,6 +118,11 @@ class BitwardenConstants(StrEnum): CREDIT_CARD_CVV = "BW_CREDIT_CARD_CVV" CREDIT_CARD_BRAND = "BW_CREDIT_CARD_BRAND" + SKYVERN_AUTH_BITWARDEN_ORGANIZATION_ID = "SKYVERN_AUTH_BITWARDEN_ORGANIZATION_ID" + SKYVERN_AUTH_BITWARDEN_MASTER_PASSWORD = "SKYVERN_AUTH_BITWARDEN_MASTER_PASSWORD" + SKYVERN_AUTH_BITWARDEN_CLIENT_ID = "SKYVERN_AUTH_BITWARDEN_CLIENT_ID" + SKYVERN_AUTH_BITWARDEN_CLIENT_SECRET = "SKYVERN_AUTH_BITWARDEN_CLIENT_SECRET" + class BitwardenQueryResult(BaseModel): credential: dict[str, str] @@ -629,3 +700,307 @@ class BitwardenService: remaining_retries=remaining_retries, fail_reasons=fail_reasons + [f"{type(e).__name__}: {str(e)}"], ) + + @staticmethod + async def _sync_using_server() -> None: + await aiohttp_post(f"{BITWARDEN_SERVER_BASE_URL}/sync") + + @staticmethod + async def _unlock_using_server(master_password: str) -> None: + status_response = await aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/status") + status = status_response["data"]["template"]["status"] + if status != "unlocked": + await aiohttp_post(f"{BITWARDEN_SERVER_BASE_URL}/unlock", data={"password": master_password}) + + @staticmethod + async def _get_login_item_by_id_using_server(item_id: str) -> PasswordCredential: + response = await aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/object/item/{item_id}") + if not response or response.get("success") is False: + raise BitwardenGetItemError(f"Failed to get login item by ID: {item_id}") + + login = response["data"]["login"] + + if not login: + raise BitwardenGetItemError(f"Item with ID: {item_id} is not a login item") + + return PasswordCredential( + username=login["username"], + password=login["password"], + ) + + @staticmethod + async def _create_login_item_using_server( + bw_organization_id: str, + collection_id: str, + name: str, + credential: PasswordCredential, + ) -> str: + item_template = await aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/object/template/item") + login_template = await aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/object/template/item.login") + + item_template = item_template["data"]["template"] + login_template = login_template["data"]["template"] + + login_template["username"] = credential.username + login_template["password"] = credential.password + + item_template["type"] = get_bitwarden_item_type_code(BitwardenItemType.LOGIN) + item_template["name"] = name + item_template["login"] = login_template + item_template["collectionIds"] = [collection_id] + item_template["organizationId"] = bw_organization_id + + response = await aiohttp_post(f"{BITWARDEN_SERVER_BASE_URL}/object/item", data=item_template) + if not response or response.get("success") is False: + raise BitwardenCreateLoginItemError("Failed to create login item") + + return response["data"]["id"] + + @staticmethod + async def _create_credit_card_item_using_server( + bw_organization_id: str, + collection_id: str, + name: str, + credential: CreditCardCredential, + ) -> str: + item_template = await aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/object/template/item") + credit_card_template = await aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/object/template/item.card") + + item_template = item_template["data"]["template"] + credit_card_template = credit_card_template["data"]["template"] + + credit_card_template["cardholderName"] = credential.card_holder_name + credit_card_template["number"] = credential.card_number + credit_card_template["expMonth"] = credential.card_exp_month + credit_card_template["expYear"] = credential.card_exp_year + credit_card_template["code"] = credential.card_cvv + credit_card_template["brand"] = credential.card_brand + + item_template["type"] = get_bitwarden_item_type_code(BitwardenItemType.CREDIT_CARD) + item_template["name"] = name + item_template["card"] = credit_card_template + item_template["collectionIds"] = [collection_id] + item_template["organizationId"] = bw_organization_id + + response = await aiohttp_post(f"{BITWARDEN_SERVER_BASE_URL}/object/item", data=item_template) + if not response or response.get("success") is False: + raise BitwardenCreateCreditCardItemError("Failed to create credit card item") + + return response["data"]["id"] + + @staticmethod + async def create_credential_item( + collection_id: str, + name: str, + credential: PasswordCredential | CreditCardCredential, + ) -> str: + try: + master_password, bw_organization_id, _, _ = await BitwardenService._get_skyvern_auth_secrets() + + await BitwardenService._sync_using_server() + await BitwardenService._unlock_using_server(master_password) + if isinstance(credential, PasswordCredential): + return await BitwardenService._create_login_item_using_server( + bw_organization_id=bw_organization_id, + collection_id=collection_id, + name=name, + credential=credential, + ) + else: + return await BitwardenService._create_credit_card_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 _get_skyvern_auth_master_password() -> str: + master_password = settings.SKYVERN_AUTH_BITWARDEN_MASTER_PASSWORD + if not master_password: + master_password = await aws_client.get_secret(BitwardenConstants.SKYVERN_AUTH_BITWARDEN_MASTER_PASSWORD) + if not master_password: + raise BitwardenSecretError("Skyvern auth master password is not set") + return master_password + + @staticmethod + async def _get_skyvern_auth_organization_id() -> str: + bw_organization_id = settings.SKYVERN_AUTH_BITWARDEN_ORGANIZATION_ID + if not bw_organization_id: + bw_organization_id = await aws_client.get_secret(BitwardenConstants.SKYVERN_AUTH_BITWARDEN_ORGANIZATION_ID) + if not bw_organization_id: + raise BitwardenSecretError("Skyvern auth organization ID is not set") + return bw_organization_id + + @staticmethod + async def _get_skyvern_auth_client_id() -> str: + client_id = settings.SKYVERN_AUTH_BITWARDEN_CLIENT_ID + if not client_id: + client_id = await aws_client.get_secret(BitwardenConstants.SKYVERN_AUTH_BITWARDEN_CLIENT_ID) + if not client_id: + raise BitwardenSecretError("Skyvern auth client ID is not set") + return client_id + + @staticmethod + async def _get_skyvern_auth_client_secret() -> str: + client_secret = settings.SKYVERN_AUTH_BITWARDEN_CLIENT_SECRET + if not client_secret: + client_secret = await aws_client.get_secret(BitwardenConstants.SKYVERN_AUTH_BITWARDEN_CLIENT_SECRET) + if not client_secret: + raise BitwardenSecretError("Skyvern auth client secret is not set") + return client_secret + + @staticmethod + async def create_collection( + name: str, + ) -> str: + """ + Create a collection in Bitwarden and return the collection ID. + """ + try: + master_password, bw_organization_id, _, _ = await BitwardenService._get_skyvern_auth_secrets() + + await BitwardenService._sync_using_server() + await BitwardenService._unlock_using_server(master_password) + return await BitwardenService._create_collection_using_server(bw_organization_id, name) + + except Exception as e: + raise e + + @staticmethod + async def _create_collection_using_server(bw_organization_id: str, name: str) -> str: + collection_template_response = await aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/object/template/collection") + collection_template = collection_template_response["data"]["template"] + + collection_template["name"] = name + collection_template["organizationId"] = bw_organization_id + + response = await aiohttp_post( + f"{BITWARDEN_SERVER_BASE_URL}/object/org-collection?organizationId={bw_organization_id}", + data=collection_template, + ) + if not response or response.get("success") is False: + raise BitwardenCreateCollectionError("Failed to create collection") + + return response["data"]["id"] + + @staticmethod + async def _get_skyvern_auth_secrets() -> Tuple[str, str, str, str]: + master_password, bw_organization_id, client_id, client_secret = await asyncio.gather( + BitwardenService._get_skyvern_auth_master_password(), + BitwardenService._get_skyvern_auth_organization_id(), + BitwardenService._get_skyvern_auth_client_id(), + BitwardenService._get_skyvern_auth_client_secret(), + ) + return master_password, bw_organization_id, client_id, client_secret + + @staticmethod + async def get_items_by_item_ids( + item_ids: list[str], + ) -> list[CredentialItem]: + try: + master_password, _, _, _ = await BitwardenService._get_skyvern_auth_secrets() + await BitwardenService._sync_using_server() + await BitwardenService._unlock_using_server(master_password) + return await BitwardenService._get_items_by_item_ids_using_server(item_ids) + except Exception as e: + raise e + + @staticmethod + async def _get_items_by_item_ids_using_server(item_ids: list[str]) -> list[CredentialItem]: + responses = await asyncio.gather( + *[aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/object/item/{item_id}") for item_id in item_ids] + ) + if not responses or any(response.get("success") is False for response in responses): + raise BitwardenGetItemError("Failed to get collection items") + + return [get_list_response_item_from_bitwarden_item(response["data"]) for response in responses] + + @staticmethod + async def get_collection_items( + collection_id: str, + ) -> list[CredentialItem]: + try: + master_password, _, _, _ = await BitwardenService._get_skyvern_auth_secrets() + await BitwardenService._sync_using_server() + await BitwardenService._unlock_using_server(master_password) + return await BitwardenService._get_collection_items_using_server(collection_id) + except Exception as e: + raise e + + @staticmethod + async def _get_collection_items_using_server(collection_id: str) -> list[CredentialItem]: + response = await aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/list/object/items?collectionId={collection_id}") + if not response or response.get("success") is False: + raise BitwardenGetItemError("Failed to get collection items") + + items = response["data"]["data"] + items = map(lambda item: get_list_response_item_from_bitwarden_item(item), items) + return list(items) + + @staticmethod + async def get_credential_item( + item_id: str, + ) -> CredentialItem: + try: + master_password, _, _, _ = await BitwardenService._get_skyvern_auth_secrets() + await BitwardenService._sync_using_server() + await BitwardenService._unlock_using_server(master_password) + return await BitwardenService._get_credential_item_by_id_using_server(item_id) + except Exception as e: + raise e + + @staticmethod + async def _get_credential_item_by_id_using_server(item_id: str) -> CredentialItem: + response = await aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/object/item/{item_id}") + if not response or response.get("success") is False: + raise BitwardenGetItemError(f"Failed to get credential item by ID: {item_id}") + + if response["data"]["type"] == BitwardenItemType.LOGIN: + login_item = response["data"]["login"] + name = response["data"]["name"] + return CredentialItem( + item_id=item_id, + credential_type=CredentialType.PASSWORD, + name=name, + credential=PasswordCredential( + username=login_item["username"], + password=login_item["password"], + ), + ) + elif response["data"]["type"] == BitwardenItemType.CREDIT_CARD: + credit_card_item = response["data"]["card"] + name = response["data"]["name"] + return CredentialItem( + item_id=item_id, + credential_type=CredentialType.CREDIT_CARD, + name=name, + credential=CreditCardCredential( + card_holder_name=credit_card_item["cardholderName"], + card_number=credit_card_item["number"], + card_exp_month=credit_card_item["expMonth"], + card_exp_year=credit_card_item["expYear"], + card_cvv=credit_card_item["code"], + card_brand=credit_card_item["brand"], + ), + ) + else: + raise BitwardenGetItemError(f"Unsupported item type: {response['data']['type']}") + + @staticmethod + async def delete_credential_item( + item_id: str, + ) -> None: + try: + master_password, _, _, _ = await BitwardenService._get_skyvern_auth_secrets() + await BitwardenService._sync_using_server() + await BitwardenService._unlock_using_server(master_password) + await BitwardenService._delete_credential_item_using_server(item_id) + except Exception as e: + raise e + + @staticmethod + async def _delete_credential_item_using_server(item_id: str) -> None: + await aiohttp_delete(f"{BITWARDEN_SERVER_BASE_URL}/object/item/{item_id}") diff --git a/skyvern/forge/sdk/workflow/context_manager.py b/skyvern/forge/sdk/workflow/context_manager.py index 7386f916..ae4dd9bc 100644 --- a/skyvern/forge/sdk/workflow/context_manager.py +++ b/skyvern/forge/sdk/workflow/context_manager.py @@ -1,4 +1,3 @@ -import json import uuid from typing import TYPE_CHECKING, Any, Self @@ -8,10 +7,10 @@ from skyvern.config import settings from skyvern.exceptions import ( BitwardenBaseError, CredentialParameterNotFoundError, - CredentialParameterParsingError, SkyvernException, WorkflowRunContextNotInitialized, ) +from skyvern.forge import app from skyvern.forge.sdk.api.aws import AsyncAWSClient from skyvern.forge.sdk.schemas.organizations import Organization from skyvern.forge.sdk.schemas.tasks import TaskStatus @@ -78,9 +77,7 @@ class WorkflowRunContext: if isinstance(secrete_parameter, AWSSecretParameter): await workflow_run_context.register_aws_secret_parameter_value(aws_client, secrete_parameter) elif isinstance(secrete_parameter, CredentialParameter): - await workflow_run_context.register_credential_parameter_value( - aws_client, secrete_parameter, organization - ) + await workflow_run_context.register_credential_parameter_value(secrete_parameter, organization) elif isinstance(secrete_parameter, BitwardenLoginCredentialParameter): await workflow_run_context.register_bitwarden_login_credential_parameter_value( aws_client, secrete_parameter, organization @@ -176,30 +173,21 @@ class WorkflowRunContext: async def register_credential_parameter_value( self, - aws_client: AsyncAWSClient, parameter: CredentialParameter, organization: Organization, ) -> None: LOG.info(f"Fetching credential parameter value for credential: {parameter.credential_id}") - org_secret_values = await aws_client.get_secret(organization.organization_id) - if org_secret_values is None: - raise CredentialParameterNotFoundError(parameter.credential_id) - # Parse the items and extract credentials - try: - org_secret_values_json = json.loads(org_secret_values) - - except json.JSONDecodeError: - raise CredentialParameterParsingError( - f"Failed to parse credential JSON. Credential ID: {parameter.credential_id}" - ) - - credentials = org_secret_values_json.get(parameter.credential_id) - if credentials is None: + credential_item = await app.DATABASE.get_credential( + parameter.credential_id, organization_id=organization.organization_id + ) + if credential_item is None: raise CredentialParameterNotFoundError(parameter.credential_id) + credential = await BitwardenService.get_credential_item(credential_item.item_id) + credential_dict = credential.credential.model_dump() self.parameters[parameter.key] = parameter self.values[parameter.key] = {} - for key, value in credentials.items(): + for key, value in credential_dict.items(): random_secret_id = self.generate_random_secret_id() secret_id = f"{random_secret_id}_{key}" self.secrets[secret_id] = value