Use bitwarden server to manage credentials (#1806)

Co-authored-by: Muhammed Salih Altun <muhammedsalihaltun@gmail.com>
This commit is contained in:
Shuchang Zheng 2025-02-20 13:50:41 -08:00 committed by GitHub
parent 902c0ad4ce
commit 02a8861d4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 609 additions and 35 deletions

View file

@ -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 ###

View file

@ -148,6 +148,14 @@ class Settings(BaseSettings):
BITWARDEN_CLIENT_SECRET: str | None = None BITWARDEN_CLIENT_SECRET: str | None = None
BITWARDEN_MASTER_PASSWORD: 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 SVG_MAX_LENGTH: int = 100000
ENABLE_LOG_ARTIFACTS: bool = False ENABLE_LOG_ARTIFACTS: bool = False

View file

@ -266,6 +266,11 @@ class NoFileDownloadTriggered(SkyvernException):
super().__init__(f"Clicking on element doesn't trigger the file download. element_id={element_id}") 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): class BitwardenBaseError(SkyvernException):
def __init__(self, message: str) -> None: def __init__(self, message: str) -> None:
super().__init__(f"Bitwarden error: {message}") super().__init__(f"Bitwarden error: {message}")
@ -281,6 +286,31 @@ class BitwardenUnlockError(BitwardenBaseError):
super().__init__(f"Error unlocking Bitwarden: {message}") 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): class BitwardenListItemsError(BitwardenBaseError):
def __init__(self, message: str) -> None: def __init__(self, message: str) -> None:
super().__init__(f"Error listing items in Bitwarden: {message}") super().__init__(f"Error listing items in Bitwarden: {message}")

View file

@ -114,3 +114,36 @@ async def aiohttp_post(
await asyncio.sleep(retry_timeout) await asyncio.sleep(retry_timeout)
count += 1 count += 1
raise Exception(f"Failed post request url={url}") 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}")

View file

