Add endpoint to update existing credential data (#SKY-7883) (#4693)

Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
This commit is contained in:
Suchintan
2026-02-11 00:04:51 -05:00
committed by GitHub
parent 8b49620f9e
commit 8c35adf3b9
6 changed files with 387 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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