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)。
| 項目 | 定案值 | 來源 |
|---|---|---|
| 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 設計):
QueryEntity 每個欄位必為 Optional[X] = None,不可 = 0 / = False。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 | 對應子任務 | 產出 | 依賴 | 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 |
Files:
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 帳號套。
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;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.sqlExpected: 無錯誤,最後 INSERT 0 1(schema_migrations)。
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。
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 決定時機)。
說明: 複製 remote_agent 模組九層骨架建 detection_tools 模組。本 Task 只做目錄唯讀(GET 清單 / GET by uid),租戶設定 CRUD 在 Task 3。逐層照抄範本見附錄 A。所有 entity 守「幽靈 WHERE / 幽靈覆寫」兩陷阱。
Files:
infra/detection_tools/model/detection_tool.pyinfra/detection_tools/mapper/detection_tool_mapper.pyinfra/detection_tools/repository/detection_tool_repo_impl.pydomain/detection_tools/entity/detection_tool_entity.pydomain/detection_tools/entity/detection_tool_query_entity.pydomain/detection_tools/repository/detection_tool.pydomain/detection_tools/service/detection_tool_domain_service.pyapp/detection_tools/service/detection_tool_service.pyapi/detection_tools/serializers/detection_tool.pyapi/detection_tools/routes/detection_tool_route.pyapi/detection_tools/__init__.pycommon/code/detection_tools_error_code.pydi_containers/detection_tools/detection_tools_containers.pydi_containers/containers.py(加 container wiring)config/app_modules.py(append "detection_tools")test/test_detection_tool_service.pyCreate 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")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 欄位仍有)。
Entity 每個可更新欄位預設 None(幽靈覆寫防護);QueryEntity 每欄 Optional[X] = None(幽靈 WHERE 防護),含 code / status / enabled 供過濾。
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)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 沿用。
Run: pytest test/test_detection_tool_service.py -v Expected: FAIL(模組尚未建齊 / import error)。
DetectionToolResponse(uid/code/name/description/connection_type/config_field_schema(Raw)/status/enabled)。GET /detection-tools(清單)、GET /detection-tools/<uid>(單筆)。唯讀 GET 只需 @jwt_required(),不掛 capability(讀目錄租戶管理員都能看)。api/detection_tools/__init__.py:create_module() 掛 /api/1.0 prefix + add_resource(照附錄 A.8)。di_containers/containers.py wiring + config/app_modules.py append "detection_tools"(照附錄 A.9)。Run: pytest test/test_detection_tool_service.py -v Expected: PASS。
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.toolExpected: envelope {"code":1,"data":[...]},含 openvas / nessus / sonarqube。若 500,先 tail -200 log/app.log | grep -A 30 -i 'detection\|Traceback\|ERROR'。
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/覆寫兩陷阱。"說明: 建 tenant_detection_tool_config 的 DDD 九層(同樣複製 remote_agent 骨架,但這張表是 tenant-scoped,model 要繼承 TenantScopedMixinModel)。憑證加解密複用既有 FernetCrypto,但用新金鑰 DETECTION_TOOL_ENCRYPTION_KEY。寫入 API 掛 capability 守門(軸④)。
計畫補遺(2026-07-26,T-1.5 實作時發現遺漏): 原計畫只列 create/update/reset/test-connection,漏了「列出本租戶所有設定」的唯讀 endpoint——FE 管理頁需要知道每個工具的租戶設定狀態(是否已設定、enabled/disabled)才能正確渲染。已補:
GET /detection-tools/configs(list_tenant_configs(tenant_id),只需@jwt_required()不掛 capability,回傳逐筆含detection_tool_code供 FE 對應卡片、has_credentials布林不回憑證),對應test_list_tenant_configs_attaches_tool_code測項。FEDetectionToolService.listConfigs()呼叫此端點。
Files:
infra/detection_tools/model/tenant_detection_tool_config.pyinfra/detection_tools/mapper/tenant_detection_tool_config_mapper.pyinfra/detection_tools/repository/tenant_detection_tool_config_repo_impl.pydomain/detection_tools/entity/tenant_detection_tool_config_entity.pydomain/detection_tools/entity/tenant_detection_tool_config_query_entity.pydomain/detection_tools/repository/tenant_detection_tool_config.pydomain/detection_tools/service/tenant_detection_tool_config_domain_service.pyapp/detection_tools/service/detection_tool_service.py(加租戶設定 CRUD 方法)api/detection_tools/serializers/detection_tool.py(加 config request/response schema)api/detection_tools/routes/detection_tool_route.py(加 config route)api/detection_tools/__init__.py(掛新 resource)di_containers/detection_tools/detection_tools_containers.py(wire crypto + config service)config/config.py(加 DETECTION_TOOL_ENCRYPTION_KEY 讀取)test/test_tenant_detection_tool_config_service.pyModify 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設一把測試金鑰。
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) 統一載入。)
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。
Entity 含 credentials(明文,僅記憶體用,不落 DB)與 credentials_encrypted(密文,落 DB)兩個概念——app service 負責 encrypt(credentials)→credentials_encrypted 再存。所有可更新欄位預設 None。
domain service 不直接碰 crypto(crypto 在 app service 注入並呼叫),domain 只做 repo CRUD + verify_config_is_exist。
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 值保留Run: pytest test/test_tenant_detection_tool_config_service.py -v → FAIL。
在 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 清空。
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。
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,不含憑證本身。
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 陷阱。"說明: 兩個小功能。(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:
infra/detection_tools/connector/openvas_probe.py(連線探測,輕量;非 FR-056.3 的完整 connector)app/detection_tools/service/detection_tool_service.py(test_connection / count_referencing_tasks)api/detection_tools/routes/detection_tool_route.py(兩 endpoint)test/test_detection_tool_connection.pydef 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") == 0infra/detection_tools/connector/openvas_probe.py:一個 check(creds: dict) -> bool 方法,對 OpenVAS 服務打一次健康檢查(HTTP HEAD / 登入探測),逾時/失敗回 False。不做實際掃描(那是 FR-056.3)。實作可先簡單(requests HEAD base_url + 逾時),細節 runner 依 OpenVAS 實際 API 調整。
@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 0POST /detection-tools/configs/<uid>/test-connection → test_connectionGET /detection-tools/configs/<uid>/referencing-tasks-count → {"count": N}(供 FE 停用/重置前彈提醒)手測(先透過 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}}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 回填)。"說明: 綠地重寫 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:
src/components/detection-tools/DetectionConfigField.vue(config-schema 驅動的單欄位渲染,含 v-model + 必填驗證)src/views/plugin/ToolPluginManage.vuesrc/service/DetectionToolService.js(繼承 BaseService)src/config/api/api.js(加端點常數)src/config/locales/i18n/{zh-tw,en}/pages.json(plugin_manage 區塊補文案)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`),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();依 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 重打。)
結構(抄 RemoteAgentManage.vue 的 UX 骨架):
onMounted:DetectionToolService.listTools() + listConfigs(),合併成「工具卡片 + 各租戶設定狀態」。status==='coming_soon' 的灰掉 + disabled + Tag「敬請期待」,不給設定。available 工具:Tag 顯示 enabled/disabled 狀態;按「設定」開 Dialog。v-for 跑該工具的 config_field_schema → <DetectionConfigField v-model="form[field.key]" :field="field"/>;底部「測試連線」按鈕(:loading)+ 儲存。onToggleEnabled)。getRefCount(uid),若 >0 用 useConfirm 彈「有 N 個任務正使用此工具設定,仍要停用嗎?」(軟提醒,確認即放行)。searchedUser → 統一 searchedTool)。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 兩份都補。
驗收清單:
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。"remote_agent 模組)完整程式碼骨架已在調查中取得,runner 逐檔對照
remote_agent對應檔案複製、改名remote_agent→detection_tool、欄位換成本表欄位即可。關鍵紀律:
BaseModel(目錄表)或 BaseModel, TenantScopedMixinModel(租戶設定表);__table_args__ = {"schema": "config", "comment": "..."};不自宣告 tenant_id/audit 欄位(來自 mixin)。@staticmethod to_entity / to_list_entity;getattr(model, "tenant_id", None) 防禦讀取。IxxxRepo[...], BaseRepositoryImpl[Entity, Query, Model, Mapper];__init__ 只 super().__init__(model=..., mapper=...);標準 CRUD 零 override;self.session 是繼承來的 lazy property,勿在 __init__ 取。None(幽靈覆寫);QueryEntity @dataclass 每欄 Optional[X] = None + to_dict() 濾 None(幽靈 WHERE)。class IxxxRepo(IBaseRepo[T, Q]): pass(空 body,CRUD 來自 base)。__init__ 注入 repo;get_by_uid / get_all_by_fields(QueryEntity(<strong>kwargs)) / verify_..._is_exist(raise NotFound)/ create / update(update 回 None 則 raise NotFound)/ delete_by_uid;不開 @transaction**。@transaction;UID 在此層 str(uuid.uuid4()) 生成;必填驗證在此 raise BadRequestError;partial update 只把非 None 欄位放進 entity。MethodResource;decorator 順序 @doc → @use_kwargs → @marshal_with → @jwt_required() → @require_capability(...) → @inject;api/detection_tools/__init__.py 的 create_module() 掛 /api/1.0 prefix + add_resource。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_APPS append "detection_tools"。兩處都要改(app_modules 註冊 + __init__.py::create_module),漏一個 route 不掛。@require_capability("detection-tools-manage.<action>")(寫入端);唯讀 GET 只 @jwt_required()。不用軸① platform_admin(那是跨租戶全域)、不用軸③ project-role。