FR-056.1 檢測工具管理(config schema)Implementation Plan
For agentic workers / runner: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.本計畫僅涵蓋 FR-056.1(地基階段)。 FR-056.2/.3/.4 各有獨立計畫檔(
implementation-plan-phase2.md等)。母案設計見design.md。
Goal: 建立 config schema 與三張表(檢測工具目錄 / 租戶加密憑證 / 動態參數定義),讓「檢測工具管理頁」從純前端 mock 變成真後端驅動——工具清單由 DB 提供、租戶可設定各自的工具憑證(加密存放)、可測試連線、可啟用/停用/重置,OpenVAS 可設定其餘工具灰掉。
Architecture: BE 走標準 DDD 九層,直接複製 remote_agent 模組骨架(最乾淨的 tenant-scoped CRUD 範本,且與 FR-039 Agent 同族)。憑證加密沿用既有 TokenCryptoService + FernetCrypto(Fernet 對稱加密,金鑰走環境變數),但用獨立金鑰 DETECTION_TOOL_ENCRYPTION_KEY(與 Drive token 金鑰分離,洩漏影響面隔離)。FE 檢測工具管理頁綠地重寫,UX 抄 RemoteAgentManage.vue(同為 agent 管理頁、含測試連線 pattern),欄位渲染做一個「夠用版」config-schema 驅動小 renderer(支援 text/password/number/select 四型即可,YAGNI 不做完整表單引擎)。
Tech Stack: Python 3.11 / Flask-RESTful / SQLAlchemy / dependency-injector / marshmallow / pytest(BE);PostgreSQL(新 config schema);Vue 3 Composition API / PrimeVue 3.53 / Pinia(FE)。
前置:命名與決策速查(runner 動工前必讀)
| 項目 | 定案值 | 來源 |
|---|---|---|
| schema 名 | config |
D1(typed 表命名空間,非通用 blob) |
| 三張表 | config.detection_tools / config.tenant_detection_tool_configs / config.detection_tool_param_schemas |
design §5 |
| 加密金鑰環境變數 | DETECTION_TOOL_ENCRYPTION_KEY(新開,不共用 Drive 的) |
D2 + 本計畫決策 |
| 加密介面 | 複用 domain/cloud_integration/service/token_crypto_service.py(TokenCryptoService ABC)+ infra/cloud_integration/crypto/fernet_crypto.py(FernetCrypto) |
Explore |
| authz 軸 | 軸④ capability,@require_capability("detection-tools-manage.<action>");能力點未 seed 前過渡用 require_super_admin() |
common/authz/__init__.py 決策表 |
| DDD 複製範本 | remote_agent 模組(9 層逐檔對照見附錄 A) |
Explore |
| error code 檔 | common/code/detection_tools_error_code.py,格式 DETECTION_TOOLS_<HTTP><序> |
慣例 |
| org_unit_id | 表結構留欄位,第一版邏輯只做租戶層級 | D7 |
兩個必守陷阱(entity 設計):
1. 幽靈 WHERE:QueryEntity 每個欄位必為 Optional[X] = None,不可 = 0 / = False。
2. 幽靈覆寫:Entity.__init__ 每個可更新欄位預設必為 None,不可預設真值(否則 partial update 靜默把 DB 值蓋回預設)。
跨 schema 慣例: 三張表都放 config schema,表間 FK 可建(同 schema 內);跨 schema 引用(如 tenant_detection_tool_configs 指向 job_executions)走 soft-ref(不建實體 FK,comment 註明),比照既有慣例避免跨 schema CASCADE。
Task 分佈總覽(對應 design 的 T-1.x)
| Task | 對應子任務 | 產出 | 依賴 | Repo |
|---|---|---|---|---|
| Task 1 | T-1.1 | config schema + 三表 migration + seed OpenVAS |
— | BE (scripts/sql) |
| Task 2 | T-1.2 | detection_tools 目錄唯讀 API(DDD 九層) | Task 1 | BE |
| Task 3 | T-1.3 | 租戶工具設定 CRUD + 憑證加密 | Task 1、Task 2 | BE |
| Task 4 | T-1.4 | 測試連線 endpoint + 停用/重置引用任務數提醒 | Task 3 | BE |
| Task 5 | T-1.5 | FE 檢測工具管理頁重做 | Task 2/3/4 | FE |
Task 1(T-1.1): config schema + 三表 migration + seed OpenVAS
Files:
- Create: scripts/sql/2026-07-26-fr056-1-detection-tools-config-schema.sql
說明: 這是純 SQL migration,非 TDD(DB DDL 沒有單元測試層;驗收靠實際套進 DEV 後 psql 查詢)。務必遵守 sql-migration skill:檔頭 -- Date: + 需求編號 + 表用途、每表 GRANT cm_app + sequence 權限、結尾 INSERT public.schema_migrations、一律 psql --single-transaction -v ON_ERROR_STOP=1 用 cmmgr 帳號套。
- Step 1: 寫 migration SQL
Create scripts/sql/2026-07-26-fr056-1-detection-tools-config-schema.sql:
-- Date: 2026-07-26
-- FR-056.1 檢測工具管理 config schema — 新建 config schema + 三張表
-- config.detection_tools :檢測工具目錄(取代 FE 寫死清單,平台層維護)
-- config.tenant_detection_tool_configs :租戶各自的工具設定與憑證(credentials 加密存放)
-- config.detection_tool_param_schemas :任務執行時要填的掃描參數定義(版更用)
-- tenant_detection_tool_configs 含 tenant_id + org_unit_id(org_unit 預留,FR-056.1 邏輯只做租戶層級)+ RLS
-- detection_tools / detection_tool_param_schemas 為平台層目錄,不含 tenant_id / RLS
-- ===== 0. Schema =====
CREATE SCHEMA IF NOT EXISTS config;
GRANT USAGE ON SCHEMA config TO cm_app;
-- ===== 1. 工具目錄(平台層,無 tenant)=====
CREATE TABLE config.detection_tools (
id BIGSERIAL PRIMARY KEY,
uid VARCHAR(36) NOT NULL UNIQUE,
code VARCHAR(50) NOT NULL UNIQUE, -- openvas / nessus / sonarqube
name VARCHAR(255) NOT NULL,
description TEXT,
connection_type VARCHAR(20) NOT NULL, -- API / CLI
config_field_schema JSONB NOT NULL DEFAULT '[]'::jsonb, -- 設定頁欄位定義
status VARCHAR(20) NOT NULL DEFAULT 'coming_soon', -- available / coming_soon
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_user VARCHAR(255),
updated_user VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE config.detection_tools IS 'FR-056.1 檢測工具目錄(平台層維護,取代 FE 寫死清單)';
COMMENT ON COLUMN config.detection_tools.connection_type IS 'API / CLI — 決定 Agent executor 走哪條';
COMMENT ON COLUMN config.detection_tools.config_field_schema IS '租戶設定頁要填的欄位定義 JSON array,每項 {key,label,type,required,secret}';
COMMENT ON COLUMN config.detection_tools.status IS 'available=可設定 / coming_soon=灰掉不給設定';
GRANT SELECT, INSERT, UPDATE, DELETE ON config.detection_tools TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE config.detection_tools_id_seq TO cm_app;
-- ===== 2. 租戶工具設定 + 憑證(tenant-scoped + RLS)=====
CREATE TABLE config.tenant_detection_tool_configs (
id BIGSERIAL PRIMARY KEY,
uid VARCHAR(36) NOT NULL UNIQUE,
tenant_id BIGINT NOT NULL,
org_unit_id BIGINT, -- 預留部門層級(FR-056.1 不使用)
detection_tool_id BIGINT NOT NULL, -- soft-ref → config.detection_tools.id(同 schema,可建 FK)
credentials_encrypted TEXT, -- 加密後的 url/key/token/帳密 JSON 密文
field_values JSONB NOT NULL DEFAULT '{}'::jsonb, -- 非機敏欄位值
status VARCHAR(20) NOT NULL DEFAULT 'disabled', -- enabled / disabled
last_tested_at TIMESTAMPTZ,
last_test_result VARCHAR(20), -- success / fail
created_user VARCHAR(255),
updated_user VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT fk_tdtc_detection_tool FOREIGN KEY (detection_tool_id)
REFERENCES config.detection_tools (id),
CONSTRAINT uq_tdtc_tenant_tool UNIQUE (tenant_id, detection_tool_id) -- 每租戶每工具一筆
);
COMMENT ON TABLE config.tenant_detection_tool_configs IS 'FR-056.1 租戶各自的檢測工具設定與憑證(credentials 加密)';
COMMENT ON COLUMN config.tenant_detection_tool_configs.credentials_encrypted IS 'FernetCrypto 加密後的憑證 JSON 密文(金鑰 env DETECTION_TOOL_ENCRYPTION_KEY)';
COMMENT ON COLUMN config.tenant_detection_tool_configs.org_unit_id IS '預留部門層級,FR-056.1 邏輯只做租戶層級';
GRANT SELECT, INSERT, UPDATE, DELETE ON config.tenant_detection_tool_configs TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE config.tenant_detection_tool_configs_id_seq TO cm_app;
ALTER TABLE config.tenant_detection_tool_configs ENABLE ROW LEVEL SECURITY;
CREATE POLICY tdtc_tenant_isolation ON config.tenant_detection_tool_configs
USING (
current_setting('app.is_super_admin', TRUE) = 'true'
OR tenant_id = ANY (string_to_array(current_setting('app.allowed_tenant_paths', TRUE), ',')::BIGINT[])
);
-- ===== 3. 掃描參數定義(平台層,版更用)=====
CREATE TABLE config.detection_tool_param_schemas (
id BIGSERIAL PRIMARY KEY,
uid VARCHAR(36) NOT NULL UNIQUE,
detection_tool_id BIGINT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
param_schema JSONB NOT NULL DEFAULT '[]'::jsonb, -- 任務執行時參數欄位定義
is_current BOOLEAN NOT NULL DEFAULT TRUE,
created_user VARCHAR(255),
updated_user VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT fk_dtps_detection_tool FOREIGN KEY (detection_tool_id)
REFERENCES config.detection_tools (id),
CONSTRAINT uq_dtps_tool_version UNIQUE (detection_tool_id, version)
);
COMMENT ON TABLE config.detection_tool_param_schemas IS 'FR-056.1 檢測工具任務參數定義(版更不覆蓋舊版,FR-056.2 消費)';
COMMENT ON COLUMN config.detection_tool_param_schemas.is_current IS '當前生效版本(同工具僅一筆 true)';
GRANT SELECT, INSERT, UPDATE, DELETE ON config.detection_tool_param_schemas TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE config.detection_tool_param_schemas_id_seq TO cm_app;
-- ===== 4. Seed OpenVAS(available)+ 其餘工具(coming_soon)=====
INSERT INTO config.detection_tools (uid, code, name, description, connection_type, config_field_schema, status, enabled)
VALUES
(gen_random_uuid()::text, 'openvas', 'OpenVAS', '開源弱點掃描器', 'API',
'[{"key":"base_url","label":"服務 URL","type":"text","required":true,"secret":false},{"key":"username","label":"帳號","type":"text","required":true,"secret":false},{"key":"password","label":"密碼","type":"password","required":true,"secret":true},{"key":"port","label":"連接埠","type":"number","required":false,"secret":false}]'::jsonb,
'available', TRUE),
(gen_random_uuid()::text, 'nessus', 'Nessus', '商用弱點掃描器(敬請期待)', 'API', '[]'::jsonb, 'coming_soon', TRUE),
(gen_random_uuid()::text, 'sonarqube', 'SonarQube', '靜態程式碼分析(敬請期待)', 'API', '[]'::jsonb, 'coming_soon', TRUE)
ON CONFLICT (code) DO NOTHING;
-- ===== 5. Record migration =====
INSERT INTO public.schema_migrations(filename, note) VALUES
('2026-07-26-fr056-1-detection-tools-config-schema.sql',
'FR-056.1 新建 config schema + detection_tools / tenant_detection_tool_configs / detection_tool_param_schemas 三表 + seed OpenVAS')
ON CONFLICT (filename) DO NOTHING;
- Step 2: 套進 DEV DB
Run(密碼查 .env DB_SECRET,用 cmmgr 帳號):
PGPASSWORD=<cmmgr_pw> psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev \
--single-transaction -v ON_ERROR_STOP=1 \
-f scripts/sql/2026-07-26-fr056-1-detection-tools-config-schema.sql
Expected: 無錯誤,最後 INSERT 0 1(schema_migrations)。
- Step 3: 驗證 schema、表、seed、RLS、GRANT
Run:
PGPASSWORD=<cmmgr_pw> psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -c "\dt config.*"
PGPASSWORD=<cmmgr_pw> psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -c "SELECT code,name,status FROM config.detection_tools ORDER BY id;"
PGPASSWORD=<cmmgr_pw> psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev -c "SELECT relname,relrowsecurity FROM pg_class WHERE relname='tenant_detection_tool_configs';"
Expected: 三張表都在;detection_tools 有 openvas(available) / nessus(coming_soon) / sonarqube(coming_soon);tenant_detection_tool_configs 的 relrowsecurity = t。
- Step 4: Commit
git add scripts/sql/2026-07-26-fr056-1-detection-tools-config-schema.sql
git commit -m "feat(fr056): add config schema + detection tool tables (T-1.1)
新建 config schema 與三表(detection_tools 目錄 / tenant_detection_tool_configs 加密憑證 / detection_tool_param_schemas 版更參數),seed OpenVAS available + Nessus/SonarQube coming_soon。tenant_detection_tool_configs 含 RLS + org_unit_id 預留。"
注意: STG / POC 環境的 migration 套用等 user 指示,不自動跨環境套(照 sql-migration 規範,三環境對齊由 user 決定時機)。
Task 2(T-1.2): detection_tools 目錄唯讀 API(DDD 九層)
說明: 複製 remote_agent 模組九層骨架建 detection_tools 模組。本 Task 只做目錄唯讀(GET 清單 / GET by uid),租戶設定 CRUD 在 Task 3。逐層照抄範本見附錄 A。所有 entity 守「幽靈 WHERE / 幽靈覆寫」兩陷阱。
Files:
- Create: infra/detection_tools/model/detection_tool.py
- Create: infra/detection_tools/mapper/detection_tool_mapper.py
- Create: infra/detection_tools/repository/detection_tool_repo_impl.py
- Create: domain/detection_tools/entity/detection_tool_entity.py
- Create: domain/detection_tools/entity/detection_tool_query_entity.py
- Create: domain/detection_tools/repository/detection_tool.py
- Create: domain/detection_tools/service/detection_tool_domain_service.py
- Create: app/detection_tools/service/detection_tool_service.py
- Create: api/detection_tools/serializers/detection_tool.py
- Create: api/detection_tools/routes/detection_tool_route.py
- Create: api/detection_tools/__init__.py
- Create: common/code/detection_tools_error_code.py
- Create: di_containers/detection_tools/detection_tools_containers.py
- Modify: di_containers/containers.py(加 container wiring)
- Modify: config/app_modules.py(append "detection_tools")
- Test: test/test_detection_tool_service.py
- Step 1: 寫 error code 檔
Create common/code/detection_tools_error_code.py:
from jedi_common.enums.base_code import BaseCode
class DetectionToolsErrorCode(BaseCode):
DETECTION_TOOL_NOT_FOUND = ("Detection tool not found", "DETECTION_TOOLS_404001")
DETECTION_TOOL_CONFIG_NOT_FOUND = ("Tenant detection tool config not found", "DETECTION_TOOLS_404002")
DETECTION_TOOL_NAME_REQUIRED = ("Detection tool code is required", "DETECTION_TOOLS_400001")
DETECTION_TOOL_NOT_AVAILABLE = ("Detection tool is not available for configuration", "DETECTION_TOOLS_400002")
DETECTION_TOOL_CONFIG_EXISTS = ("Config for this tool already exists", "DETECTION_TOOLS_409001")
- Step 2: 寫 infra model(照抄附錄 A.1,欄位對齊 Task 1 的
config.detection_tools)
infra/detection_tools/model/detection_tool.py:__tablename__ = "detection_tools"、__table_args__ = {"schema": "config", "comment": "FR-056.1 檢測工具目錄"},欄位 uid/code/name/description/connection_type/config_field_schema(JSONB)/status/enabled + 審計欄位。目錄表無 tenant,因此不繼承 TenantScopedMixinModel,只繼承 BaseModel(audit 欄位仍有)。
- Step 3: 寫 domain entity + query entity
Entity 每個可更新欄位預設 None(幽靈覆寫防護);QueryEntity 每欄 Optional[X] = None(幽靈 WHERE 防護),含 code / status / enabled 供過濾。
-
[ ] Step 4: 寫 repo interface / repo impl / mapper / domain service(逐檔照抄附錄 A.2/A.3/A.5/A.6)
-
[ ] Step 5: 寫 app service(唯讀)
app/detection_tools/service/detection_tool_service.py:
from jedi_common.session.database.db import transaction
from domain.detection_tools.service.detection_tool_domain_service import DetectionToolDomainService
class DetectionToolService:
def __init__(self, detection_tool_domain_service: DetectionToolDomainService):
self._domain = detection_tool_domain_service
@transaction
def list_tools(self) -> list:
# 目錄全撈(含 coming_soon),FE 自行灰掉;只回 enabled=True
return self._domain.get_tools(enabled=True)
@transaction
def get_tool(self, uid: str):
return self._domain.verify_tool_is_exist(uid)
- Step 6: 寫 failing test(app service 唯讀)
Create test/test_detection_tool_service.py。必 patch logger(照 memory feedback_test_logger_patch_db_handler):
from unittest.mock import MagicMock, patch
import pytest
from app.detection_tools.service.detection_tool_service import DetectionToolService
from domain.detection_tools.entity.detection_tool_entity import DetectionToolEntity
@pytest.fixture(autouse=True)
def _patch_transaction():
# @transaction 需要 session context;測試以 no-op 包裝
with patch("app.detection_tools.service.detection_tool_service.transaction", lambda f: f):
yield
def test_list_tools_returns_enabled_only():
domain = MagicMock()
domain.get_tools.return_value = [DetectionToolEntity(uid="u1", code="openvas", status="available")]
svc = DetectionToolService(domain)
result = svc.list_tools()
domain.get_tools.assert_called_once_with(enabled=True)
assert result[0].code == "openvas"
def test_get_tool_delegates_to_verify():
domain = MagicMock()
domain.verify_tool_is_exist.return_value = DetectionToolEntity(uid="u1", code="openvas")
svc = DetectionToolService(domain)
result = svc.get_tool("u1")
domain.verify_tool_is_exist.assert_called_once_with("u1")
assert result.uid == "u1"
@transaction測試處理(動工前先定調): runner 在寫第一個 app service test 前,先grep -l "@transaction\|session_scope\|transaction" test/test_*_service.py | head -3找既有 app service test,打開看它們怎麼處理@transaction(多半是 mock session / patchget_session,而非 patch decorator 本身)。照該既有慣例做,本計畫上面的patch(..., transaction, lambda f: f)只是示意,實際以既有 test 檔的作法為準(避免每個 test 各自發明繞法)。此決定一次定調、後續所有 detection_tools test 沿用。
- Step 7: 跑 test 確認失敗
Run: pytest test/test_detection_tool_service.py -v
Expected: FAIL(模組尚未建齊 / import error)。
-
[ ] Step 8: 補齊 serializer / route / blueprint / DI / 註冊,讓 test 過
-
Serializer:
DetectionToolResponse(uid/code/name/description/connection_type/config_field_schema(Raw)/status/enabled)。 - Route:
GET /detection-tools(清單)、GET /detection-tools/<uid>(單筆)。唯讀 GET 只需@jwt_required(),不掛 capability(讀目錄租戶管理員都能看)。 - Blueprint
api/detection_tools/__init__.py:create_module()掛/api/1.0prefix + add_resource(照附錄 A.8)。 -
DI container +
di_containers/containers.pywiring +config/app_modules.pyappend"detection_tools"(照附錄 A.9)。 -
[ ] Step 9: 跑 test 確認通過
Run: pytest test/test_detection_tool_service.py -v
Expected: PASS。
- Step 10: 起 BE 手測目錄 API
Run(BE 重啟由 Claude/runner 負責,見 CLAUDE.md):
# 重啟 BE(main_app.py,port 8000)後
curl -s -H "Authorization: Bearer <dev_jwt>" http://localhost:8000/api/1.0/detection-tools | python3 -m json.tool
Expected: envelope {"code":1,"data":[...]},含 openvas / nessus / sonarqube。若 500,先 tail -200 log/app.log | grep -A 30 -i 'detection\|Traceback\|ERROR'。
- Step 11: Commit
git add infra/detection_tools/ domain/detection_tools/ app/detection_tools/ api/detection_tools/ common/code/detection_tools_error_code.py di_containers/detection_tools/ di_containers/containers.py config/app_modules.py test/test_detection_tool_service.py
git commit -m "feat(fr056): detection_tools catalog read-only API (T-1.2)
複製 remote_agent 九層骨架建 detection_tools 模組(目錄唯讀 GET 清單/單筆),清單改由 DB 提供。entity 守幽靈 WHERE/覆寫兩陷阱。"
Task 3(T-1.3): 租戶工具設定 CRUD + 憑證加密
說明: 建 tenant_detection_tool_config 的 DDD 九層(同樣複製 remote_agent 骨架,但這張表是 tenant-scoped,model 要繼承 TenantScopedMixinModel)。憑證加解密複用既有 FernetCrypto,但用新金鑰 DETECTION_TOOL_ENCRYPTION_KEY。寫入 API 掛 capability 守門(軸④)。
Files:
- Create: infra/detection_tools/model/tenant_detection_tool_config.py
- Create: infra/detection_tools/mapper/tenant_detection_tool_config_mapper.py
- Create: infra/detection_tools/repository/tenant_detection_tool_config_repo_impl.py
- Create: domain/detection_tools/entity/tenant_detection_tool_config_entity.py
- Create: domain/detection_tools/entity/tenant_detection_tool_config_query_entity.py
- Create: domain/detection_tools/repository/tenant_detection_tool_config.py
- Create: domain/detection_tools/service/tenant_detection_tool_config_domain_service.py
- Modify: app/detection_tools/service/detection_tool_service.py(加租戶設定 CRUD 方法)
- Modify: api/detection_tools/serializers/detection_tool.py(加 config request/response schema)
- Modify: api/detection_tools/routes/detection_tool_route.py(加 config route)
- Modify: api/detection_tools/__init__.py(掛新 resource)
- Modify: di_containers/detection_tools/detection_tools_containers.py(wire crypto + config service)
- Modify: config/config.py(加 DETECTION_TOOL_ENCRYPTION_KEY 讀取)
- Test: test/test_tenant_detection_tool_config_service.py
- Step 1: config.py 加金鑰讀取
Modify config/config.py(比照 DRIVE_TOKEN_ENCRYPTION_KEY 那行,約 line 161 附近):
DETECTION_TOOL_ENCRYPTION_KEY = os.getenv("DETECTION_TOOL_ENCRYPTION_KEY", "")
注意: 這是新環境變數。部署文件要補(本計畫不改部署文件,runner 完成後在收尾提醒 user 各環境
.env要設此金鑰;產金鑰指令python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")。DEV 環境 runner 動工前先在本地.env設一把測試金鑰。
- Step 2: DI 加 crypto provider
Modify di_containers/detection_tools/detection_tools_containers.py:
from infra.cloud_integration.crypto.fernet_crypto import FernetCrypto
...
detection_tool_crypto = providers.Singleton(FernetCrypto, key=config.DETECTION_TOOL_ENCRYPTION_KEY)
(config.DETECTION_TOOL_ENCRYPTION_KEY 走既有 config.from_dict(app.config) 統一載入。)
- Step 3: 寫 tenant config 的 infra model(tenant-scoped)
infra/detection_tools/model/tenant_detection_tool_config.py:繼承 BaseModel, TenantScopedMixinModel(兩者都要,順序不可反),__table_args__ = {"schema": "config", "comment": "FR-056.1 租戶檢測工具設定與憑證"}。欄位對齊 Task 1 的 tenant_detection_tool_configs(不含 tenant_id/audit 欄位自己宣告——來自 mixin),只宣告 uid/detection_tool_id/credentials_encrypted/field_values(JSONB)/status/last_tested_at/last_test_result/org_unit_id。
- Step 4: 寫 entity + query entity(兩陷阱防護)
Entity 含 credentials(明文,僅記憶體用,不落 DB)與 credentials_encrypted(密文,落 DB)兩個概念——app service 負責 encrypt(credentials)→credentials_encrypted 再存。所有可更新欄位預設 None。
- Step 5: 寫 domain service(含加解密委派點)
domain service 不直接碰 crypto(crypto 在 app service 注入並呼叫),domain 只做 repo CRUD + verify_config_is_exist。
- Step 6: 寫 failing test(加密往返 + partial update 不清空憑證)
Create test/test_tenant_detection_tool_config_service.py,關鍵測項:
def test_create_config_encrypts_credentials():
"""建立設定時,credentials 明文經 crypto.encrypt 後才落 DB。"""
crypto = MagicMock()
crypto.encrypt.return_value = "ENC(secret)"
domain = MagicMock()
svc = DetectionToolService(detection_tool_domain_service=MagicMock(),
tenant_config_domain_service=domain,
crypto=crypto)
svc.create_tenant_config(tool_uid="t1", payload={"credentials": {"password": "p"}, "field_values": {}}, curr_user="u", tenant_id=1)
crypto.encrypt.assert_called_once()
saved_entity = domain.create_config.call_args[0][0]
assert saved_entity.credentials_encrypted == "ENC(secret)"
assert getattr(saved_entity, "credentials", None) in (None, {"password": "p"}) # 明文不落 repo
def test_partial_update_does_not_wipe_credentials():
"""只改 status 的 partial update,不可把 credentials_encrypted 蓋成 None(幽靈覆寫)。"""
crypto = MagicMock()
domain = MagicMock()
domain.verify_config_is_exist.return_value = MagicMock()
svc = DetectionToolService(detection_tool_domain_service=MagicMock(),
tenant_config_domain_service=domain, crypto=crypto)
svc.update_tenant_config(config_uid="c1", payload={"status": "disabled"}, curr_user="u", tenant_id=1)
crypto.encrypt.assert_not_called() # payload 無 credentials → 不加密
updated_entity = domain.update_config.call_args[0][0]
assert updated_entity.credentials_encrypted is None # None → update() 會 skip,DB 值保留
- Step 7: 跑 test 確認失敗
Run: pytest test/test_tenant_detection_tool_config_service.py -v → FAIL。
- Step 8: 寫 app service 租戶設定 CRUD
在 DetectionToolService 加(注意 constructor 要多注入 tenant_config_domain_service 與 crypto):
@transaction
def create_tenant_config(self, tool_uid, payload, curr_user, tenant_id):
tool = self._domain.verify_tool_is_exist(tool_uid)
if tool.status != "available":
raise BadRequestError(DetectionToolsErrorCode.DETECTION_TOOL_NOT_AVAILABLE)
creds = payload.get("credentials")
entity = TenantDetectionToolConfigEntity(
uid=str(uuid.uuid4()),
detection_tool_id=tool.id,
credentials_encrypted=self._crypto.encrypt(json.dumps(creds)) if creds else None,
field_values=payload.get("field_values") or {},
status=payload.get("status") or "disabled",
tenant_id=tenant_id,
created_user=curr_user, updated_user=curr_user,
)
return self._tenant_config_domain.create_config(entity)
@transaction
def update_tenant_config(self, config_uid, payload, curr_user, tenant_id):
self._tenant_config_domain.verify_config_is_exist(config_uid)
creds = payload.get("credentials")
entity = TenantDetectionToolConfigEntity(
uid=config_uid,
credentials_encrypted=self._crypto.encrypt(json.dumps(creds)) if creds else None, # 無 creds → None → skip
field_values=payload.get("field_values"), # None → skip
status=payload.get("status"), # None → skip
updated_user=curr_user,
)
return self._tenant_config_domain.update_config(entity)
@transaction
def reset_tenant_config_key(self, config_uid, curr_user):
"""重置:清空憑證(credentials_encrypted 設空字串,非 None,才能真的清)。"""
self._tenant_config_domain.verify_config_is_exist(config_uid)
# 註:清空要用特殊 sentinel 或專用 repo 方法,因 None 會被 update() skip。
return self._tenant_config_domain.clear_credentials(config_uid, curr_user)
重要陷阱: 「重置 key」要把 DB 憑證真的清空,但
update()遇None會 skip → 無法用一般 entity 清空。需在 domain/repo 加專用clear_credentials(uid)方法直接 SETcredentials_encrypted = ''。這是 partial-update 語意的反面,runner 務必用專用方法不要試圖用 entity 傳 None 清空。
- Step 9: 補 serializer / route(掛 capability 守門)
Route 寫入端掛軸④:
@require_capability("detection-tools-manage.create") # POST 建設定
@require_capability("detection-tools-manage.update") # PUT 改設定
@require_capability("detection-tools-manage.delete") # DELETE / 重置
若 capability 點未 seed,過渡改
from common.authz import require_super_admin在 app service 內呼叫(見 design D6 附註)。runner 動工前確認 RBAC 是否已有detection-tools-manage.*能力點;沒有就走過渡方案並在收尾提醒 user seed。
Response schema 絕不回傳 credentials 明文/密文——只回 has_credentials: bool(credentials_encrypted is not None)。
讀取端(清單 + 單筆,只掛 @jwt_required(),不掛 capability——讀自己租戶設定租戶管理員都能看):
GET /detection-tools/configs # 列出本租戶所有工具設定含狀態(FE 管理頁渲染用)
GET /detection-tools/configs/<uid> # 單筆設定(編輯 Dialog 帶入用)
⚠️ 這支清單 endpoint 是 FE 管理頁的關鍵: FE 要靠它知道「每個工具在本租戶設定過沒、enabled/disabled」才能正確渲染卡片(已設定的顯示狀態、未設定的顯示「設定」按鈕),避免使用者每次都走建立流程誤觸 409。對應 FE T-1.5 的
DetectionToolService.listConfigs()。app service
list_tenant_configs()→ domainget_all_by_fields(TenantDetectionToolConfigQueryEntity(tenant_id=...));RLS 自動租戶隔離,Python 不用加 tenant filter。回傳 list,每筆含:uid/detection_tool_id/detection_tool_code(讓 FE 對應卡片)/status/has_credentials(bool,絕不回憑證本身) /last_tested_at/last_test_result。有 pytest。
- Step 10: 跑 test 確認通過 + 手測加密往返 + 手測清單
Run: pytest test/test_tenant_detection_tool_config_service.py -v → PASS。
手測:建一筆 OpenVAS 設定 → psql 查 SELECT credentials_encrypted FROM config.tenant_detection_tool_configs 應為密文(非明文密碼)。
手測清單:curl -s -H "Authorization: Bearer <dev_jwt>" http://localhost:8000/api/1.0/detection-tools/configs | python3 -m json.tool → 回本租戶設定 list,每筆有 has_credentials(true/false)、status、detection_tool_code,不含憑證本身。
- Step 11: Commit
git add infra/detection_tools/ domain/detection_tools/ app/detection_tools/ api/detection_tools/ di_containers/detection_tools/ config/config.py test/test_tenant_detection_tool_config_service.py
git commit -m "feat(fr056): tenant detection tool config CRUD + credential encryption (T-1.3)
租戶工具設定 CRUD,憑證用 FernetCrypto 加密(新金鑰 DETECTION_TOOL_ENCRYPTION_KEY)。寫入掛 capability 守門。response 不回憑證明文。重置 key 用專用 clear_credentials 避開 partial-update None-skip 陷阱。"
Task 4(T-1.4): 測試連線 endpoint + 停用/重置引用任務數提醒
說明: 兩個小功能。(a) 測試連線:拿租戶設定的憑證實際打一次 OpenVAS 驗證可連。(b) 停用/重置前,查「有多少任務綁了這個工具設定」回傳數量供 FE 軟提醒(D6,不擋)。
依賴前置: 「引用任務數」需查
agent_tasks(FR-056.3 建)或任務綁定表(FR-056.2 建)。FR-056.1 時這些表還不存在 → 本 Task 的引用數查詢先回 stub(固定回 0 + TODO 註記),待 FR-056.2/.3 完成後再回填真實查詢。這是刻意的階段隔離,不是漏做——在程式碼與 commit message 明確標 TODO(FR-056.2)。
Files:
- Create: infra/detection_tools/connector/openvas_probe.py(連線探測,輕量;非 FR-056.3 的完整 connector)
- Modify: app/detection_tools/service/detection_tool_service.py(test_connection / count_referencing_tasks)
- Modify: api/detection_tools/routes/detection_tool_route.py(兩 endpoint)
- Test: test/test_detection_tool_connection.py
- Step 1: 寫 failing test(測試連線成功/失敗分流 + 引用數 stub)
def test_test_connection_success():
crypto = MagicMock(); crypto.decrypt.return_value = '{"base_url":"http://ov","username":"u","password":"p"}'
probe = MagicMock(); probe.check.return_value = True
domain = MagicMock(); domain.verify_config_is_exist.return_value = MagicMock(credentials_encrypted="ENC")
svc = DetectionToolService(..., tenant_config_domain_service=domain, crypto=crypto, openvas_probe=probe)
result = svc.test_connection("c1")
assert result["success"] is True
probe.check.assert_called_once()
def test_count_referencing_tasks_stub_returns_zero():
"""FR-056.1 階段引用數固定 0(TODO FR-056.2/.3 回填)。"""
svc = DetectionToolService(..., tenant_config_domain_service=MagicMock())
assert svc.count_referencing_tasks("c1") == 0
-
[ ] Step 2: 跑 test 確認失敗 →
pytest test/test_detection_tool_connection.py -v→ FAIL。 -
[ ] Step 3: 寫 openvas_probe(輕量連線檢查)
infra/detection_tools/connector/openvas_probe.py:一個 check(creds: dict) -> bool 方法,對 OpenVAS 服務打一次健康檢查(HTTP HEAD / 登入探測),逾時/失敗回 False。不做實際掃描(那是 FR-056.3)。實作可先簡單(requests HEAD base_url + 逾時),細節 runner 依 OpenVAS 實際 API 調整。
- Step 4: app service 加兩方法
@transaction
def test_connection(self, config_uid):
config = self._tenant_config_domain.verify_config_is_exist(config_uid)
if not config.credentials_encrypted:
return {"success": False, "message": "尚未設定憑證"}
creds = json.loads(self._crypto.decrypt(config.credentials_encrypted))
ok = self._openvas_probe.check(creds)
self._tenant_config_domain.record_test_result(config_uid, "success" if ok else "fail")
return {"success": ok}
@transaction
def count_referencing_tasks(self, config_uid) -> int:
# TODO(FR-056.2/.3): 查 agent_tasks / 任務綁定表回真實引用數。
# FR-056.1 階段這些表尚未建立,先回 0(軟提醒,不擋)。
return 0
-
[ ] Step 5: route 加兩 endpoint
-
POST /detection-tools/configs/<uid>/test-connection→test_connection -
GET /detection-tools/configs/<uid>/referencing-tasks-count→{"count": N}(供 FE 停用/重置前彈提醒) -
[ ] Step 6: 跑 test 確認通過 + 起 BE 手測兩 endpoint → PASS。
手測(先透過 Task 3 建一筆 OpenVAS 租戶設定拿到 config uid):
# 測試連線
curl -s -X POST -H "Authorization: Bearer <dev_jwt>" \
http://localhost:8000/api/1.0/detection-tools/configs/<config_uid>/test-connection | python3 -m json.tool
# Expected: {"code":1,"data":{"success":true|false}}
# 引用任務數(FR-056.1 階段固定回 0)
curl -s -H "Authorization: Bearer <dev_jwt>" \
http://localhost:8000/api/1.0/detection-tools/configs/<config_uid>/referencing-tasks-count | python3 -m json.tool
# Expected: {"code":1,"data":{"count":0}}
- Step 7: Commit
git add infra/detection_tools/connector/ app/detection_tools/ api/detection_tools/ test/test_detection_tool_connection.py
git commit -m "feat(fr056): connection test + referencing-task count stub (T-1.4)
測試連線 endpoint(實際探測 OpenVAS 可連);停用/重置引用任務數查詢先回 0 stub(TODO FR-056.2/.3 回填)。"
Task 5(T-1.5): FE 檢測工具管理頁重做
說明: 綠地重寫 ToolPluginManage.vue,接真 API、依 config_field_schema 動態渲染設定欄位、啟用/停用/重置/測試連線、coming_soon 灰掉。UX 抄 RemoteAgentManage.vue(同為 agent 管理頁、含測試連線 + 樂觀更新回滾 + type-to-confirm)。動態欄位做「夠用版」小 renderer(text/password/number/select 四型),不做完整表單引擎(YAGNI)。
此 Task 在 FE repo
~/Projects/Billows/Audit-Manager/compliance-manager-fe/。BE 只讀參考。測試策略註記: 本 Task 無 BE 端單元測試(FE 元件單測在此專案非強制);正確性驗證靠 Step 6 手測 checklist,端到端回歸另走 test repo(
compliance-manager-test)的 e2e,屬 FR-056 收尾階段另開的測試計畫,不在本實作計畫內。此為既定分工,非 TDD 遺漏。
Files:
- Create: src/components/detection-tools/DetectionConfigField.vue(config-schema 驅動的單欄位渲染,含 v-model + 必填驗證)
- Rewrite: src/views/plugin/ToolPluginManage.vue
- Create: src/service/DetectionToolService.js(繼承 BaseService)
- Modify: src/config/api/api.js(加端點常數)
- Modify: src/config/locales/i18n/{zh-tw,en}/pages.json(plugin_manage 區塊補文案)
- Step 1: api.js 加端點
DETECTION_TOOLS: getUrl('/detection-tools'),
DETECTION_TOOL_CONFIG: getUrl('/detection-tools/configs'),
DETECTION_TOOL_TEST_CONN: (uid) => getUrl(`/detection-tools/configs/${uid}/test-connection`),
DETECTION_TOOL_REF_COUNT: (uid) => getUrl(`/detection-tools/configs/${uid}/referencing-tasks-count`),
- Step 2: 寫 DetectionToolService.js(繼承 BaseService,單例 export,照
CloudIntegrationService.jspattern):
import { BaseService } from './BaseService';
import { API } from '@/config/api/api';
class DetectionToolService extends BaseService {
listTools() { return this.get(API.DETECTION_TOOLS); }
listConfigs() { return this.get(API.DETECTION_TOOL_CONFIG); }
createConfig(data) { return this.post(API.DETECTION_TOOL_CONFIG, data); }
updateConfig(uid, data) { return this.put(API.DETECTION_TOOL_CONFIG + '/' + uid, data); }
testConnection(uid) { return this.post(API.DETECTION_TOOL_TEST_CONN(uid)); }
getRefCount(uid) { return this.get(API.DETECTION_TOOL_REF_COUNT(uid)); }
}
export default new DetectionToolService();
- Step 3: 寫 DetectionConfigField.vue(動態欄位 renderer,夠用版)
依 field.type 渲染對應 PrimeVue 元件,補上 DynamicPrimeComponent.vue 欠缺的 v-model + 必填驗證:
<!-- props: field {key,label,type,required,secret}, modelValue; emit update:modelValue -->
<template>
<div class="field mb-3">
<label :for="field.key">{{ field.label }} <span v-if="field.required" class="required-star">*</span></label>
<InputText v-if="field.type==='text'" :id="field.key" :modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)" :class="{'p-invalid': showError}" class="w-full"/>
<Password v-else-if="field.type==='password'" :modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)" :feedback="false" toggleMask class="w-full"/>
<InputNumber v-else-if="field.type==='number'" :modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)" class="w-full"/>
<Dropdown v-else-if="field.type==='select'" :modelValue="modelValue" :options="field.options"
@update:modelValue="$emit('update:modelValue', $event)" class="w-full"/>
<small v-if="showError" class="p-error">{{ field.label }} 為必填</small>
</div>
</template>
(showError = required && touched && empty;password 型別編輯既有設定時顯示 placeholder「••••(已設定,留空不變更)」,避免逼 user 重打。)
- Step 4: 重寫 ToolPluginManage.vue
結構(抄 RemoteAgentManage.vue 的 UX 骨架):
- onMounted:DetectionToolService.listTools() + listConfigs(),合併成「工具卡片 + 各租戶設定狀態」。
- 卡片列表:每工具一張 Card,status==='coming_soon' 的灰掉 + disabled + Tag「敬請期待」,不給設定。
- available 工具:Tag 顯示 enabled/disabled 狀態;按「設定」開 Dialog。
- 設定 Dialog:v-for 跑該工具的 config_field_schema → <DetectionConfigField v-model="form[field.key]" :field="field"/>;底部「測試連線」按鈕(:loading)+ 儲存。
- 啟用/停用:樂觀更新 + 失敗回滾(抄 onToggleEnabled)。
- 停用/重置前:先 getRefCount(uid),若 >0 用 useConfirm 彈「有 N 個任務正使用此工具設定,仍要停用嗎?」(軟提醒,確認即放行)。
- 重置 key:type-to-confirm 或一般 confirm,呼叫 update 清憑證。
- 搜尋框:修掉現況的變數名 bug(searchedUser → 統一 searchedTool)。
- Step 5: i18n 補文案
pages.json plugin_manage 區塊補:coming_soon、test_connection、conn_ok、conn_fail、config_saved、reset_key、reset_confirm、disable_with_refs(含 {count} 插值)等 key,zh-tw + en 兩份都補。
- Step 6: 手測(FE headless 或 user 開瀏覽器)
驗收清單: - 頁面載入顯示三工具,Nessus/SonarQube 灰掉「敬請期待」不可設定。 - OpenVAS 可開設定 Dialog,欄位依 schema 動態出現(URL text / 帳號 text / 密碼 password / 埠 number)。 - 填憑證 → 測試連線回成功/失敗 toast。 - 儲存 → 重整後設定持久化;密碼欄顯示「已設定」不外洩。 - 停用時若有引用任務跳提醒;無則直接停用。 - 搜尋框可用(bug 修掉)。
- Step 7: Commit(FE repo,顯式 git add)
git add src/components/detection-tools/DetectionConfigField.vue src/views/plugin/ToolPluginManage.vue src/service/DetectionToolService.js src/config/api/api.js src/config/locales/i18n/zh-tw/pages.json src/config/locales/i18n/en/pages.json
git commit -m "feat(fr056): rebuild detection tool management page with real API (T-1.5)
檢測工具管理頁綠地重寫:接真後端、config_field_schema 驅動動態欄位、測試連線、啟用/停用/重置、coming_soon 灰掉。新 DetectionConfigField 動態欄位 renderer(text/password/number/select)。修掉現況搜尋變數名 bug。"
完成後收尾(等 user 下令,勿自動做)
- FE error-code.json 同步(CLAUDE.md 明文規範,勿漏):Task 2 新增的 5 個
DetectionToolsErrorCode(DETECTION_TOOLS_404001/404002/400001/400002/409001)必須同步到 FEsrc/config/locales/i18n/{zh-tw,en,zh-cn}/error-code.json三語系(zh-cn 整批補,勿只補一語),否則 FE 收到新錯誤碼會顯示原始 code 而非在地化訊息。見 memoryfeedback_be_error_code_fe_i18n_sync。 - 各環境
.env補DETECTION_TOOL_ENCRYPTION_KEY(提醒 user,附產金鑰指令python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")。 - STG / POC 套 migration(等 user 指示時機)。
- capability 點
detection-tools-manage.*seed(若過渡用 super_admin,提醒 user 補 RBAC)。 - 更新頁面 spec(
docs/specs/current/,走 writing-feature-specs skill)。 - Notion FR-056.1 子卡標「修正待驗證」+ 回寫進度。
附錄 A:DDD 九層照抄範本(來源 remote_agent 模組)
完整程式碼骨架已在調查中取得,runner 逐檔對照
remote_agent對應檔案複製、改名remote_agent→detection_tool、欄位換成本表欄位即可。關鍵紀律:
- A.1 Model:繼承
BaseModel(目錄表)或BaseModel, TenantScopedMixinModel(租戶設定表);__table_args__ = {"schema": "config", "comment": "..."};不自宣告 tenant_id/audit 欄位(來自 mixin)。 - A.2 Mapper:
@staticmethodto_entity/to_list_entity;getattr(model, "tenant_id", None)防禦讀取。 - A.3 Repo Impl:繼承
IxxxRepo[...], BaseRepositoryImpl[Entity, Query, Model, Mapper];__init__只super().__init__(model=..., mapper=...);標準 CRUD 零 override;self.session是繼承來的 lazy property,勿在__init__取。 - A.4 Entity / Query Entity:Entity 每個可更新欄位預設
None(幽靈覆寫);QueryEntity@dataclass每欄Optional[X] = None+to_dict()濾 None(幽靈 WHERE)。 - A.5 Repo Interface:
class IxxxRepo(IBaseRepo[T, Q]): pass(空 body,CRUD 來自 base)。 - A.6 Domain Service:
__init__注入 repo;get_by_uid/get_all_by_fields(QueryEntity(**kwargs))/verify_..._is_exist(raise NotFound)/create/update(update 回 None 則 raise NotFound)/delete_by_uid;不開 @transaction。 - A.7 App Service:每 public method
@transaction;UID 在此層str(uuid.uuid4())生成;必填驗證在此 raise BadRequestError;partial update 只把非 None 欄位放進 entity。 - A.8 Route + Blueprint:Resource 用
MethodResource;decorator 順序@doc → @use_kwargs → @marshal_with → @jwt_required() → @require_capability(...) → @inject;api/detection_tools/__init__.py的create_module()掛/api/1.0prefix +add_resource。 - A.9 DI + 註冊:
di_containers/detection_tools/detection_tools_containers.py宣告 repo/domain/app service Factory + crypto Singleton;di_containers/containers.py加detection_tools_container = providers.Container(...);config/app_modules.py的REGISTERED_APPSappend"detection_tools"。兩處都要改(app_modules 註冊 +__init__.py::create_module),漏一個 route 不掛。 - Authz 軸:租戶管理員層級 → 軸④
@require_capability("detection-tools-manage.<action>")(寫入端);唯讀 GET 只@jwt_required()。不用軸① platform_admin(那是跨租戶全域)、不用軸③ project-role。