@ -25,6 +25,7 @@ from skyvern.forge.sdk.db.models import (
ObserverCruiseModel, ObserverCruiseModel,
ObserverThoughtModel, ObserverThoughtModel,
OrganizationAuthTokenModel, OrganizationAuthTokenModel,
OrganizationBitwardenCollectionModel,
OrganizationModel, OrganizationModel,
OutputParameterModel, OutputParameterModel,
PersistentBrowserSessionModel, 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.ai_suggestions import AISuggestion
from skyvern.forge.sdk.schemas.credentials import Credential, CredentialType 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.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.organizations import Organization, OrganizationAuthToken
from skyvern.forge.sdk.schemas.persistent_browser_sessions import PersistentBrowserSession from skyvern.forge.sdk.schemas.persistent_browser_sessions import PersistentBrowserSession
from skyvern.forge.sdk.schemas.task_generations import TaskGeneration from skyvern.forge.sdk.schemas.task_generations import TaskGeneration
@ -2745,14 +2747,18 @@ class AgentDB:
return TaskRun.model_validate(task_run) return TaskRun.model_validate(task_run)
async def create_credential( 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: ) -> Credential:
async with self.Session() as session: async with self.Session() as session:
credential = CredentialModel( credential = CredentialModel(
organization_id=organization_id, organization_id=organization_id,
name=name, name=name,
website_url=website_url,
credential_type=credential_type, credential_type=credential_type,
item_id=item_id,
) )
session.add(credential) session.add(credential)
await session.commit() await session.commit()
@ -2773,7 +2779,7 @@ class AgentDB:
return Credential.model_validate(credential) return Credential.model_validate(credential)
raise NotFoundError(f"Credential {credential_id} not found") 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: async with self.Session() as session:
credentials = ( credentials = (
await session.scalars( await session.scalars(
@ -2781,6 +2787,8 @@ class AgentDB:
.filter_by(organization_id=organization_id) .filter_by(organization_id=organization_id)
.filter(CredentialModel.deleted_at.is_(None)) .filter(CredentialModel.deleted_at.is_(None))
.order_by(CredentialModel.created_at.desc()) .order_by(CredentialModel.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
) )
).all() ).all()
return [Credential.model_validate(credential) for credential in credentials] return [Credential.model_validate(credential) for credential in credentials]
@ -2822,6 +2830,34 @@ class AgentDB:
await session.refresh(credential) await session.refresh(credential)
return None 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 def cache_task_run(self, run_id: str, organization_id: str | None = None) -> TaskRun:
async with self.Session() as session: async with self.Session() as session:
task_run = ( task_run = (

View file

@ -35,6 +35,8 @@ BITWARDEN_CREDIT_CARD_DATA_PARAMETER_PREFIX = "bccd"
BITWARDEN_LOGIN_CREDENTIAL_PARAMETER_PREFIX = "blc" BITWARDEN_LOGIN_CREDENTIAL_PARAMETER_PREFIX = "blc"
BITWARDEN_SENSITIVE_INFORMATION_PARAMETER_PREFIX = "bsi" BITWARDEN_SENSITIVE_INFORMATION_PARAMETER_PREFIX = "bsi"
CREDENTIAL_PARAMETER_PREFIX = "cp" CREDENTIAL_PARAMETER_PREFIX = "cp"
CREDENTIAL_PREFIX = "cred"
ORGANIZATION_BITWARDEN_COLLECTION_PREFIX = "obc"
OBSERVER_CRUISE_ID = "oc" OBSERVER_CRUISE_ID = "oc"
OBSERVER_THOUGHT_ID = "ot" OBSERVER_THOUGHT_ID = "ot"
ORGANIZATION_AUTH_TOKEN_PREFIX = "oat" ORGANIZATION_AUTH_TOKEN_PREFIX = "oat"
@ -52,7 +54,6 @@ WORKFLOW_PERMANENT_ID_PREFIX = "wpid"
WORKFLOW_PREFIX = "w" WORKFLOW_PREFIX = "w"
WORKFLOW_RUN_BLOCK_PREFIX = "wrb" WORKFLOW_RUN_BLOCK_PREFIX = "wrb"
WORKFLOW_RUN_PREFIX = "wr" WORKFLOW_RUN_PREFIX = "wr"
CREDENTIAL_PREFIX = "cred"
def generate_workflow_id() -> str: def generate_workflow_id() -> str:
@ -175,14 +176,19 @@ def generate_task_run_id() -> str:
return f"{TASK_RUN_PREFIX}_{int_id}" 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: def generate_credential_id() -> str:
int_id = generate_id() int_id = generate_id()
return f"{CREDENTIAL_PREFIX}_{int_id}" return f"{CREDENTIAL_PREFIX}_{int_id}"
def generate_credential_parameter_id() -> str: def generate_organization_bitwarden_collection_id() -> str:
int_id = generate_id() int_id = generate_id()
return f"{CREDENTIAL_PARAMETER_PREFIX}_{int_id}" return f"{ORGANIZATION_BITWARDEN_COLLECTION_PREFIX}_{int_id}"
def generate_id() -> int: def generate_id() -> int:

View file

@ -33,6 +33,7 @@ from skyvern.forge.sdk.db.id import (
generate_observer_thought_id, generate_observer_thought_id,
generate_org_id, generate_org_id,
generate_organization_auth_token_id, generate_organization_auth_token_id,
generate_organization_bitwarden_collection_id,
generate_output_parameter_id, generate_output_parameter_id,
generate_persistent_browser_session_id, generate_persistent_browser_session_id,
generate_step_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) 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): class CredentialModel(Base):
__tablename__ = "credentials" __tablename__ = "credentials"
credential_id = Column(String, primary_key=True, default=generate_credential_id) credential_id = Column(String, primary_key=True, default=generate_credential_id)
organization_id = Column(String, nullable=False) organization_id = Column(String, nullable=False)
item_id = Column(String, nullable=True)
credential_type = Column(String, nullable=False)
name = 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) created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
modified_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow, nullable=False) modified_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow, nullable=False)

View file

