mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
SKY-11678 Add local credential vault persistence to OSS Docker (#7003)
Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
ef22a35461
commit
720d47d754
23 changed files with 753 additions and 46 deletions
|
|
@ -4,6 +4,9 @@
|
|||
**/traces
|
||||
**/inputs
|
||||
**/har
|
||||
**/downloads
|
||||
**/browser_sessions
|
||||
**/credential_vault
|
||||
**/.git
|
||||
**/.github
|
||||
**/.mypy_cache
|
||||
|
|
|
|||
17
.env.example
17
.env.example
|
|
@ -139,12 +139,29 @@ ANALYTICS_ID="anonymous"
|
|||
# OP_SERVICE_ACCOUNT_TOKEN: API token for 1Password integration
|
||||
OP_SERVICE_ACCOUNT_TOKEN=""
|
||||
|
||||
# =============================================================================
|
||||
# SKYVERN CREDENTIAL VAULT CONFIGURATION
|
||||
# =============================================================================
|
||||
# Built-in encrypted local vault used by default by the OSS Docker Compose setup.
|
||||
# Docker also sets ENABLE_LOCAL_CREDENTIAL_VAULT=true and persists this under /data/credential_vault.
|
||||
CREDENTIAL_VAULT_TYPE=skyvern
|
||||
ENABLE_LOCAL_CREDENTIAL_VAULT=true
|
||||
# LOCAL_CREDENTIAL_VAULT_PATH=/data/credential_vault
|
||||
# Production self-hosters should set LOCAL_CREDENTIAL_VAULT_KEY from an external secret store.
|
||||
# If unset, Skyvern generates .fernet_key inside LOCAL_CREDENTIAL_VAULT_PATH; backups or host-dir
|
||||
# exposure of that directory include both encrypted items and the key needed to decrypt them.
|
||||
# LOCAL_CREDENTIAL_VAULT_KEY=
|
||||
# The OSS Docker volumes for browser_sessions/ and downloads/ can contain plaintext cookies,
|
||||
# session tokens, and downloaded files. Treat them as sensitive and exclude them from casual backups.
|
||||
|
||||
# Enable recording skyvern logs as artifacts
|
||||
ENABLE_LOG_ARTIFACTS=false
|
||||
|
||||
# =============================================================================
|
||||
# SKYVERN BITWARDEN CONFIGURATION
|
||||
# =============================================================================
|
||||
# Set CREDENTIAL_VAULT_TYPE=bitwarden to use Bitwarden or vaultwarden instead
|
||||
# of the built-in Skyvern credential vault.
|
||||
# Your organization ID in official Bitwarden server or vaultwarden (if using organizations)
|
||||
SKYVERN_AUTH_BITWARDEN_ORGANIZATION_ID=your-org-id-here
|
||||
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -162,6 +162,7 @@ ig_*
|
|||
|
||||
# Skyvern ignores
|
||||
browser_sessions/
|
||||
credential_vault/
|
||||
videos/
|
||||
skyvern/artifacts/
|
||||
artifacts/
|
||||
|
|
|
|||
12
Dockerfile
12
Dockerfile
|
|
@ -11,6 +11,7 @@ RUN uv pip compile pyproject.toml --extra server --python-version 3.11 -o requir
|
|||
FROM python:3.11-slim-bookworm
|
||||
WORKDIR /app
|
||||
COPY --from=requirements-stage /tmp/requirements.txt /app/requirements.txt
|
||||
COPY ./skyvern/forge/sdk/utils/tesseract_languages.py /tmp/tesseract_languages.py
|
||||
RUN pip install --upgrade pip setuptools wheel
|
||||
# --no-deps: requirements.txt is fully resolved by uv, including the
|
||||
# pyproject overrides that loosen litellm's jsonschema==4.23.0 pin.
|
||||
|
|
@ -18,7 +19,13 @@ RUN pip install --upgrade pip setuptools wheel
|
|||
RUN pip install --no-cache-dir --no-deps -r requirements.txt
|
||||
RUN playwright install-deps
|
||||
RUN playwright install
|
||||
RUN apt-get install -y xauth x11-apps netpbm gpg ca-certificates x11vnc && apt-get clean
|
||||
RUN apt-get update && \
|
||||
apt-get install -y xauth x11-apps netpbm gpg ca-certificates x11vnc tesseract-ocr $(python /tmp/tesseract_languages.py --apt-packages) && \
|
||||
tesseract --version && \
|
||||
rm /tmp/tesseract_languages.py && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir websockify
|
||||
|
||||
COPY .nvmrc /app/.nvmrc
|
||||
|
|
@ -47,6 +54,9 @@ ENV VIDEO_PATH=/data/videos
|
|||
ENV HAR_PATH=/data/har
|
||||
ENV LOG_PATH=/data/log
|
||||
ENV ARTIFACT_STORAGE_PATH=/data/artifacts
|
||||
ENV DOWNLOAD_PATH=/data/downloads
|
||||
ENV BROWSER_SESSION_BASE_PATH=/data/browser_sessions
|
||||
ENV LOCAL_CREDENTIAL_VAULT_PATH=/data/credential_vault
|
||||
|
||||
# cache tiktoken
|
||||
RUN python /app/scripts/load_tiktoken.py
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ services:
|
|||
- ./videos:/data/videos
|
||||
- ./har:/data/har
|
||||
- ./log:/data/log
|
||||
- ./downloads:/data/downloads
|
||||
- ./browser_sessions:/data/browser_sessions
|
||||
- ./credential_vault:/data/credential_vault
|
||||
# Generated credentials allow the UI to pick up the local API key on first startup.
|
||||
- ./.skyvern:/app/.skyvern
|
||||
# Uncomment the following two lines if you want to connect to any local changes
|
||||
|
|
@ -62,6 +65,11 @@ services:
|
|||
- BROWSER_REMOTE_DEBUGGING_URL=${BROWSER_REMOTE_DEBUGGING_URL:-http://127.0.0.1:9222}
|
||||
- BROWSER_REMOTE_DEBUGGING_HOST_HEADER=${BROWSER_REMOTE_DEBUGGING_HOST_HEADER:-}
|
||||
- BROWSER_CDP_CONNECT_TIMEOUT_MS=${BROWSER_CDP_CONNECT_TIMEOUT_MS:-120000}
|
||||
- DOWNLOAD_PATH=/data/downloads
|
||||
- BROWSER_SESSION_BASE_PATH=/data/browser_sessions
|
||||
- CREDENTIAL_VAULT_TYPE=${CREDENTIAL_VAULT_TYPE:-skyvern}
|
||||
- ENABLE_LOCAL_CREDENTIAL_VAULT=${ENABLE_LOCAL_CREDENTIAL_VAULT:-true}
|
||||
- LOCAL_CREDENTIAL_VAULT_PATH=/data/credential_vault
|
||||
- ENABLE_CODE_BLOCK=true
|
||||
# --- Control your own browser (Chrome/Chromium) ---
|
||||
# Prefer Chrome's chrome://inspect/#remote-debugging flow for an existing profile.
|
||||
|
|
|
|||
|
|
@ -3645,10 +3645,10 @@
|
|||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault')",
|
||||
"description": "Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault')",
|
||||
"title": "Vault Type"
|
||||
},
|
||||
"description": "Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault')"
|
||||
"description": "Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault')"
|
||||
},
|
||||
{
|
||||
"name": "credential_type",
|
||||
|
|
@ -10550,7 +10550,7 @@
|
|||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Which vault stores this credential (e.g., 'bitwarden', 'azure_vault', 'custom')"
|
||||
"description": "Which vault stores this credential (e.g., 'skyvern', 'bitwarden', 'azure_vault', 'custom')"
|
||||
},
|
||||
"browser_profile_id": {
|
||||
"anyOf": [
|
||||
|
|
@ -10670,6 +10670,7 @@
|
|||
"CredentialVaultType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"skyvern",
|
||||
"bitwarden",
|
||||
"azure_vault",
|
||||
"gcp",
|
||||
|
|
|
|||
|
|
@ -363,7 +363,7 @@ Restart Skyvern after setting these variables.
|
|||
| Status stays Inactive | Verify the API base URL is a valid URL and the API token is not empty. The configuration is validated on save but does not make a live request to your server. |
|
||||
| Credentials not created | Review your API logs for auth errors. Ensure the response includes an `id` field. Skyvern expects HTTP 200 for all operations. |
|
||||
| Credentials not retrieved | Ensure the GET response includes all required fields for the credential type (`username` and `password` for passwords, all card fields for credit cards, `secret_value` for secrets). |
|
||||
| Env config not working | Restart Skyvern after setting variables. Verify `CREDENTIAL_VAULT_TYPE=custom` is set and both URL and token are provided. The default vault type is `bitwarden`, so this variable must be explicitly set. |
|
||||
| Env config not working | Restart Skyvern after setting variables. Verify `CREDENTIAL_VAULT_TYPE=custom` is set and both URL and token are provided. Deployment defaults vary, so `custom` must be explicitly set for an external service. |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ for (const c of creds) {
|
|||
|-----------|------|----------|---------|-------------|
|
||||
| `page` | `int` | No | `None` | Page number. |
|
||||
| `page_size` | `int` | No | `None` | Results per page. |
|
||||
| `vault_type` | `CredentialVaultType` | No | `None` | Filter credentials by vault type (e.g. `"custom"`, `"bitwarden"`, `"azure_vault"`). |
|
||||
| `vault_type` | `CredentialVaultType` | No | `None` | Filter credentials by vault type (e.g. `"skyvern"`, `"custom"`, `"bitwarden"`, `"azure_vault"`). |
|
||||
| `request_options` | `RequestOptions` | No | `None` | Per-request configuration (see below). |
|
||||
|
||||
### Returns `list[CredentialResponse]`
|
||||
|
|
|
|||
|
|
@ -3343,10 +3343,10 @@
|
|||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault')",
|
||||
"description": "Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault')",
|
||||
"title": "Vault Type"
|
||||
},
|
||||
"description": "Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault')"
|
||||
"description": "Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault')"
|
||||
},
|
||||
{
|
||||
"name": "credential_type",
|
||||
|
|
@ -9678,7 +9678,7 @@
|
|||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Which vault stores this credential (e.g., 'bitwarden', 'azure_vault', 'custom')"
|
||||
"description": "Which vault stores this credential (e.g., 'skyvern', 'bitwarden', 'azure_vault', 'custom')"
|
||||
},
|
||||
"browser_profile_id": {
|
||||
"anyOf": [
|
||||
|
|
@ -9767,6 +9767,7 @@
|
|||
"CredentialVaultType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"skyvern",
|
||||
"bitwarden",
|
||||
"azure_vault",
|
||||
"gcp",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,6 @@ export interface GetCredentialsRequest {
|
|||
page?: number;
|
||||
/** Number of items per page */
|
||||
page_size?: number;
|
||||
/** Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault') */
|
||||
/** Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault') */
|
||||
vault_type?: Skyvern.CredentialVaultType;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export interface CredentialResponse {
|
|||
credential_type: Skyvern.CredentialTypeOutput;
|
||||
/** Name of the credential */
|
||||
name: string;
|
||||
/** Which vault stores this credential (e.g., 'bitwarden', 'azure_vault', 'custom') */
|
||||
/** Which vault stores this credential (e.g., 'skyvern', 'bitwarden', 'azure_vault', 'custom') */
|
||||
vault_type?: Skyvern.CredentialVaultType;
|
||||
/** Browser profile ID linked to this credential */
|
||||
browser_profile_id?: string;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
// This file was auto-generated by Fern from our API Definition.
|
||||
|
||||
export const CredentialVaultType = {
|
||||
Skyvern: "skyvern",
|
||||
Bitwarden: "bitwarden",
|
||||
AzureVault: "azure_vault",
|
||||
Gcp: "gcp",
|
||||
Custom: "custom",
|
||||
} as const;
|
||||
export type CredentialVaultType = (typeof CredentialVaultType)[keyof typeof CredentialVaultType];
|
||||
|
|
|
|||
|
|
@ -2079,7 +2079,7 @@ class Skyvern:
|
|||
Number of items per page
|
||||
|
||||
vault_type : typing.Optional[CredentialVaultType]
|
||||
Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault')
|
||||
Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault')
|
||||
|
||||
request_options : typing.Optional[RequestOptions]
|
||||
Request-specific configuration.
|
||||
|
|
@ -5138,7 +5138,7 @@ class AsyncSkyvern:
|
|||
Number of items per page
|
||||
|
||||
vault_type : typing.Optional[CredentialVaultType]
|
||||
Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault')
|
||||
Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault')
|
||||
|
||||
request_options : typing.Optional[RequestOptions]
|
||||
Request-specific configuration.
|
||||
|
|
|
|||
|
|
@ -2952,7 +2952,7 @@ class RawSkyvern:
|
|||
Number of items per page
|
||||
|
||||
vault_type : typing.Optional[CredentialVaultType]
|
||||
Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault')
|
||||
Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault')
|
||||
|
||||
request_options : typing.Optional[RequestOptions]
|
||||
Request-specific configuration.
|
||||
|
|
@ -6804,7 +6804,7 @@ class AsyncRawSkyvern:
|
|||
Number of items per page
|
||||
|
||||
vault_type : typing.Optional[CredentialVaultType]
|
||||
Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault')
|
||||
Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault')
|
||||
|
||||
request_options : typing.Optional[RequestOptions]
|
||||
Request-specific configuration.
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class CredentialResponse(UniversalBaseModel):
|
|||
|
||||
vault_type: typing.Optional[CredentialVaultType] = pydantic.Field(default=None)
|
||||
"""
|
||||
Which vault stores this credential (e.g., 'bitwarden', 'azure_vault', 'custom')
|
||||
Which vault stores this credential (e.g., 'skyvern', 'bitwarden', 'azure_vault', 'custom')
|
||||
"""
|
||||
|
||||
browser_profile_id: typing.Optional[str] = pydantic.Field(default=None)
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
|
||||
import typing
|
||||
|
||||
CredentialVaultType = typing.Union[typing.Literal["bitwarden", "azure_vault", "custom"], typing.Any]
|
||||
CredentialVaultType = typing.Union[typing.Literal["skyvern", "bitwarden", "azure_vault", "gcp", "custom"], typing.Any]
|
||||
|
|
|
|||
|
|
@ -562,8 +562,11 @@ class Settings(BaseSettings):
|
|||
BITWARDEN_EMAIL: str | None = None
|
||||
OP_SERVICE_ACCOUNT_TOKEN: str | None = None
|
||||
|
||||
# Where credentials are stored: bitwarden, azure_vault, gcp, or custom
|
||||
# Where credentials are stored: skyvern, bitwarden, azure_vault, gcp, or custom
|
||||
CREDENTIAL_VAULT_TYPE: str = "bitwarden"
|
||||
ENABLE_LOCAL_CREDENTIAL_VAULT: bool | None = None
|
||||
LOCAL_CREDENTIAL_VAULT_PATH: str = str(Path.home() / ".skyvern" / "credential_vault")
|
||||
LOCAL_CREDENTIAL_VAULT_KEY: str | None = None
|
||||
|
||||
# GCP Secret Manager credential vault settings
|
||||
GCP_CREDENTIAL_VAULT_PROJECT_ID: str | None = None # project hosting the Secret Manager secrets
|
||||
|
|
@ -888,6 +891,11 @@ class Settings(BaseSettings):
|
|||
"""
|
||||
return self.ENV != "local"
|
||||
|
||||
def is_local_credential_vault_enabled(self) -> bool:
|
||||
if self.ENABLE_LOCAL_CREDENTIAL_VAULT is not None:
|
||||
return self.ENABLE_LOCAL_CREDENTIAL_VAULT
|
||||
return not self.is_cloud_environment()
|
||||
|
||||
def execute_all_steps(self) -> bool:
|
||||
"""
|
||||
This provides the functionality to execute steps one by one through the local UI.
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ from skyvern.forge.sdk.services.credential.bitwarden_credential_service import B
|
|||
from skyvern.forge.sdk.services.credential.credential_vault_service import CredentialVaultService
|
||||
from skyvern.forge.sdk.services.credential.custom_credential_vault_service import CustomCredentialVaultService
|
||||
from skyvern.forge.sdk.services.credential.gcp_credential_vault_service import GcpCredentialVaultService
|
||||
from skyvern.forge.sdk.services.credential.skyvern_credential_vault_service import SkyvernCredentialVaultService
|
||||
from skyvern.forge.sdk.settings_manager import SettingsManager
|
||||
from skyvern.forge.sdk.workflow.context_manager import WorkflowContextManager
|
||||
from skyvern.forge.sdk.workflow.service import WorkflowService
|
||||
|
|
@ -95,6 +96,7 @@ class ForgeApp:
|
|||
AGENT_FUNCTION: AgentFunction
|
||||
PERSISTENT_SESSIONS_MANAGER: PersistentSessionsManager
|
||||
BROWSER_SESSION_RECORDING_SERVICE: BrowserSessionRecordingService
|
||||
SKYVERN_CREDENTIAL_VAULT_SERVICE: SkyvernCredentialVaultService | None
|
||||
BITWARDEN_CREDENTIAL_VAULT_SERVICE: BitwardenCredentialVaultService
|
||||
AZURE_CREDENTIAL_VAULT_SERVICE: AzureCredentialVaultService | None
|
||||
GCP_CREDENTIAL_VAULT_SERVICE: GcpCredentialVaultService | None
|
||||
|
|
@ -283,6 +285,9 @@ def create_forge_app() -> ForgeApp:
|
|||
|
||||
app.AZURE_CLIENT_FACTORY = RealAzureClientFactory()
|
||||
app.GCP_CLIENT_FACTORY = RealGcpClientFactory()
|
||||
app.SKYVERN_CREDENTIAL_VAULT_SERVICE = (
|
||||
SkyvernCredentialVaultService() if settings.is_local_credential_vault_enabled() else None
|
||||
)
|
||||
app.BITWARDEN_CREDENTIAL_VAULT_SERVICE = BitwardenCredentialVaultService()
|
||||
|
||||
# Azure Credential Vault Service
|
||||
|
|
@ -329,6 +334,7 @@ def create_forge_app() -> ForgeApp:
|
|||
else CustomCredentialVaultService() # Create service without client for organization-based configuration
|
||||
)
|
||||
app.CREDENTIAL_VAULT_SERVICES = {
|
||||
CredentialVaultType.SKYVERN: app.SKYVERN_CREDENTIAL_VAULT_SERVICE,
|
||||
CredentialVaultType.BITWARDEN: app.BITWARDEN_CREDENTIAL_VAULT_SERVICE,
|
||||
CredentialVaultType.AZURE_VAULT: app.AZURE_CREDENTIAL_VAULT_SERVICE,
|
||||
CredentialVaultType.GCP: app.GCP_CREDENTIAL_VAULT_SERVICE,
|
||||
|
|
|
|||
|
|
@ -934,9 +934,10 @@ async def test_login(
|
|||
organization_id=organization_id,
|
||||
)
|
||||
try:
|
||||
await app.DATABASE.credentials.delete_credential(
|
||||
await _delete_temporary_test_login_credential(
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
reason="workflow setup error",
|
||||
)
|
||||
except Exception:
|
||||
LOG.warning(
|
||||
|
|
@ -1374,20 +1375,11 @@ async def cancel_credential_test(
|
|||
# Only clean up temporary credentials after successful cancellation.
|
||||
# The background task may also try to delete — that's fine, it handles NotFound gracefully.
|
||||
try:
|
||||
credential = await app.DATABASE.credentials.get_credential(
|
||||
await _delete_temporary_test_login_credential(
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
reason="test cancellation",
|
||||
)
|
||||
if credential and credential.name.startswith("_test_login_"):
|
||||
await app.DATABASE.credentials.delete_credential(
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
LOG.info(
|
||||
"Cleaned up temporary credential after test cancellation",
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
except Exception:
|
||||
LOG.warning(
|
||||
"Failed to clean up temporary credential after test cancellation",
|
||||
|
|
@ -1441,14 +1433,10 @@ async def _create_browser_profile_after_workflow(
|
|||
# Clean up temporary credentials created by test-login
|
||||
if credential_name.startswith("_test_login_"):
|
||||
try:
|
||||
await app.DATABASE.credentials.delete_credential(
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
LOG.info(
|
||||
"Deleted temporary credential after failed test",
|
||||
await _delete_temporary_test_login_credential(
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
reason="failed test",
|
||||
)
|
||||
except Exception:
|
||||
LOG.warning(
|
||||
|
|
@ -1601,9 +1589,10 @@ async def _create_browser_profile_after_workflow(
|
|||
# Clean up temporary credentials on poll timeout
|
||||
if credential_name.startswith("_test_login_"):
|
||||
try:
|
||||
await app.DATABASE.credentials.delete_credential(
|
||||
await _delete_temporary_test_login_credential(
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
reason="poll timeout",
|
||||
)
|
||||
except Exception:
|
||||
LOG.warning(
|
||||
|
|
@ -1620,9 +1609,10 @@ async def _create_browser_profile_after_workflow(
|
|||
# Clean up temporary credentials on unexpected error
|
||||
if credential_name.startswith("_test_login_"):
|
||||
try:
|
||||
await app.DATABASE.credentials.delete_credential(
|
||||
await _delete_temporary_test_login_credential(
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
reason="profile persistence error",
|
||||
)
|
||||
except Exception:
|
||||
LOG.warning(
|
||||
|
|
@ -1838,7 +1828,8 @@ async def get_credential_totp_code(
|
|||
if cached_response is not None:
|
||||
return cached_response
|
||||
|
||||
credential_service = await _get_credential_vault_service(vault_type_override=credential.vault_type)
|
||||
vault_type = credential.vault_type or CredentialVaultType.BITWARDEN
|
||||
credential_service = await _get_credential_vault_service(vault_type_override=vault_type)
|
||||
try:
|
||||
credential_item = await credential_service.get_credential_item(credential)
|
||||
except SkyvernHttpException as e:
|
||||
|
|
@ -1981,7 +1972,7 @@ async def get_credentials(
|
|||
),
|
||||
vault_type: CredentialVaultType | None = Query(
|
||||
default=None,
|
||||
description="Filter credentials by vault type (e.g. 'custom', 'bitwarden', 'azure_vault')",
|
||||
description="Filter credentials by vault type (e.g. 'skyvern', 'custom', 'bitwarden', 'azure_vault')",
|
||||
),
|
||||
credential_type: CredentialType | None = Query(
|
||||
default=None,
|
||||
|
|
@ -2886,7 +2877,13 @@ async def _get_credential_vault_service(
|
|||
vault_type_override: CredentialVaultType | None = None,
|
||||
) -> CredentialVaultService:
|
||||
vault_type = vault_type_override or settings.CREDENTIAL_VAULT_TYPE
|
||||
if vault_type == CredentialVaultType.BITWARDEN:
|
||||
if vault_type == CredentialVaultType.SKYVERN:
|
||||
if not settings.is_local_credential_vault_enabled():
|
||||
raise HTTPException(status_code=400, detail="Skyvern local credential vault is not enabled")
|
||||
if not app.SKYVERN_CREDENTIAL_VAULT_SERVICE:
|
||||
raise HTTPException(status_code=400, detail="Skyvern local credential vault is not configured")
|
||||
return app.SKYVERN_CREDENTIAL_VAULT_SERVICE
|
||||
elif vault_type == CredentialVaultType.BITWARDEN:
|
||||
return app.BITWARDEN_CREDENTIAL_VAULT_SERVICE
|
||||
elif vault_type == CredentialVaultType.AZURE_VAULT:
|
||||
if not app.AZURE_CREDENTIAL_VAULT_SERVICE:
|
||||
|
|
@ -2904,6 +2901,30 @@ async def _get_credential_vault_service(
|
|||
raise HTTPException(status_code=400, detail="Credential storage not supported")
|
||||
|
||||
|
||||
async def _delete_temporary_test_login_credential(
|
||||
*,
|
||||
credential_id: str,
|
||||
organization_id: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
credential = await app.DATABASE.credentials.get_credential(
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
if not credential or not credential.name.startswith("_test_login_"):
|
||||
return
|
||||
|
||||
vault_type = credential.vault_type or CredentialVaultType.BITWARDEN
|
||||
credential_service = await _get_credential_vault_service(vault_type_override=vault_type)
|
||||
await credential_service.delete_credential(credential)
|
||||
LOG.info(
|
||||
"Deleted temporary credential",
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
def _convert_to_response(credential: Credential) -> CredentialResponse:
|
||||
"""Convert an internal ``Credential`` to a safe API response.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from skyvern.utils.url_validators import validate_url
|
|||
|
||||
|
||||
class CredentialVaultType(StrEnum):
|
||||
SKYVERN = "skyvern"
|
||||
BITWARDEN = "bitwarden"
|
||||
AZURE_VAULT = "azure_vault"
|
||||
GCP = "gcp"
|
||||
|
|
@ -220,7 +221,7 @@ class CreateCredentialRequest(BaseModel):
|
|||
default=None,
|
||||
description="Which vault to store this credential in. If omitted, uses the instance default. "
|
||||
"Use this to mix Skyvern-hosted and custom credentials within the same organization.",
|
||||
examples=["custom", "azure_vault", "bitwarden"],
|
||||
examples=["skyvern", "custom", "azure_vault", "bitwarden"],
|
||||
)
|
||||
proxy_location: ProxyLocationInput = Field(
|
||||
default=None,
|
||||
|
|
@ -257,7 +258,7 @@ class CredentialResponse(BaseModel):
|
|||
name: str = Field(..., description="Name of the credential", examples=["Amazon Login"])
|
||||
vault_type: CredentialVaultType | None = Field(
|
||||
default=None,
|
||||
description="Which vault stores this credential (e.g., 'bitwarden', 'azure_vault', 'custom')",
|
||||
description="Which vault stores this credential (e.g., 'skyvern', 'bitwarden', 'azure_vault', 'custom')",
|
||||
)
|
||||
browser_profile_id: str | None = Field(default=None, description="Browser profile ID linked to this credential")
|
||||
tested_url: str | None = Field(default=None, description="Login page URL used during the credential test")
|
||||
|
|
@ -337,7 +338,7 @@ class Credential(BaseModel):
|
|||
..., description="ID of the organization that owns the credential", examples=["o_1234567890"]
|
||||
)
|
||||
name: str = Field(..., description="Name of the credential", examples=["Skyvern Login"])
|
||||
vault_type: CredentialVaultType | None = Field(..., description="Where the secret is stored: Bitwarden vs Azure")
|
||||
vault_type: CredentialVaultType | None = Field(..., description="Where the secret is stored")
|
||||
item_id: str = Field(..., description="ID of the associated credential item", examples=["item_1234567890"])
|
||||
credential_type: CredentialType = Field(..., description="Type of the credential. Eg password, credit card, etc.")
|
||||
username: str | None = Field(..., description="For password credentials: the username")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,208 @@
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from fastapi import HTTPException
|
||||
|
||||
from skyvern.config import settings
|
||||
from skyvern.forge import app
|
||||
from skyvern.forge.sdk.schemas.credentials import (
|
||||
CreateCredentialRequest,
|
||||
Credential,
|
||||
CredentialItem,
|
||||
CredentialType,
|
||||
CredentialVaultType,
|
||||
CreditCardCredential,
|
||||
)
|
||||
from skyvern.forge.sdk.services.credential.credential_vault_service import CredentialVaultService
|
||||
|
||||
LOG = structlog.get_logger()
|
||||
|
||||
_ITEM_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
|
||||
|
||||
class SkyvernCredentialVaultService(CredentialVaultService):
|
||||
"""Local encrypted credential vault for self-hosted Skyvern deployments."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._fernet: Fernet | None = None
|
||||
|
||||
async def create_credential(self, organization_id: str, data: CreateCredentialRequest) -> Credential:
|
||||
item_id = self._generate_item_id()
|
||||
item = CredentialItem(
|
||||
item_id=item_id,
|
||||
name=data.name,
|
||||
credential_type=data.credential_type,
|
||||
credential=data.credential,
|
||||
)
|
||||
await self._store_item(item)
|
||||
|
||||
try:
|
||||
return await self._create_db_credential(
|
||||
organization_id=organization_id,
|
||||
data=data,
|
||||
item_id=item_id,
|
||||
vault_type=CredentialVaultType.SKYVERN,
|
||||
)
|
||||
except Exception:
|
||||
self._delete_item_file(item_id)
|
||||
raise
|
||||
|
||||
async def update_credential(self, credential: Credential, data: CreateCredentialRequest) -> Credential:
|
||||
credential_data = data.credential
|
||||
if data.credential_type == CredentialType.CREDIT_CARD and isinstance(credential_data, CreditCardCredential):
|
||||
credential_data = await self._preserve_omitted_credit_card_fields(
|
||||
credential=credential,
|
||||
updated_credential=credential_data,
|
||||
)
|
||||
|
||||
new_item_id = self._generate_item_id()
|
||||
item = CredentialItem(
|
||||
item_id=new_item_id,
|
||||
name=data.name,
|
||||
credential_type=data.credential_type,
|
||||
credential=credential_data,
|
||||
)
|
||||
await self._store_item(item)
|
||||
|
||||
try:
|
||||
return await self._update_db_credential(
|
||||
credential=credential,
|
||||
data=data,
|
||||
item_id=new_item_id,
|
||||
)
|
||||
except Exception:
|
||||
self._delete_item_file(new_item_id)
|
||||
raise
|
||||
|
||||
async def delete_credential(self, credential: Credential) -> None:
|
||||
await app.DATABASE.credentials.delete_credential(credential.credential_id, credential.organization_id)
|
||||
self._delete_item_file(credential.item_id)
|
||||
|
||||
async def post_delete_credential_item(self, item_id: str, organization_id: str | None = None) -> None:
|
||||
self._delete_item_file(item_id)
|
||||
|
||||
async def get_credential_item(self, db_credential: Credential) -> CredentialItem:
|
||||
item_path = self._item_path(db_credential.item_id)
|
||||
if not item_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Credential vault item not found")
|
||||
|
||||
try:
|
||||
encrypted_payload = item_path.read_bytes()
|
||||
payload = self._get_fernet().decrypt(encrypted_payload).decode("utf-8")
|
||||
item = CredentialItem.model_validate(json.loads(payload))
|
||||
except InvalidToken as exc:
|
||||
raise HTTPException(status_code=500, detail="Credential vault item could not be decrypted") from exc
|
||||
except Exception as exc:
|
||||
LOG.warning(
|
||||
"Failed to read local credential vault item",
|
||||
credential_id=db_credential.credential_id,
|
||||
item_id=db_credential.item_id,
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Credential vault item could not be read") from exc
|
||||
|
||||
return item
|
||||
|
||||
async def _store_item(self, item: CredentialItem) -> None:
|
||||
item_path = self._item_path(item.item_id)
|
||||
payload = json.dumps(item.model_dump(mode="json"), separators=(",", ":"))
|
||||
encrypted_payload = self._get_fernet().encrypt(payload.encode("utf-8"))
|
||||
|
||||
temp_path = item_path.with_suffix(f".{secrets.token_urlsafe(8)}.tmp")
|
||||
try:
|
||||
self._write_file_durable(temp_path, encrypted_payload, mode=0o600)
|
||||
temp_path.replace(item_path)
|
||||
self._fsync_directory_best_effort(item_path.parent)
|
||||
finally:
|
||||
with suppress(FileNotFoundError):
|
||||
temp_path.unlink()
|
||||
|
||||
def _get_fernet(self) -> Fernet:
|
||||
if self._fernet is not None:
|
||||
return self._fernet
|
||||
|
||||
key = settings.LOCAL_CREDENTIAL_VAULT_KEY
|
||||
if key:
|
||||
self._fernet = Fernet(key.encode("utf-8"))
|
||||
return self._fernet
|
||||
|
||||
vault_dir = self._vault_dir()
|
||||
key_path = vault_dir / ".fernet_key"
|
||||
if key_path.exists():
|
||||
key_bytes = key_path.read_bytes().strip()
|
||||
else:
|
||||
key_bytes = self._create_or_read_key_file(key_path)
|
||||
|
||||
self._fernet = Fernet(key_bytes)
|
||||
return self._fernet
|
||||
|
||||
def _create_or_read_key_file(self, key_path: Path) -> bytes:
|
||||
key_bytes = Fernet.generate_key()
|
||||
temp_path = key_path.with_name(f".fernet_key.{secrets.token_urlsafe(8)}.tmp")
|
||||
try:
|
||||
self._write_file_durable(temp_path, key_bytes + b"\n", mode=0o600)
|
||||
try:
|
||||
os.link(temp_path, key_path)
|
||||
self._fsync_directory_best_effort(key_path.parent)
|
||||
return key_bytes
|
||||
except FileExistsError:
|
||||
return key_path.read_bytes().strip()
|
||||
finally:
|
||||
with suppress(FileNotFoundError):
|
||||
temp_path.unlink()
|
||||
|
||||
def _vault_dir(self) -> Path:
|
||||
vault_dir = Path(settings.LOCAL_CREDENTIAL_VAULT_PATH).expanduser().resolve()
|
||||
vault_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._chmod_best_effort(vault_dir, 0o700)
|
||||
return vault_dir
|
||||
|
||||
def _item_path(self, item_id: str) -> Path:
|
||||
if not item_id or not _ITEM_ID_PATTERN.fullmatch(item_id):
|
||||
raise HTTPException(status_code=400, detail="Invalid credential vault item ID")
|
||||
return self._vault_dir() / f"{item_id}.bin"
|
||||
|
||||
def _delete_item_file(self, item_id: str) -> None:
|
||||
try:
|
||||
self._item_path(item_id).unlink(missing_ok=True)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
LOG.warning("Failed to delete local credential vault item", item_id=item_id, exc_info=True)
|
||||
|
||||
@staticmethod
|
||||
def _generate_item_id() -> str:
|
||||
return f"creditem_{secrets.token_urlsafe(24)}"
|
||||
|
||||
@staticmethod
|
||||
def _chmod_best_effort(path: Path, mode: int) -> None:
|
||||
with suppress(OSError):
|
||||
os.chmod(path, mode)
|
||||
|
||||
@staticmethod
|
||||
def _write_file_durable(path: Path, payload: bytes, *, mode: int) -> None:
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as file:
|
||||
fd = -1
|
||||
file.write(payload)
|
||||
file.flush()
|
||||
os.fsync(file.fileno())
|
||||
finally:
|
||||
if fd != -1:
|
||||
os.close(fd)
|
||||
|
||||
@staticmethod
|
||||
def _fsync_directory_best_effort(path: Path) -> None:
|
||||
with suppress(OSError):
|
||||
fd = os.open(path, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
|
@ -1,11 +1,16 @@
|
|||
"""Tests for per-credential vault_type override in CreateCredentialRequest and vault service routing."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from skyvern.forge.sdk.routes.credentials import _get_credential_vault_service
|
||||
from skyvern.config import Settings
|
||||
from skyvern.forge.sdk.routes.credentials import (
|
||||
_delete_temporary_test_login_credential,
|
||||
_get_credential_vault_service,
|
||||
)
|
||||
from skyvern.forge.sdk.schemas.credentials import (
|
||||
CreateCredentialRequest,
|
||||
CredentialResponse,
|
||||
|
|
@ -19,6 +24,22 @@ from skyvern.forge.sdk.schemas.credentials import (
|
|||
from skyvern.forge.sdk.services.credential.credential_vault_service import CredentialVaultService
|
||||
|
||||
|
||||
class TestLocalCredentialVaultSettings:
|
||||
"""Verify the local filesystem vault is enabled only in local envs by default."""
|
||||
|
||||
def test_local_credential_vault_defaults_enabled_for_local_env(self) -> None:
|
||||
settings = Settings(_env_file=None, ENV="local", ENABLE_LOCAL_CREDENTIAL_VAULT=None)
|
||||
assert settings.is_local_credential_vault_enabled()
|
||||
|
||||
def test_local_credential_vault_defaults_disabled_for_non_local_env(self) -> None:
|
||||
settings = Settings(_env_file=None, ENV="prod", ENABLE_LOCAL_CREDENTIAL_VAULT=None)
|
||||
assert not settings.is_local_credential_vault_enabled()
|
||||
|
||||
def test_local_credential_vault_can_be_explicitly_enabled_for_non_local_env(self) -> None:
|
||||
settings = Settings(_env_file=None, ENV="prod", ENABLE_LOCAL_CREDENTIAL_VAULT=True)
|
||||
assert settings.is_local_credential_vault_enabled()
|
||||
|
||||
|
||||
class TestCreateCredentialRequestVaultType:
|
||||
"""Verify the optional vault_type field on CreateCredentialRequest."""
|
||||
|
||||
|
|
@ -57,6 +78,15 @@ class TestCreateCredentialRequestVaultType:
|
|||
)
|
||||
assert req.vault_type == CredentialVaultType.BITWARDEN
|
||||
|
||||
def test_vault_type_can_be_set_to_skyvern(self) -> None:
|
||||
req = CreateCredentialRequest(
|
||||
name="Test",
|
||||
credential_type=CredentialType.PASSWORD,
|
||||
credential=NonEmptyPasswordCredential(username="u", password="p"),
|
||||
vault_type=CredentialVaultType.SKYVERN,
|
||||
)
|
||||
assert req.vault_type == CredentialVaultType.SKYVERN
|
||||
|
||||
def test_vault_type_serializes_in_json(self) -> None:
|
||||
req = CreateCredentialRequest(
|
||||
name="Test",
|
||||
|
|
@ -145,6 +175,69 @@ class TestGetCredentialVaultServiceRouting:
|
|||
result = await _get_credential_vault_service()
|
||||
assert result is mock_bw
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_override_uses_global_skyvern(self) -> None:
|
||||
mock_skyvern = MagicMock(spec=CredentialVaultService)
|
||||
with (
|
||||
patch("skyvern.forge.sdk.routes.credentials.settings") as mock_settings,
|
||||
patch("skyvern.forge.sdk.routes.credentials.app") as mock_app,
|
||||
):
|
||||
mock_settings.CREDENTIAL_VAULT_TYPE = CredentialVaultType.SKYVERN
|
||||
mock_settings.is_local_credential_vault_enabled.return_value = True
|
||||
mock_app.SKYVERN_CREDENTIAL_VAULT_SERVICE = mock_skyvern
|
||||
result = await _get_credential_vault_service()
|
||||
assert result is mock_skyvern
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_override_skyvern_raises_when_local_vault_disabled(self) -> None:
|
||||
with (
|
||||
patch("skyvern.forge.sdk.routes.credentials.settings") as mock_settings,
|
||||
patch("skyvern.forge.sdk.routes.credentials.app") as mock_app,
|
||||
):
|
||||
mock_settings.CREDENTIAL_VAULT_TYPE = CredentialVaultType.SKYVERN
|
||||
mock_settings.is_local_credential_vault_enabled.return_value = False
|
||||
mock_app.SKYVERN_CREDENTIAL_VAULT_SERVICE = MagicMock(spec=CredentialVaultService)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _get_credential_vault_service()
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "local credential vault is not enabled" in str(exc_info.value.detail)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_override_skyvern_ignores_global(self) -> None:
|
||||
mock_bw = MagicMock(spec=CredentialVaultService)
|
||||
mock_skyvern = MagicMock(spec=CredentialVaultService)
|
||||
with (
|
||||
patch("skyvern.forge.sdk.routes.credentials.settings") as mock_settings,
|
||||
patch("skyvern.forge.sdk.routes.credentials.app") as mock_app,
|
||||
):
|
||||
mock_settings.CREDENTIAL_VAULT_TYPE = CredentialVaultType.BITWARDEN
|
||||
mock_settings.is_local_credential_vault_enabled.return_value = True
|
||||
mock_app.BITWARDEN_CREDENTIAL_VAULT_SERVICE = mock_bw
|
||||
mock_app.SKYVERN_CREDENTIAL_VAULT_SERVICE = mock_skyvern
|
||||
result = await _get_credential_vault_service(
|
||||
vault_type_override=CredentialVaultType.SKYVERN,
|
||||
)
|
||||
assert result is mock_skyvern
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_override_skyvern_raises_when_local_vault_disabled(self) -> None:
|
||||
mock_bw = MagicMock(spec=CredentialVaultService)
|
||||
mock_skyvern = MagicMock(spec=CredentialVaultService)
|
||||
with (
|
||||
patch("skyvern.forge.sdk.routes.credentials.settings") as mock_settings,
|
||||
patch("skyvern.forge.sdk.routes.credentials.app") as mock_app,
|
||||
):
|
||||
mock_settings.CREDENTIAL_VAULT_TYPE = CredentialVaultType.BITWARDEN
|
||||
mock_settings.is_local_credential_vault_enabled.return_value = False
|
||||
mock_app.BITWARDEN_CREDENTIAL_VAULT_SERVICE = mock_bw
|
||||
mock_app.SKYVERN_CREDENTIAL_VAULT_SERVICE = mock_skyvern
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _get_credential_vault_service(
|
||||
vault_type_override=CredentialVaultType.SKYVERN,
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "local credential vault is not enabled" in str(exc_info.value.detail)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_override_custom_ignores_global(self) -> None:
|
||||
mock_bw = MagicMock(spec=CredentialVaultService)
|
||||
|
|
@ -234,3 +327,88 @@ class TestGetCredentialVaultServiceRouting:
|
|||
mock_app.CUSTOM_CREDENTIAL_VAULT_SERVICE = mock_custom
|
||||
result = await _get_credential_vault_service(vault_type_override=None)
|
||||
assert result is mock_custom
|
||||
|
||||
|
||||
class TestTemporaryTestLoginCredentialCleanup:
|
||||
"""Verify temporary login-test credentials are deleted through the owning vault service."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deletes_temporary_skyvern_credential_through_vault_service(self) -> None:
|
||||
credential = SimpleNamespace(
|
||||
credential_id="cred_temp",
|
||||
organization_id="org_test",
|
||||
name="_test_login_example",
|
||||
vault_type=CredentialVaultType.SKYVERN,
|
||||
)
|
||||
mock_repository = MagicMock()
|
||||
mock_repository.get_credential = AsyncMock(return_value=credential)
|
||||
mock_service = MagicMock(spec=CredentialVaultService)
|
||||
mock_service.delete_credential = AsyncMock()
|
||||
|
||||
with (
|
||||
patch("skyvern.forge.sdk.routes.credentials.settings") as mock_settings,
|
||||
patch("skyvern.forge.sdk.routes.credentials.app") as mock_app,
|
||||
):
|
||||
mock_settings.is_local_credential_vault_enabled.return_value = True
|
||||
mock_app.DATABASE.credentials = mock_repository
|
||||
mock_app.SKYVERN_CREDENTIAL_VAULT_SERVICE = mock_service
|
||||
|
||||
await _delete_temporary_test_login_credential(
|
||||
credential_id="cred_temp",
|
||||
organization_id="org_test",
|
||||
reason="test",
|
||||
)
|
||||
|
||||
mock_service.delete_credential.assert_awaited_once_with(credential)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_defaults_missing_vault_type_to_bitwarden_for_legacy_credentials(self) -> None:
|
||||
credential = SimpleNamespace(
|
||||
credential_id="cred_temp",
|
||||
organization_id="org_test",
|
||||
name="_test_login_example",
|
||||
vault_type=None,
|
||||
)
|
||||
mock_repository = MagicMock()
|
||||
mock_repository.get_credential = AsyncMock(return_value=credential)
|
||||
mock_service = MagicMock(spec=CredentialVaultService)
|
||||
mock_service.delete_credential = AsyncMock()
|
||||
|
||||
with (
|
||||
patch("skyvern.forge.sdk.routes.credentials.settings"),
|
||||
patch("skyvern.forge.sdk.routes.credentials.app") as mock_app,
|
||||
):
|
||||
mock_app.DATABASE.credentials = mock_repository
|
||||
mock_app.BITWARDEN_CREDENTIAL_VAULT_SERVICE = mock_service
|
||||
|
||||
await _delete_temporary_test_login_credential(
|
||||
credential_id="cred_temp",
|
||||
organization_id="org_test",
|
||||
reason="test",
|
||||
)
|
||||
|
||||
mock_service.delete_credential.assert_awaited_once_with(credential)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ignores_non_temporary_credentials(self) -> None:
|
||||
credential = SimpleNamespace(
|
||||
credential_id="cred_regular",
|
||||
organization_id="org_test",
|
||||
name="regular credential",
|
||||
vault_type=CredentialVaultType.SKYVERN,
|
||||
)
|
||||
mock_repository = MagicMock()
|
||||
mock_repository.get_credential = AsyncMock(return_value=credential)
|
||||
mock_service = MagicMock(spec=CredentialVaultService)
|
||||
mock_service.delete_credential = AsyncMock()
|
||||
|
||||
with patch("skyvern.forge.sdk.routes.credentials.app") as mock_app:
|
||||
mock_app.DATABASE.credentials = mock_repository
|
||||
|
||||
await _delete_temporary_test_login_credential(
|
||||
credential_id="cred_regular",
|
||||
organization_id="org_test",
|
||||
reason="test",
|
||||
)
|
||||
|
||||
mock_service.delete_credential.assert_not_awaited()
|
||||
|
|
|
|||
242
tests/unit/test_skyvern_credential_vault_service.py
Normal file
242
tests/unit/test_skyvern_credential_vault_service.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from fastapi import HTTPException
|
||||
|
||||
from skyvern.config import settings
|
||||
from skyvern.forge import app
|
||||
from skyvern.forge.sdk.schemas.credentials import (
|
||||
CreateCredentialRequest,
|
||||
Credential,
|
||||
CredentialType,
|
||||
CredentialVaultType,
|
||||
NonEmptyPasswordCredential,
|
||||
)
|
||||
from skyvern.forge.sdk.services.credential.skyvern_credential_vault_service import (
|
||||
SkyvernCredentialVaultService,
|
||||
)
|
||||
|
||||
|
||||
class _FakeCredentialRepository:
|
||||
def __init__(self) -> None:
|
||||
self.deleted: tuple[str, str] | None = None
|
||||
|
||||
async def create_credential(self, **kwargs: object) -> Credential:
|
||||
return self._credential(
|
||||
credential_id="cred_test",
|
||||
organization_id=str(kwargs["organization_id"]),
|
||||
name=str(kwargs["name"]),
|
||||
vault_type=kwargs["vault_type"],
|
||||
item_id=str(kwargs["item_id"]),
|
||||
credential_type=kwargs["credential_type"],
|
||||
username=kwargs["username"],
|
||||
)
|
||||
|
||||
async def update_credential_vault_data(self, **kwargs: object) -> Credential:
|
||||
return self._credential(
|
||||
credential_id=str(kwargs["credential_id"]),
|
||||
organization_id=str(kwargs["organization_id"]),
|
||||
name=str(kwargs["name"]),
|
||||
vault_type=CredentialVaultType.SKYVERN,
|
||||
item_id=str(kwargs["item_id"]),
|
||||
credential_type=kwargs["credential_type"],
|
||||
username=kwargs["username"],
|
||||
)
|
||||
|
||||
async def delete_credential(self, credential_id: str, organization_id: str) -> None:
|
||||
self.deleted = (credential_id, organization_id)
|
||||
|
||||
@staticmethod
|
||||
def _credential(
|
||||
*,
|
||||
credential_id: str,
|
||||
organization_id: str,
|
||||
name: str,
|
||||
vault_type: object,
|
||||
item_id: str,
|
||||
credential_type: object,
|
||||
username: object,
|
||||
) -> Credential:
|
||||
return Credential(
|
||||
credential_id=credential_id,
|
||||
organization_id=organization_id,
|
||||
name=name,
|
||||
vault_type=vault_type,
|
||||
item_id=item_id,
|
||||
credential_type=credential_type,
|
||||
username=username,
|
||||
totp_type="none",
|
||||
totp_identifier=None,
|
||||
card_last4=None,
|
||||
card_brand=None,
|
||||
secret_label=None,
|
||||
browser_profile_id=None,
|
||||
tested_url=None,
|
||||
user_context=None,
|
||||
save_browser_session_intent=False,
|
||||
folder_id=None,
|
||||
created_at=datetime(2026, 1, 1),
|
||||
modified_at=datetime(2026, 1, 1),
|
||||
deleted_at=None,
|
||||
)
|
||||
|
||||
|
||||
class _FailingCreateCredentialRepository(_FakeCredentialRepository):
|
||||
async def create_credential(self, **kwargs: object) -> Credential:
|
||||
raise RuntimeError("database unavailable")
|
||||
|
||||
|
||||
class _FailingUpdateCredentialRepository(_FakeCredentialRepository):
|
||||
async def update_credential_vault_data(self, **kwargs: object) -> Credential:
|
||||
raise RuntimeError("database unavailable")
|
||||
|
||||
|
||||
def _password_request(name: str = "Login", password: str = "secret-password") -> CreateCredentialRequest:
|
||||
return CreateCredentialRequest(
|
||||
name=name,
|
||||
credential_type=CredentialType.PASSWORD,
|
||||
credential=NonEmptyPasswordCredential(username="user@example.com", password=password),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skyvern_credential_vault_round_trips_encrypted_password(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_PATH", str(tmp_path))
|
||||
monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_KEY", None)
|
||||
|
||||
repository = _FakeCredentialRepository()
|
||||
monkeypatch.setattr(app.DATABASE, "credentials", repository)
|
||||
|
||||
service = SkyvernCredentialVaultService()
|
||||
request = _password_request()
|
||||
|
||||
credential = await service.create_credential("org_test", request)
|
||||
|
||||
assert credential.vault_type == CredentialVaultType.SKYVERN
|
||||
item_path = tmp_path / f"{credential.item_id}.bin"
|
||||
assert item_path.exists()
|
||||
assert b"secret-password" not in item_path.read_bytes()
|
||||
|
||||
item = await service.get_credential_item(credential)
|
||||
assert item.name == "Login"
|
||||
assert item.credential_type == CredentialType.PASSWORD
|
||||
assert item.credential.username == "user@example.com"
|
||||
assert item.credential.password == "secret-password"
|
||||
|
||||
await service.delete_credential(credential)
|
||||
assert repository.deleted == ("cred_test", "org_test")
|
||||
assert not item_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skyvern_credential_vault_update_creates_new_item_and_cleans_old(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_PATH", str(tmp_path))
|
||||
monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_KEY", None)
|
||||
|
||||
monkeypatch.setattr(app.DATABASE, "credentials", _FakeCredentialRepository())
|
||||
|
||||
service = SkyvernCredentialVaultService()
|
||||
request = _password_request(password="old-password")
|
||||
credential = await service.create_credential("org_test", request)
|
||||
old_item_id = credential.item_id
|
||||
|
||||
update_request = _password_request(name="Login Updated", password="new-password")
|
||||
updated = await service.update_credential(credential, update_request)
|
||||
|
||||
assert updated.item_id != old_item_id
|
||||
assert (tmp_path / f"{old_item_id}.bin").exists()
|
||||
|
||||
await service.post_delete_credential_item(old_item_id)
|
||||
assert not (tmp_path / f"{old_item_id}.bin").exists()
|
||||
|
||||
item = await service.get_credential_item(updated)
|
||||
assert item.name == "Login Updated"
|
||||
assert item.credential.password == "new-password"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skyvern_credential_vault_cleans_item_when_create_db_fails(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_PATH", str(tmp_path))
|
||||
monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_KEY", None)
|
||||
monkeypatch.setattr(app.DATABASE, "credentials", _FailingCreateCredentialRepository())
|
||||
|
||||
service = SkyvernCredentialVaultService()
|
||||
|
||||
with pytest.raises(RuntimeError, match="database unavailable"):
|
||||
await service.create_credential("org_test", _password_request())
|
||||
|
||||
assert list(tmp_path.glob("*.bin")) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skyvern_credential_vault_cleans_new_item_when_update_db_fails(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_PATH", str(tmp_path))
|
||||
monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_KEY", None)
|
||||
monkeypatch.setattr(app.DATABASE, "credentials", _FailingUpdateCredentialRepository())
|
||||
|
||||
service = SkyvernCredentialVaultService()
|
||||
credential = await service.create_credential("org_test", _password_request(password="old-password"))
|
||||
old_item_path = tmp_path / f"{credential.item_id}.bin"
|
||||
|
||||
with pytest.raises(RuntimeError, match="database unavailable"):
|
||||
await service.update_credential(
|
||||
credential,
|
||||
_password_request(name="Login Updated", password="new-password"),
|
||||
)
|
||||
|
||||
assert old_item_path.exists()
|
||||
assert list(tmp_path.glob("*.bin")) == [old_item_path]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skyvern_credential_vault_rejects_invalid_item_id(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_PATH", str(tmp_path))
|
||||
monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_KEY", None)
|
||||
credential = _FakeCredentialRepository._credential(
|
||||
credential_id="cred_test",
|
||||
organization_id="org_test",
|
||||
name="Login",
|
||||
vault_type=CredentialVaultType.SKYVERN,
|
||||
item_id="../outside",
|
||||
credential_type=CredentialType.PASSWORD,
|
||||
username="user@example.com",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await SkyvernCredentialVaultService().get_credential_item(credential)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skyvern_credential_vault_wrong_key_cannot_decrypt_item(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_PATH", str(tmp_path))
|
||||
monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_KEY", Fernet.generate_key().decode("utf-8"))
|
||||
monkeypatch.setattr(app.DATABASE, "credentials", _FakeCredentialRepository())
|
||||
|
||||
credential = await SkyvernCredentialVaultService().create_credential("org_test", _password_request())
|
||||
monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_KEY", Fernet.generate_key().decode("utf-8"))
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await SkyvernCredentialVaultService().get_credential_item(credential)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
|
||||
|
||||
def test_skyvern_credential_vault_key_file_creation_is_single_winner(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_PATH", str(tmp_path))
|
||||
monkeypatch.setattr(settings, "LOCAL_CREDENTIAL_VAULT_KEY", None)
|
||||
key_path = tmp_path / ".fernet_key"
|
||||
|
||||
def create_key_file() -> bytes:
|
||||
return SkyvernCredentialVaultService()._create_or_read_key_file(key_path)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
returned_keys = list(executor.map(lambda _: create_key_file(), range(8)))
|
||||
|
||||
stored_key = key_path.read_bytes().strip()
|
||||
assert len(set(returned_keys)) == 1
|
||||
assert returned_keys[0] == stored_key
|
||||
assert list(tmp_path.glob("*.tmp")) == []
|
||||
Loading…
Add table
Add a link
Reference in a new issue