本卡屬 FR-162(母卡見「母卡」欄),批 1:在 jedi-oscal-v2 建對照庫的全部地基。後面每張卡都吃這張。只改套件 repo。

這張卡做什麼(白話)

「對照庫」是一份知識:框架 A 的哪條要求對應框架 B 的哪條,細到評估目標。這張卡在套件裡建兩張表存它、寫一支「給我一個代號,告訴我對到哪些框架的哪些代號」的解析服務、做 OSCAL 標準格式與 Excel 的匯入匯出,並把 CMMC↔NIST 800-171 這組「編號本來就相同」的對應用規則自動產生。畫面與主專案接線都在後面的卡。

怎麼做

① Migration 021 jedi_oscal_v2/migrations/021-control-mappings.sql(DDL,冪等 CREATE TABLE IF NOT EXISTS):

CREATE TABLE IF NOT EXISTS oscal.control_mappings (
  id BIGSERIAL PRIMARY KEY,
  uuid UUID NOT NULL UNIQUE,
  source_framework_version_id BIGINT NOT NULL REFERENCES oscal.framework_versions(id),
  target_framework_version_id BIGINT NOT NULL REFERENCES oscal.framework_versions(id),
  title TEXT NOT NULL,
  method VARCHAR(64),                 -- OSCAL provenance.method:manual / tool-assisted / rule
  matching_rationale VARCHAR(32),     -- syntactic / semantic / functional(NIST IR 8477)
  status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published','deprecated')),
  provenance JSONB,                   -- 來源文件、頁次、網址、匯入檔名、匯入時間
  remarks TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  created_user VARCHAR(255), updated_user VARCHAR(255),
  UNIQUE (source_framework_version_id, target_framework_version_id)
);
CREATE TABLE IF NOT EXISTS oscal.control_mapping_entries (
  id BIGSERIAL PRIMARY KEY,
  uuid UUID NOT NULL UNIQUE,
  mapping_id BIGINT NOT NULL REFERENCES oscal.control_mappings(id) ON DELETE CASCADE,
  source_level VARCHAR(10) NOT NULL CHECK (source_level IN ('control','objective')),
  source_control_code VARCHAR(128) NOT NULL,     -- = catalog_controls.control_id
  source_part_id VARCHAR(255),                   -- objective 時 = catalog_control_parts.part_id
  target_level VARCHAR(10) NOT NULL CHECK (target_level IN ('control','objective')),
  target_control_code VARCHAR(128) NOT NULL,
  target_part_id VARCHAR(255),
  relationship VARCHAR(20) NOT NULL CHECK (relationship IN ('equivalent-to','subset-of','superset-of','intersects-with','not-related-to')),
  confidence NUMERIC(3,2) CHECK (confidence BETWEEN 0 AND 1),
  origin VARCHAR(16) NOT NULL CHECK (origin IN ('official','curated','ai-suggested')),
  status VARCHAR(16) NOT NULL DEFAULT 'active' CHECK (status IN ('active','disabled')),
  overrides_entry_id BIGINT REFERENCES oscal.control_mapping_entries(id),
  remarks TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  created_user VARCHAR(255), updated_user VARCHAR(255)
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_cme_edge ON oscal.control_mapping_entries
  (mapping_id, source_control_code, COALESCE(source_part_id,''), target_control_code, COALESCE(target_part_id,''), origin);
CREATE INDEX IF NOT EXISTS idx_cme_src ON oscal.control_mapping_entries (mapping_id, source_control_code);
CREATE INDEX IF NOT EXISTS idx_cme_tgt ON oscal.control_mapping_entries (mapping_id, target_control_code);
-- GRANT SELECT/INSERT/UPDATE/DELETE ON 兩表 TO cm_app;GRANT USAGE, SELECT ON 兩個 sequence TO cm_app
-- 兩表不掛 RLS:對照是產品知識,跨租戶共用

② Migration 022 022-reference-framework-versions-seed.sql:在 oscal.frameworks 建 NIST_SP_800_53_R5(name「NIST SP 800-53 Rev. 5」、authority NIST)與 NIST_SP_800_171_R2(name「NIST SP 800-171 Rev. 2」、authority NIST),各一筆 framework_versions(version Rev. 5/Rev. 2,catalog_id NULL,publish_status='published')。uid 寫死在檔內,ON CONFLICT (uid) DO NOTHING;frameworks.main_version_id 版本插完回填。metadata 不必建(這兩筆沒有 catalog)。

③ 套件 model/entity/mapper/repo/domain service:新目錄 infra/model/mapping/、domain/entity/mapping/、infra/mapper/mapping/、domain/repository/mapping/、infra/repository/mapping/,檔名與寫法照 profile_import 那組(oscal_profile_import.py/oscal_profile_import_entity.py/profile_import_mapper.py/profile_import.py/profile_import_repo_impl.py)。repo 繼承 BaseRepositoryImpl;entries repo 另加 get_active_by_mapping(mapping_id)(排除 status='disabled' 與被任何 overrides_entry_id 指到的列)、find_by_source(mapping_id, control_code, part_id)、find_by_target(...)。

④ 解析服務 domain/service/mapping/control_mapping_resolution_service.py(照 ProfileResolutionService 的建構風格,repo 可注入):

resolve(source_framework_version_id: int, control_code: str, part_id: str | None,
        target_framework_version_ids: list[int] | None = None, max_hops: int = 3) -> list[ResolvedMapping]
# ResolvedMapping: target_framework_version_id, target_control_code, target_part_id|None,
#                  relationship, confidence|None, origin, hops, path(list of entry ids)
規則:
1. 只看 active 且未被 overrides 指到的列;mapping 本身 status != 'deprecated'。
2. 方向:source→target 與 target→source 都算(對照雙向等價)。
3. part_id 給了:先找 objective 層命中;沒有才用 control 層,此時 target_part_id=None、confidence *= 0.7
   (常數 CONTROL_LEVEL_FALLBACK_FACTOR 放 jedi_oscal_v2/common/constant/mapping.py)。
4. 多跳:BFS 最多 max_hops;confidence 連乘(None 視為 1.0 但結果標 confidence=None);
   relationship 取最弱:equivalent-to > superset-of/subset-of > intersects-with。
5. not-related-to 命中即排除該目標、不再往下跳。
6. 同一目標多條路徑取 hops 最少、再取 confidence 最高。

⑤ OSCAL mapping-collection 匯入匯出:在 app/service/io/ 新開 mapping_io_service.py(不塞進 oscal_io_service.py,它已大),對齊 1.2.2 schema(outline 在 oscal-knowledge skill references/model-schemas/mapping-collection-outline.md,JSON schema 在 references/json-schemas/oscal_mapping-collection_schema.json):mapping-resource-reference.href 寫 #<framework_version uid>、type='catalog';mapping-item.type 用 control 或 part,id-ref 用控制項代號或 part_id;provenance 必填照表欄位;map.props 放 origin/status(ns 用我們自己的 URI)。匯入:框架版本 uid 對不到 → raise BadRequestError 列出缺哪些;更新語意照 D6(同鍵覆蓋、新鍵新增、不刪),origin 以檔內 props 為準、沒有則參數指定。

⑥ Excel/CSV 匯入匯出 app/service/io/mapping_excel_service.py:固定欄位 source_framework_code, source_version, source_control_code, source_part_id, target_framework_code, target_version, target_control_code, target_part_id, relationship, confidence, remarks;提供 blank_template()。匯入時 control_code 不存在於該框架 catalog(參考框架即 catalog_id NULL 者除外)→ 整檔退回列出錯行,不半寫。匯出可選 effective_only=True(被 overrides 的列不出)。

⑦ CMMC↔800-171 規則生成 app/service/mapping/cmmc_171_rule_generator.py:讀 CMMC L1/L2 catalog 的 control_id(AC.L2-3.1.1)→ 正規式 ^[A-Z]{2}\.L[123]-(\d+\.\d+\.\d+)$ 取 3.1.1 當 800-171 代號;控制項層一筆 equivalent-to;該控制項每個 AO <control_id>_obj.N 對 3.1.1_obj.N 一筆 equivalent-to(800-171 r2 無 catalog,目標 part_id 直接依序號產生)。origin='official'、confidence=1.0、method='rule'。L1 的 AC.L1-b.1.i 這種 FAR 編號對不到 3.x.y,跳過並在回傳清單列出。

⑧ 測試 tests/mapping/:解析服務(直連、反向、AO 層優先、退路折扣、三跳連乘、not-related 排除、overrides 退場);Excel 匯入退回錯行;OSCAL 匯出 round-trip(匯出再匯入筆數不變);規則生成 L2 110 條與 AO 數對得上 catalog。突變:把 0.7 折扣拿掉、把 overrides 過濾拿掉,對應測試要紅。

手測(DEV)