@ -9,6 +9,15 @@ class CredentialType(StrEnum):
CREDIT_CARD = "credit_card" CREDIT_CARD = "credit_card"
class PasswordCredentialResponse(BaseModel):
username: str
class CreditCardCredentialResponse(BaseModel):
last_four: str
brand: str
class PasswordCredential(BaseModel): class PasswordCredential(BaseModel):
password: str password: str
username: str username: str
@ -23,27 +32,36 @@ class CreditCardCredential(BaseModel):
card_holder_name: str card_holder_name: str
class UpdateCredentialRequest(BaseModel): class CredentialItem(BaseModel):
name: str | None = None item_id: str
website_url: str | None = None name: str
credential_type: CredentialType
credential: PasswordCredential | CreditCardCredential
class CreateCredentialRequest(BaseModel): class CreateCredentialRequest(BaseModel):
name: str name: str
website_url: str | None = None
credential_type: CredentialType credential_type: CredentialType
credential: PasswordCredential | CreditCardCredential credential: PasswordCredential | CreditCardCredential
class CredentialResponse(BaseModel):
credential_id: str
credential: PasswordCredentialResponse | CreditCardCredentialResponse
credential_type: CredentialType
name: str
class Credential(BaseModel): class Credential(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
credential_id: str credential_id: str
organization_id: str organization_id: str
name: str name: str
website_url: str | None = None
credential_type: CredentialType credential_type: CredentialType
item_id: str
created_at: datetime created_at: datetime
modified_at: datetime modified_at: datetime
deleted_at: datetime | None = None deleted_at: datetime | None = None

View file

@ -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

View file

@ -3,7 +3,8 @@ import json
import os import os
import re import re
import urllib.parse import urllib.parse
from enum import StrEnum from enum import IntEnum, StrEnum
from typing import Tuple
import structlog import structlog
import tldextract import tldextract
@ -12,15 +13,80 @@ from pydantic import BaseModel
from skyvern.config import settings from skyvern.config import settings
from skyvern.exceptions import ( from skyvern.exceptions import (
BitwardenAccessDeniedError, BitwardenAccessDeniedError,
BitwardenCreateCollectionError,
BitwardenCreateCreditCardItemError,
BitwardenCreateLoginItemError,
BitwardenGetItemError,
BitwardenListItemsError, BitwardenListItemsError,
BitwardenLoginError, BitwardenLoginError,
BitwardenLogoutError, BitwardenLogoutError,
BitwardenSecretError,
BitwardenSyncError, BitwardenSyncError,
BitwardenUnlockError, 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() 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: def is_valid_email(email: str | None) -> bool:
if not email: if not email:
@ -52,6 +118,11 @@ class BitwardenConstants(StrEnum):
CREDIT_CARD_CVV = "BW_CREDIT_CARD_CVV" CREDIT_CARD_CVV = "BW_CREDIT_CARD_CVV"
CREDIT_CARD_BRAND = "BW_CREDIT_CARD_BRAND" 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): class BitwardenQueryResult(BaseModel):
credential: dict[str, str] credential: dict[str, str]
@ -629,3 +700,307 @@ class BitwardenService:
remaining_retries=remaining_retries, remaining_retries=remaining_retries,
fail_reasons=fail_reasons + [f"{type(e).__name__}: {str(e)}"], 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}")

View file

@ -1,4 +1,3 @@
import json
import uuid import uuid
from typing import TYPE_CHECKING, Any, Self from typing import TYPE_CHECKING, Any, Self
@ -8,10 +7,10 @@ from skyvern.config import settings
from skyvern.exceptions import ( from skyvern.exceptions import (
BitwardenBaseError, BitwardenBaseError,
CredentialParameterNotFoundError, CredentialParameterNotFoundError,
CredentialParameterParsingError,
SkyvernException, SkyvernException,
WorkflowRunContextNotInitialized, WorkflowRunContextNotInitialized,
) )
from skyvern.forge import app
from skyvern.forge.sdk.api.aws import AsyncAWSClient from skyvern.forge.sdk.api.aws import AsyncAWSClient
from skyvern.forge.sdk.schemas.organizations import Organization from skyvern.forge.sdk.schemas.organizations import Organization
from skyvern.forge.sdk.schemas.tasks import TaskStatus from skyvern.forge.sdk.schemas.tasks import TaskStatus
@ -78,9 +77,7 @@ class WorkflowRunContext:
if isinstance(secrete_parameter, AWSSecretParameter): if isinstance(secrete_parameter, AWSSecretParameter):
await workflow_run_context.register_aws_secret_parameter_value(aws_client, secrete_parameter) await workflow_run_context.register_aws_secret_parameter_value(aws_client, secrete_parameter)
elif isinstance(secrete_parameter, CredentialParameter): elif isinstance(secrete_parameter, CredentialParameter):
await workflow_run_context.register_credential_parameter_value( await workflow_run_context.register_credential_parameter_value(secrete_parameter, organization)
aws_client, secrete_parameter, organization
)
elif isinstance(secrete_parameter, BitwardenLoginCredentialParameter): elif isinstance(secrete_parameter, BitwardenLoginCredentialParameter):
await workflow_run_context.register_bitwarden_login_credential_parameter_value( await workflow_run_context.register_bitwarden_login_credential_parameter_value(
aws_client, secrete_parameter, organization aws_client, secrete_parameter, organization
@ -176,30 +173,21 @@ class WorkflowRunContext:
async def register_credential_parameter_value( async def register_credential_parameter_value(
self, self,
aws_client: AsyncAWSClient,
parameter: CredentialParameter, parameter: CredentialParameter,
organization: Organization, organization: Organization,
) -> None: ) -> None:
LOG.info(f"Fetching credential parameter value for credential: {parameter.credential_id}") LOG.info(f"Fetching credential parameter value for credential: {parameter.credential_id}")
org_secret_values = await aws_client.get_secret(organization.organization_id) credential_item = await app.DATABASE.get_credential(
if org_secret_values is None: parameter.credential_id, organization_id=organization.organization_id
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}"
) )
if credential_item is None:
credentials = org_secret_values_json.get(parameter.credential_id)
if credentials is None:
raise CredentialParameterNotFoundError(parameter.credential_id) 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.parameters[parameter.key] = parameter
self.values[parameter.key] = {} 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() random_secret_id = self.generate_random_secret_id()
secret_id = f"{random_secret_id}_{key}" secret_id = f"{random_secret_id}_{key}"
self.secrets[secret_id] = value self.secrets[secret_id] = value