feat: add custom credential service (#4129)
Co-authored-by: Stanislav Novosad <stas@skyvern.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
203
fern/credentials/custom-credential-service.mdx
Normal file
203
fern/credentials/custom-credential-service.mdx
Normal file
@@ -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
|
||||
@@ -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
|
||||
</Card>
|
||||
<Card
|
||||
title="Custom Credential Service"
|
||||
icon="api"
|
||||
href="/credentials/custom-credential-service"
|
||||
>
|
||||
Integrate your own HTTP API for credential management
|
||||
</Card>
|
||||
<Card
|
||||
title="Keeper Integration"
|
||||
icon="lock-keyhole"
|
||||
|
||||
@@ -131,6 +131,8 @@ navigation:
|
||||
path: credentials/totp.mdx
|
||||
- page: Bitwarden
|
||||
path: credentials/bitwarden.mdx
|
||||
- page: Custom Credential Service
|
||||
path: credentials/custom-credential-service.mdx
|
||||
- section: Browser Sessions (Beta)
|
||||
contents:
|
||||
- page: Introduction
|
||||
|
||||
@@ -237,6 +237,29 @@ export interface AzureClientSecretCredentialResponse {
|
||||
token: AzureOrganizationAuthToken;
|
||||
}
|
||||
|
||||
export interface CustomCredentialServiceConfig {
|
||||
api_base_url: string;
|
||||
api_token: string;
|
||||
}
|
||||
|
||||
export interface CustomCredentialServiceOrganizationAuthToken {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
token: string; // JSON string containing CustomCredentialServiceConfig
|
||||
created_at: string;
|
||||
modified_at: string;
|
||||
token_type: string;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export interface CreateCustomCredentialServiceConfigRequest {
|
||||
config: CustomCredentialServiceConfig;
|
||||
}
|
||||
|
||||
export interface CustomCredentialServiceConfigResponse {
|
||||
token: CustomCredentialServiceOrganizationAuthToken;
|
||||
}
|
||||
|
||||
// TODO complete this
|
||||
export const ActionTypes = {
|
||||
InputText: "input_text",
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@/components/ui/form";
|
||||
import { useCustomCredentialServiceConfig } from "@/hooks/useCustomCredentialServiceConfig";
|
||||
import { EyeOpenIcon, EyeClosedIcon, GlobeIcon } from "@radix-ui/react-icons";
|
||||
|
||||
const CustomCredentialServiceConfigSchema = z
|
||||
.object({
|
||||
api_base_url: z
|
||||
.string()
|
||||
.min(1, "API Base URL is required")
|
||||
.url("Must be a valid URL"),
|
||||
api_token: z.string().min(1, "API Token is required"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
config: CustomCredentialServiceConfigSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export function CustomCredentialServiceConfigForm() {
|
||||
const [showApiToken, setShowApiToken] = useState(false);
|
||||
const {
|
||||
customCredentialServiceAuthToken,
|
||||
parsedConfig,
|
||||
isLoading,
|
||||
createOrUpdateConfig,
|
||||
isUpdating,
|
||||
} = useCustomCredentialServiceConfig();
|
||||
|
||||
const form = useForm<FormData>({
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Custom Credential Service</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure your custom HTTP API for credential management. Your API
|
||||
should support the standard CRUD operations.
|
||||
</p>
|
||||
</div>
|
||||
{customCredentialServiceAuthToken && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Status:</span>
|
||||
<span
|
||||
className={`text-sm ${customCredentialServiceAuthToken.valid ? "text-green-600" : "text-red-600"}`}
|
||||
>
|
||||
{customCredentialServiceAuthToken.valid ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.api_base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Base URL</FormLabel>
|
||||
<FormDescription>
|
||||
The base URL of your custom credential service API (e.g.,
|
||||
https://credentials.company.com/api/v1)
|
||||
</FormDescription>
|
||||
<div className="relative">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="url"
|
||||
placeholder="https://credentials.company.com/api/v1"
|
||||
disabled={isLoading || isUpdating}
|
||||
/>
|
||||
</FormControl>
|
||||
<GlobeIcon className="absolute right-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.api_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Token</FormLabel>
|
||||
<FormDescription>
|
||||
Bearer token for authenticating with your custom credential
|
||||
service
|
||||
</FormDescription>
|
||||
<div className="relative">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type={showApiToken ? "text" : "password"}
|
||||
placeholder="your_api_token_here"
|
||||
disabled={isLoading || isUpdating}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={toggleApiTokenVisibility}
|
||||
disabled={isLoading || isUpdating}
|
||||
>
|
||||
{showApiToken ? (
|
||||
<EyeClosedIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeOpenIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button type="submit" disabled={isLoading || isUpdating}>
|
||||
{isUpdating ? "Updating..." : "Update Configuration"}
|
||||
</Button>
|
||||
|
||||
{customCredentialServiceAuthToken && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Last updated:{" "}
|
||||
{new Date(
|
||||
customCredentialServiceAuthToken.modified_at,
|
||||
).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{customCredentialServiceAuthToken && (
|
||||
<div className="rounded-md bg-muted p-4">
|
||||
<h4 className="mb-2 text-sm font-medium">
|
||||
Configuration Information
|
||||
</h4>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<div>ID: {customCredentialServiceAuthToken.id}</div>
|
||||
<div>Type: {customCredentialServiceAuthToken.token_type}</div>
|
||||
<div>
|
||||
Created:{" "}
|
||||
{new Date(
|
||||
customCredentialServiceAuthToken.created_at,
|
||||
).toLocaleDateString()}
|
||||
</div>
|
||||
{parsedConfig && (
|
||||
<div className="mt-2">
|
||||
<div>
|
||||
<strong>Configured API URL:</strong>{" "}
|
||||
{parsedConfig.api_base_url}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Token (masked):</strong>{" "}
|
||||
{parsedConfig.api_token.length > 8
|
||||
? `${parsedConfig.api_token.slice(0, 8)}...`
|
||||
: "********"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<CustomCredentialServiceOrganizationAuthToken>({
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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() {
|
||||
<AzureClientSecretCredentialTokenForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="border-b-2">
|
||||
<CardTitle className="text-lg">Custom Credential Service</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your custom HTTP API for credential management.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-8">
|
||||
<CustomCredentialServiceConfigForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
279
skyvern/forge/sdk/api/custom_credential_client.py
Normal file
279
skyvern/forge/sdk/api/custom_credential_client.py
Normal file
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
class CredentialVaultType(StrEnum):
|
||||
BITWARDEN = "bitwarden"
|
||||
AZURE_VAULT = "azure_vault"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class CredentialType(StrEnum):
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user