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

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}")
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}")

View file

@ -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}")

View file

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

View file

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

View file

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

View file

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

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 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}")

View file

@ -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}"
credential_item = await app.DATABASE.get_credential(
parameter.credential_id, organization_id=organization.organization_id
)
credentials = org_secret_values_json.get(parameter.credential_id)
if credentials is None:
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