Skip to content

feat(certs): keep tenant states from identity events and fail closed - #798

Merged
Ruisi-Lu merged 5 commits into
mainfrom
feat/778-certs-tenant-state
Oct 30, 2026
Merged

Ruisi-Lu merged 5 commits into
mainfrom
feat/778-certs-tenant-state

Conversation

@Ruisi-Lu

Copy link
Copy Markdown
Member

摘要

certs 新增第二個 outbox 消費端,從頭訂閱身分服務的 log(certs.identity-events),以 identity.tenant_created 與 identity.tenant_state_changed 維護每個租戶的狀態(清單 B1 的通知模式:只保留版本較大的快照)。換發只給狀態是「正常」的租戶:停權、刪除、這個版本不認得的狀態,以及沒有紀錄的租戶都讀成停權(fail closed)。兩個消費端任一個落後或 lag 不明時,換發回 503。

變更

  • services/certs/src/tenant_states.rs(新):TenantStateHandler。tenant_created 存成正常、版本 1;tenant_state_changed 存 payload 的狀態與 state_version。租戶取自 envelope,並檢查 aggregate 是該租戶的 tenant。租戶事件的任何失敗(不認得的事件版本、解碼失敗、沒有租戶、aggregate 不符、state_version 小於 2、資料庫錯誤)都重試,處理常式不送進 dead letter,略過就可能遺失一次停權;卡住時 lag 增加、換發回 503。不認得的 state 值存成 unrecognized。租戶 aggregate 上不認得的事件類型略過並記 WARN。
  • services/certs/migrations/postgres/0007_tenant_state_events.sql(新):tenant_statuses 的 CHECK 加上 deleted 與 unrecognized,lock_timeout 5 秒。
  • services/certs/src/bindings.rs:store_tenant_status 讓 deleted 成為終態,unrecognized 可以被同一個版本號的已知狀態取代(升級後重播)。reads_as_suspended:只有存下 active 不是停權。Projection::tenant_suspended 改用它。
  • services/certs/src/renewal.rs:換發的 JOIN 讀出的狀態也經 reads_as_suspended 判斷,讀取日誌加上 tenant_status。slowest 取兩個消費端中較慢的 lag:任一個不明就不明,事件數相加,最舊的事件取較舊的。
  • services/certs/src/lib.rs:啟動第二個消費端,換發的 lag 改為兩個消費端的 slowest。平台角色連線池維持 4 條。
  • tools/migrate/src/registry.rs:certs consumes: &["assets", "identity"],Job 讓 certs_platform 讀 identity.outbox_event。
  • 測試:新增 tests/tenant_states.rs 與 tests/tenant_state_lag.rs。tests/renewal.rs、tests/bindings.rs、tests/dynsec.rs 改成先寫入正常的狀態,並加上刪除、沒有紀錄的案例。tools/migrate/tests/fresh_database.rs 檢查 certs_platform 在 identity 只有 outbox_event 的 SELECT。scripts/tests/product-logins.test.ts 跟著改成 certs 消費 assets 與 identity。certs 加上 dev-dependency identity,以身分服務自己的程式碼建立租戶。
  • 文件:services/certs/README.md(「租戶狀態」重寫,以及換發的檢查順序、紀錄、規則與取捨、資料庫、權限、測試)、docs/architecture/decisions.md 決策 12 (3)、docs/requirements/rebuild-checklist.md 的 B1 與 D2、tools/migrate/README.md、deploy/chart/README.md。

驗證

證據報告:docs/test-reports/2026-10-30-778-certs-tenant-state.md。以下都在建置主機(arm64 Linux VM)上以 CI=1 執行,容器測試沒有略過:

  • CI=1 cargo test --locked -p certs:全部通過,包含新的 tenant_states、tenant_state_lag。
  • CI=1 cargo test --locked -p migrate:全部通過,包含 fresh_database。
  • cargo fmt --all --check、cargo clippy --locked --workspace --all-targets -- -D warnings、CI=1 cargo test --locked --workspace --exclude agent(含 sql-check、repo-check):在建置主機上通過,202 個測試執行檔 0 失敗。
  • bun test scripts 抓到 scripts/tests/product-logins.test.ts 仍寫著 certs 只消費 assets,已修正。修正後在本機(x86_64 Linux)1711 個通過、0 失敗;建置主機沒有 helm,無法在那裡跑。bun run --cwd scripts typecheck 也通過。
  • cargo deny --locked check:advisories、bans、licenses、sources 都 ok。
  • gitleaks:分支的 commit 與 git archive HEAD 的樹都沒有發現。

