feat(custom): add managed variables

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
funnywolf
2026-08-01 18:12:53 +08:00
co-authored by Copilot
parent 5f1660c2e4
commit be83c727ec
14 changed files with 592 additions and 319 deletions
+1 -1
Submodule asp-doc updated: 8835c768f7...c87ef70df2
+7
View File
@@ -7,6 +7,7 @@ from django.conf import settings
from django.utils import timezone as django_timezone
from apps.settings.runtime_config import get_prompt_language
from apps.settings.custom_variables import get_custom_variable
class BasePlaybook:
@@ -43,6 +44,9 @@ class BasePlaybook:
raise FileNotFoundError(f"Custom playbook prompt not found: {path}")
return path.read_text(encoding="utf-8")
def get_variable(self, key):
return get_custom_variable(key)
class BaseModule:
NAME = ""
@@ -53,6 +57,9 @@ class BaseModule:
def run(self, message):
raise NotImplementedError
def get_variable(self, key):
return get_custom_variable(key)
def parse_event_time(value, default=None):
if not value:
+2
View File
@@ -38,6 +38,7 @@ RESOURCE_MODEL_TO_KEY = {
"siemelkconfig": "siem-elk",
"ldapconfig": "ldap",
"runtimeconfig": "runtime",
"customvariable": "custom-variables",
}
RESOURCE_LABELS = {
@@ -55,6 +56,7 @@ RESOURCE_LABELS = {
"siemelkconfig": "ELK Settings",
"ldapconfig": "LDAP Settings",
"runtimeconfig": "Runtime Settings",
"customvariable": "Custom Variable",
}
@@ -0,0 +1,9 @@
from .models import CustomVariable
def get_custom_variable(key):
return (
CustomVariable.objects.filter(key=key, enabled=True)
.values_list("value", flat=True)
.first()
)
@@ -0,0 +1,32 @@
# Generated by Django 6.0.7 on 2026-08-01 09:55
import django.core.validators
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('settings', '0004_runtimeconfig_dashboard_refresh_interval_seconds'),
]
operations = [
migrations.CreateModel(
name='CustomVariable',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('key', models.CharField(max_length=128, unique=True, validators=[django.core.validators.RegexValidator(message='Key must start with an uppercase letter and contain only uppercase letters, numbers, and underscores.', regex='^[A-Z][A-Z0-9_]{0,127}$')])),
('value', models.TextField()),
('is_secret', models.BooleanField(default=False)),
('description', models.TextField(blank=True, default='')),
('enabled', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'setting_custom_variables',
'ordering': ['key'],
},
),
]
+28
View File
@@ -1,5 +1,6 @@
import uuid
from django.core.validators import RegexValidator
from django.db import models
@@ -156,3 +157,30 @@ class RuntimeConfig(models.Model):
def get_current(cls):
instance, _ = cls.objects.get_or_create(singleton_id=1)
return instance
class CustomVariable(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
key = models.CharField(
max_length=128,
unique=True,
validators=[
RegexValidator(
regex=r"^[A-Z][A-Z0-9_]{0,127}$",
message="Key must start with an uppercase letter and contain only uppercase letters, numbers, and underscores.",
)
],
)
value = models.TextField()
is_secret = models.BooleanField(default=False)
description = models.TextField(blank=True, default="")
enabled = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "setting_custom_variables"
ordering = ["key"]
def __str__(self):
return self.key
+71
View File
@@ -1,6 +1,7 @@
from rest_framework import serializers
from .models import (
CustomVariable,
LdapConfig,
LLMProviderConfig,
RuntimeConfig,
@@ -11,6 +12,76 @@ from .models import (
)
MAX_CUSTOM_VARIABLE_VALUE_BYTES = 65_536
class CustomVariableSerializer(serializers.ModelSerializer):
value_configured = serializers.SerializerMethodField()
confirm_secret_exposure = serializers.BooleanField(write_only=True, required=False, default=False)
class Meta:
model = CustomVariable
fields = (
"id",
"key",
"value",
"value_configured",
"is_secret",
"description",
"enabled",
"created_at",
"updated_at",
"confirm_secret_exposure",
)
read_only_fields = ("id", "value_configured", "created_at", "updated_at")
extra_kwargs = {
"value": {"required": False, "trim_whitespace": False, "allow_blank": False},
"description": {"required": False, "allow_blank": True},
}
def get_value_configured(self, obj):
return bool(obj.value)
def validate_key(self, value):
if self.instance is not None and value != self.instance.key:
raise serializers.ValidationError("Key cannot be changed.")
return value
def validate_value(self, value):
if value == "":
raise serializers.ValidationError("Value cannot be empty.")
if len(value.encode("utf-8")) > MAX_CUSTOM_VARIABLE_VALUE_BYTES:
raise serializers.ValidationError(
f"Value cannot exceed {MAX_CUSTOM_VARIABLE_VALUE_BYTES:,} UTF-8 bytes."
)
return value
def validate(self, attrs):
attrs = super().validate(attrs)
confirmation = attrs.pop("confirm_secret_exposure", False)
if self.instance is None:
if "value" not in attrs:
raise serializers.ValidationError({"value": "Value is required."})
return attrs
if not self.partial and "value" not in attrs and not self.instance.is_secret:
raise serializers.ValidationError({"value": "Value is required."})
next_is_secret = attrs.get("is_secret", self.instance.is_secret)
if self.instance.is_secret and not next_is_secret and not confirmation:
raise serializers.ValidationError({
"confirm_secret_exposure": "Confirm that this secret value may be exposed."
})
return attrs
def to_representation(self, instance):
data = super().to_representation(instance)
if instance.is_secret:
data["value"] = ""
return data
class LLMProviderConfigSerializer(serializers.ModelSerializer):
api_key_configured = serializers.SerializerMethodField()
+5
View File
@@ -10,6 +10,7 @@ from .custom_views import (
CustomModuleStreamMessagesView,
)
from .views import (
CustomVariableViewSet,
LLMProviderConfigViewSet,
LdapConfigView,
LdapTestView,
@@ -29,6 +30,9 @@ router = DefaultRouter()
router.register("llm-providers", LLMProviderConfigViewSet, basename="llm-provider")
router.register("audit-logs", AdminAuditLogViewSet, basename="settings-audit-log")
custom_router = DefaultRouter()
custom_router.register("variables", CustomVariableViewSet, basename="custom-variable")
urlpatterns = [
path("settings/threat-intel/otx/", ThreatIntelAlienVaultOTXConfigView.as_view(), name="threat-intel-otx-config"),
path("settings/threat-intel/otx/test/", ThreatIntelAlienVaultOTXTestView.as_view(), name="threat-intel-otx-test"),
@@ -46,5 +50,6 @@ urlpatterns = [
path("custom/modules/stream/message/", CustomModuleStreamMessageView.as_view(), name="custom-module-stream-message"),
path("custom/playbooks/", CustomDefinitionsPlaybookView.as_view(), name="custom-definitions-playbooks"),
path("custom/siem/", CustomDefinitionsSiemView.as_view(), name="custom-definitions-siem"),
path("custom/", include(custom_router.urls)),
path("settings/", include(router.urls)),
]
+94
View File
@@ -15,6 +15,7 @@ from apps.audit.models import AuditLog
from apps.common.advanced_filters import AdvancedFilterBackend
from apps.common.operation_timeout import OperationTimeoutError, run_with_operation_timeout
from .models import (
CustomVariable,
LdapConfig,
LLMProviderConfig,
RuntimeConfig,
@@ -25,6 +26,7 @@ from .models import (
)
from .runtime_config import invalidate
from .serializers import (
CustomVariableSerializer,
LLMProviderConfigSerializer,
LdapConfigSerializer,
SiemElkConfigSerializer,
@@ -64,6 +66,7 @@ RUNTIME_AUDIT_FIELDS = (
"stream_maxlen",
"dashboard_refresh_interval_seconds",
)
CUSTOM_VARIABLE_AUDIT_FIELDS = ("key", "value", "is_secret", "description", "enabled")
def _snapshot(instance, fields):
@@ -202,6 +205,97 @@ class LLMProviderConfigViewSet(viewsets.ModelViewSet):
return Response(result, status=status.HTTP_200_OK)
class CustomVariableViewSet(viewsets.ModelViewSet):
queryset = CustomVariable.objects.all()
serializer_class = CustomVariableSerializer
permission_classes = [permissions.IsAuthenticated, IsAdmin]
filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter, AdvancedFilterBackend)
search_fields = ("key", "description")
filterset_fields = ("is_secret", "enabled")
ordering_fields = ("key", "is_secret", "enabled", "created_at", "updated_at")
advanced_filter_fields = {
"key": "text",
"description": "text",
"is_secret": "select",
"enabled": "select",
"created_at": "date",
"updated_at": "date",
}
@staticmethod
def _safe_changes(before, after):
changes = {}
for field in CUSTOM_VARIABLE_AUDIT_FIELDS:
old_value = before.get(field) if before else None
new_value = after.get(field) if after else None
if old_value == new_value:
continue
if field == "value":
changes[field] = {"from": "***", "to": "***"}
else:
changes[field] = {"from": old_value, "to": new_value}
return changes
@transaction.atomic
def perform_create(self, serializer):
instance = serializer.save()
after = _snapshot(instance, CUSTOM_VARIABLE_AUDIT_FIELDS)
_write_audit(
instance,
"create",
self.request.user,
changes=self._safe_changes(None, after),
metadata={"key": instance.key, "value_changed": True},
)
@transaction.atomic
def perform_update(self, serializer):
instance = self.get_object()
before = _snapshot(instance, CUSTOM_VARIABLE_AUDIT_FIELDS)
instance = serializer.save()
after = _snapshot(instance, CUSTOM_VARIABLE_AUDIT_FIELDS)
changes = self._safe_changes(before, after)
if changes:
_write_audit(
instance,
"update",
self.request.user,
changes=changes,
metadata={"key": instance.key, "value_changed": before["value"] != after["value"]},
)
@transaction.atomic
def perform_destroy(self, instance):
before = _snapshot(instance, CUSTOM_VARIABLE_AUDIT_FIELDS)
_write_audit(
instance,
"delete",
self.request.user,
changes=self._safe_changes(before, None),
metadata={
"key": instance.key,
"description": instance.description,
"is_secret": instance.is_secret,
"enabled": instance.enabled,
},
)
instance.delete()
@action(detail=True, methods=["post"])
def reveal(self, request, pk=None):
instance = self.get_object()
if not instance.is_secret:
return Response(
{"detail": "Only secret variables can be revealed."},
status=status.HTTP_400_BAD_REQUEST,
)
_write_audit(instance, "reveal", request.user, metadata={"key": instance.key})
response = Response({"value": instance.value})
response["Cache-Control"] = "no-store"
response["Pragma"] = "no-cache"
return response
def _otx_config_from_instance(instance, values):
config = _snapshot(instance, OTX_AUDIT_FIELDS)
config.update(values)
-316
View File
@@ -1,316 +0,0 @@
# Custom Variables
Status: Confirmed
## 1. Purpose
提供一个由 Admin 通过 UI 管理、由自定义 Playbook 和 Agentic Module 在后端运行时读取的字符串变量仓库。它类似受控的数据库版环境变量,但不会修改真实 `os.environ`,也不影响 Django 全局配置。
典型用途:
- 外部系统 base URL。
- API token、username/password。
- tenant/project ID。
- 自定义 JSON 字符串。
- Module 或 Playbook 的运行参数。
## 2. Scope
### Consumers
- `BasePlaybook.get_variable(key)`
- `BaseModule.get_variable(key)`
### Not consumers
- Agent API。
- CLI 或插件。
- 前端运行时。
- Django template。
- 其他任意后端模块。
- 系统 Runtime Settings。
### Excluded
- 写入真实进程环境变量。
- 全局配置覆盖。
- per-Playbook/per-Module scope。
- 变量继承或 override。
- value 类型系统。
- `.env` 导入导出。
- REQUIRED_VARIABLES 声明。
- Secret 加密、版本历史或自动泄露防护。
## 3. Data model
建议模型 `CustomVariable`
| Field | Type | Rules |
| --- | --- | --- |
| id | UUID | primary key |
| key | CharField(128) | unique, immutable |
| value | TextField | non-empty, max 65,536 UTF-8 bytes |
| is_secret | Boolean | controls API masking only |
| description | TextField | optional |
| enabled | Boolean | default true |
| created_at | DateTime | auto |
| updated_at | DateTime | auto |
### Key validation
Regex
```text
[A-Z][A-Z0-9_]{0,127}
```
- 全局唯一。
- 大小写敏感,但合法输入只有大写。
- 创建后不可改名。
- 修改 key 的 PATCH/PUT 返回 validation error。
### Value validation
- 必须至少一个字符。
- 以 UTF-8 bytes 计算,最多 65,536 bytes。
- 可包含换行。
- 不支持 binary。
- 不做 trim;空格可能是有意值,但零长度禁止。
- 更新 Secret 时省略 value 表示保留旧值。
- 显式 `""` 始终拒绝。
## 4. Storage security
已确认:
- Secret 和非 Secret 均以 PostgreSQL 明文保存。
- 数据库管理员、数据库泄露和未加密备份可以读取 Secret。
- `is_secret` 只控制 API/UI 显示与审计,不提供 cryptographic protection。
- 用户文档必须明确该限制。
不得在 AuditLog、普通 API response、异常文本或日志中复制 value。
## 5. Runtime API
Base class helper
```python
def get_variable(self, key):
...
```
返回:
- Enabled 且存在:原始 `str`
- 不存在:`None`
- Disabled`None`
- 已删除:`None`
行为:
- 每次调用都查询 PostgreSQL。
- 不做 per-run、per-message 或 Worker 全局缓存。
- Admin 在 Playbook/Module 运行中修改、Disabled 或删除变量,下一次读取立即看到变化。
- 不抛 missing variable 异常。
- 不支持 default 参数作为平台约定;自定义代码自行使用 `or` 或显式 None 处理。
- 不返回 is_secret/description 等 metadata。
- 不记录 read audit 或 usage relation。
建议共享 service 位于 `apps.settings.custom_variables` 或等价窄模块,BasePlaybook/BaseModule 只做代理,避免重复 ORM 代码。
## 6. Permissions
| Action | Admin | User | Viewer | Playbook/Module Worker |
| --- | --- | --- | --- | --- |
| List metadata | Yes | No | No | No |
| Read non-secret value via Admin API | Yes | No | No | N/A |
| Reveal secret | Yes | No | No | N/A |
| Create/update/delete | Yes | No | No | No |
| get_variable runtime | No direct API | No direct API | No direct API | Yes |
Worker 读取不根据发起 Playbook 的用户角色过滤。User 发起的 Playbook 仍可读取所有 Custom Variables,因此 Admin 必须只安装可信自定义代码。
## 7. Admin API
建议资源:
`/api/settings/custom-variables/`
### List/retrieve default representation
非 Secret
```json
{
"id": "...",
"key": "EDR_BASE_URL",
"value": "https://edr.internal.example",
"is_secret": false,
"description": "Production EDR API",
"enabled": true
}
```
Secret
```json
{
"id": "...",
"key": "EDR_API_TOKEN",
"value": "",
"value_configured": true,
"is_secret": true,
"description": "Production EDR token",
"enabled": true
}
```
### Reveal
`POST /api/settings/custom-variables/{id}/reveal/`
- 仅 active Admin session。
- 不要求重新输入本地或 LDAP 密码。
- 返回一次明文 value。
- 每次请求写 AuditLog。
- 不应支持 bulk reveal。
- response 应使用 no-store cache headers。
### Update
- key 不可改。
- Secret update 未包含 value:保留旧值。
- Secret→non-secretAPI 需要显式确认字段,例如 `confirm_secret_exposure=true`
- 没有确认返回 400。
- non-secret→secret 正常允许。
- 所有 changes 审计不包含 value。
### Delete
- 允许硬删除。
- UI 必须二次确认。
- API 不负责扫描 Python 代码引用。
- 删除后运行时返回 None。
- 删除 AuditLog 保存 key、description、is_secret、enabled,不保存 value。
## 8. Audit
写 AuditLog
- create
- update
- enable/disable(作为 update
- reveal
- delete
不写:
- get_variable runtime read。
- Admin 普通 list/retrieve。
Secret 和非 Secret value 均不得进入 changes。可使用:
```json
{
"changes": {
"value": {"from": "***", "to": "***"},
"enabled": {"from": true, "to": false}
},
"metadata": {
"key": "EDR_API_TOKEN",
"value_changed": true
}
}
```
不保留旧 value,不支持 rollback。
## 9. Frontend
位置:System Settings 中新增 `Custom Variables`
### List
- Key。
- Description。
- Secret 标记。
- Enabled。
- Value configured。
- Updated time。
- Edit/Delete actions。
- Secret 默认不可见。
### Create/edit modal
- Key 创建时可编辑,编辑时只读。
- Value 使用 multiline inputSecret 使用 password input。
- is_secret switch。
- enabled switch。
- description。
- value byte limit 提示。
- 编辑 Secret 时 value 留空表示不修改,UI 必须明确说明。
### Reveal
- Secret 行显示 Reveal。
- active Admin session 直接调用。
- 明文只显示在临时 Modal。
- Modal 关闭后从前端 state 清除。
- 不复制到列表 state、URL、localStorage 或 console。
### Secret downgrade
从 Secret 改为普通变量时显示额外确认:
> This value will become visible in normal Admin API responses and UI.
## 10. Interaction with custom code
示例:
```python
class Playbook(BasePlaybook):
def run(self):
base_url = self.get_variable("EDR_BASE_URL")
token = self.get_variable("EDR_API_TOKEN")
if not base_url or not token:
raise ValueError("EDR custom variables are not configured.")
```
自定义代码责任:
- 检查 None。
- 解析 JSON/boolean/number。
- 不把 Secret 写入 log、Stage summary、remark、Enrichment 或异常。
- 为 HTTP 调用设置 timeout、TLS、proxy、重试和幂等。
平台不静态分析 key,也不展示“变量被哪些脚本使用”。
## 11. Migration and backup
- 新表 migration,无旧数据迁移。
- v0.5.2 现有 LLM/SIEM/LDAP/TI Secret 不自动复制到 Custom Variables。
- 备份/恢复自然包含明文 value。
- Compose 文档需提醒保护数据库备份。
## 12. Acceptance criteria
1. 只有 Admin 能访问资源和 Reveal。
2. key regex、唯一和不可改名规则生效。
3. 空 value 和超过 65,536 bytes 被拒绝。
4. Secret 默认 response 不含明文。
5. Admin Reveal 返回明文并写不含 value 的审计。
6. Secret→普通无确认被拒绝。
7. Playbook 和 Module 每次读取当前数据库值。
8. Disabled/missing/deleted 返回 None。
9. Agent API、CLI 和普通用户没有读取端点。
10. CRUD AuditLog 永不包含 old/new value。
11. 删除无需引用检查且立即影响运行时读取。
## 13. Known tradeoffs
- PostgreSQL 和备份保存明文 Secret。
- active Admin session 无需重新认证即可 Reveal。
- User 发起的可信 Playbook 可以间接使用全部 Secret。
- 平台无法阻止恶意或错误自定义代码泄露 Secret。
- 每次 ORM 查询增加少量开销,这是为即时一致性接受的成本。
-1
View File
@@ -10,7 +10,6 @@
| [01-bulk-case-triage.md](01-bulk-case-triage.md) | Confirmed | Case 批量分诊、共享状态机、通知与审计 |
| [02-case-merge.md](02-case-merge.md) | Confirmed | 多源案件合并、数据迁移、只读源案件与幂等 |
| [03-playbook-execution.md](03-playbook-execution.md) | Confirmed | Playbook Run、结构化 Stage、取消、重试与 Worker 语义 |
| [04-custom-variables.md](04-custom-variables.md) | Confirmed | Playbook/Module 可读取的 UI 管理变量 |
| [05-worker-health.md](05-worker-health.md) | Confirmed | Redis 心跳、Worker 状态、积压指标与 Admin API |
| [06-integration-health.md](06-integration-health.md) | Confirmed | 定时集成检查、当前状态持久化与安全错误 |
| [07-sla-management.md](07-sla-management.md) | Confirmed | TTD/TTA/TTR 时限、Severity 策略、通知和 Dashboard 达标率 |
+38
View File
@@ -98,6 +98,14 @@ const llmProviderStatusTag = (v: unknown) => {
const enabled = v === true || v === 'true'
return choiceTag(enabled ? 'Enabled' : 'Disabled', enabled ? 'green' : 'default')
}
const customVariableSecretTag = (v: unknown) => {
const secret = v === true || v === 'true'
return choiceTag(secret ? 'Secret' : 'Plain', secret ? 'purple' : 'default')
}
const customVariableConfiguredTag = (v: unknown) => {
const configured = v === true || v === 'true'
return choiceTag(configured ? 'Configured' : 'Not configured', configured ? 'green' : 'red')
}
const llmTagColors: Record<string, string> = {
fast: 'blue',
powerful: 'purple',
@@ -1011,6 +1019,36 @@ export const resourceConfigs: Record<string, ResourceConfig<RecordRow>> = {
basicSections: [],
tabs: [],
},
'custom-variables': {
key: 'custom-variables',
label: 'Custom Variables',
icon: <ApiOutlined/>,
endpoint: '/custom/variables/',
rowKey: 'id',
searchPlaceholder: 'Key, Description',
filters: [
{key: 'is_secret', label: 'Secret', valueType: 'select', options: [{label: 'Secret', value: 'true'}, {label: 'Plain', value: 'false'}], width: L132},
{key: 'enabled', label: 'Enabled', valueType: 'select', options: [{label: 'Enabled', value: 'true'}, {label: 'Disabled', value: 'false'}], width: L132},
],
advancedFilters: [
field('key', 'Key', 'text'),
field('description', 'Description', 'text'),
{key: 'is_secret', label: 'Secret', valueType: 'select', options: [{label: 'Secret', value: 'true'}, {label: 'Plain', value: 'false'}]},
{key: 'enabled', label: 'Enabled', valueType: 'select', options: [{label: 'Enabled', value: 'true'}, {label: 'Disabled', value: 'false'}]},
field('created_at', 'Created At', 'date'),
field('updated_at', 'Updated At', 'date'),
],
columns: [
column('key', 'Key', L240, {required: true, defaultVisible: true, openRecord: true, sorter: true}),
column('description', 'Description', L360, {defaultVisible: true}),
column('is_secret', 'Secret', L96, {defaultVisible: true, sorter: true, render: customVariableSecretTag}),
column('enabled', 'Enabled', L96, {defaultVisible: true, sorter: true, render: llmProviderStatusTag}),
column('value_configured', 'Value', L132, {defaultVisible: true, render: customVariableConfiguredTag}),
column('updated_at', 'Updated At', L160, {defaultVisible: true, sorter: true, render: date('updated_at')}),
],
basicSections: [],
tabs: [],
},
}
export function getResourceConfig(key: string): ResourceConfig<RecordRow> {
+3 -1
View File
@@ -3,11 +3,12 @@ import {Alert, Button, Descriptions, Drawer, Empty, Flex, Input, InputNumber, Li
import {message} from '../utils/appMessage'
import {ReloadOutlined} from '@ant-design/icons'
import type {ColumnsType} from 'antd/es/table'
import {Boxes, BrainCircuit, DatabaseZap} from 'lucide-react'
import {Boxes, BrainCircuit, DatabaseZap, KeyRound} from 'lucide-react'
import client from '../api/client'
import JsonViewer from '../components/JsonViewer'
import IconTabLabel from '../components/IconTabLabel'
import OverflowTags from '../components/OverflowTags'
import CustomVariablesSettings from './CustomVariablesSettings'
import {comfortableTagProps} from '../utils/tagStyles'
type SourceType = 'official' | 'custom'
@@ -585,6 +586,7 @@ export default function CustomDefinitions() {
{ key: 'modules', label: <IconTabLabel icon={Boxes}>Modules</IconTabLabel>, children: <ModulesTab /> },
{ key: 'playbooks', label: <IconTabLabel icon={BrainCircuit}>Playbooks</IconTabLabel>, children: <PlaybooksTab /> },
{ key: 'siem', label: <IconTabLabel icon={DatabaseZap}>SIEM YAML</IconTabLabel>, children: <SiemTab /> },
{ key: 'variables', label: <IconTabLabel icon={KeyRound}>Variables</IconTabLabel>, children: <CustomVariablesSettings /> },
]}
/>
</div>
@@ -0,0 +1,302 @@
import {useMemo, useState} from 'react'
import {App as AntApp, Button, Form, Input, Modal, Popconfirm, Space, Switch, Typography} from 'antd'
import {DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined} from '@ant-design/icons'
import client from '../api/client'
import DataTable from '../components/DataTable'
import {getResourceConfig} from '../config/resources'
import {message} from '../utils/appMessage'
type CustomVariable = Record<string, unknown> & {
id: string
key: string
value: string
value_configured: boolean
is_secret: boolean
description: string
enabled: boolean
created_at: string
updated_at: string
}
interface CustomVariableFormValues {
key: string
value?: string
is_secret: boolean
description?: string
enabled: boolean
}
interface RevealedValue {
key: string
value: string
}
const MAX_VALUE_BYTES = 65_536
function apiErrorMessage(error: unknown, fallback: string) {
const data = (error as { response?: { data?: unknown } }).response?.data
if (typeof data === 'string') return data
if (data && typeof data === 'object') {
const detail = (data as { detail?: unknown }).detail
if (typeof detail === 'string') return detail
return JSON.stringify(data)
}
return fallback
}
function initialValues(): CustomVariableFormValues {
return {
key: '',
value: '',
is_secret: false,
description: '',
enabled: true,
}
}
export default function CustomVariablesSettings() {
const {modal} = AntApp.useApp()
const config = useMemo(() => getResourceConfig('custom-variables'), [])
const [form] = Form.useForm<CustomVariableFormValues>()
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<CustomVariable | null>(null)
const [saving, setSaving] = useState(false)
const [revealingId, setRevealingId] = useState<string | null>(null)
const [revealed, setRevealed] = useState<RevealedValue | null>(null)
const [refreshKey, setRefreshKey] = useState(0)
const isSecret = Form.useWatch('is_secret', form) ?? false
const refresh = () => setRefreshKey((value) => value + 1)
const openCreate = () => {
setEditing(null)
form.setFieldsValue(initialValues())
setModalOpen(true)
}
const openEdit = async (record: CustomVariable) => {
try {
const {data} = await client.get<CustomVariable>(`/custom/variables/${record.id}/`)
setEditing(data)
form.setFieldsValue({...initialValues(), ...data})
setModalOpen(true)
} catch (error: unknown) {
message.error(apiErrorMessage(error, 'Failed to load custom variable'))
}
}
const closeEditor = () => {
setModalOpen(false)
setEditing(null)
form.resetFields()
}
const persist = async (values: CustomVariableFormValues, confirmSecretExposure: boolean) => {
const payload: Record<string, unknown> = {
is_secret: values.is_secret,
description: values.description || '',
enabled: values.enabled,
}
if (!editing) payload.key = values.key
if (values.value !== '' && values.value !== undefined) payload.value = values.value
if (confirmSecretExposure) payload.confirm_secret_exposure = true
if (editing) {
await client.patch(`/custom/variables/${editing.id}/`, payload)
message.success('Custom variable updated')
} else {
await client.post('/custom/variables/', payload)
message.success('Custom variable created')
}
closeEditor()
refresh()
}
const saveVariable = async () => {
setSaving(true)
try {
const values = await form.validateFields()
if (editing?.is_secret && !values.is_secret) {
modal.confirm({
title: `Expose ${editing.key} as a non-secret variable?`,
content: 'This value will become visible in normal Admin API responses and UI.',
okText: 'Expose value',
okButtonProps: {danger: true},
onOk: async () => {
try {
await persist(values, true)
} catch (error: unknown) {
message.error(apiErrorMessage(error, 'Failed to save custom variable'))
throw error
}
},
})
} else {
await persist(values, false)
}
} catch (error: unknown) {
message.error(apiErrorMessage(error, 'Failed to save custom variable'))
} finally {
setSaving(false)
}
}
const revealVariable = async (record: CustomVariable) => {
setRevealingId(record.id)
try {
const {data} = await client.post<{value: string}>(`/custom/variables/${record.id}/reveal/`)
setRevealed({key: record.key, value: data.value})
} catch (error: unknown) {
message.error(apiErrorMessage(error, 'Failed to reveal custom variable'))
} finally {
setRevealingId(null)
}
}
const deleteVariable = async (record: CustomVariable) => {
try {
await client.delete(`/custom/variables/${record.id}/`)
message.success('Custom variable deleted')
refresh()
} catch (error: unknown) {
message.error(apiErrorMessage(error, 'Failed to delete custom variable'))
}
}
const validateValue = (_: unknown, value: string | undefined) => {
if (!value && !editing?.is_secret) {
return Promise.reject(new Error('Value is required.'))
}
if (value !== undefined && new TextEncoder().encode(value).length > MAX_VALUE_BYTES) {
return Promise.reject(new Error(`Value cannot exceed ${MAX_VALUE_BYTES.toLocaleString()} UTF-8 bytes.`))
}
return Promise.resolve()
}
return (
<div style={{height: 'calc(100vh - 188px)', minHeight: 360}}>
<DataTable
key={refreshKey}
endpoint={config.endpoint}
tableKey={config.key}
rowKey={config.rowKey}
columns={config.columns}
filters={config.filters}
advancedFilters={config.advancedFilters}
searchPlaceholder={config.searchPlaceholder}
actions={<Button icon={<PlusOutlined />} onClick={openCreate} />}
actionColumnWidth={136}
rowActions={(record) => {
const variable = record as CustomVariable
return (
<Space size={4} align="center" className="table-row-actions">
{variable.is_secret ? (
<Button
size="small"
type="text"
icon={<EyeOutlined />}
loading={revealingId === variable.id}
onClick={(event) => {
event.stopPropagation()
revealVariable(variable)
}}
/>
) : null}
<Button
size="small"
type="text"
icon={<EditOutlined />}
onClick={(event) => {
event.stopPropagation()
openEdit(variable)
}}
/>
<Popconfirm
title={`Delete ${variable.key}?`}
description="This variable will be permanently deleted."
okText="Delete"
okButtonProps={{danger: true}}
onConfirm={(event) => {
event?.stopPropagation()
return deleteVariable(variable)
}}
onCancel={(event) => event?.stopPropagation()}
>
<Button
size="small"
type="text"
danger
icon={<DeleteOutlined />}
onClick={(event) => event.stopPropagation()}
/>
</Popconfirm>
</Space>
)
}}
onRowClick={(record) => openEdit(record as CustomVariable)}
dense
fillParent
/>
<Modal
title={editing ? `Custom Variable: ${editing.key}` : 'Add Custom Variable'}
open={modalOpen}
onCancel={closeEditor}
destroyOnHidden
footer={(
<Space>
<Button onClick={closeEditor}>Cancel</Button>
<Button type="primary" loading={saving} onClick={saveVariable}>Save</Button>
</Space>
)}
>
<Form form={form} layout="vertical" initialValues={initialValues()} style={{paddingTop: 8}}>
<Form.Item
name="key"
label="Key"
rules={[
{required: true},
{pattern: /^[A-Z][A-Z0-9_]{0,127}$/, message: 'Use uppercase letters, numbers, and underscores.'},
]}
>
<Input disabled={editing !== null} placeholder="EDR_API_TOKEN" maxLength={128} />
</Form.Item>
<Form.Item
name="value"
label="Value"
rules={[{validator: validateValue}]}
extra={editing?.is_secret
? 'Leave blank to keep the current value. Maximum 65,536 UTF-8 bytes.'
: 'Maximum 65,536 UTF-8 bytes.'}
>
{isSecret
? <Input.Password autoComplete="new-password" />
: <Input.TextArea autoSize={{minRows: 3, maxRows: 10}} />}
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea autoSize={{minRows: 2, maxRows: 5}} />
</Form.Item>
<Form.Item name="is_secret" label="Secret" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="enabled" label="Enabled" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
<Modal
title={revealed ? `Secret: ${revealed.key}` : 'Secret'}
open={revealed !== null}
onCancel={() => setRevealed(null)}
destroyOnHidden
footer={<Button onClick={() => setRevealed(null)}>Close</Button>}
>
<Typography.Paragraph type="secondary">
This value is shown only in this dialog.
</Typography.Paragraph>
<Input.TextArea value={revealed?.value || ''} readOnly autoSize={{minRows: 2, maxRows: 10}} />
</Modal>
</div>
)
}