# 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`](./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`：

```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` 帳號）:
```bash
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:
```bash
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**

```bash
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`:
```python
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`：
```python
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`）：
```python
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 沿用。

- [ ] **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.0` prefix + add_resource（照附錄 A.8）。
- DI container + `di_containers/containers.py` wiring + `config/app_modules.py` append `"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）:
```bash
# 重啟 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**

```bash
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 守門（軸④）。

> **計畫補遺（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` 測項。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`

- [ ] **Step 1: config.py 加金鑰讀取**

Modify `config/config.py`（比照 `DRIVE_TOKEN_ENCRYPTION_KEY` 那行，約 line 161 附近）：
```python
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`：
```python
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`，關鍵測項：
```python
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`）：
```python
@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 清空。

- [ ] **Step 9: 補 serializer / route（掛 capability 守門）**

Route 寫入端掛軸④：
```python
@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——讀自己租戶設定租戶管理員都能看）：**
```python
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。

- [ ] **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**

```bash
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）**

```python
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 加兩方法**

```python
@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）:
```bash
# 測試連線
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**

```bash
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 加端點**

```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.js` pattern）:
```js
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 + 必填驗證**：
```vue
<!-- 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）**

```bash
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`）必須同步到 FE `src/config/locales/i18n/{zh-tw,en,zh-cn}/error-code.json` **三語系**（zh-cn 整批補，勿只補一語），否則 FE 收到新錯誤碼會顯示原始 code 而非在地化訊息。見 memory `feedback_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**：`@staticmethod` `to_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.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.py` 加 `detection_tools_container = providers.Container(...)`；`config/app_modules.py` 的 `REGISTERED_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。
