Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Workflow Secrets #3620

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ import {
ArrowUpRightIcon,
CodeBracketIcon,
WrenchIcon,
KeyIcon,
} from "@heroicons/react/24/outline";
import { Workflow } from "@/shared/api/workflows";
import { WorkflowBuilderWidget } from "@/widgets/workflow-builder";
import WorkflowOverview from "./workflow-overview";
import WorkflowSecrets from "./workflow-secrets";
import { useConfig } from "utils/hooks/useConfig";
import { AiOutlineSwap } from "react-icons/ai";
import { ErrorComponent, TabNavigationLink } from "@/shared/ui";
Expand Down Expand Up @@ -44,6 +46,8 @@ export default function WorkflowDetailPage({
setTabIndex(2);
} else if (tab === "builder") {
setTabIndex(1);
} else if (tab === "secrets") {
setTabIndex(3);
} else {
setTabIndex(0);
}
Expand Down Expand Up @@ -73,6 +77,9 @@ export default function WorkflowDetailPage({
case 2:
router.push(`${basePath}?tab=yaml`);
break;
case 3:
router.push(`${basePath}?tab=secrets`);
break;
}
};

Expand All @@ -83,6 +90,7 @@ export default function WorkflowDetailPage({
<Tab icon={AiOutlineSwap}>Overview</Tab>
<Tab icon={WrenchIcon}>Builder</Tab>
<Tab icon={CodeBracketIcon}>YAML Definition</Tab>
<Tab icon={KeyIcon}>Secrets</Tab>
<TabNavigationLink
href="https://www.youtube.com/@keepalerting"
icon={ArrowUpRightIcon}
Expand Down Expand Up @@ -131,8 +139,11 @@ export default function WorkflowDetailPage({
</Card>
)}
</TabPanel>
<TabPanel>
<WorkflowSecrets workflowId={params.workflow_id} />
</TabPanel>
</TabPanels>
</TabGroup>
</div>
);
}
}
121 changes: 121 additions & 0 deletions keep-ui/app/(keep)/workflows/[workflow_id]/workflow-secrets.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import React, { useState, useEffect } from "react";
import { Card } from "@tremor/react";
import { PlusIcon, TrashIcon, EyeIcon, EyeOffIcon } from "@heroicons/react/24/outline";
import { GenericTable } from "@/components/table/GenericTable";
import { DisplayColumnDef } from "@tanstack/react-table";
import { useSecrets } from "@/utils/hooks/useWorkFlowSecrets";

interface Secret {
name: string;
value: string;
}

const WorkflowSecrets = ({ workflowId }: { workflowId: string }) => {
const { secrets, error, addOrUpdateSecret, readSecret, deleteSecret } = useSecrets(workflowId);
const [newSecret, setNewSecret] = useState({ name: "", value: "" });
const [showValues, setShowValues] = useState<Record<string, boolean>>({});

const handleAddSecret = async () => {
if (!newSecret.name || !newSecret.value) return;
await addOrUpdateSecret(newSecret.name, newSecret.value);
setNewSecret({ name: "", value: "" });
};

const handleDeleteSecret = async (secretName: string) => {
await deleteSecret(secretName);
};

const toggleShowValue = async (secretName: string) => {
if (!showValues[secretName]) {
const secretValue = await readSecret(secretName);
if (secretValue) {
setShowValues((prev) => ({ ...prev, [secretName]: true }));
}
} else {
setShowValues((prev) => ({ ...prev, [secretName]: false }));
}
};

const columns: DisplayColumnDef<Secret>[] = [
{
id: "name",
header: "Name",
cell: ({ row }) => (
<code className="bg-gray-100 px-2 py-1 rounded">{`{{ secrets.${row.original.name} }}`}</code>
),
},
{
id: "value",
header: "Value",
cell: ({ row }) => (
<div className="flex items-center gap-2">
{showValues[row.original.name] ? row.original.value : "β€’β€’β€’β€’β€’β€’β€’β€’"}
</div>
),
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => (
<div className="flex gap-2">
<button
onClick={() => toggleShowValue(row.original.name)}
className="p-1 hover:bg-gray-100 rounded"
>
{showValues[row.original.name] ? <EyeOffIcon className="w-4 h-4" /> : <EyeIcon className="w-4 h-4" />}
</button>
<button
onClick={() => handleDeleteSecret(row.original.name)}
className="p-1 hover:bg-gray-100 rounded"
>
<TrashIcon className="w-4 h-4 text-red-500" />
</button>
</div>
),
},
];

return (
<Card className="p-4">
<h2 className="text-xl font-semibold">Workflow Secrets</h2>

{error && <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">{error}</div>}

<div className="flex gap-4 my-4">
<input
type="text"
placeholder="Secret name"
value={newSecret.name}
onChange={(e) => setNewSecret((prev) => ({ ...prev, name: e.target.value }))}
className="flex-1 border rounded px-3 py-2"
/>
<input
type="password"
placeholder="Secret value"
value={newSecret.value}
onChange={(e) => setNewSecret((prev) => ({ ...prev, value: e.target.value }))}
className="flex-1 border rounded px-3 py-2"
/>
<button
onClick={handleAddSecret}
className="flex items-center gap-2 bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
<PlusIcon className="w-4 h-4" />
Add Secret
</button>
</div>

<GenericTable
data={secrets}
columns={columns}
rowCount={secrets.length}
offset={0}
limit={10}
onPaginationChange={() => {}}
dataFetchedAtOneGO={true}
/>
</Card>
);
};

export default WorkflowSecrets;
54 changes: 54 additions & 0 deletions keep-ui/utils/hooks/useWorkFlowSecrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useState } from "react";
import { useApi } from "@/shared/lib/hooks/useApi";

