Add endpoint to update existing credential data (#SKY-7883) (#4693)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
This commit is contained in:
@@ -66,6 +66,35 @@ class AzureCredentialVaultService(CredentialVaultService):
|
||||
|
||||
return credential
|
||||
|
||||
async def update_credential(self, credential: Credential, data: CreateCredentialRequest) -> Credential:
|
||||
# Azure supports in-place secret updates, so we reuse the same item_id.
|
||||
# NOTE: If the DB update below fails, the vault will contain the new data
|
||||
# while DB metadata (name, type, username) remains stale. The actual credential
|
||||
# data in the vault is still correct since it uses the same item_id. A retry
|
||||
# of the update call will reconcile the DB metadata.
|
||||
await self._update_azure_secret_item(
|
||||
item_id=credential.item_id,
|
||||
credential=data.credential,
|
||||
)
|
||||
|
||||
try:
|
||||
updated_credential = await self._update_db_credential(
|
||||
credential=credential,
|
||||
data=data,
|
||||
item_id=credential.item_id,
|
||||
)
|
||||
except Exception:
|
||||
LOG.error(
|
||||
"DB update failed after Azure vault secret was already overwritten. "
|
||||
"Vault data is updated but DB metadata may be stale.",
|
||||
organization_id=credential.organization_id,
|
||||
credential_id=credential.credential_id,
|
||||
item_id=credential.item_id,
|
||||
)
|
||||
raise
|
||||
|
||||
return updated_credential
|
||||
|
||||
async def delete_credential(
|
||||
self,
|
||||
credential: Credential,
|
||||
@@ -78,7 +107,7 @@ class AzureCredentialVaultService(CredentialVaultService):
|
||||
secret_value="",
|
||||
)
|
||||
|
||||
async def post_delete_credential_item(self, item_id: str) -> None:
|
||||
async def post_delete_credential_item(self, item_id: str, _organization_id: str | None = None) -> None:
|
||||
"""
|
||||
Background task to delete the credential item from Azure Key Vault.
|
||||
This allows the API to respond quickly while the deletion happens asynchronously.
|
||||
@@ -184,3 +213,42 @@ class AzureCredentialVaultService(CredentialVaultService):
|
||||
secret_name=secret_name,
|
||||
secret_value=secret_value,
|
||||
)
|
||||
|
||||
async def _update_azure_secret_item(
|
||||
self,
|
||||
item_id: str,
|
||||
credential: PasswordCredential | CreditCardCredential | SecretCredential,
|
||||
) -> None:
|
||||
if isinstance(credential, PasswordCredential):
|
||||
data = AzureCredentialVaultService._PasswordCredentialDataImage(
|
||||
type="password",
|
||||
username=credential.username,
|
||||
password=credential.password,
|
||||
totp=credential.totp,
|
||||
)
|
||||
elif isinstance(credential, CreditCardCredential):
|
||||
data = AzureCredentialVaultService._CreditCardCredentialDataImage(
|
||||
type="credit_card",
|
||||
card_number=credential.card_number,
|
||||
card_cvv=credential.card_cvv,
|
||||
card_exp_month=credential.card_exp_month,
|
||||
card_exp_year=credential.card_exp_year,
|
||||
card_brand=credential.card_brand,
|
||||
card_holder_name=credential.card_holder_name,
|
||||
)
|
||||
elif isinstance(credential, SecretCredential):
|
||||
data = AzureCredentialVaultService._SecretCredentialDataImage(
|
||||
type="secret",
|
||||
secret_value=credential.secret_value,
|
||||
secret_label=credential.secret_label,
|
||||
)
|
||||
else:
|
||||
raise TypeError(f"Invalid credential type: {type(credential)}")
|
||||
|
||||
secret_value = data.model_dump_json(exclude_none=True)
|
||||
|
||||
await self._client.create_or_update_secret(
|
||||
vault_name=self._vault_name,
|
||||
secret_name=item_id,
|
||||
secret_value=secret_value,
|
||||
)
|
||||
|
||||
@@ -46,6 +46,45 @@ class BitwardenCredentialVaultService(CredentialVaultService):
|
||||
|
||||
return credential
|
||||
|
||||
async def update_credential(self, credential: Credential, data: CreateCredentialRequest) -> Credential:
|
||||
org_collection = await app.DATABASE.get_organization_bitwarden_collection(credential.organization_id)
|
||||
|
||||
if not org_collection:
|
||||
raise HTTPException(status_code=404, detail="Credential account not found. It might have been deleted.")
|
||||
|
||||
# Create new vault item with the updated data
|
||||
new_item_id = await BitwardenService.create_credential_item(
|
||||
collection_id=org_collection.collection_id,
|
||||
name=data.name,
|
||||
credential=data.credential,
|
||||
)
|
||||
|
||||
# Update DB record to point to the new vault item
|
||||
try:
|
||||
updated_credential = await self._update_db_credential(
|
||||
credential=credential,
|
||||
data=data,
|
||||
item_id=new_item_id,
|
||||
)
|
||||
except Exception:
|
||||
LOG.warning(
|
||||
"DB update failed, attempting to clean up new Bitwarden vault item",
|
||||
organization_id=credential.organization_id,
|
||||
new_item_id=new_item_id,
|
||||
)
|
||||
try:
|
||||
await BitwardenService.delete_credential_item(new_item_id)
|
||||
except Exception as cleanup_error:
|
||||
LOG.error(
|
||||
"Failed to clean up orphaned Bitwarden vault item",
|
||||
organization_id=credential.organization_id,
|
||||
new_item_id=new_item_id,
|
||||
error=str(cleanup_error),
|
||||
)
|
||||
raise
|
||||
|
||||
return updated_credential
|
||||
|
||||
async def delete_credential(
|
||||
self,
|
||||
credential: Credential,
|
||||
@@ -59,5 +98,20 @@ class BitwardenCredentialVaultService(CredentialVaultService):
|
||||
await app.DATABASE.delete_credential(credential.credential_id, credential.organization_id)
|
||||
await BitwardenService.delete_credential_item(credential.item_id)
|
||||
|
||||
async def post_delete_credential_item(self, item_id: str, organization_id: str | None = None) -> None:
|
||||
try:
|
||||
await BitwardenService.delete_credential_item(item_id)
|
||||
LOG.info(
|
||||
"Successfully deleted credential item from Bitwarden in background",
|
||||
item_id=item_id,
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.warning(
|
||||
"Failed to delete credential item from Bitwarden in background",
|
||||
item_id=item_id,
|
||||
error=str(e),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def get_credential_item(self, db_credential: Credential) -> CredentialItem:
|
||||
return await BitwardenService.get_credential_item(db_credential.item_id)
|
||||
|
||||
@@ -21,11 +21,15 @@ class CredentialVaultService(ABC):
|
||||
async def create_credential(self, organization_id: str, data: CreateCredentialRequest) -> Credential:
|
||||
"""Create a new credential in the vault and database."""
|
||||
|
||||
@abstractmethod
|
||||
async def update_credential(self, credential: Credential, data: CreateCredentialRequest) -> Credential:
|
||||
"""Update an existing credential's vault data. Returns the updated credential."""
|
||||
|
||||
@abstractmethod
|
||||
async def delete_credential(self, credential: Credential) -> None:
|
||||
"""Delete a credential from the vault and database."""
|
||||
|
||||
async def post_delete_credential_item(self, item_id: str) -> None:
|
||||
async def post_delete_credential_item(self, item_id: str, organization_id: str | None = None) -> None:
|
||||
"""
|
||||
Optional hook for scheduling background cleanup tasks after credential deletion.
|
||||
Default implementation does nothing. Override in subclasses as needed.
|
||||
@@ -84,3 +88,52 @@ class CredentialVaultService(ABC):
|
||||
)
|
||||
else:
|
||||
raise Exception(f"Unsupported credential type: {data.credential_type}")
|
||||
|
||||
@staticmethod
|
||||
async def _update_db_credential(
|
||||
credential: Credential,
|
||||
data: CreateCredentialRequest,
|
||||
item_id: str,
|
||||
) -> Credential:
|
||||
if data.credential_type == CredentialType.PASSWORD:
|
||||
return await app.DATABASE.update_credential_vault_data(
|
||||
credential_id=credential.credential_id,
|
||||
organization_id=credential.organization_id,
|
||||
item_id=item_id,
|
||||
name=data.name,
|
||||
credential_type=data.credential_type,
|
||||
username=data.credential.username,
|
||||
totp_type=data.credential.totp_type,
|
||||
totp_identifier=data.credential.totp_identifier,
|
||||
card_last4=None,
|
||||
card_brand=None,
|
||||
)
|
||||
elif data.credential_type == CredentialType.CREDIT_CARD:
|
||||
return await app.DATABASE.update_credential_vault_data(
|
||||
credential_id=credential.credential_id,
|
||||
organization_id=credential.organization_id,
|
||||
item_id=item_id,
|
||||
name=data.name,
|
||||
credential_type=data.credential_type,
|
||||
username=None,
|
||||
totp_type="none",
|
||||
card_last4=data.credential.card_number[-4:],
|
||||
card_brand=data.credential.card_brand,
|
||||
totp_identifier=None,
|
||||
)
|
||||
elif data.credential_type == CredentialType.SECRET:
|
||||
return await app.DATABASE.update_credential_vault_data(
|
||||
credential_id=credential.credential_id,
|
||||
organization_id=credential.organization_id,
|
||||
item_id=item_id,
|
||||
name=data.name,
|
||||
credential_type=data.credential_type,
|
||||
username=None,
|
||||
totp_type="none",
|
||||
card_last4=None,
|
||||
card_brand=None,
|
||||
totp_identifier=None,
|
||||
secret_label=data.credential.secret_label,
|
||||
)
|
||||
else:
|
||||
raise Exception(f"Unsupported credential type: {data.credential_type}")
|
||||
|
||||
@@ -160,6 +160,102 @@ class CustomCredentialVaultService(CredentialVaultService):
|
||||
)
|
||||
raise
|
||||
|
||||
async def update_credential(self, credential: Credential, data: CreateCredentialRequest) -> Credential:
|
||||
LOG.info(
|
||||
"Updating credential in custom vault",
|
||||
organization_id=credential.organization_id,
|
||||
credential_id=credential.credential_id,
|
||||
name=data.name,
|
||||
credential_type=data.credential_type,
|
||||
)
|
||||
|
||||
try:
|
||||
client = await self._get_client_for_organization(credential.organization_id)
|
||||
|
||||
# Create new credential in the external API
|
||||
new_item_id = await client.create_credential(
|
||||
name=data.name,
|
||||
credential=data.credential,
|
||||
)
|
||||
|
||||
# Update DB record to point to the new vault item
|
||||
try:
|
||||
updated_credential = await self._update_db_credential(
|
||||
credential=credential,
|
||||
data=data,
|
||||
item_id=new_item_id,
|
||||
)
|
||||
except Exception:
|
||||
LOG.warning(
|
||||
"DB update failed, attempting to clean up new external credential",
|
||||
organization_id=credential.organization_id,
|
||||
new_item_id=new_item_id,
|
||||
)
|
||||
try:
|
||||
await client.delete_credential(new_item_id)
|
||||
except Exception as cleanup_error:
|
||||
LOG.error(
|
||||
"Failed to clean up orphaned external credential",
|
||||
organization_id=credential.organization_id,
|
||||
new_item_id=new_item_id,
|
||||
error=str(cleanup_error),
|
||||
)
|
||||
raise
|
||||
|
||||
LOG.info(
|
||||
"Successfully updated credential in custom vault",
|
||||
organization_id=credential.organization_id,
|
||||
credential_id=credential.credential_id,
|
||||
old_item_id=credential.item_id,
|
||||
new_item_id=new_item_id,
|
||||
)
|
||||
|
||||
return updated_credential
|
||||
|
||||
except Exception as e:
|
||||
LOG.error(
|
||||
"Failed to update credential in custom vault",
|
||||
organization_id=credential.organization_id,
|
||||
credential_id=credential.credential_id,
|
||||
error=str(e),
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
async def post_delete_credential_item(self, item_id: str, organization_id: str | None = None) -> None:
|
||||
"""
|
||||
Background task to delete the old credential item from the custom vault
|
||||
after an update or delete operation.
|
||||
"""
|
||||
try:
|
||||
if organization_id is None and self._client is None:
|
||||
LOG.warning(
|
||||
"Skipping custom vault cleanup; organization_id is required for per-organization configuration",
|
||||
item_id=item_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
return
|
||||
|
||||
if self._client is not None:
|
||||
client = self._client
|
||||
else:
|
||||
assert organization_id is not None
|
||||
client = await self._get_client_for_organization(organization_id)
|
||||
await client.delete_credential(item_id)
|
||||
LOG.info(
|
||||
"Successfully deleted credential item from custom vault in background",
|
||||
organization_id=organization_id,
|
||||
item_id=item_id,
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.warning(
|
||||
"Failed to delete credential item from custom vault in background",
|
||||
organization_id=organization_id,
|
||||
item_id=item_id,
|
||||
error=str(e),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def delete_credential(self, credential: Credential) -> None:
|
||||
"""
|
||||
Delete a credential from the custom vault and database.
|
||||
|
||||
Reference in New Issue
Block a user