本章目標與前置條件
本章說明「請求屬於哪個 tenant」如何一路傳到 PostgreSQL,以及為什麼應用層條件不能取代 RLS。 前置條件是能閱讀 Go transaction、PostgreSQL policy 與前端同源 request;不需要先啟動資料庫。
核心概念
三層租戶上下文
- Verified identity:租戶來自 access token 的
tenant_idclaim(值來自開通時寫入的 Keycloak 使用者屬性)。identityMiddleware(internal/api/v1/middleware.go)把 Bearer token 交給identity.Resolver(internal/service/identity/resolver.go);internal/platform/keycloak/verifier.go驗簽並要求 claim 是合法 UUID,resolver 再以該 claim 開租戶交易,依(issuer, subject)查user_identities取得 account。通過後 TenantID、AccountID 才寫進RequestContext(internal/api/v1/context.go)。 - Application context:Gin handler 以
request.TenantID呼叫 service;service 只把 tenant ID 交給repository.UnitOfWork[T].WithinTenant,且不得 importinternal/platform(tests/contract/import_boundaries_test.go)。 - Database context:
internal/repository/postgres透過internal/platform/postgres的WithTenantTx,在 transaction 內設定 transaction-local 的app.tenant_idGUC,RLS policy 讀取它。
claim 只用來選 RLS 租戶,不是授權依據:偽造成其他租戶,只會在該租戶查不到 (issuer, subject) 綁定而得到 401,
看不到任何資料。user_identities 本身是 FORCE RLS,所以只能「先定租戶、再查身分」。升權會話不屬於 identity:
X-Elevation-Session header 在建立請求上下文時另外讀取,到 authorize 階段才由 authz.Decide 驗證
(見 身分、IAM 與權限目錄)。
同一個 tenant ID 必須由上到下保持一致;Repository 不接受由瀏覽器任意指定的租戶作為授權依據。
WithTenantTx 與讀取 snapshot
internal/platform/postgres/tx.go 提供 WithTenantTx(ReadCommitted)與 repeatable-read、唯讀的 WithTenantReadSnapshot。前者啟動
tenant transaction、執行:
SELECT set_config('app.tenant_id', $1, true)true 表示只在目前 transaction 有效,效果等同 SET LOCAL,但程式碼一律用 set_config;巢狀 tenant transaction
會被拒絕(ErrNestedTransaction)。持有租戶交易時若需要跨域唯讀,用 DetachTenantTransaction 讓該讀取另開一個短交易
(目前只在 internal/wiring 的跨域 port 使用),不要把另一個 domain 塞進同一個交易。WithGlobalTx 目前只給 outbox
dispatch queue 使用,不可拿來繞過業務 tenant boundary。
RLS policy 的標準形狀
業務表一般有 tenant_id、租戶複合唯一鍵/外鍵,migration 同時設定:
ALTER TABLE public.attachments ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.attachments FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_attachments ON public.attachments
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);tests/contract/schema_lint_test.go 會檢查:ENABLE 與 FORCE 都要有;每張租戶表恰好一條 baseline policy,名稱為
tenant_isolation_<table>,USING 與 WITH CHECK 都是上面這個精確的 NULLIF 運算式。NULLIF 不能省:連線池回收的連線上,
未設定的自訂 GUC 會是空字串而不是 NULL,少了它會拋 22P02 而不是回 0 行(tests/integration/postgres/rls_test.go
有反例測試)。形狀例外只有 outbox_dispatch_queue(依角色與命令拆成多條 policy)與 tenants。
tenants 本身是 root table,以 id 作為隔離欄位,另有三條只在特定 GUC 下對特定角色生效的 SELECT policy(見下一節);
其他表不能因為是「基礎資料」就省略 policy,db/CONVENTIONS.md 目前沒有任何 platform_rls 例外。
跨租戶背景工作的受控入口
背景工作沒有 BYPASSRLS,也沒有「看得到全部租戶」的業務交易。需要逐租戶處理時,先經窄化入口列出租戶 ID,
再對每個租戶各開一次 WithinTenant;catalog reconcile 與 IAM 回收都是單一租戶失敗只記 log 並繼續。
| 入口 | 放寬了什麼 | 目前使用者 |
|---|---|---|
WithGlobalTx | 不設 GUC;只有 outbox_dispatch_queue 對 nexus_worker_access 有 USING (true) 的 policy(000003) | worker 的 outbox dispatch claim |
app.catalog_reconcile = 'on' | nexus_api_access 可 SELECT 全部 tenants 列(000012) | API 啟動時的 catalog 逐租戶 reconcile |
app.iam_reclaim = 'on' | nexus_worker_access 可 SELECT 全部 tenants 列(000016) | 綁定到期回收、attendance digest、附件清理等 worker 排程 |
app.tenant_slug | nexus_ops_access 可依 slug 讀回單一 tenant(000008) | cmd/ops 開通租戶(WithinTenantSlug,取得實際 id 後改設 app.tenant_id 並清空 slug) |
catalog reconcile 與 IAM reclaim 兩個 scope 交易都不設 app.tenant_id,其他業務表在裡面仍是 0 行。tenants 的 policy
集合(baseline 加上三條 scoped policy)由 schema lint 精確鎖定;新增這類入口是架構變更,不要在業務碼自己設 GUC。
PostgreSQL 角色
cmd/db-provision 建立的角色全部是 NOSUPERUSER、NOBYPASSRLS:
nexus_migration:執行 migration,是業務表的 owner。FORCE RLS 對 owner 一樣生效,migration 裡未設app.tenant_id的 UPDATE/DELETE 會靜默影響 0 列;回填既有列要改用ADD COLUMN … DEFAULT再移除 default 這類 DDL(docs/infra/03-database.md§2 第 11 條)。nexus_api、nexus_worker、nexus_ops:runtime 登入角色,直接表權限會被撤掉,只經 NOLOGIN 的nexus_api_access、nexus_worker_access、nexus_ops_access取得權限;每張表的命令權限由建立該表的 migration 逐表 GRANT。- 啟動時
internal/platform/postgres/role.go檢查 runtime role 是否為 superuser、具 BYPASSRLS 或擁有業務表。只有 production-like 環境或DB_REQUIRE_SAFE_ROLE=true時才拒絕啟動;這個鍵預設false(此時只記 warning),docker-compose.yml的 api、worker、ops 都設為true。
功能清單與現況
| 邊界 | 目前可核實的實作 | 狀態 |
|---|---|---|
| identity → request tenant | internal/api/v1/middleware.go、internal/service/identity/resolver.go、internal/platform/keycloak/verifier.go、internal/api/v1/context.go | 已實作(原始碼層級) |
| transaction-local GUC | internal/platform/postgres/tx.go | 已實作(原始碼層級) |
| schema RLS | 全部 migration(baseline、attachments、notifications 為範例)與 tests/contract/schema_lint_test.go | 已實作(原始碼層級) |
| 跨租戶列舉入口 | 000008/000012/000016 與 tenant_bootstrap.go、catalog_reconcile.go、iam_reclaim.go | 已實作;窄化 policy,不是 RLS 例外 |
| runtime role 安全檢查 | internal/platform/postgres/role.go、DB_REQUIRE_SAFE_ROLE | 已實作;development 預設只記 warning |
| business data scope | internal/authz decision 與 repository filter | 是應用授權,不取代 RLS |
| 前端 identity key | 各 feature 自行把 tenant 與 account ID 放進 key(例如 features/forms/api.ts、hooks/api/useIamCatalogApi.ts) | feature 級約定,不是全域機制;不提供安全保證 |
附件與通知都各自展示了 tenant policy;它們不是「特殊例外」,而是新增表的應遵循形狀。
實際請求鏈
Bearer JWT(proxy.ts 由 httpOnly cookie 注入)
→ identityMiddleware → identity.Resolver
→ 以 tenant_id claim 開租戶交易,查 user_identities 取得 account
→ RequestContext.TenantID / AccountID
→ authorizeMiddleware:authz.Decide(同一 tenant)
→ handler / service
→ UnitOfWork.WithinTenant(ctx, tenantID, fn)
→ WithTenantTx + set_config('app.tenant_id', $1, true)
→ sqlc query + PostgreSQL RLS
→ envelope / error mappinguseGetMe 本身的 SWR key 只是 /v1/me,libs/api/client.ts 也沒有 identity key 邏輯。需要隔離的 feature hook 或元件
自行把 ${tenant.id}:${account.id} 放進 SWR key 或 React key(例如 features/forms/api.ts、features/hr-employees/api.ts、
hooks/api/useIamCatalogApi.ts),核心 IAM 列表 hooks 與 useGetMenus 沒有這樣做;登出由 libs/auth.ts 整頁跳轉。
這只防止畫面快取串租戶,不能替代 backend 的 identity、authz 或 RLS。
開發步驟
- 先確認用例是否真的跨 tenant;正常業務讀寫使用一個 tenant transaction,需要逐租戶的背景工作用上面的列舉入口加逐租戶
WithinTenant。 - 在 migration 新增
tenant_id NOT NULL REFERENCES tenants(id)、UNIQUE (tenant_id, id)與複合 FK;ENABLE與FORCE ROW LEVEL SECURITY;恰好一條tenant_isolation_<table>policy;逐表把需要的命令 GRANT 給nexus_api_access/nexus_worker_access(沒有 GRANT,runtime role 就沒有該表權限);非空的COMMENT ON TABLE(tests/contract/schema_comment_lint_test.go);最後把新檔的 SHA-256 追加到db/migrations/FROZEN.sha256(tests/contract/migrations_contract_test.go)。 - 在 db/queries 的每個讀寫操作明確使用 tenant 交易提供的 context;不要把 raw pgx.Tx 向上傳。
- 透過 Repository interface 將
UnitOfWork.WithinTenant接到 service;跨域副作用改用 outbox/job,跨域同步唯讀走DetachTenantTransaction的 port。 - 對 NULL、空 GUC、錯租戶 ID、INSERT mismatch 與 runtime role 做負向測試;沿用
tests/integration/postgres/rls_test.go與各域*_rls_test.go的寫法。 - 新增前端 hook 時,以
tenant.id:account.id等 identity key 隔離 cache,身份變更時清除 local draft。
預期結果與驗證
預期結果是:同一 tenant 的合法請求可讀寫;沒有 GUC 時查詢不回傳租戶資料;tenant A 的 transaction
不能讀/改 tenant B;INSERT 的 row tenant 與 GUC 不一致時被拒絕;runtime role 沒有 BYPASSRLS
(production-like 或 DB_REQUIRE_SAFE_ROLE=true 時由啟動檢查強制)。
建議驗證矩陣:
| 案例 | 應觀察的結果 |
|---|---|
| tenant A 讀自己的附件/通知 | 只看見 A 的 rows |
| tenant A 讀 tenant B 的 UUID | 空結果或既定 not-found,不洩漏存在性 |
| transaction 未設定 GUC | 0 rows 或 policy reject,不是全表 |
| INSERT 帶錯 tenant | WITH CHECK reject |
worker 在 app.iam_reclaim scope 交易內查業務表 | 0 rows,只看得到 tenants 列 |
| service 需要跨域副作用 | 本域提交後由 outbox/worker 處理 |
| 前端切換 identity | 舊 tenant 的 SWR rows 不再顯示 |
現有證據:tests/integration/postgres/rls_test.go 涵蓋租戶隔離與 FORCE、巢狀交易拒絕、回收連線無 GUC 回 0 行、
缺 NULLIF 的 22P02 反例、缺 FORCE 時 owner 繞過的反例與 runtime role 安全檢查;另有 identity_rls_test.go、
authz_rls_test.go、outbox_rls_test.go、audit_rls_test.go、agent_rls_test.go,schema 形狀由
tests/contract/schema_lint_test.go 守門。
本文只列出檔案與驗證方式;本輪沒有執行 PostgreSQL integration、E2E 或部署資料庫驗收。
常見錯誤
- 用
WithGlobalTx(或任何未設app.tenant_id的交易)讀業務資料:FORCE RLS 下只會靜默得到 0 行,看起來像「沒有資料」。真正會打開跨租戶可見性的是按角色或 GUC 放寬的 policy(如USING (true)),新增這類 policy 必須經架構審查。 - 只在 service 加 tenant filter,卻沒有 migration policy 或 FORCE。
- 把 current_setting(..., true) 的空值當成某個預設 tenant。
- 在 migration 用 UPDATE/DELETE 回填既有列;
nexus_migration沒有 BYPASSRLS,會靜默影響 0 列。 - 讓前端送 tenant_id 並直接信任,或以 URL path 決定授權租戶。
- 在一個 transaction 內同步呼叫另一個 domain service,破壞一個 transaction 不跨域;跨域唯讀改用
DetachTenantTransaction另開交易。 - 看到 application data scope = tenant 就以為可跳過資料庫隔離;data scope 只回答可見範圍。
- 以為本機 runtime role 一定安全:
DB_REQUIRE_SAFE_ROLE預設 false,development 只記 warning。 - 把本地單元測試通過寫成 RLS 已通過;SQL、role 與 migration 仍需整合驗證。
相關文件
- 技術架構:資料事實來源、分層與 outbox 邊界。
- Repository 與資料存取:migration、sqlc、mapper 與 tenant transaction。
- 身分、IAM 與權限目錄:認證身分、data scope 與 field policy。
- 附件與物件儲存:完整 RLS 業務表與 owner 授權示例。
- 通知中心與投遞:recipient tenant isolation 與 worker transaction。
- 外部系統與平台適配層:Keycloak 使用者的 tenant 屬性,也就是
tenant_idclaim 的來源。