跳至主要内容

代理與模擬(Acting)使用指南

Package:io.leandev.appfuse.security.auth.* 核心類別:ActingContext、ActingClaims、JwtTokenProvider 決策背景:ADR-013: 代理與模擬——ActingContext 框架能力

框架以最薄的機制面支援「以他人身份/權限操作系統」的兩種能力——代理(Delegation)與模擬(Impersonation)。框架交付 token 簽發擴充、JWT claim wire 契約、ActingContext 讀取工具與 request-level 稽核事件能力;啟用時機、閘門、授權與啟用記錄、資料表、instance 級過濾一律留給應用層(由 app-tenant-server / app-office 出參考實作)。

本指南分兩半:前半是框架設計(你在任何 appfuse 專案都能用的機制),後半是參考實作走查(花店 app-office 如何在框架機制上落地一套完整政策)。


一、兩種能力的語意差異​

代理與模擬常被混為一談,但語意根本不同——這組差異是整個設計的核心:

面向代理 Delegation模擬 Impersonation
一句話登入者借用他人權限操作登入者取得他人身份操作
實際執行者(對系統而言)仍是登入者 A是被模擬者 B
身份(JWT sub)維持登入者 A切成被模擬者 B
權限(JWT auth)A ∪ 授權人的權限= B 的權限
稽核 @LastModifiedBy記 A記 B
對象數多個(0..n)單一
跨租戶不跨(同租戶)可跨(管理/客服超能力)
額外 claimgrantors(授權人 subject 集合)actor(真實操作者 subject)
前端提示低調指示器(身份未變)醒目橫幅(身份被換掉)

判斷句:identity 被換掉 → 模擬(橫幅);只是多借到權限、身份沒變 → 代理(指示器)。

兩者正交、可同時發生:代理與模擬不是二選一——它們是兩個獨立維度,可共存於同一 token(如「A 模擬 B,而 B 本身被 C 指派代理」)。框架不設 act_mode 這類模式判別器,模擬性/代理性由 actor/grantors claim 的存在性各自獨立判定(見 §3、§4 的正交模型)。

兩者不互相取代。模擬能跨租戶、換身份,看似「更強」,但它抹掉了「真正是誰在操作」——代理保留登入者身份、只補權限,稽核直指真人。用途不同,並存。


二、設計哲學:框架只交付四件​

appfuse-server 對「代理/模擬」刻意保持薄機制面,理由見 ADR-013:授權模型(誰可對誰、時限、scope)與持久化因應用而異,框架若硬塞資料表或端點會限制適應性,也與「框架只擁有 tenant 隔離、resource 級留應用層」的既有邊界矛盾。

因此框架只提供:

#交付物類別職責
1Token 簽發擴充JwtTokenProvider正確地簽出代理/模擬 token(claim 對稱)
2Claim wire 契約ActingClaims簽發端 ↔ 讀取端唯一約定的 claim 名與組裝
3讀取工具ActingContext從當前 JWT 算出 {subject, grantors[], actor}
4稽核事件能力ActingAuditInterceptor代行請求完成後發布 ACTING_ACCESS

acting capability 不提供:授權表、啟用記錄表、enter/exit 端點、閘門 authority 或 URL 掛載範圍。 通用審計 sink 是獨立 framework capability;具體 schema、儲存接線與應用政策仍由應用設計。

2.1 Feature 導入、預設與邊界​

