diff --git a/asp-doc b/asp-doc index 8835c76..c87ef70 160000 --- a/asp-doc +++ b/asp-doc @@ -1 +1 @@ -Subproject commit 8835c768f775f49f16715ebd73c6eb17cad2c643 +Subproject commit c87ef70df283965d4f7e4f5f6fe068710175fff5 diff --git a/backend/apps/agentic/runtime/base.py b/backend/apps/agentic/runtime/base.py index b4370e9..ddb04de 100644 --- a/backend/apps/agentic/runtime/base.py +++ b/backend/apps/agentic/runtime/base.py @@ -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: diff --git a/backend/apps/audit/views.py b/backend/apps/audit/views.py index 686e23f..4735094 100644 --- a/backend/apps/audit/views.py +++ b/backend/apps/audit/views.py @@ -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", } diff --git a/backend/apps/settings/custom_variables.py b/backend/apps/settings/custom_variables.py new file mode 100644 index 0000000..9963566 --- /dev/null +++ b/backend/apps/settings/custom_variables.py @@ -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() + ) diff --git a/backend/apps/settings/migrations/0005_customvariable.py b/backend/apps/settings/migrations/0005_customvariable.py new file mode 100644 index 0000000..cdb886f --- /dev/null +++ b/backend/apps/settings/migrations/0005_customvariable.py @@ -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'], + }, + ), + ] diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 3766fde..2903f8c 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -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 diff --git a/backend/apps/settings/serializers.py b/backend/apps/settings/serializers.py index 7fe5ddf..ab201df 100644 --- a/backend/apps/settings/serializers.py +++ b/backend/apps/settings/serializers.py @@ -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() diff --git a/backend/apps/settings/urls.py b/backend/apps/settings/urls.py index 1a2ac88..f752a9f 100644 --- a/backend/apps/settings/urls.py +++ b/backend/apps/settings/urls.py @@ -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)), ] diff --git a/backend/apps/settings/views.py b/backend/apps/settings/views.py index 251863b..308f9a2 100644 --- a/backend/apps/settings/views.py +++ b/backend/apps/settings/views.py @@ -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) diff --git a/docs/specs/v0.6.0/04-custom-variables.md b/docs/specs/v0.6.0/04-custom-variables.md deleted file mode 100644 index 3e33b1f..0000000 --- a/docs/specs/v0.6.0/04-custom-variables.md +++ /dev/null @@ -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-secret:API 需要显式确认字段,例如 `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 input;Secret 使用 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 查询增加少量开销,这是为即时一致性接受的成本。 diff --git a/docs/specs/v0.6.0/README.md b/docs/specs/v0.6.0/README.md index bb7b363..55335de 100644 --- a/docs/specs/v0.6.0/README.md +++ b/docs/specs/v0.6.0/README.md @@ -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 达标率 | diff --git a/frontend/src/config/resources.tsx b/frontend/src/config/resources.tsx index f67f33e..2f0d0ca 100644 --- a/frontend/src/config/resources.tsx +++ b/frontend/src/config/resources.tsx @@ -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 = { fast: 'blue', powerful: 'purple', @@ -1011,6 +1019,36 @@ export const resourceConfigs: Record> = { basicSections: [], tabs: [], }, + 'custom-variables': { + key: 'custom-variables', + label: 'Custom Variables', + icon: , + 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 { diff --git a/frontend/src/pages/CustomDefinitions.tsx b/frontend/src/pages/CustomDefinitions.tsx index ece92e1..33afb84 100644 --- a/frontend/src/pages/CustomDefinitions.tsx +++ b/frontend/src/pages/CustomDefinitions.tsx @@ -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: Modules, children: }, { key: 'playbooks', label: Playbooks, children: }, { key: 'siem', label: SIEM YAML, children: }, + { key: 'variables', label: Variables, children: }, ]} /> diff --git a/frontend/src/pages/CustomVariablesSettings.tsx b/frontend/src/pages/CustomVariablesSettings.tsx new file mode 100644 index 0000000..974afbb --- /dev/null +++ b/frontend/src/pages/CustomVariablesSettings.tsx @@ -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 & { + 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() + const [modalOpen, setModalOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [saving, setSaving] = useState(false) + const [revealingId, setRevealingId] = useState(null) + const [revealed, setRevealed] = useState(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(`/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 = { + 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 ( +
+ } onClick={openCreate} />} + actionColumnWidth={136} + rowActions={(record) => { + const variable = record as CustomVariable + return ( + + {variable.is_secret ? ( + + + + )} + > +
+ + + + + {isSecret + ? + : } + + + + + + + + + + +
+ + + setRevealed(null)} + destroyOnHidden + footer={} + > + + This value is shown only in this dialog. + + + +
+ ) +}