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
|
**/traces
|
||||||
**/inputs
|
**/inputs
|
||||||
**/har
|
**/har
|
||||||
|
**/downloads
|
||||||
|
**/browser_sessions
|
||||||
|
**/credential_vault
|
||||||
**/.git
|
**/.git
|
||||||
**/.github
|
**/.github
|
||||||
**/.mypy_cache
|
**/.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: 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
1
.gitignore
vendored
|
|
@ -162,6 +162,7 @@ ig_*
|
||||||
|
|
||||||
# Skyvern ignores
|
# Skyvern ignores
|
||||||
browser_sessions/
|
browser_sessions/
|
||||||
|
credential_vault/
|
||||||
videos/
|
videos/
|
||||||
skyvern/artifacts/
|
skyvern/artifacts/
|
||||||
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]`
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
"""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()
|
||||||
|
|
|
||||||
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