搜尋開發指南

試試「多租戶」、「啟動」或「outbox」。
搜尋僅使用本站文件,不會傳送至外部服務。

文件目錄

NEXUSPRO / HANDBOOK

租戶上下文與 PostgreSQL RLS

追蹤 tenant identity 從 HTTP request 進入 transaction、GUC 與 Row-Level Security 的完整路徑。

內容核對 2026-09-28·圖文指南

本章目標與前置條件

本章說明「請求屬於哪個 tenant」如何一路傳到 PostgreSQL,以及為什麼應用層條件不能取代 RLS。 前置條件是能閱讀 Go transaction、PostgreSQL policy 與前端同源 request;不需要先啟動資料庫。

核心概念

三層租戶上下文

  1. Verified identity:租戶來自 access token 的 tenant_id claim(值來自開通時寫入的 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)。
  2. Application context:Gin handler 以 request.TenantID 呼叫 service;service 只把 tenant ID 交給 repository.UnitOfWork[T].WithinTenant,且不得 import internal/platform(tests/contract/import_boundaries_test.go)。
  3. Database context:internal/repository/postgres 透過 internal/platform/postgres 的 WithTenantTx,在 transaction 內設定 transaction-local 的 app.tenant_id GUC,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、執行:

sql
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 同時設定:

sql
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_slugnexus_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 tenantinternal/api/v1/middleware.go、internal/service/identity/resolver.go、internal/platform/keycloak/verifier.go、internal/api/v1/context.go已實作(原始碼層級)
transaction-local GUCinternal/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 scopeinternal/authz decision 與 repository filter是應用授權,不取代 RLS
前端 identity key各 feature 自行把 tenant 與 account ID 放進 key(例如 features/forms/api.ts、hooks/api/useIamCatalogApi.ts)feature 級約定,不是全域機制;不提供安全保證

附件與通知都各自展示了 tenant policy;它們不是「特殊例外」,而是新增表的應遵循形狀。

實際請求鏈

text
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 mapping

useGetMe 本身的 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。

開發步驟

  1. 先確認用例是否真的跨 tenant;正常業務讀寫使用一個 tenant transaction,需要逐租戶的背景工作用上面的列舉入口加逐租戶 WithinTenant。
  2. 在 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)。
  3. 在 db/queries 的每個讀寫操作明確使用 tenant 交易提供的 context;不要把 raw pgx.Tx 向上傳。
  4. 透過 Repository interface 將 UnitOfWork.WithinTenant 接到 service;跨域副作用改用 outbox/job,跨域同步唯讀走 DetachTenantTransaction 的 port。
  5. 對 NULL、空 GUC、錯租戶 ID、INSERT mismatch 與 runtime role 做負向測試;沿用 tests/integration/postgres/rls_test.go 與各域 *_rls_test.go 的寫法。
  6. 新增前端 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 未設定 GUC0 rows 或 policy reject,不是全表
INSERT 帶錯 tenantWITH 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 仍需整合驗證。

相關文件

內容來源與核實範圍

以下路徑相對於所列業務倉庫;核實層級為「已讀原始碼」,不是本輪業務測試或部署驗收。

  • nexus-pro-be-plus/internal/api/v1/routes.go
  • nexus-pro-be-plus/internal/api/v1/middleware.go
  • nexus-pro-be-plus/internal/api/v1/context.go
  • nexus-pro-be-plus/internal/service/identity/resolver.go
  • nexus-pro-be-plus/internal/platform/keycloak/verifier.go
  • nexus-pro-be-plus/internal/repository/uow.go
  • nexus-pro-be-plus/internal/platform/postgres/tx.go
  • nexus-pro-be-plus/internal/platform/postgres/role.go
  • nexus-pro-be-plus/internal/platform/postgres/catalog_reconcile.go
  • nexus-pro-be-plus/internal/platform/postgres/iam_reclaim.go
  • nexus-pro-be-plus/internal/platform/postgres/tenant_bootstrap.go
  • nexus-pro-be-plus/internal/config/keys_core.go
  • nexus-pro-be-plus/cmd/db-provision/provision.go
  • nexus-pro-be-plus/docker-compose.yml
  • nexus-pro-be-plus/db/CONVENTIONS.md
  • nexus-pro-be-plus/db/migrations/000001_baseline.sql
  • nexus-pro-be-plus/db/migrations/000003_outbox_events.sql
  • nexus-pro-be-plus/db/migrations/000008_tenant_slug_bootstrap.sql
  • nexus-pro-be-plus/db/migrations/000012_catalog_reconcile_scope.sql
  • nexus-pro-be-plus/db/migrations/000016_iam_reclaim_scope.sql
  • nexus-pro-be-plus/db/migrations/000031_attachments.sql
  • nexus-pro-be-plus/db/migrations/000045_notifications.sql
  • nexus-pro-be-plus/tests/contract/schema_lint_test.go
  • nexus-pro-be-plus/tests/contract/schema_comment_lint_test.go
  • nexus-pro-be-plus/tests/contract/migrations_contract_test.go
  • nexus-pro-be-plus/tests/integration/postgres/rls_test.go
  • nexus-pro-be-plus/docs/infra/03-database.md
  • nexus-pro-be-plus/docs/infra/08-tenancy-identity.md
  • nexus-pro-web-plus/proxy.ts
  • nexus-pro-web-plus/hooks/api/useMeApi.ts
  • nexus-pro-web-plus/libs/api/client.ts
  • nexus-pro-web-plus/features/forms/api.ts
NexusPro 開發指南以程式碼為準 · 以驗證為據