interface Secret {
name: string;
value: string;
}

export function useSecrets(workflowId: string) {
const api = useApi();
const [secrets, setSecrets] = useState<Secret[]>([]);
const [error, setError] = useState<string>("");

const addOrUpdateSecret = async (name: string, value: string) => {
try {
await api.post(`/workflows/${workflowId}/new-secret`, {
secret_name: name,
secret_value: value,
});

setSecrets((prev) => [...prev.filter((s) => s.name !== name), { name, value }]);
setError("");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to write secret");
}
};

const readSecret = async (name: string, isJson = false) => {
try {
const secretValue = await api.get(`/workflows/${workflowId}/secrets/${name}`, {
params: { is_json: isJson },
});

setSecrets((prev) => [...prev.filter((s) => s.name !== name), { name, value: secretValue }]);

return secretValue;
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to read secret");
return null;
}
};

const deleteSecret = async (name: string) => {
try {
await api.delete(`/workflows/${workflowId}/secrets/${name}`);

setSecrets((prev) => prev.filter((s) => s.name !== name));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete secret");
}
};

return { secrets, error, addOrUpdateSecret, readSecret, deleteSecret };
}
77 changes: 76 additions & 1 deletion keep/api/routes/workflows.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import datetime
import logging
import os
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Union

import validators
from fastapi import (
Expand Down Expand Up @@ -44,6 +44,8 @@
from keep.parser.parser import Parser
from keep.workflowmanager.workflowmanager import WorkflowManager
from keep.workflowmanager.workflowstore import WorkflowStore
from keep.secretmanager.secretmanagerfactory import SecretManagerFactory


router = APIRouter()
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -868,3 +870,76 @@ def toggle_workflow_state(
"status": "success",
"is_disabled": workflow.is_disabled,
}

@router.post(
"/{workflow_id}/new-secret",
description="Write a new secret or update existing secret for a workflow",
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@router.post(
"/{workflow_id}/new-secret",
description="Write a new secret or update existing secret for a workflow",
)
@router.post(
"/{workflow_id}/secrets",
description="Write a new secret or update existing secret for a workflow",
)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

def write_workflow_secret(
workflow_id: str,
secret_name: str,
secret_value: str,
authenticated_entity: AuthenticatedEntity = Depends(
IdentityManagerFactory.get_auth_verifier(["write:secrets"])
),
) -> dict:
"""
Write a secret for a workflow. Creates a new secret if it doesn't exist,
or updates the existing secret if it does.
"""
tenant_id = authenticated_entity.tenant_id
secret_manager = SecretManagerFactory.get_secret_manager()

secret_key = f"{tenant_id}_{workflow_id}_{secret_name}"
secret_manager.write_secret(
secret_name=secret_key,
secret_value=secret_value
)

return {"status": "success", "message": "Secret written successfully"}

@router.get(
"/{workflow_id}/secrets/{secret_name}",
description="Read a workflow secret",
)
def read_workflow_secret(
workflow_id: str,
secret_name: str,
is_json: bool = False,
authenticated_entity: AuthenticatedEntity = Depends(
IdentityManagerFactory.get_auth_verifier(["read:secrets"])
),
) -> Union[Dict, str]:
"""
Read a secret value for a workflow. Optionally parse as JSON if is_json is True.
"""
tenant_id = authenticated_entity.tenant_id
secret_manager = SecretManagerFactory.get_secret_manager()

secret_key = f"{tenant_id}_{workflow_id}_{secret_name}"
return secret_manager.read_secret(
secret_name=secret_key,
is_json=is_json
)

@router.delete(
"/{workflow_id}/secrets/{secret_name}",
description="Delete a workflow secret",
)
def delete_workflow_secret(
workflow_id: str,
secret_name: str,
authenticated_entity: AuthenticatedEntity = Depends(
IdentityManagerFactory.get_auth_verifier(["write:secrets"])
),
) -> dict:
"""
Delete a secret for a workflow.
"""
tenant_id = authenticated_entity.tenant_id
secret_manager = SecretManagerFactory.get_secret_manager()

secret_key = f"{tenant_id}_{workflow_id}_{secret_name}"
secret_manager.delete_secret(secret_key)

return {"status": "success", "message": "Secret deleted successfully"}
Loading