diff --git a/README.md b/README.md index 5f65be10..41017b8b 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,7 @@ Examples include: ### Password Manager Integrations Skyvern currently supports the following password manager integrations: - [x] Bitwarden +- [x] Custom Credential Service (HTTP API) - [ ] 1Password - [ ] LastPass diff --git a/fern/credentials/custom-credential-service.mdx b/fern/credentials/custom-credential-service.mdx new file mode 100644 index 00000000..a1141b79 --- /dev/null +++ b/fern/credentials/custom-credential-service.mdx @@ -0,0 +1,203 @@ +--- +title: Custom Credential Service +subtitle: Integrate your own HTTP API for credential management +slug: credentials/custom-credential-service +--- + +Skyvern supports integrating with custom HTTP APIs for credential management, allowing you to use your existing credential infrastructure instead of third-party services. + +## Overview + +The custom credential service feature enables Skyvern to store and retrieve credentials from external HTTP APIs. This is perfect for organizations that: + +- Have existing credential management systems +- Need to maintain credentials in their own infrastructure +- Want to integrate with proprietary credential vaults +- Require custom authentication flows + +## API Contract + +Your custom credential service must implement these HTTP endpoints: + +### Create Credential +```http +POST {API_BASE_URL} +Authorization: Bearer {API_TOKEN} +Content-Type: application/json + +{ + "name": "My Credential", + "type": "password", + "username": "user@example.com", + "password": "secure_password", + "totp": "JBSWY3DPEHPK3PXP", + "totp_type": "authenticator" +} +``` + +**Response:** +```json +{ + "id": "cred_123456" +} +``` + +### Get Credential +```http +GET {API_BASE_URL}/{credential_id} +Authorization: Bearer {API_TOKEN} +``` + +**Response:** +```json +{ + "type": "password", + "username": "user@example.com", + "password": "secure_password", + "totp": "JBSWY3DPEHPK3PXP", + "totp_type": "authenticator" +} +``` + +### Delete Credential +```http +DELETE {API_BASE_URL}/{credential_id} +Authorization: Bearer {API_TOKEN} +``` + +**Response:** HTTP 200 (empty body acceptable) + +## Configuration + +### Environment Variables (Self-hosted) + +Set these environment variables in your `.env` file: + +```bash +CREDENTIAL_VAULT_TYPE=custom +CUSTOM_CREDENTIAL_API_BASE_URL=https://credentials.company.com/api/v1/credentials +CUSTOM_CREDENTIAL_API_TOKEN=your_api_token_here +``` + +### Organization Configuration (Cloud) + +Use the Skyvern API to configure per-organization: + +```http +POST /api/v1/credentials/custom_credential/create +Authorization: Bearer {SKYVERN_API_KEY} +Content-Type: application/json + +{ + "config": { + "api_base_url": "https://credentials.company.com/api/v1/credentials", + "api_token": "your_api_token_here" + } +} +``` + +### UI Configuration + +1. Navigate to **Settings** → **Custom Credential Service** +2. Enter your API Base URL and API Token +3. Click **Test Connection** to verify connectivity +4. Click **Update Configuration** to save + +## Example Implementation + +Here's a minimal example using FastAPI: + +```python +from fastapi import FastAPI, HTTPException, Depends, Header +from pydantic import BaseModel +from typing import Optional +import uuid + +app = FastAPI() + +# In-memory storage (use a real database in production) +credentials_store = {} + +class CreateCredentialRequest(BaseModel): + name: str + type: str # "password" or "credit_card" + username: Optional[str] = None + password: Optional[str] = None + totp: Optional[str] = None + totp_type: Optional[str] = None + +class CredentialResponse(BaseModel): + id: str + +def verify_token(authorization: str = Header(...)): + if not authorization.startswith("Bearer "): + raise HTTPException(401, "Invalid authorization header") + + token = authorization.split("Bearer ")[1] + if token != "your_expected_api_token": + raise HTTPException(401, "Invalid API token") + +@app.post("/api/v1/credentials", response_model=CredentialResponse) +async def create_credential( + request: CreateCredentialRequest, + _: None = Depends(verify_token) +): + credential_id = f"cred_{uuid.uuid4().hex[:12]}" + credentials_store[credential_id] = request.model_dump() + return CredentialResponse(id=credential_id) + +@app.get("/api/v1/credentials/{credential_id}") +async def get_credential( + credential_id: str, + _: None = Depends(verify_token) +): + if credential_id not in credentials_store: + raise HTTPException(404, "Credential not found") + return credentials_store[credential_id] + +@app.delete("/api/v1/credentials/{credential_id}") +async def delete_credential( + credential_id: str, + _: None = Depends(verify_token) +): + if credential_id not in credentials_store: + raise HTTPException(404, "Credential not found") + del credentials_store[credential_id] + return {"status": "deleted"} +``` + +## Security Considerations + +- API tokens are stored encrypted in the database +- Bearer tokens are transmitted over HTTPS only +- Frontend masks sensitive tokens in the UI +- API credentials are never logged in plaintext +- Implement proper rate limiting and authentication in your API + +## Troubleshooting + +### Connection Test Fails + +1. Verify API base URL is correct and accessible +2. Check that API token is valid +3. Check firewall and network connectivity +4. Note: Connection test only verifies basic connectivity - 404/405 responses are considered successful if the server is reachable + +### Credentials Not Created + +1. Review API logs for authentication errors +2. Verify request format matches expected schema +3. Ensure API returns `id` in response + +### Environment Configuration Not Working + +1. Restart Skyvern after setting environment variables +2. Verify `CREDENTIAL_VAULT_TYPE=custom` is set +3. Check both URL and token are provided + +## Limitations + +- Connection testing verifies network connectivity and basic API reachability but not full endpoint implementation +- API must support all required endpoints (no partial implementation) +- Token rotation requires manual reconfiguration +- No built-in credential synchronization between vaults diff --git a/fern/credentials/introduction.mdx b/fern/credentials/introduction.mdx index 77ac0388..ac2f6e6f 100644 --- a/fern/credentials/introduction.mdx +++ b/fern/credentials/introduction.mdx @@ -58,6 +58,7 @@ If you have your own password manager, Skyvern can integrate with it. Skyvern ca **Supported password manager types**: - Bitwarden - 1Password Integration +- Custom Credential Service (HTTP API) **Coming Soon**: - LastPass Integration @@ -88,6 +89,13 @@ Contact [Skyvern Support](mailto:support@skyvern.com) if you want access to the > (coming soon) Securely manage your passwords with LastPass + + Integrate your own HTTP API for credential management + ; + +export function CustomCredentialServiceConfigForm() { + const [showApiToken, setShowApiToken] = useState(false); + const { + customCredentialServiceAuthToken, + parsedConfig, + isLoading, + createOrUpdateConfig, + isUpdating, + } = useCustomCredentialServiceConfig(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + config: parsedConfig || { + api_base_url: "", + api_token: "", + }, + }, + }); + + const onSubmit = (data: FormData) => { + createOrUpdateConfig(data); + }; + + const toggleApiTokenVisibility = () => { + setShowApiToken((v) => !v); + }; + + useEffect(() => { + if (parsedConfig) { + form.reset({ config: parsedConfig }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [parsedConfig]); + + return ( +
+
+
+

