fix: handle credential decryption errors gracefully (#740)

- Credential.get_all() now uses per-row error handling instead of failing on first bad row
- Broken credentials include decryption_error field with descriptive message
- DELETE endpoint falls back to direct DB delete when credential can't be decrypted
- Frontend shows amber warning alert for broken credentials with disabled test/edit/discover
- Added i18n translation keys for decryption error warning in all 9 locales
This commit is contained in:
Luis Novo 2026-04-12 21:22:37 -03:00
parent 4ae459ca5e
commit ba01f7df4e
15 changed files with 120 additions and 18 deletions

View file

@ -53,6 +53,7 @@ class Credential(ObjectModel):
provider: str
modalities: List[str] = []
api_key: Optional[SecretStr] = None
decryption_error: Optional[str] = None
base_url: Optional[str] = None
endpoint: Optional[str] = None
api_version: Optional[str] = None
@ -130,18 +131,47 @@ class Credential(ObjectModel):
@classmethod
async def get_all(cls, order_by=None) -> List["Credential"]:
"""Override get_all() to handle api_key decryption."""
instances = await super().get_all(order_by=order_by)
for instance in instances:
if instance.api_key:
raw = (
instance.api_key.get_secret_value()
if isinstance(instance.api_key, SecretStr)
else instance.api_key
"""Override get_all() to handle api_key decryption with per-row error handling."""
order_clause = f" ORDER BY {order_by}" if order_by else ""
results = await repo_query(
f"SELECT * FROM {cls.table_name}{order_clause}",
{},
)
credentials = []
for row in results:
try:
cred = cls._from_db_row(row)
credentials.append(cred)
except Exception as e:
logger.warning(
f"Failed to decrypt credential {row.get('id', 'unknown')}: {e}"
)
decrypted = decrypt_value(raw)
object.__setattr__(instance, "api_key", SecretStr(decrypted))
return instances
# Create a minimal credential with error info from raw DB fields
try:
error_cred = cls(
name=row.get("name", "Unknown"),
provider=row.get("provider", "unknown"),
modalities=row.get("modalities", []),
decryption_error=f"Failed to decrypt API key. The encryption key may have changed. Error: {e}",
)
# Preserve the DB id, created, updated from the raw row
if row.get("id"):
object.__setattr__(error_cred, "id", str(row["id"]))
if row.get("created"):
object.__setattr__(error_cred, "created", row["created"])
if row.get("updated"):
object.__setattr__(error_cred, "updated", row["updated"])
# Mark that it had an api_key (even though we can't decrypt it)
if row.get("api_key"):
object.__setattr__(
error_cred, "api_key", SecretStr("UNDECRYPTABLE")
)
credentials.append(error_cred)
except Exception as inner_e:
logger.error(
f"Failed to create error credential for {row.get('id', 'unknown')}: {inner_e}"
)
return credentials
async def get_linked_models(self) -> list:
"""Get all models linked to this credential."""
@ -159,6 +189,8 @@ class Credential(ObjectModel):
"""Override to encrypt api_key before storage."""
data = {}
for key, value in self.model_dump().items():
if key == "decryption_error":
continue
if key == "api_key":
# Handle SecretStr: extract, encrypt, store
if self.api_key: