SKY-11678 Add local credential vault persistence to OSS Docker (#7003)

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Suchintan 2026-07-02 17:24:17 -04:00 committed by GitHub
parent ef22a35461
commit 720d47d754
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 753 additions and 46 deletions

View file

@ -4,6 +4,9 @@
**/traces **/traces
**/inputs **/inputs
**/har **/har
**/downloads
**/browser_sessions
**/credential_vault
**/.git **/.git
**/.github **/.github
**/.mypy_cache **/.mypy_cache

View file

@ -139,12 +139,29 @@ ANALYTICS_ID="anonymous"
# OP_SERVICE_ACCOUNT_TOKEN: API token for 1Password integration # OP_SERVICE_ACCOUNT_TOKEN: API token for 1Password integration
OP_SERVICE_ACCOUNT_TOKEN="" 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 recording skyvern logs as artifacts
ENABLE_LOG_ARTIFACTS=false ENABLE_LOG_ARTIFACTS=false
# ============================================================================= # =============================================================================
# SKYVERN BITWARDEN CONFIGURATION # 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) # Your organization ID in official Bitwarden server or vaultwarden (if using organizations)
SKYVERN_AUTH_BITWARDEN_ORGANIZATION_ID=your-org-id-here SKYVERN_AUTH_BITWARDEN_ORGANIZATION_ID=your-org-id-here

1
.gitignore vendored
View file

@ -162,6 +162,7 @@ ig_*
# Skyvern ignores # Skyvern ignores
browser_sessions/ browser_sessions/
credential_vault/
videos/ videos/
skyvern/artifacts/ skyvern/artifacts/
artifacts/ artifacts/

View file

@ -11,6 +11,7 @@ RUN uv pip compile pyproject.toml --extra server --python-version 3.11 -o requir
FROM python:3.11-slim-bookworm FROM python:3.11-slim-bookworm
WORKDIR /app WORKDIR /app
COPY --from=requirements-stage /tmp/requirements.txt /app/requirements.txt 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 RUN pip install --upgrade pip setuptools wheel
# --no-deps: requirements.txt is fully resolved by uv, including the # --no-deps: requirements.txt is fully resolved by uv, including the
# pyproject overrides that loosen litellm's jsonschema==4.23.0 pin. # 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 pip install --no-cache-dir --no-deps -r requirements.txt
RUN playwright install-deps RUN playwright install-deps
RUN playwright install 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 RUN pip install --no-cache-dir websockify
COPY .nvmrc /app/.nvmrc COPY .nvmrc /app/.nvmrc
@ -47,6 +54,9 @@ ENV VIDEO_PATH=/data/videos
ENV HAR_PATH=/data/har ENV HAR_PATH=/data/har
ENV LOG_PATH=/data/log ENV LOG_PATH=/data/log
ENV ARTIFACT_STORAGE_PATH=/data/artifacts 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 # cache tiktoken
RUN python /app/scripts/load_tiktoken.py RUN python /app/scripts/load_tiktoken.py

View file

@ -38,6 +38,9 @@ services:
- ./videos:/data/videos - ./videos:/data/videos
- ./har:/data/har - ./har:/data/har
- ./log:/data/log - ./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. # Generated credentials allow the UI to pick up the local API key on first startup.
- ./.skyvern:/app/.skyvern - ./.skyvern:/app/.skyvern
# Uncomment the following two lines if you want to connect to any local changes # 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_URL=${BROWSER_REMOTE_DEBUGGING_URL:-http://127.0.0.1:9222}
- BROWSER_REMOTE_DEBUGGING_HOST_HEADER=${BROWSER_REMOTE_DEBUGGING_HOST_HEADER:-} - BROWSER_REMOTE_DEBUGGING_HOST_HEADER=${BROWSER_REMOTE_DEBUGGING_HOST_HEADER:-}
- BROWSER_CDP_CONNECT_TIMEOUT_MS=${BROWSER_CDP_CONNECT_TIMEOUT_MS:-120000} - 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 - ENABLE_CODE_BLOCK=true
# --- Control your own browser (Chrome/Chromium) --- # --- Control your own browser (Chrome/Chromium) ---
# Prefer Chrome's chrome://inspect/#remote-debugging flow for an existing profile. # Prefer Chrome's chrome://inspect/#remote-debugging flow for an existing profile.

View file

@ -3645,10 +3645,10 @@
"type": "null" "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" "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", "name": "credential_type",
@ -10550,7 +10550,7 @@
"type": "null" "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": { "browser_profile_id": {
"anyOf": [ "anyOf": [
@ -10670,6 +10670,7 @@
"CredentialVaultType": { "CredentialVaultType": {
"type": "string", "type": "string",
"enum": [ "enum": [
"skyvern",
"bitwarden", "bitwarden",
"azure_vault", "azure_vault",
"gcp", "gcp",

View file

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

View file

@ -26,7 +26,7 @@ for (const c of creds) {
|-----------|------|----------|---------|-------------| |-----------|------|----------|---------|-------------|
| `page` | `int` | No | `None` | Page number. | | `page` | `int` | No | `None` | Page number. |
| `page_size` | `int` | No | `None` | Results per page. | | `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). | | `request_options` | `RequestOptions` | No | `None` | Per-request configuration (see below). |
### Returns `list[CredentialResponse]` ### Returns `list[CredentialResponse]`

View file

@ -3343,10 +3343,10 @@
"type": "null" "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" "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", "name": "credential_type",
@ -9678,7 +9678,7 @@
"type": "null" "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": { "browser_profile_id": {
"anyOf": [ "anyOf": [
@ -9767,6 +9767,7 @@
"CredentialVaultType": { "CredentialVaultType": {
"type": "string", "type": "string",
"enum": [ "enum": [
"skyvern",
"bitwarden", "bitwarden",
"azure_vault", "azure_vault",
"gcp", "gcp",

View file

@ -15,6 +15,6 @@ export interface GetCredentialsRequest {
page?: number; page?: number;
/** Number of items per page */ /** Number of items per page */
page_size?: number; 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; vault_type?: Skyvern.CredentialVaultType;
} }

View file

@ -14,7 +14,7 @@ export interface CredentialResponse {
credential_type: Skyvern.CredentialTypeOutput; credential_type: Skyvern.CredentialTypeOutput;
/** Name of the credential */ /** Name of the credential */
name: string; 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; vault_type?: Skyvern.CredentialVaultType;
/** Browser profile ID linked to this credential */ /** Browser profile ID linked to this credential */
browser_profile_id?: string; browser_profile_id?: string;

View file

@ -1,8 +1,10 @@
// This file was auto-generated by Fern from our API Definition. // This file was auto-generated by Fern from our API Definition.
export const CredentialVaultType = { export const CredentialVaultType = {
Skyvern: "skyvern",
Bitwarden: "bitwarden", Bitwarden: "bitwarden",
AzureVault: "azure_vault", AzureVault: "azure_vault",
Gcp: "gcp",
Custom: "custom", Custom: "custom",
} as const; } as const;
export type CredentialVaultType = (typeof CredentialVaultType)[keyof typeof CredentialVaultType]; export type CredentialVaultType = (typeof CredentialVaultType)[keyof typeof CredentialVaultType];

View file

@ -2079,7 +2079,7 @@ class Skyvern:
Number of items per page Number of items per page
vault_type : typing.Optional[CredentialVaultType] 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_options : typing.Optional[RequestOptions]
Request-specific configuration. Request-specific configuration.
@ -5138,7 +5138,7 @@ class AsyncSkyvern:
Number of items per page Number of items per page
vault_type : typing.Optional[CredentialVaultType] 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_options : typing.Optional[RequestOptions]
Request-specific configuration. Request-specific configuration.

View file

@ -2952,7 +2952,7 @@ class RawSkyvern:
Number of items per page Number of items per page
vault_type : typing.Optional[CredentialVaultType] 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_options : typing.Optional[RequestOptions]
Request-specific configuration. Request-specific configuration.
@ -6804,7 +6804,7 @@ class AsyncRawSkyvern:
Number of items per page Number of items per page
vault_type : typing.Optional[CredentialVaultType] 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_options : typing.Optional[RequestOptions]
Request-specific configuration. Request-specific configuration.

View file

@ -37,7 +37,7 @@ class CredentialResponse(UniversalBaseModel):
vault_type: typing.Optional[CredentialVaultType] = pydantic.Field(default=None) 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) browser_profile_id: typing.Optional[str] = pydantic.Field(default=None)

View file

@ -2,4 +2,4 @@
import typing 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]

View file

@ -562,8 +562,11 @@ class Settings(BaseSettings):
BITWARDEN_EMAIL: str | None = None BITWARDEN_EMAIL: str | None = None
OP_SERVICE_ACCOUNT_TOKEN: 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" 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 Secret Manager credential vault settings
GCP_CREDENTIAL_VAULT_PROJECT_ID: str | None = None # project hosting the Secret Manager secrets 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" 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: def execute_all_steps(self) -> bool:
""" """
This provides the functionality to execute steps one by one through the local UI. This provides the functionality to execute steps one by one through the local UI.

View file

@ -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.credential_vault_service import CredentialVaultService
from skyvern.forge.sdk.services.credential.custom_credential_vault_service import CustomCredentialVaultService 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.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.settings_manager import SettingsManager
from skyvern.forge.sdk.workflow.context_manager import WorkflowContextManager from skyvern.forge.sdk.workflow.context_manager import WorkflowContextManager
from skyvern.forge.sdk.workflow.service import WorkflowService from skyvern.forge.sdk.workflow.service import WorkflowService
@ -95,6 +96,7 @@ class ForgeApp:
AGENT_FUNCTION: AgentFunction AGENT_FUNCTION: AgentFunction
PERSISTENT_SESSIONS_MANAGER: PersistentSessionsManager PERSISTENT_SESSIONS_MANAGER: PersistentSessionsManager
BROWSER_SESSION_RECORDING_SERVICE: BrowserSessionRecordingService BROWSER_SESSION_RECORDING_SERVICE: BrowserSessionRecordingService
SKYVERN_CREDENTIAL_VAULT_SERVICE: SkyvernCredentialVaultService | None
BITWARDEN_CREDENTIAL_VAULT_SERVICE: BitwardenCredentialVaultService BITWARDEN_CREDENTIAL_VAULT_SERVICE: BitwardenCredentialVaultService
AZURE_CREDENTIAL_VAULT_SERVICE: AzureCredentialVaultService | None AZURE_CREDENTIAL_VAULT_SERVICE: AzureCredentialVaultService | None
GCP_CREDENTIAL_VAULT_SERVICE: GcpCredentialVaultService | None GCP_CREDENTIAL_VAULT_SERVICE: GcpCredentialVaultService | None
@ -283,6 +285,9 @@ def create_forge_app() -> ForgeApp:
app.AZURE_CLIENT_FACTORY = RealAzureClientFactory() app.AZURE_CLIENT_FACTORY = RealAzureClientFactory()
app.GCP_CLIENT_FACTORY = RealGcpClientFactory() 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() app.BITWARDEN_CREDENTIAL_VAULT_SERVICE = BitwardenCredentialVaultService()
# Azure Credential Vault Service # Azure Credential Vault Service
@ -329,6 +334,7 @@ def create_forge_app() -> ForgeApp:
else CustomCredentialVaultService() # Create service without client for organization-based configuration else CustomCredentialVaultService() # Create service without client for organization-based configuration
) )
app.CREDENTIAL_VAULT_SERVICES = { app.CREDENTIAL_VAULT_SERVICES = {
CredentialVaultType.SKYVERN: app.SKYVERN_CREDENTIAL_VAULT_SERVICE,
CredentialVaultType.BITWARDEN: app.BITWARDEN_CREDENTIAL_VAULT_SERVICE, CredentialVaultType.BITWARDEN: app.BITWARDEN_CREDENTIAL_VAULT_SERVICE,
CredentialVaultType.AZURE_VAULT: app.AZURE_CREDENTIAL_VAULT_SERVICE, CredentialVaultType.AZURE_VAULT: app.AZURE_CREDENTIAL_VAULT_SERVICE,
CredentialVaultType.GCP: app.GCP_CREDENTIAL_VAULT_SERVICE, CredentialVaultType.GCP: app.GCP_CREDENTIAL_VAULT_SERVICE,

View file

@ -934,9 +934,10 @@ async def test_login(
organization_id=organization_id, organization_id=organization_id,
) )
try: try:
await app.DATABASE.credentials.delete_credential( await _delete_temporary_test_login_credential(
credential_id=credential_id, credential_id=credential_id,
organization_id=organization_id, organization_id=organization_id,
reason="workflow setup error",
) )
except Exception: except Exception:
LOG.warning( LOG.warning(
@ -1374,20 +1375,11 @@ async def cancel_credential_test(
# Only clean up temporary credentials after successful cancellation. # Only clean up temporary credentials after successful cancellation.
# The background task may also try to delete — that's fine, it handles NotFound gracefully. # The background task may also try to delete — that's fine, it handles NotFound gracefully.
try: try:
credential = await app.DATABASE.credentials.get_credential( await _delete_temporary_test_login_credential(
credential_id=credential_id, credential_id=credential_id,
organization_id=organization_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: except Exception:
LOG.warning( LOG.warning(
"Failed to clean up temporary credential after test cancellation", "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 # Clean up temporary credentials created by test-login
if credential_name.startswith("_test_login_"): if credential_name.startswith("_test_login_"):
try: try:
await app.DATABASE.credentials.delete_credential( await _delete_temporary_test_login_credential(
credential_id=credential_id,
organization_id=organization_id,
)
LOG.info(
"Deleted temporary credential after failed test",
credential_id=credential_id, credential_id=credential_id,
organization_id=organization_id, organization_id=organization_id,
reason="failed test",
) )
except Exception: except Exception:
LOG.warning( LOG.warning(
@ -1601,9 +1589,10 @@ async def _create_browser_profile_after_workflow(
# Clean up temporary credentials on poll timeout # Clean up temporary credentials on poll timeout
if credential_name.startswith("_test_login_"): if credential_name.startswith("_test_login_"):
try: try:
await app.DATABASE.credentials.delete_credential( await _delete_temporary_test_login_credential(
credential_id=credential_id, credential_id=credential_id,
organization_id=organization_id, organization_id=organization_id,
reason="poll timeout",
) )
except Exception: except Exception:
LOG.warning( LOG.warning(
@ -1620,9 +1609,10 @@ async def _create_browser_profile_after_workflow(
# Clean up temporary credentials on unexpected error # Clean up temporary credentials on unexpected error
if credential_name.startswith("_test_login_"): if credential_name.startswith("_test_login_"):
try: try:
await app.DATABASE.credentials.delete_credential( await _delete_temporary_test_login_credential(
credential_id=credential_id, credential_id=credential_id,
organization_id=organization_id, organization_id=organization_id,
reason="profile persistence error",
) )
except Exception: except Exception:
LOG.warning( LOG.warning(
@ -1838,7 +1828,8 @@ async def get_credential_totp_code(
if cached_response is not None: if cached_response is not None:
return cached_response 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: try:
credential_item = await credential_service.get_credential_item(credential) credential_item = await credential_service.get_credential_item(credential)
except SkyvernHttpException as e: except SkyvernHttpException as e:
@ -1981,7 +1972,7 @@ async def get_credentials(
), ),
vault_type: CredentialVaultType | None = Query( vault_type: CredentialVaultType | None = Query(
default=None, 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( credential_type: CredentialType | None = Query(
default=None, default=None,
@ -2886,7 +2877,13 @@ async def _get_credential_vault_service(
vault_type_override: CredentialVaultType | None = None, vault_type_override: CredentialVaultType | None = None,
) -> CredentialVaultService: ) -> CredentialVaultService:
vault_type = vault_type_override or settings.CREDENTIAL_VAULT_TYPE 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 return app.BITWARDEN_CREDENTIAL_VAULT_SERVICE
elif vault_type == CredentialVaultType.AZURE_VAULT: elif vault_type == CredentialVaultType.AZURE_VAULT:
if not app.AZURE_CREDENTIAL_VAULT_SERVICE: 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") 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: def _convert_to_response(credential: Credential) -> CredentialResponse:
"""Convert an internal ``Credential`` to a safe API response. """Convert an internal ``Credential`` to a safe API response.

View file

@ -12,6 +12,7 @@ from skyvern.utils.url_validators import validate_url
class CredentialVaultType(StrEnum): class CredentialVaultType(StrEnum):
SKYVERN = "skyvern"
BITWARDEN = "bitwarden" BITWARDEN = "bitwarden"
AZURE_VAULT = "azure_vault" AZURE_VAULT = "azure_vault"
GCP = "gcp" GCP = "gcp"
@ -220,7 +221,7 @@ class CreateCredentialRequest(BaseModel):
default=None, default=None,
description="Which vault to store this credential in. If omitted, uses the instance default. " 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.", "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( proxy_location: ProxyLocationInput = Field(
default=None, default=None,
@ -257,7 +258,7 @@ class CredentialResponse(BaseModel):
name: str = Field(..., description="Name of the credential", examples=["Amazon Login"]) name: str = Field(..., description="Name of the credential", examples=["Amazon Login"])
vault_type: CredentialVaultType | None = Field( vault_type: CredentialVaultType | None = Field(
default=None, 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") 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") 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"] ..., description="ID of the organization that owns the credential", examples=["o_1234567890"]
) )
name: str = Field(..., description="Name of the credential", examples=["Skyvern Login"]) 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"]) 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.") credential_type: CredentialType = Field(..., description="Type of the credential. Eg password, credit card, etc.")
username: str | None = Field(..., description="For password credentials: the username") username: str | None = Field(..., description="For password credentials: the username")

View file

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

View file

@ -1,11 +1,16 @@
"""Tests for per-credential vault_type override in CreateCredentialRequest and vault service routing.""" """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 import pytest
from fastapi import HTTPException 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 ( from skyvern.forge.sdk.schemas.credentials import (
CreateCredentialRequest, CreateCredentialRequest,
CredentialResponse, CredentialResponse,
@ -19,6 +24,22 @@ from skyvern.forge.sdk.schemas.credentials import (
from skyvern.forge.sdk.services.credential.credential_vault_service import CredentialVaultService 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: class TestCreateCredentialRequestVaultType:
"""Verify the optional vault_type field on CreateCredentialRequest.""" """Verify the optional vault_type field on CreateCredentialRequest."""
@ -57,6 +78,15 @@ class TestCreateCredentialRequestVaultType:
) )
assert req.vault_type == CredentialVaultType.BITWARDEN 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: def test_vault_type_serializes_in_json(self) -> None:
req = CreateCredentialRequest( req = CreateCredentialRequest(
name="Test", name="Test",
@ -145,6 +175,69 @@ class TestGetCredentialVaultServiceRouting:
result = await _get_credential_vault_service() result = await _get_credential_vault_service()
assert result is mock_bw 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 @pytest.mark.asyncio
async def test_override_custom_ignores_global(self) -> None: async def test_override_custom_ignores_global(self) -> None:
mock_bw = MagicMock(spec=CredentialVaultService) mock_bw = MagicMock(spec=CredentialVaultService)
@ -234,3 +327,88 @@ class TestGetCredentialVaultServiceRouting:
mock_app.CUSTOM_CREDENTIAL_VAULT_SERVICE = mock_custom mock_app.CUSTOM_CREDENTIAL_VAULT_SERVICE = mock_custom
result = await _get_credential_vault_service(vault_type_override=None) result = await _get_credential_vault_service(vault_type_override=None)
assert result is mock_custom 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()

View 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")) == []