跳转到内容

多租户(x/tenant)

本指南展示如何使用 x/tenant 为 Plumego 服务添加多租户隔离。你将从每个请求中解析租户身份、在中间件层执行策略,并在 handler 和存储调用中读取租户 ID。

边界说明见 x/tenant Primer

x/tenant 是 experimental 且高风险的扩展家族,因为租户错误可能直接演变成隔离失败。租户 wiring 必须保持显式,并在 resolution、policy、quota、session 错误上 fail closed;在把它当作冻结的生产契约前,请先查看发布策略扩展成熟度

什么时候用 x/tenant,什么时候直接读 header

Section titled “什么时候用 x/tenant,什么时候直接读 header”

如果你只需要从 header 中提取租户标识符并传给存储查询,可以直接在应用代码中完成,无需 x/tenant

tenantID := r.Header.Get("X-Tenant-ID")
rows, err := db.QueryContext(ctx, db, "SELECT * FROM items WHERE tenant_id = $1", tenantID)

以下场景才需要 x/tenant

  • 强制执行、fail-closed 的租户解析(拒绝无租户的请求)
  • 策略评估(每租户 feature flag、模型访问、工具白名单)
  • Retry-After 反馈的每租户配额执行
  • JWT 支持的租户会话生命周期

在应用的中间件栈中添加 resolve.Middleware,放在 auth 之后以确保 JWT principal 已在 context 中:

import (
authmw "github.com/spcent/plumego/middleware/auth"
resolve "github.com/spcent/plumego/x/tenant/resolve"
"github.com/spcent/plumego/security/jwt"
)
// auth 先运行,JWT principal 才会可用
authMw, err := authmw.Authenticate(jwtManager.Authenticator(jwt.TokenTypeAccess))
if err != nil {
return err
}
app.Use(authMw)
// 解析器先从 principal 提取租户 ID,再回退到 X-Tenant-ID header
app.Use(resolve.Middleware(resolve.Options{
AllowMissing: false, // 拒绝无租户 ID 的请求(fail-closed)
}))

解析顺序:

  1. context 中 authn.Principal.TenantID(由 JWT 中间件设置)
  2. 自定义 Extractor 函数(如已配置)
  3. X-Tenant-ID HTTP header
app.Use(resolve.Middleware(resolve.Options{
Extractor: func(r *http.Request) (string, error) {
// 示例:从子域名提取 tenant.example.com
host := r.Host
parts := strings.SplitN(host, ".", 2)
if len(parts) < 2 {
return "", nil
}
return parts[0], nil
},
AllowMissing: false,
}))

第二步 — 在 handler 中读取租户 ID

Section titled “第二步 — 在 handler 中读取租户 ID”
import tenantcore "github.com/spcent/plumego/x/tenant/core"
func (h ItemHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID := tenantcore.TenantIDFromContext(r.Context())
items, err := h.db.QueryContext(r.Context(),
"SELECT id, name FROM items WHERE tenant_id = $1", tenantID)
// ...
}

第三步 — 执行租户策略(可选)

Section titled “第三步 — 执行租户策略(可选)”

实现 tenantcore.PolicyEvaluator 并挂载 policy.Middleware

import (
"github.com/spcent/plumego/x/tenant/policy"
tenantcore "github.com/spcent/plumego/x/tenant/core"
)
type MyPolicyEvaluator struct {
db *sql.DB
}
func (e *MyPolicyEvaluator) Evaluate(ctx context.Context, tenantID string, req tenantcore.PolicyRequest) (tenantcore.PolicyResult, error) {
// 在数据库中查找租户能力
allowed, err := e.isAllowed(ctx, tenantID, req.Path, req.Method)
if err != nil {
return tenantcore.PolicyResult{}, err
}
return tenantcore.PolicyResult{Allowed: allowed}, nil
}
app.Use(policy.Middleware(policy.Options{
Evaluator: &MyPolicyEvaluator{db: sqlDB},
}))

第四步 — 执行每租户配额(可选)

Section titled “第四步 — 执行每租户配额(可选)”

实现 tenantcore.QuotaManager 并挂载 quota.Middleware

import (
"github.com/spcent/plumego/x/tenant/quota"
tenantcore "github.com/spcent/plumego/x/tenant/core"
)
app.Use(quota.Middleware(quota.Options{
Manager: myQuotaManager, // 你的 tenantcore.QuotaManager 实现
Estimator: func(r *http.Request) int {
// 从请求估算 token/请求消耗
return 1
},
}))

租户配额耗尽时,中间件返回 429,并附带 Retry-AfterX-Quota-Remaining: 0

在 auth 之后、路由 handler 之前挂载租户中间件:

app.Use(requestid.Middleware())
recoveryMw, err := recovery.Middleware(recovery.Config{Logger: app.Logger()})
if err != nil {
return err
}
accesslogMw, err := accesslog.Middleware(accesslog.Config{Logger: app.Logger()})
if err != nil {
return err
}
app.Use(recoveryMw)
app.Use(accesslogMw)
authMw, err := authmw.Authenticate(authenticator)
if err != nil {
return err
}
app.Use(authMw) // auth 在租户之前
app.Use(resolve.Middleware(resolveOpts)) // 租户解析
app.Use(policy.Middleware(policyOpts)) // 策略在解析之后
app.Use(quota.Middleware(quotaOpts)) // 配额在解析之后