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)。


§1

前置:命名與決策速查(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.pyTokenCryptoService ABC)+ infra/cloud_integration/crypto/fernet_crypto.pyFernetCrypto 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. 幽靈 WHEREQueryEntity 每個欄位必為 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。


§2

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

§3

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=1cmmgr 帳號套。

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.sql

Expected: 無錯誤,最後 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 決定時機)。


§4

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

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")

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 / patch get_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)。

  • 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__.pycreate_module()/api/1.0 prefix + add_resource(照附錄 A.8)。
  • DI container + 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.tool

Expected: 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/覆寫兩陷阱。"

§5

Task 3(T-1.3): 租戶工具設定 CRUD + 憑證加密

說明: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/configslist_tenant_configs(tenant_id),只需 @jwt_required() 不掛 capability,回傳逐筆含 detection_tool_code 供 FE 對應卡片、has_credentials 布林不回憑證),對應 test_list_tenant_configs_attaches_tool_code 測項。FE DetectionToolService.listConfigs() 呼叫此端點。

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

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 設一把測試金鑰。

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_servicecrypto):

@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) 方法直接 SET credentials_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: boolcredentials_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() → domain get_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)、statusdetection_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 陷阱。"

§6

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
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

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 調整。

@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
  • POST /detection-tools/configs/<uid>/test-connectiontest_connection
  • GET /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 回填)。"

§7

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.jsonplugin_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 骨架):

  • onMountedDetectionToolService.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)。

pages.json plugin_manage 區塊補:coming_soontest_connectionconn_okconn_failconfig_savedreset_keyreset_confirmdisable_with_refs(含 {count} 插值)等 key,zh-tw + en 兩份都補。

驗收清單:

  • 頁面載入顯示三工具,Nessus/SonarQube 灰掉「敬請期待」不可設定。
  • OpenVAS 可開設定 Dialog,欄位依 schema 動態出現(URL text / 帳號 text / 密碼 password / 埠 number)。
  • 填憑證 → 測試連線回成功/失敗 toast。
  • 儲存 → 重整後設定持久化;密碼欄顯示「已設定」不外洩。
  • 停用時若有引用任務跳提醒;無則直接停用。
  • 搜尋框可用(bug 修掉)。
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。"

§8

完成後收尾(等 user 下令,勿自動做)


§9

附錄 A:DDD 九層照抄範本(來源 remote_agent 模組)

完整程式碼骨架已在調查中取得,runner 逐檔對照 remote_agent 對應檔案複製、改名 remote_agentdetection_tool、欄位換成本表欄位即可。關鍵紀律:

  • A.1 Model:繼承 BaseModel(目錄表)或 BaseModel, TenantScopedMixinModel(租戶設定表);__table_args__ = {"schema": "config", "comment": "..."};不自宣告 tenant_id/audit 欄位(來自 mixin)。
  • A.2 Mapper@staticmethod to_entity / to_list_entitygetattr(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 Interfaceclass IxxxRepo(IBaseRepo[T, Q]): pass(空 body,CRUD 來自 base)。
  • A.6 Domain Service__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**。
  • 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(...) → @injectapi/detection_tools/__init__.pycreate_module()/api/1.0 prefix + add_resource
  • A.9 DI + 註冊di_containers/detection_tools/detection_tools_containers.py 宣告 repo/domain/app service Factory + crypto Singleton;di_containers/containers.pydetection_tools_container = providers.Container(...)config/app_modules.pyREGISTERED_APPS append "detection_tools"兩處都要改(app_modules 註冊 + __init__.py::create_module),漏一個 route 不掛。
  • Authz 軸:租戶管理員層級 → 軸④ @require_capability("detection-tools-manage.<action>")(寫入端);唯讀 GET 只 @jwt_required()不用軸① platform_admin(那是跨租戶全域)、不用軸③ project-role。