Custom Credential Service

+

+ Configure your custom HTTP API for credential management. Your API + should support the standard CRUD operations. +

+
+ {customCredentialServiceAuthToken && ( +
+ Status: + + {customCredentialServiceAuthToken.valid ? "Active" : "Inactive"} + +
+ )} +
+ +
+ + ( + + API Base URL + + The base URL of your custom credential service API (e.g., + https://credentials.company.com/api/v1) + +
+ + + + +
+ +
+ )} + /> + + ( + + API Token + + Bearer token for authenticating with your custom credential + service + +
+ + + + +
+ +
+ )} + /> + +
+ + + {customCredentialServiceAuthToken && ( +
+ Last updated:{" "} + {new Date( + customCredentialServiceAuthToken.modified_at, + ).toLocaleDateString()} +
+ )} +
+ + + + {customCredentialServiceAuthToken && ( +
+

+ Configuration Information +

+
+
ID: {customCredentialServiceAuthToken.id}
+
Type: {customCredentialServiceAuthToken.token_type}
+
+ Created:{" "} + {new Date( + customCredentialServiceAuthToken.created_at, + ).toLocaleDateString()} +
+ {parsedConfig && ( +
+
+ Configured API URL:{" "} + {parsedConfig.api_base_url} +
+
+ Token (masked):{" "} + {parsedConfig.api_token.length > 8 + ? `${parsedConfig.api_token.slice(0, 8)}...` + : "********"} +
+
+ )} +
+
+ )} +
+ ); +} diff --git a/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts b/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts new file mode 100644 index 00000000..c62f8553 --- /dev/null +++ b/skyvern-frontend/src/hooks/useCustomCredentialServiceConfig.ts @@ -0,0 +1,92 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { getClient } from "@/api/AxiosClient"; +import { useCredentialGetter } from "./useCredentialGetter"; +import { + CustomCredentialServiceConfigResponse, + CustomCredentialServiceOrganizationAuthToken, + CreateCustomCredentialServiceConfigRequest, + CustomCredentialServiceConfig, +} from "@/api/types"; +import { useToast } from "@/components/ui/use-toast"; + +export function useCustomCredentialServiceConfig() { + const credentialGetter = useCredentialGetter(); + const queryClient = useQueryClient(); + const { toast } = useToast(); + + const { data: customCredentialServiceAuthToken, isLoading } = + useQuery({ + queryKey: ["customCredentialServiceAuthToken"], + queryFn: async () => { + const client = await getClient(credentialGetter, "sans-api-v1"); + return await client + .get("/credentials/custom_credential/get") + .then((response) => response.data.token) + .catch((error) => { + // 404 likely means not configured yet - return null silently + if (error?.response?.status === 404) { + return null; + } + // Log other errors for debugging but still return null + console.warn( + "Failed to fetch custom credential service config:", + error, + ); + return null; + }); + }, + }); + + // Parse the configuration from the stored token + const parsedConfig: CustomCredentialServiceConfig | null = useMemo(() => { + if (!customCredentialServiceAuthToken?.token) return null; + + try { + return JSON.parse(customCredentialServiceAuthToken.token); + } catch { + return null; + } + }, [customCredentialServiceAuthToken?.token]); + + const createOrUpdateConfigMutation = useMutation({ + mutationFn: async (data: CreateCustomCredentialServiceConfigRequest) => { + const client = await getClient(credentialGetter, "sans-api-v1"); + return await client + .post("/credentials/custom_credential/create", data) + .then( + (response) => response.data as CustomCredentialServiceConfigResponse, + ); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["customCredentialServiceAuthToken"], + }); + toast({ + title: "Success", + description: + "Custom credential service configuration updated successfully", + }); + }, + onError: (error: unknown) => { + const message = + (error as { response?: { data?: { detail?: string } } })?.response?.data + ?.detail || + (error as Error)?.message || + "Failed to update custom credential service configuration"; + toast({ + title: "Error", + description: message, + variant: "destructive", + }); + }, + }); + + return { + customCredentialServiceAuthToken, + parsedConfig, + isLoading, + createOrUpdateConfig: createOrUpdateConfigMutation.mutate, + isUpdating: createOrUpdateConfigMutation.isPending, + }; +} diff --git a/skyvern-frontend/src/routes/settings/Settings.tsx b/skyvern-frontend/src/routes/settings/Settings.tsx index bb7df454..686f89d1 100644 --- a/skyvern-frontend/src/routes/settings/Settings.tsx +++ b/skyvern-frontend/src/routes/settings/Settings.tsx @@ -18,6 +18,7 @@ import { getRuntimeApiKey } from "@/util/env"; import { HiddenCopyableInput } from "@/components/ui/hidden-copyable-input"; import { OnePasswordTokenForm } from "@/components/OnePasswordTokenForm"; import { AzureClientSecretCredentialTokenForm } from "@/components/AzureClientSecretCredentialTokenForm"; +import { CustomCredentialServiceConfigForm } from "@/components/CustomCredentialServiceConfigForm"; function Settings() { const { environment, organization, setEnvironment, setOrganization } = @@ -97,6 +98,17 @@ function Settings() {
+ + + Custom Credential Service + + Configure your custom HTTP API for credential management. + + + + + + ); } diff --git a/skyvern/config.py b/skyvern/config.py index e1d5aa8b..956f3f8c 100644 --- a/skyvern/config.py +++ b/skyvern/config.py @@ -340,6 +340,10 @@ class Settings(BaseSettings): # The Azure Key Vault name to store credentials AZURE_CREDENTIAL_VAULT: str | None = None + # Custom Credential Service Settings + CUSTOM_CREDENTIAL_API_BASE_URL: str | None = None + CUSTOM_CREDENTIAL_API_TOKEN: str | None = None + # Skyvern Auth Bitwarden Settings SKYVERN_AUTH_BITWARDEN_CLIENT_ID: str | None = None SKYVERN_AUTH_BITWARDEN_CLIENT_SECRET: str | None = None diff --git a/skyvern/forge/forge_app.py b/skyvern/forge/forge_app.py index c45a9ba5..15284368 100644 --- a/skyvern/forge/forge_app.py +++ b/skyvern/forge/forge_app.py @@ -11,6 +11,7 @@ from skyvern.forge.agent import ForgeAgent from skyvern.forge.agent_functions import AgentFunction from skyvern.forge.forge_openai_client import ForgeAsyncHttpxClientWrapper from skyvern.forge.sdk.api.azure import AzureClientFactory +from skyvern.forge.sdk.api.custom_credential_client import CustomCredentialAPIClient from skyvern.forge.sdk.api.llm.api_handler_factory import LLMAPIHandlerFactory from skyvern.forge.sdk.api.llm.models import LLMAPIHandler from skyvern.forge.sdk.api.real_azure import RealAzureClientFactory @@ -27,6 +28,7 @@ from skyvern.forge.sdk.schemas.organizations import AzureClientSecretCredential, from skyvern.forge.sdk.services.credential.azure_credential_vault_service import AzureCredentialVaultService from skyvern.forge.sdk.services.credential.bitwarden_credential_service import BitwardenCredentialVaultService 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.settings_manager import SettingsManager from skyvern.forge.sdk.workflow.context_manager import WorkflowContextManager from skyvern.forge.sdk.workflow.service import WorkflowService @@ -69,6 +71,7 @@ class ForgeApp: PERSISTENT_SESSIONS_MANAGER: PersistentSessionsManager BITWARDEN_CREDENTIAL_VAULT_SERVICE: BitwardenCredentialVaultService AZURE_CREDENTIAL_VAULT_SERVICE: AzureCredentialVaultService | None + CUSTOM_CREDENTIAL_VAULT_SERVICE: CustomCredentialVaultService | None CREDENTIAL_VAULT_SERVICES: dict[str, CredentialVaultService | None] scrape_exclude: ScrapeExcludeFunc | None authentication_function: Callable[[str], Awaitable[Organization]] | None @@ -191,9 +194,20 @@ def create_forge_app() -> ForgeApp: if settings.AZURE_CREDENTIAL_VAULT else None ) + app.CUSTOM_CREDENTIAL_VAULT_SERVICE = ( + CustomCredentialVaultService( + CustomCredentialAPIClient( + api_base_url=settings.CUSTOM_CREDENTIAL_API_BASE_URL, # type: ignore + api_token=settings.CUSTOM_CREDENTIAL_API_TOKEN, # type: ignore + ) + ) + if settings.CUSTOM_CREDENTIAL_API_BASE_URL and settings.CUSTOM_CREDENTIAL_API_TOKEN + else CustomCredentialVaultService() # Create service without client for organization-based configuration + ) app.CREDENTIAL_VAULT_SERVICES = { CredentialVaultType.BITWARDEN: app.BITWARDEN_CREDENTIAL_VAULT_SERVICE, CredentialVaultType.AZURE_VAULT: app.AZURE_CREDENTIAL_VAULT_SERVICE, + CredentialVaultType.CUSTOM: app.CUSTOM_CREDENTIAL_VAULT_SERVICE, } app.scrape_exclude = None diff --git a/skyvern/forge/sdk/api/custom_credential_client.py b/skyvern/forge/sdk/api/custom_credential_client.py new file mode 100644 index 00000000..9bab3354 --- /dev/null +++ b/skyvern/forge/sdk/api/custom_credential_client.py @@ -0,0 +1,279 @@ +from typing import Any + +import structlog + +from skyvern.exceptions import HttpException +from skyvern.forge.sdk.core.aiohttp_helper import aiohttp_delete, aiohttp_get_json, aiohttp_post +from skyvern.forge.sdk.schemas.credentials import ( + CredentialItem, + CredentialType, + CreditCardCredential, + PasswordCredential, +) + +LOG = structlog.get_logger() + + +class CustomCredentialAPIClient: + """HTTP client for interacting with custom credential service APIs.""" + + def __init__(self, api_base_url: str, api_token: str): + """ + Initialize the custom credential API client. + + Args: + api_base_url: Base URL for the custom credential API + api_token: Bearer token for authentication + """ + self.api_base_url = api_base_url.rstrip("/") + self.api_token = api_token + + def _get_auth_headers(self) -> dict[str, str]: + """Get headers for API authentication.""" + return { + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json", + } + + def _credential_to_api_payload(self, credential: PasswordCredential | CreditCardCredential) -> dict[str, Any]: + """Convert Skyvern credential to API payload format.""" + if isinstance(credential, PasswordCredential): + return { + "type": "password", + "username": credential.username, + "password": credential.password, + "totp": credential.totp, + "totp_type": credential.totp_type, + } + elif isinstance(credential, CreditCardCredential): + return { + "type": "credit_card", + "card_holder_name": credential.card_holder_name, + "card_number": credential.card_number, + "card_exp_month": credential.card_exp_month, + "card_exp_year": credential.card_exp_year, + "card_cvv": credential.card_cvv, + "card_brand": credential.card_brand, + } + else: + raise TypeError(f"Unsupported credential type: {type(credential)}") + + def _api_response_to_credential(self, credential_data: dict[str, Any], name: str, item_id: str) -> CredentialItem: + """Convert API response to Skyvern CredentialItem.""" + credential_type = credential_data.get("type") + + if credential_type == "password": + required_fields = ["username", "password"] + missing = [f for f in required_fields if f not in credential_data] + if missing: + raise ValueError(f"Missing required password fields from API: {missing}") + + credential = PasswordCredential( + username=credential_data["username"], + password=credential_data["password"], + totp=credential_data.get("totp"), + totp_type=credential_data.get("totp_type", "none"), + ) + return CredentialItem( + item_id=item_id, + credential=credential, + name=name, + credential_type=CredentialType.PASSWORD, + ) + elif credential_type == "credit_card": + required_fields = [ + "card_holder_name", + "card_number", + "card_exp_month", + "card_exp_year", + "card_cvv", + "card_brand", + ] + missing = [f for f in required_fields if f not in credential_data] + if missing: + raise ValueError(f"Missing required credit card fields from API: {missing}") + + credential = CreditCardCredential( + card_holder_name=credential_data["card_holder_name"], + card_number=credential_data["card_number"], + card_exp_month=credential_data["card_exp_month"], + card_exp_year=credential_data["card_exp_year"], + card_cvv=credential_data["card_cvv"], + card_brand=credential_data["card_brand"], + ) + return CredentialItem( + item_id=item_id, + credential=credential, + name=name, + credential_type=CredentialType.CREDIT_CARD, + ) + else: + raise ValueError(f"Unsupported credential type from API: {credential_type}") + + async def create_credential(self, name: str, credential: PasswordCredential | CreditCardCredential) -> str: + """ + Create a credential using the custom API. + + Args: + name: Name of the credential + credential: Credential data to store + + Returns: + The credential ID returned by the API + + Raises: + HttpException: If the API request fails + """ + url = f"{self.api_base_url}" + headers = self._get_auth_headers() + + payload = { + "name": name, + **self._credential_to_api_payload(credential), + } + + LOG.info( + "Creating credential via custom API", + url=url, + name=name, + credential_type=type(credential).__name__, + ) + + try: + response = await aiohttp_post( + url=url, + data=payload, + headers=headers, + raise_exception=True, + ) + + if not response: + raise HttpException(500, url, "Empty response from custom credential API") + + # Extract credential ID from response + credential_id = response.get("id") + if not credential_id: + LOG.error( + "Custom credential API response missing id field", + url=url, + response=response, + ) + raise HttpException(500, url, "Invalid response format from custom credential API") + + LOG.info( + "Successfully created credential via custom API", + url=url, + name=name, + credential_id=credential_id, + ) + + return str(credential_id) + + except HttpException: + raise + except Exception as e: + LOG.error( + "Failed to create credential via custom API", + url=url, + name=name, + error=str(e), + exc_info=True, + ) + raise HttpException(500, url, f"Failed to create credential via custom API: {e!s}") from e + + async def get_credential(self, credential_id: str, name: str) -> CredentialItem: + """ + Get a credential using the custom API. + + Args: + credential_id: ID of the credential to retrieve + name: Name of the credential (for constructing CredentialItem) + + Returns: + The credential data + + Raises: + HttpException: If the API request fails + """ + url = f"{self.api_base_url}/{credential_id}" + headers = self._get_auth_headers() + + LOG.info( + "Retrieving credential via custom API", + url=url, + credential_id=credential_id, + ) + + try: + response = await aiohttp_get_json( + url=url, + headers=headers, + raise_exception=True, + ) + + if not response: + raise HttpException(404, url, f"Credential not found: {credential_id}") + + LOG.info( + "Successfully retrieved credential via custom API", + url=url, + credential_id=credential_id, + ) + + return self._api_response_to_credential(response, name, credential_id) + + except HttpException: + raise + except Exception as e: + LOG.error( + "Failed to retrieve credential via custom API", + url=url, + credential_id=credential_id, + error=str(e), + exc_info=True, + ) + raise HttpException(500, url, f"Failed to retrieve credential via custom API: {e!s}") from e + + async def delete_credential(self, credential_id: str) -> None: + """ + Delete a credential using the custom API. + + Args: + credential_id: ID of the credential to delete + + Raises: + HttpException: If the API request fails + """ + url = f"{self.api_base_url}/{credential_id}" + headers = self._get_auth_headers() + + LOG.info( + "Deleting credential via custom API", + url=url, + credential_id=credential_id, + ) + + try: + await aiohttp_delete( + url=url, + headers=headers, + raise_exception=True, + ) + + LOG.info( + "Successfully deleted credential via custom API", + url=url, + credential_id=credential_id, + ) + + except HttpException: + raise + except Exception as e: + LOG.error( + "Failed to delete credential via custom API", + url=url, + credential_id=credential_id, + error=str(e), + exc_info=True, + ) + raise HttpException(500, url, f"Failed to delete credential via custom API: {e!s}") from e diff --git a/skyvern/forge/sdk/db/client.py b/skyvern/forge/sdk/db/client.py index 4f023738..05e54c49 100644 --- a/skyvern/forge/sdk/db/client.py +++ b/skyvern/forge/sdk/db/client.py @@ -910,7 +910,7 @@ class AgentDB: async def get_valid_org_auth_token( self, organization_id: str, - token_type: Literal["api", "onepassword_service_account"], + token_type: Literal["api", "onepassword_service_account", "custom_credential_service"], ) -> OrganizationAuthToken | None: ... @overload @@ -923,7 +923,9 @@ class AgentDB: async def get_valid_org_auth_token( self, organization_id: str, - token_type: Literal["api", "onepassword_service_account", "azure_client_secret_credential"], + token_type: Literal[ + "api", "onepassword_service_account", "azure_client_secret_credential", "custom_credential_service" + ], ) -> OrganizationAuthToken | AzureOrganizationAuthToken | None: try: async with self.Session() as session: diff --git a/skyvern/forge/sdk/db/enums.py b/skyvern/forge/sdk/db/enums.py index 1718358d..de227125 100644 --- a/skyvern/forge/sdk/db/enums.py +++ b/skyvern/forge/sdk/db/enums.py @@ -5,6 +5,7 @@ class OrganizationAuthTokenType(StrEnum): api = "api" onepassword_service_account = "onepassword_service_account" azure_client_secret_credential = "azure_client_secret_credential" + custom_credential_service = "custom_credential_service" class TaskType(StrEnum): diff --git a/skyvern/forge/sdk/routes/credentials.py b/skyvern/forge/sdk/routes/credentials.py index d328c3de..cb2b9d9a 100644 --- a/skyvern/forge/sdk/routes/credentials.py +++ b/skyvern/forge/sdk/routes/credentials.py @@ -1,3 +1,5 @@ +import json + import structlog from fastapi import BackgroundTasks, Body, Depends, HTTPException, Path, Query @@ -31,8 +33,10 @@ from skyvern.forge.sdk.schemas.credentials import ( from skyvern.forge.sdk.schemas.organizations import ( AzureClientSecretCredentialResponse, CreateAzureClientSecretCredentialRequest, + CreateCustomCredentialServiceConfigRequest, CreateOnePasswordTokenRequest, CreateOnePasswordTokenResponse, + CustomCredentialServiceConfigResponse, Organization, ) from skyvern.forge.sdk.schemas.totp_codes import OTPType, TOTPCode, TOTPCodeCreate @@ -616,6 +620,112 @@ async def update_azure_client_secret_credential( ) +@base_router.get( + "/credentials/custom_credential/get", + response_model=CustomCredentialServiceConfigResponse, + summary="Get Custom Credential Service Configuration", + description="Retrieves the current custom credential service configuration for the organization.", + include_in_schema=False, +) +@base_router.get( + "/credentials/custom_credential/get/", + response_model=CustomCredentialServiceConfigResponse, + include_in_schema=False, +) +async def get_custom_credential_service_config( + current_org: Organization = Depends(org_auth_service.get_current_org), +) -> CustomCredentialServiceConfigResponse: + """ + Get the current custom credential service configuration for the organization. + """ + try: + auth_token = await app.DATABASE.get_valid_org_auth_token( + organization_id=current_org.organization_id, + token_type=OrganizationAuthTokenType.custom_credential_service.value, + ) + if not auth_token: + raise HTTPException( + status_code=404, + detail="No custom credential service configuration found for this organization", + ) + + return CustomCredentialServiceConfigResponse(token=auth_token) + + except HTTPException: + raise + except Exception as e: + LOG.error( + "Failed to get custom credential service configuration", + organization_id=current_org.organization_id, + error=str(e), + exc_info=True, + ) + raise HTTPException( + status_code=500, + detail=f"Failed to get custom credential service configuration: {e!s}", + ) from e + + +@base_router.post( + "/credentials/custom_credential/create", + response_model=CustomCredentialServiceConfigResponse, + summary="Create or update Custom Credential Service Configuration", + description="Creates or updates a custom credential service configuration for the current organization. Only one valid configuration is allowed per organization.", + include_in_schema=False, +) +@base_router.post( + "/credentials/custom_credential/create/", + response_model=CustomCredentialServiceConfigResponse, + include_in_schema=False, +) +async def update_custom_credential_service_config( + request: CreateCustomCredentialServiceConfigRequest, + current_org: Organization = Depends(org_auth_service.get_current_org), +) -> CustomCredentialServiceConfigResponse: + """ + Create or update a custom credential service configuration for the current organization. + + This endpoint ensures only one valid custom credential service configuration exists per organization. + If a valid configuration already exists, it will be invalidated before creating the new one. + """ + try: + # Invalidate any existing valid custom credential service configuration for this organization + await app.DATABASE.invalidate_org_auth_tokens( + organization_id=current_org.organization_id, + token_type=OrganizationAuthTokenType.custom_credential_service, + ) + + # Store the configuration as JSON in the token field + config_json = json.dumps(request.config.model_dump()) + + # Create the new configuration + auth_token = await app.DATABASE.create_org_auth_token( + organization_id=current_org.organization_id, + token_type=OrganizationAuthTokenType.custom_credential_service, + token=config_json, + ) + + LOG.info( + "Created or updated custom credential service configuration", + organization_id=current_org.organization_id, + token_id=auth_token.id, + ) + + return CustomCredentialServiceConfigResponse(token=auth_token) + + except Exception as e: + LOG.error( + "Failed to create or update custom credential service configuration", + organization_id=current_org.organization_id, + error=str(e), + exc_info=True, + ) + raise HTTPException( + status_code=500, + detail=f"Failed to create or update custom credential service configuration: {e!s}", + ) from e + + async def _get_credential_vault_service() -> CredentialVaultService: if settings.CREDENTIAL_VAULT_TYPE == CredentialVaultType.BITWARDEN: return app.BITWARDEN_CREDENTIAL_VAULT_SERVICE @@ -623,6 +733,10 @@ async def _get_credential_vault_service() -> CredentialVaultService: if not app.AZURE_CREDENTIAL_VAULT_SERVICE: raise HTTPException(status_code=400, detail="Azure Vault credential is not supported") return app.AZURE_CREDENTIAL_VAULT_SERVICE + elif settings.CREDENTIAL_VAULT_TYPE == CredentialVaultType.CUSTOM: + if not app.CUSTOM_CREDENTIAL_VAULT_SERVICE: + raise HTTPException(status_code=400, detail="Custom credential vault is not supported") + return app.CUSTOM_CREDENTIAL_VAULT_SERVICE else: raise HTTPException(status_code=400, detail="Credential storage not supported") diff --git a/skyvern/forge/sdk/schemas/credentials.py b/skyvern/forge/sdk/schemas/credentials.py index 0036ffe6..adb22338 100644 --- a/skyvern/forge/sdk/schemas/credentials.py +++ b/skyvern/forge/sdk/schemas/credentials.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field class CredentialVaultType(StrEnum): BITWARDEN = "bitwarden" AZURE_VAULT = "azure_vault" + CUSTOM = "custom" class CredentialType(StrEnum): diff --git a/skyvern/forge/sdk/schemas/organizations.py b/skyvern/forge/sdk/schemas/organizations.py index faaf20f8..36ef7dd6 100644 --- a/skyvern/forge/sdk/schemas/organizations.py +++ b/skyvern/forge/sdk/schemas/organizations.py @@ -80,6 +80,36 @@ class CreateAzureClientSecretCredentialRequest(BaseModel): credential: AzureClientSecretCredential +class CustomCredentialServiceConfig(BaseModel): + """Configuration for custom credential service.""" + + api_base_url: str = Field( + ..., + description="Base URL for the custom credential API", + examples=["https://credentials.company.com/api/v1/credentials"], + ) + api_token: str = Field( + ..., + description="API token for authenticating with the custom credential service", + examples=["your_api_token_here"], + ) + + +class CustomCredentialServiceConfigResponse(BaseModel): + """Response model for custom credential service operations.""" + + token: OrganizationAuthToken = Field( + ..., + description="The created or updated custom credential service configuration", + ) + + +class CreateCustomCredentialServiceConfigRequest(BaseModel): + """Request model for creating or updating custom credential service configuration.""" + + config: CustomCredentialServiceConfig + + class GetOrganizationsResponse(BaseModel): organizations: list[Organization] diff --git a/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py b/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py new file mode 100644 index 00000000..581629be --- /dev/null +++ b/skyvern/forge/sdk/services/credential/custom_credential_vault_service.py @@ -0,0 +1,249 @@ +import json + +import structlog + +from skyvern.exceptions import SkyvernException +from skyvern.forge import app +from skyvern.forge.sdk.api.custom_credential_client import CustomCredentialAPIClient +from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType +from skyvern.forge.sdk.schemas.credentials import ( + CreateCredentialRequest, + Credential, + CredentialItem, + CredentialVaultType, +) +from skyvern.forge.sdk.services.credential.credential_vault_service import CredentialVaultService + +LOG = structlog.get_logger() + + +class CustomCredentialConfigurationError(SkyvernException): + """Raised when custom credential service configuration is invalid or missing.""" + + +class CustomCredentialVaultService(CredentialVaultService): + """Custom credential vault service that uses HTTP API for storing credentials.""" + + def __init__(self, client: CustomCredentialAPIClient | None = None): + """ + Initialize the custom credential vault service. + + Args: + client: HTTP client for the custom credential API (optional, created dynamically if not provided) + """ + self._client = client + + async def _get_client_for_organization(self, organization_id: str) -> CustomCredentialAPIClient: + """ + Get or create a CustomCredentialAPIClient for the given organization. + + Args: + organization_id: ID of the organization + + Returns: + Configured API client for the organization + + Raises: + Exception: If no configuration is found for the organization + """ + # If we have a global client (from environment variables), use it + if self._client: + return self._client + + # Otherwise, get organization-specific configuration + try: + auth_token = await app.DATABASE.get_valid_org_auth_token( + organization_id=organization_id, + token_type=OrganizationAuthTokenType.custom_credential_service.value, + ) + + if not auth_token: + raise CustomCredentialConfigurationError( + f"Custom credential service not configured for organization {organization_id}" + ) + + # Parse the stored configuration + config_data = json.loads(auth_token.token) + + # Create and return the API client + return CustomCredentialAPIClient( + api_base_url=config_data["api_base_url"], + api_token=config_data["api_token"], + ) + + except json.JSONDecodeError as e: + LOG.exception( + "Failed to parse custom credential service configuration", + organization_id=organization_id, + ) + raise CustomCredentialConfigurationError( + f"Invalid custom credential service configuration for organization {organization_id}" + ) from e + except Exception: + LOG.exception( + "Failed to get custom credential service configuration", + organization_id=organization_id, + ) + raise + + async def create_credential(self, organization_id: str, data: CreateCredentialRequest) -> Credential: + """ + Create a new credential in the custom vault and database. + + Args: + organization_id: ID of the organization + data: Request data containing credential information + + Returns: + The created credential record + """ + LOG.info( + "Creating credential in custom vault", + organization_id=organization_id, + name=data.name, + credential_type=data.credential_type, + ) + + try: + # Get the API client for this organization + client = await self._get_client_for_organization(organization_id) + + # Create credential in the external API + item_id = await client.create_credential( + name=data.name, + credential=data.credential, + ) + + # Create record in Skyvern database + try: + credential = await self._create_db_credential( + organization_id=organization_id, + data=data, + item_id=item_id, + vault_type=CredentialVaultType.CUSTOM, + ) + except Exception: + # Attempt to clean up the external credential + LOG.warning( + "DB creation failed, attempting to clean up external credential", + organization_id=organization_id, + item_id=item_id, + ) + try: + await client.delete_credential(item_id) + except Exception as cleanup_error: + LOG.error( + "Failed to clean up orphaned external credential", + organization_id=organization_id, + item_id=item_id, + error=str(cleanup_error), + ) + raise + + LOG.info( + "Successfully created credential in custom vault", + organization_id=organization_id, + credential_id=credential.credential_id, + item_id=item_id, + ) + + return credential + + except Exception as e: + LOG.error( + "Failed to create credential in custom vault", + organization_id=organization_id, + name=data.name, + credential_type=data.credential_type, + error=str(e), + exc_info=True, + ) + raise + + async def delete_credential(self, credential: Credential) -> None: + """ + Delete a credential from the custom vault and database. + + Args: + credential: Credential record to delete + """ + LOG.info( + "Deleting credential from custom vault", + organization_id=credential.organization_id, + credential_id=credential.credential_id, + item_id=credential.item_id, + ) + + try: + # Get the API client for this organization + client = await self._get_client_for_organization(credential.organization_id) + + # Delete from external API first + await client.delete_credential(credential.item_id) + + # Delete from Skyvern database after successful external deletion + await app.DATABASE.delete_credential(credential.credential_id, credential.organization_id) + + LOG.info( + "Successfully deleted credential from custom vault", + organization_id=credential.organization_id, + credential_id=credential.credential_id, + item_id=credential.item_id, + ) + + except Exception as e: + LOG.error( + "Failed to delete credential from custom vault", + organization_id=credential.organization_id, + credential_id=credential.credential_id, + item_id=credential.item_id, + error=str(e), + exc_info=True, + ) + raise + + async def get_credential_item(self, db_credential: Credential) -> CredentialItem: + """ + Retrieve the full credential data from the custom vault. + + Args: + db_credential: Database credential record + + Returns: + Full credential data from the vault + """ + LOG.info( + "Retrieving credential item from custom vault", + organization_id=db_credential.organization_id, + credential_id=db_credential.credential_id, + item_id=db_credential.item_id, + ) + + try: + # Get the API client for this organization + client = await self._get_client_for_organization(db_credential.organization_id) + + credential_item = await client.get_credential( + credential_id=db_credential.item_id, + name=db_credential.name, + ) + + LOG.info( + "Successfully retrieved credential item from custom vault", + organization_id=db_credential.organization_id, + credential_id=db_credential.credential_id, + item_id=db_credential.item_id, + ) + + return credential_item + + except Exception as e: + LOG.error( + "Failed to retrieve credential item from custom vault", + organization_id=db_credential.organization_id, + credential_id=db_credential.credential_id, + item_id=db_credential.item_id, + error=str(e), + exc_info=True, + ) + raise