驗收條件逐條對照

  • 事件延遲與重播不會讓狀態倒退(測試):tests/tenant_states.rs。晚到的舊快照與整個訂閱重播(38 個事件重送)都沒有改變任何一列,包含 updated_at。
  • 停權與恢復交錯且亂序到達,最後的狀態等於最後一次操作(測試):tests/tenant_states.rs。版本 5、2、9、3、7、4、8、6 最後是 suspended 9;版本 4、7、2、10、3、6 最後是 active 10;8 個並行變更的版本連續。
  • 從頭重播後 certs 有每個既有租戶的狀態;已刪除與沒有紀錄的租戶讀成停權,換發被拒(測試):tests/tenant_states.rs 中,身分服務的 5 個租戶與 certs 的副本相同;tests/renewal.rs 中,刪除、沒有紀錄的租戶都回 403 tenant_suspended。
  • 訂閱落後時換發回 503(測試):tests/tenant_state_lag.rs 回 503 bindings_stale、Retry-After: 30,0 筆 dead letter。
  • CI=1 cargo test -p certs、CI=1 cargo test -p migrate、cargo clippy --workspace --all-targets -- -D warnings 通過。

審查

另一個 Opus 審查者做過資安審查,沒有 Critical、High 或 Medium。Low 的處理:

  • 已修:migration 加上 lock_timeout;租戶 aggregate 上不認得的事件類型記 WARN;README 與 migration 註解寫清楚降版與 outbox 本身的 dead letter 對停權的影響。
  • 留作後續:
    • 簽發交易裡再確認一次租戶狀態(FOR SHARE)。讀取與 commit 之間只有毫秒級的時間差,比已接受的 lag 時間窗小得多。
    • certs.identity-events 的 lag 與 dead letter 告警。

審查分類建議:review:human

這個 PR 決定停權租戶的閘道器還能不能拿到新憑證(對外的換發端點),又加了 migration、資料庫權限(certs_platform 讀 identity.outbox_event)與新的事件消費契約。依 review:ai 的條件(沒有權限、密鑰、對外協定的變更),它不符合。

Closes #778

🤖 Generated with Claude Code

https://claude.ai/code/session_01EnmrdxG91NgAkjWi1afk7W

Ruisi-Lu and others added 4 commits October 30, 2026 19:32
The certs service subscribes to the identity service's outbox from the
beginning: identity.tenant_created stores the state active at version 1,
identity.tenant_state_changed stores its snapshot when the version is
newer. Deleted is final and an unrecognized state takes a known state at
the same version again. Every failure of a tenant event is retried, never
dead-lettered, so a suspension is never skipped; the stall shows as lag.

Only a stored active state renews: suspended, deleted, unrecognized and
a tenant without a row read as suspended. The renewal answers 503 while
either consumer's lag is unknown or too high.

Refs #778

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnmrdxG91NgAkjWi1afk7W
…unknown tenant events

The constraint swap waits at most 5 seconds for the table's lock instead
of queueing every renewal behind it. An event type of the tenant
aggregate this build does not know is still skipped, now with a WARN.
The docs state what a rollback and the outbox's own dead letters mean
for a suspension.

Refs #778

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnmrdxG91NgAkjWi1afk7W
Refs #778

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnmrdxG91NgAkjWi1afk7W
The registry parser's check pins each service's consumed producers; certs
now reads the identity service's events for each tenant's state.

Refs #778

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnmrdxG91NgAkjWi1afk7W
@Ruisi-Lu Ruisi-Lu added the review:human 需要人類 reviewer 複審才能合併:介面、UX 功能,或無法明確判定可併入的變更;AI 不得自行合併 label Oct 30, 2026
…-state

# Conflicts:
#	docs/requirements/rebuild-checklist.md
@Ruisi-Lu
Ruisi-Lu merged commit be648de into main Oct 30, 2026
12 checks passed
@Ruisi-Lu
Ruisi-Lu deleted the feat/778-certs-tenant-state branch October 30, 2026 13:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review:human 需要人類 reviewer 複審才能合併:介面、UX 功能,或無法明確判定可併入的變更;AI 不得自行合併

Projects

None yet

Development

Successfully merging this pull request may close these issues.

certs 訂閱身分服務的租戶狀態:從頭重播涵蓋每個租戶、未知與 deleted 讀成停權(B1)

1 participant