一句話:本卡屬 FR-103(母卡 CM-母卡號),第 3 棒:第三方登入綁定(285 行)搬進
jedi-iam。FR-102 地圖 26 個模組中唯一標 🟡 殼 的。⚠️ldap_service.py(88 行)有兩條踩過坑補上的資安防護,是主專案的多租戶規則,必須走 port 不可跟著進套件。
「第三方登入綁定」是讓使用者把自己的帳號跟公司的 LDAP 綁在一起的功能。套件 jedi-iam 裡早就有完整的一整套(資料模型、資料存取、業務邏輯、LDAP 連線元件全都在),主專案這 285 行大半只是轉呼叫它的殼。
本棒把這層殼收進套件,做完主專案這塊只剩接線。跟 FR-099 把意見回饋搬進 jedi-issue 是同一種工,做法可以照抄那次(core/plugins/issue.py 就是終局形狀的樣板)。
套件已有完整全鏈(實查 jedi-iam/jedi_iam/ 底下):user_auth_provider_entity.py/user_auth_provider_query_entity.py/user_auth_provider_repo_impl.py/user_auth_provider_mapper.py/infra/models/user_auth_provider.py/user_auth_provider_domain_service.py/app/service/user_auth_provider_service.py/domain/ports/auth_provider/auth_provider.py/infra/adapter/authenticate_adapter/ldap/ldap_adapter.py。
主專案三支 route 全是一行轉呼叫(api/user_auth_provider/routes/user_auth_provider_route.py):UserAuthProvidersRoute.get → 套件 get_user_auth_providers();UserAuthProviderRoute.delete → 套件 delete_user_auth_provider();UserAuthProviderCreateRoute.post → core/plugins/identity.py 的 register_user_auth_provider()。型別直接標 JediUserAuthProviderService。
前端在用(活的,不是死碼):src/views/user-manage/UserProfileForm.vue:267 POST 綁定、:297 DELETE 解綁;FE 路由 /system/ldap-config;ui_routes ldap-config enable=1。ui_routes 沒有綁定功能的獨立選單是正常的——它不是頁面,是掛在使用者資料頁裡的功能。
四條對外 route(api/user_auth_provider/__init__.py):GET /auth-providers/user/<user_uid>、POST /auth-provider、GET|PUT|DELETE /auth-provider/<uid>、POST /ldap/connect-test。🔴 URL 一字不可改,前端已在用。
app/user_auth_provider/service/ldap_service.py 裡有兩條踩過坑補上的資安防護,而且是主專案的多租戶規則——搬進通用套件就是讓套件認得 Guidant AI 的租戶模型。必須改走 port,由主專案回答。
① CM-1283(init_config 開頭):LDAP 設定是全系統共用、實體落在 ROOT(1) 那一列,業務租戶受 RLS 讀不到(前綴比對不成立,2026-08-18 實測)。所以一律走繞 RLS 的 read_root_config_value("THIRD_PARTY_LOGIN", "LDAP")。讀不到的後果特別隱蔽:下面「沒帶 secret 就沿用舊的」會拿不到舊密碼,等於把 LDAP 綁定密碼清空,而畫面顯示儲存成功。
② CM-1564(server_changed 判斷):目標主機(server/port)變更時不可沿用舊密碼——沿用等於把服務帳號密碼送去呼叫端指定的任意主機。只有位址沒變才屬「儲存設定但沒改連線目標」的正常流程,才可比照舊行為沿用。不符時拋 ErrorCode.LDAP_SERVER_CHANGED_SECRET_REQUIRED。
③ 同檔另有一個刻意的設計不要「順手統一」:_LdapOnlyPolicyProvider 的 docstring 明寫「它組的設定來源不同:另外三處都是『讀系統已存的政策值』,這支是『拿呼叫端傳進來、可能還沒存檔的一組 LDAP 參數去試連線』。語意不同,故不收斂成同一條路徑」。它的 get_policy() 刻意回空 dict 保持行為一致,這是純搬家不是補齊。
④ CM-1560:沒帶 ca_cert_pem 時沿用既有值(避免每次存都要重貼一次),比照 secret 的防呆語意。
步驟 0:先讀 FR-099 第 2 棒的成果當樣板——core/plugins/issue.py(主專案側三段形狀)與 jedi-issue 的 plugin/ 目錄(套件側契約)。本棒是同型工作,不要另創形狀。
步驟 1:先查再寫(CLAUDE.md 鐵則)。動手前 grep 確認 jedi-iam 有沒有現成的東西可用:有沒有既有的 config port、有沒有現成的守門機制、IdentityAdapters 現在長什麼樣(FR-090 第 3 棒曾為它加過 identity 欄位)。
步驟 2:設計 port。「LDAP 設定要從哪裡讀」是主專案的答案(繞 RLS 讀 ROOT),套件只該問「給我 LDAP 設定」。比照 FR-099 的 RootIntegrateConfigProvider(core/plugins/issue.py 內)——那支做的正是同一件事。
步驟 3:route 與 service 搬進套件,主專案端刪 api/user_auth_provider/、app/user_auth_provider/,REGISTERED_APPS 移除 "user_auth_provider" 一列,接線收進 core/plugins/identity.py(該檔已存在,register_user_auth_provider 就在裡面)。