acting 是 optional Feature,本身不宣告 prerequisite;delegation 與 impersonation 需要它時,由 feature dependency 自動帶入。其組成刻意分為:

  • Feature core:ActingConfig 提供可覆寫的 ActingAuditInterceptor 預設 bean; 使用者宣告同型 bean 時,@ConditionalOnMissingBean 讓位。
  • Reference domain:ActingController 把 stable subject 經 PrincipalDirectory 即時映射成 username,並以 /api/v1/auth/acting 提供 UI 顯示契約; ActingWebConfig 選擇把 core interceptor 掛在 /api/**。
  • Catalog seam:acting 沒有 catalog seam,也沒有 property、schema 或 migration。 可覆寫 bean 是 Spring extension point;URL、response 與掛載範圍則是 app-owned reference policy,不是 framework seam。

若產品要客製,可替換 ActingAuditInterceptor bean,或直接 retarget reference controller/config;不要在 reference config 重新建一份 framework bean。若要移除,須先移除 依賴它的 delegation/impersonation,再移除 acting reference domain 與 core。

tenant 與 tenantless variant 的 acting core、reference source 與 catalog 宣告皆為 零差異;租戶差異由 token 的 tenantId 與上層 delegation/impersonation 政策承擔。 另外,interceptor 只會發布事件;另 provision audit 可保存事件,但目標 alpha.33 的新入口 可停用且可遺失,不提供合規等級的完整可追溯性。此類需求須另行設計可靠保存政策; 詳見 審計事件。


三、框架 API​

3.1 Token 簽發擴充(JwtTokenProvider)​

框架在既有的 generateToken(...) 之上加兩個代行專用簽發方法。載入被代理/被模擬者、選哪些 ROLE、決定是否允許皆由應用層負責——框架只負責正確地簽。

/// 產生代理 token:sub 維持登入者、auth 取 subject 的權限
/// (呼叫端應先把「借用 ROLE 展開的權限」併入 subject.getAuthorities())、帶 grantors claim。
public String generateDelegationToken(UserDetails subject, List<String> grantors,
Map<String, Object> extraClaims);

/// 產生模擬 token:sub/auth 換成被模擬者、帶 actor claim。
/// 被模擬者的 tenantId 由呼叫端於 extraClaims 帶入(模擬可跨租戶)。
/// 要同時具代理維(被模擬者本身被指派代理),於 extraClaims 併入 ActingClaims.delegation(...) 即可。
public String generateImpersonationToken(UserDetails impersonatedSubject, String actor,
Map<String, Object> extraClaims);
  • 代理:subject 傳登入者,但其 getAuthorities() 應已是「自身 ∪ 借用權限」的合併結果(合併在應用層做)。grantors 至少一筆。
  • 模擬:impersonatedSubject 傳被模擬者,其 stable subject/authorities 成為 token 的 sub/auth;actor 是真實操作者的 stable subject(供結束模擬與稽核)。
  • 兩維可組合:grantors 與 actor 是獨立 claim、無 key 衝突——要簽「模擬 B 且帶 B 的代理維」,把 ActingClaims.delegation([C]) merge 進模擬簽發的 extraClaims 即可。
  • 兩者的 sessionId 經 extraClaims 帶入,沿用既有黑名單撤銷機制。

3.2 Claim wire 契約(ActingClaims)​

代理授權人、模擬者與有效身份一律以不可變 stable subject 識別,對齊 JWT sub。AuthPrincipal.subject() 是簽發來源;username、email、顯示名等可變欄位不得進入 framework claim contract。需要顯示名稱時,由應用層透過 PrincipalDirectory 映射。

兩個正交 claim,無模式判別器:模擬性由 actor 存在表達、代理性由 grantors 非空表達,兩者獨立、可並存。

claim常數代理模擬說明
sub—登入者 A被模擬者 B既有;auditor 讀此
auth—A ∪ 借用權限B 的權限既有;converter 還原 authorities
tenantId—A 的 tenantB 的 tenant既有;TenantContext 解析
sessionIdCLAIM_SESSION_ID有有既有;黑名單撤銷
actingPolicyCLAIM_ACTING_POLICY選用有Refresh Session 綁定的應用政策識別
grantorsCLAIM_GRANTORS[stable subject, …]—(除非共存)代理維,非空即代表代理性(0..n)
actorCLAIM_ACTOR—(除非共存)stable subject模擬維,存在即代表模擬性

為何沒有 act_mode:模式判別交由 grantors/actor 的存在性承擔——act_mode 承載零額外資訊,且單一值天生表達不了「同時模擬又代理」。安全性不靠它把關:token 完整性由 JWT 簽章保證,無法在不重簽(必經 JwtTokenProvider)下注入假 claim。

ActingClaims 提供組裝工具,供簽發端組出代行 claim(generateDelegationToken / generateImpersonationToken 內部即呼叫):

ActingClaims.delegation(List.of("account-a", "account-b")); // {grantors=[account-a, account-b]}
ActingClaims.impersonation("account-c"); // {actor=account-c}
// 無 act_mode key → 兩者可 merge:{actor=account-c, grantors=[account-a]}

Refresh Credential 是不透明 secret,不承載 claim。Refresh Session family 保存 subject/ actorSubject/SessionPolicyBinding;每次 refresh 重載 subject、actor 與 delegation,重驗 policy 絕對效期、actor 必要 authority 及應用提供的 SessionEligibilityPolicy,再重鑄 grantors/actor/actingPolicy。政策拒絕會撤銷整個 family。

3.3 讀取工具(ActingContext)​

ActingContext 是讀取端唯一入口——把當前 JWT 的代行 claim 在框架邏輯下算成結構化脈絡,供應用層寫啟用/稽核記錄、做 instance 級過濾。

public interface ActingContext {
String getSubject(); // 有效身份 stable subject(代理=A、模擬=B,即 JWT sub)
List<String> getGrantors(); // 代理授權人 stable subject 全集(0..n;無代理回空)
String getGrantor(); // 單數便利:>1 丟 IllegalStateException、0 回 null
String getActor(); // 模擬者 stable subject;非模擬時退化回 subject
String getSessionId(); // 目前 access/refresh family 的 session id
String getActingPolicy(); // 目前 family 綁定的應用政策識別
boolean isImpersonating(); // 帶 actor claim
boolean isDelegating(); // 帶非空 grantors;可與 isImpersonating() 同時為 true
}

取得脈絡(三個靜態工廠):

// 最常用:從當前 SecurityContext 讀
ActingContext acting = ActingContext.current();

// 從 resource-server 的 Jwt principal
ActingContext acting = ActingContext.of(jwt);

// 從原始 claim map(測試或非 resource-server 情境)
ActingContext acting = ActingContext.ofClaims(claims);

語意細節(實作於 DefaultActingContext):

  • isImpersonating() / isDelegating() 是兩個獨立述詞(各由 actor / grantors 的存在性判定),可同時為 true(共存情境);無單一 getMode()。
  • getGrantor() 是給「只支援單一授權人」的應用的便利存取:恰好一筆回該筆、零筆回 null、多筆卻以單數存取則丟 IllegalStateException(不允許把多筆當一筆讀)。
  • getActor() 在模擬時回 actor claim;其餘情況退化回 subject 自身——因為「真實操作者恆為當前 principal」,代理/一般 session 下操作者就是登入者本人。

目前狀態欄為何自動正確:AuditorAware 只讀 authentication.getName()(即 sub)。代理維持 sub=登入者、模擬切 sub=被模擬者,於是 @LastModifiedBy 免改即正確。ActingContext 是旁路資訊、不改既有 auditor 預設;需要 actor/grantors 與 Entity 版本時,另選配 Entity history。


四、稽核與資料隔離的互動​

稽核:三層獨立互補​

JPA 目前狀態欄(@LastModifiedBy)只記有效身份(sub)。要看見 acting 維度,可依查詢目的獨立選配 Entity history 與 request-level audit event:

① 資料列級 — Spring Data JPA Auditing(AuditableBase)

  • @CreatedBy/@LastModifiedBy/@CreatedDate/@LastModifiedDate + AuditorAware(讀 SecurityContextHolder…getName() = sub)。
  • 每次 persist/update 把「誰(=sub)、何時」蓋到資料列。單一 @LastModifiedBy 不承載 actor/grantors;既有 Entity 與 AuditableBase 不需修改。

② Entity changeset 級 — optional Entity history

  • 應用安裝 entity-audit feature,並只對需要歷程的 Entity 加 @Audited。
  • Envers _AUD 表保存 Entity 版本;application-owned audit_revision 保存同一 transaction 的 subject/actor/grantors/principalKind/requestId/sessionId/source,tenant variant 另存 tenant。
  • impersonation/delegation 都不是必要相依;沒有對應 claim 時自然退化為 subject = actor、grantors = []。詳見 Entity 歷程稽核。

③ 存取級 — request-level acting 稽核 hook + 持久 AuditEventRepository

以下是已發布同步 JPA sink 的行為。新版非同步 JDBC feature(目標 alpha.33,人審通過、待發布) 使用 storage provider、可關閉與可遺失的共用入口;詳見 審計事件。 採用新版時,acting 事件發布仍保留,但事件寫入不再加入業務交易,不能假設立即可查。 關閉時 acting 操作可繼續且不記新事件;開關真實操作者(getActor())、模擬時的有效 subject 與前後狀態另嘗試輸出背景 INFO 日誌, 同樣可遺失、不影響交易,不能證明停用期間的完整軌跡。細節見審計事件。 ADR-013 的原強制完整存取軌推論已由 R5 部分取代。下列 JPA 交易及 in-memory provider 說明 僅描述原接線,不是新版 feature 的預設。

  • 關鍵洞察:JWT 於每次認證請求都自帶完整代行脈絡(sub/actor/grantors),Spring Security 每請求處理它 → ActingContext.current() 隨時可還原。故 acting 稽核的正確落點是存取層,不需 session 表。
  • jar 的 ActingAuditInterceptor capability 在代行脈絡下完成的每個請求,發一筆 ACTING_ACCESS AuditEvent(principal=真實操作者,data 帶 subject/actor/grantors/actingModes/sessionId/actingPolicy/method/uri/status);acting Feature 提供可覆寫的預設 bean, app-tenant-server reference domain 才選擇掛在 /api/**。轉場另發 IMPERSONATION_STARTED/ENDED、DELEGATION_APPLIED。這些事件的 principal 與 data 身份欄一律使用 stable subject;DELEGATION_APPLIED 若需與業務資料一起 commit/rollback, 發布方法須位於可寫交易。持久 sink 的交易契約如下。
  • 參考實作把這些 AuditEvent 經 PersistentAuditEventRepository(實作 Spring Boot Actuator 的 AuditEventRepository)持久落 audit_event 表,/actuator/auditevents 可查詢;Spring Security 的登入/授權稽核事件也共用此 sink。
    • 持久 sink 是框架 capability(io.leandev.appfuse.audit,opt-in,ADR-013 R1);audit Feature 本身以 in-memory sink 作零資料表預設。需要持久稽核的應用才由 reference domain 宣告 entity、repository 與 AuditPersistenceConfig,後者透過 repository provider 讓 Feature 改選持久 sink,過程只產生一個 AuditEventRepository bean。acting 的事件解算同樣是 capability;哪些 URL 納入稽核仍是應用 reference policy。
    • 交易契約(自 4.0.0-alpha.27 起,詳見 framework CHANGELOG;4.0.0-alpha.26 不包含): 無交易時建立可寫交易;唯讀外層交易時用 REQUIRES_NEW 獨立提交,外層 rollback 仍保留稽核; 讀寫外層交易時加入外層,與業務資料一起 commit/rollback。因此持久 sink 不保證保留讀寫 交易失敗操作的稽核。既有兩參數 @Bean 會自動注入 transaction manager;多 manager 時可用 三參數建構子指定與 repository 相同的 manager。寫入失敗記 WARN 並傳遞例外,外層 commit 失敗或結果不明也記 WARN;不在 WARN 放入事件或 SQL 例外內容。唯讀路徑需要額外資料庫連線, 升級後讀取類事件會增加稽核表資料量;既有遺失事件無法補回。
  • 不再有 acting 專屬 session 表——ImpersonationSession/DelegationSession 已移除(JWT 每請求自帶脈絡,session 表對「軌跡」是冗餘)。

如何選:只需要目前狀態就用 AuditableBase;要查某 Entity 每一版與 changeset actor 就加 Entity history;要查未改 Entity 的存取、登入與 HTTP 結果則用 AuditEvent。三者可單獨或一起啟用,不必把 acting 欄位塞進所有業務 Entity。

資料隔離:跨租戶不對稱​

隔離模式代理模擬
tenant同租戶;資料可見性本即整租戶,代理是「輕版」純權限合併換 tenantId claim → TenantContext + Hibernate @Filter 自動跟隨
ownership應用層讀 getGrantors() 把授權人 subject 集合折入擁有權過濾隨 B 身份自動成立
  • 模擬幾乎免費:既有 tenant/ownership 機制都 key 在 principal 身份,模擬換掉 sub/auth/tenantId 即自動跟隨,框架不改邏輯。
  • 代理不跨租戶:技術上 TenantContext/Hibernate filter 為單租戶,且實務罕見;代理 token 維持登入者 tenantId。

五、參考實作走查(花店 app-office)​

以下為 app-tenant-server / app-office 的參考實作,屬 @reference-surface(見 m-reference-code.md),下游 retarget 時替換為自己的政策。框架碼不動,只換應用層政策。

5.1 端點總覽​

方法路徑閘門職責歸屬
POST/api/v1/auth/impersonationhasAuthority('impersonation:enter')進入一般模擬Impersonation
POST/api/v1/auth/impersonation/break-glasshasAuthority('impersonation:break-glass')MFA step-up 緊急模擬Impersonation
POST/api/v1/auth/impersonation/break-glass/cases/{caseNumber}/closehasAuthority('impersonation:break-glass')結案並撤銷該案件全部 familyImpersonation
POST/api/v1/auth/impersonation/exitisAuthenticated()結束模擬Impersonation
GET/api/v1/auth/impersonation/candidateshasAuthority('impersonation:enter')可模擬對象名單Impersonation
GET/api/v1/auth/delegation/grants?activeOnly=true&page=0&size=50isAuthenticated()有界的代理人清單;每頁最多 100Delegation
POST/api/v1/auth/delegation/grantsisAuthenticated()指派代理人(同租戶)Delegation
DELETE/api/v1/auth/delegation/grants/{id}isAuthenticated()撤銷代理授權Delegation
GET/api/v1/auth/delegation/candidatesisAuthenticated()可指派代理人候選Delegation
GET/api/v1/auth/actingisAuthenticated()當前代行脈絡(渲染橫幅/指示器)Acting(中性共用)

兩功能獨立、可各自 cherry-pick:Impersonation 與 Delegation 各有獨立的 controller / service,彼此零依賴(ImpersonationService 不碰任何 delegation 元件、反之亦然)——下游只想要其一時,整組拿走即可編譯運行。GET /acting(當前代行脈絡)是兩者共用的中性讀取面,由獨立的 ActingController 直接讀框架 ActingContext,對兩功能皆中性(只用模擬 → delegating 恆 false;只用代理 → impersonating 恆 false)。候選名單也依功能拆成兩個端點,不再用單一 ?mode= 混流。

閘門是政策:一般模擬使用 impersonation:enter,跨租戶另需 impersonation:cross-tenant;緊急路徑只接受 impersonation:break-glass,且不得由一般端點 自動繞過 consent。指派自己的代理人是個人行為,只驗 isAuthenticated()。

5.2 代理:授權(grant)模型​

代理只有一種啟用方式——授權(grant)。 「代理」即「權限持有者把權限授給他人代為行使」,權限由授權人往下流出;不存在「受益人自行挑選要借誰的權限」(那是自我提權、不是代理)。所以代理一律是授權人指派代理人 → 代理人登入時自動生效,直到絕對期限或撤銷。grant 保存 ACTIVE/REVOKED 狀態、到期時間與撤銷資訊;同一 grantor/grantee 配對具有唯一限制,續授權會重新啟用既有資料列。適用休假代理、職務代理人等現實情境。

流程:授權人 A 在「我的代理人」頁指派代理人 B(DelegationGrant 持久化)→ B 登入時 /api/v1/auth/login 查其收到的授權、合併 A 的 ROLE 權限、以 generateDelegationToken(B, [A], …) 簽 token。B 身份不變(sub=B)、多了 A 的權限,grantors=[A]。框架只負責簽 token;授權關係的持久化與撤銷政策是應用層職責。

邊界分成三層:jar capability 提供 token/LoginActingContributor;delegation Feature 提供 DelegationApplication 窄契約;reference domain 才擁有 DelegationGrant entity/repository、REST URL 與帳號目錄政策。登入整合由 reference DelegationService 同時實作 DelegationApplication 與其父介面 LoginActingContributor,再由框架 LoginService(ADR-023)於登入時呼叫:

// 起點:代理人自身權限。逐一併入每位授權人的 ROLE 與其下 authority
for (DelegationGrant grant : grants) {
PrincipalView grantor = principalDirectory
.findById(grant.getGrantorSubject()).orElse(null);
if (grantor == null) continue;
grantor.roleNames().forEach(role ->
mergedAuthorities.add(new SimpleGrantedAuthority(toRoleAuthority(role))));
grantor.authorityNames().forEach(authority ->
mergedAuthorities.add(new SimpleGrantedAuthority(authority)));
grantorSubjects.add(grant.getGrantorSubject());
}
AuthPrincipal mergedActor = DefaultAuthPrincipal.builder(grantee.username())
.subject(grantee.id())
.authorities(mergedAuthorities)
.build(); // sub 仍是 B 的 stable subject
// …發 DELEGATION_APPLIED AuditEvent(新接線為可停用、可遺失的背景寫入,見 §四稽核)…

app-tenantless-server 不建立 tenant context;reference grant 本身也沒有 tenant 欄位。 真正的 instance ownership 可見性由各產品把 ActingContext.getGrantors() 折入 repository/service 過濾。app-tenant-server 則在自己的模組內維護 tenant-native 政策。

LoginService 拿到合併結果(ActingLogin)後:

var acting = actingContributor.flatMap(c -> c.contribute(account.subject()));
if (acting.isPresent()) {
var login = acting.get();
accessToken = jwtTokenProvider.generateDelegationToken(
login.effectivePrincipal(), login.grantors(), claims);
}
// Refresh Session 只保存 stable subject;refresh 時重新呼叫 contributor,反映最新 grant。

清單邊界:GET /delegation/grants 預設 activeOnly=true,只回 ACTIVE 且未到期資料; page 從 0 起、size 預設 50 且最大 100,總數與頁資訊在 X-Total-Count/X-Page/ X-Page-Size。管理歷史時才明示 activeOnly=false,避免「我的代理人」頁隨軟刪歷史無界膨脹。

關閉代理:grant 模型下代理人不能自行卸下被授予的權限(那是授權人的決定)——沒有 delegate-initiated exit 端點。要停止代理,授權人 DELETE /delegation/grants/{id} 撤銷授權(下次登入即不再套用),或代理人登出。這與模擬的 enter/exit 對稱不同:代理人從未「enter」(登入自動套用),故無對稱的「exit」。

既有 delegation_grant 升級​

有既有資料的環境不得直接讓升級後 binary 以 ddl-auto=update 首次啟動:新增的 expires_at/status 是 NOT NULL,且 pair unique constraint 可能被歷史重複資料阻擋。 請先備份並在停機窗口以既有 Flyway/Liquibase/DBA 流程完成以下順序:

  1. 先以 nullable 方式加入 expires_at、status、revoked_at、revoked_by_subject、revoke_reason。
  2. 查出 grantor_subject × grantee_subject 重複;依業務決定保留一筆並移除其餘,不得讓腳本任意猜 winner。
  3. 將舊 grant 回填為 ACTIVE,並設定明確期限。Reference 建議以 migration 時刻加 7 天,讓舊的無限期授權有受控換約窗口。
  4. 將 expires_at/status 改為 NOT NULL,再建立 uk_delegation_grant_pair。
  5. 以 ddl-auto=validate 啟動一次確認 mapping,之後才恢復該環境既定策略。

重複資料預檢(各支援資料庫通用 SQL):

SELECT grantor_subject, grantee_subject, COUNT(*) AS duplicate_count
FROM delegation_grant
GROUP BY grantor_subject, grantee_subject
HAVING COUNT(*) > 1;

MySQL 8 參考回填:

ALTER TABLE delegation_grant
ADD COLUMN expires_at DATETIME(6) NULL,
ADD COLUMN status VARCHAR(20) NULL,
ADD COLUMN revoked_at DATETIME(6) NULL,
ADD COLUMN revoked_by_subject VARCHAR(36) NULL,
ADD COLUMN revoke_reason VARCHAR(100) NULL;

UPDATE delegation_grant
SET expires_at = DATE_ADD(UTC_TIMESTAMP(6), INTERVAL 7 DAY),
status = 'ACTIVE'
WHERE expires_at IS NULL OR status IS NULL;

-- 先人工處理上述 duplicate query 的結果,再執行 constraint。
ALTER TABLE delegation_grant
MODIFY expires_at DATETIME(6) NOT NULL,
MODIFY status VARCHAR(20) NOT NULL,
ADD CONSTRAINT uk_delegation_grant_pair UNIQUE (grantor_subject, grantee_subject);

PostgreSQL 參考回填:

ALTER TABLE delegation_grant
ADD COLUMN expires_at TIMESTAMP(6) WITH TIME ZONE,
ADD COLUMN status VARCHAR(20),
ADD COLUMN revoked_at TIMESTAMP(6) WITH TIME ZONE,
ADD COLUMN revoked_by_subject VARCHAR(36),
ADD COLUMN revoke_reason VARCHAR(100);

UPDATE delegation_grant
SET expires_at = CURRENT_TIMESTAMP + INTERVAL '7 days',
status = 'ACTIVE'
WHERE expires_at IS NULL OR status IS NULL;

-- 先人工處理上述 duplicate query 的結果,再執行 constraint。
ALTER TABLE delegation_grant
ALTER COLUMN expires_at SET NOT NULL,
ALTER COLUMN status SET NOT NULL,
ADD CONSTRAINT uk_delegation_grant_pair UNIQUE (grantor_subject, grantee_subject);

SQL Server/Oracle 使用相同五步驟,但型別與 interval 語法須由該專案 migration 工具依 dialect 產生;reference 不提供一支假裝跨方言、實際可能毀損資料的通用 DDL。

5.3 模擬:enter / exit + 跨租戶閘門​

POST /impersonation 進入模擬(ImpersonationService.enter):

  1. 閘門 impersonation:enter 由 controller @PreAuthorize 把關。
  2. Service 補 runtime 政策:目標與自己不同租戶時須另具 impersonation:cross-tenant (一般 USER 因此被限縮在同租戶);拒絕模擬服務帳號、拒絕模擬自己。
  3. 呼叫 LoginService.switchToImpersonation(...) 建立新 Refresh Session family;其 subject 為被模擬者、 actorSubject 為真實操作者,並綁定 policy reference/絕對期限/必要 actor authority;access token 的 tenantId/authorities 來自被模擬者(純模擬,不繼承目標 delegation)。
  4. 原 actor family 以 identity-switch 撤銷;refresh 由 family 的 actorSubject 重鑄 actor claim,避免刷新後降級成被模擬者的直接登入。
  5. 發 IMPERSONATION_STARTED AuditApplicationEvent(principal=actor,data 帶 subject/crossTenant/sessionId)→ 交給選定的 audit sink。新接線可停用、可遺失且延遲可見;Refresh Session 表只保存 authentication state,不取代 audit 軌。
  6. 每次 refresh 重驗 actor/subject 狀態、actor authority、綁定 policy 與 consent;consent 撤銷時應用亦以 policy reference 主動撤銷所有既有 family。

為何純模擬:框架層雖容許「模擬 B 且帶 B 的代理維」兩維共存(把 ActingClaims.delegation(...) merge 進 extraClaims 即可),但參考實作刻意讓兩功能獨立——ImpersonationService 不依賴 DelegationService,下游可只取模擬。需要該組合的下游自行在 enter wire 即可。

POST /impersonation/exit(ImpersonationService.exit):ActingContext.current().isImpersonating() 確認確在模擬中,取 getActor() 拿回真實操作者,建立 actor 的新 family、撤銷模擬 family 並短效 blacklist 舊 access family,發 IMPERSONATION_ENDED AuditEvent,再以正常 access token 換回身份。

一般 consent 與緊急 break-glass 是兩條不同產品路徑:一般端點只接受結構政策或有效 consent; break-glass 端點要求 IdP amr/auth_time 證明近期 MFA step-up、理由、案件編號,並簽發短效、 non-remembered、具絕對有效期的 family。首次進入會登錄 OPEN case;結案端點持久標記 CLOSED、 立即依 case number 撤銷所有 family,之後 refresh 與重新進入都 fail closed。

Reference seed 不把 impersonation:break-glass 配給預設 ADMIN/SUPER_ADMIN。普通 local login 也沒有 amr/auth_time,所以即使手動配權仍會 fail closed;只有接上企業 IdP MFA step-up 的 專用 operations 角色才應取得該 authority。

app-office/MSW 刻意不提供 break-glass UI。它只示範普通 impersonation 警示;正式產品 必須先完成 IdP step-up journey,再以 reason/caseNumber 呼叫 server API,並在緊急 session 全程 顯示更強警示。沒有 IdP 的 mock 表單不得被當成 MFA UAT 證據。

5.4 候選名單(政策在後端)​

候選名單依功能拆成兩個獨立端點(不再用 ?mode= 混流),各由自己的 service 依政策過濾,前端只呈現、不自行過濾:

  • GET /impersonation/candidates(ImpersonationService):先要求 impersonation:enter,再依 consent、保護角色/帳號與 anti-escalation 政策逐筆過濾。
  • GET /delegation/candidates(DelegationService):同租戶、且尚未指派過(避免重複授權)。
  • 兩者皆排除自己與服務帳號。

5.5 前端兩級提示(app-office)​

兩級各綁一個維度、由 GET /acting 回的兩正交述詞獨立觸發、彼此獨立顯示。框架容許兩維共存,故前端天生支援同時顯示(橫幅+指示器);但花店參考實作的模擬保持純粹(不併入被模擬者的代理權)、兩功能又各自獨立,故 florist 不會由模擬觸發共存——同時顯示是框架+前端就緒的能力,留給需要該組合的下游:

觸發述詞UI元件
模擬impersonating(帶 actor)醒目橫幅「模擬中:B」+ 結束鍵acting-banner.tsx
代理delegating(grantors 非空)低調指示器(Header chip「代理中:{授權人}」,純顯示無停止鍵;關閉由授權人撤銷 grant)delegation-indicator.tsx

ActingResponse 回 { impersonating, delegating, subject, actor, grantors } 兩布林 + username 顯示欄,無單一 mode;controller 在 reference boundary 將 framework stable subject 即時映射為 username。兩元件各自依述詞顯示,天然支援共存。進入模擬經「切換身份」 對話框(switch-identity-dialog.tsx,候選 Select);代理由授權人在「我的代理人」設定頁 (my-delegates-page.tsx)指派、代理人登入即生效。


六、導入你自己的專案​

在框架四件套之上,你需要作者化下列應用層政策(可直接參考 app-tenant-server):

  • 端點:模擬 enter/exit、代理 grants CRUD、acting 脈絡查詢、候選名單。
  • 閘門 authority:模擬用特權 authority(+跨租戶額外 authority);代理指派依你的政策(參考實作用 isAuthenticated())。
  • 持久化:代理授權關係 DelegationGrant(grantor→grantee,這是活的授權、非稽核)。
  • 稽核:依需求選擇 Entity history(版本+changeset actor)及/或 request-level acting hook(HandlerInterceptor 讀 ActingContext)發 AuditEvent,明確採用 sink 的停用/遺失契約;不建 acting 專屬 session 表(JWT 每請求自帶脈絡,見 §四)。
  • 權限合併(代理):登入時把授權人的 ROLE 權限併入,sub 維持代理人。
  • instance 級過濾(ownership 應用):讀 getGrantors() 折入擁有權過濾。
  • 前端兩級 UI:模擬橫幅 vs 代理指示器,靠 GET /acting 驅動。

框架不做的事,別在應用層繞過框架自己簽 token——一律經 JwtTokenProvider 的兩個代行方法,確保 claim 對稱、ActingContext 讀得到。


七、相關文檔​

外部參考​

  • RFC 8693(OAuth 2.0 Token Exchange,act/may_act claim 的既有先例,供 claim 命名參考)
  • Spring Security SwitchUserFilter(switch-user 既有模式;本設計採 token-based 而非 filter-based)