学习目标
学完本章,你应该能够:
- 说清「集团 → 子公司 → 团队 → 项目」四级组织架构在 PaaS 平台里如何映射为租户与 Namespace。
- 独立设计一套「Namespace 级 + API 级 + 数据级」三层 RBAC 模型,并落到 K8s Role/RoleBinding。
- 把企业既有 LDAP / 企业微信 / OAuth2 身份源接入平台 SSO,并实现账号生命周期同步。
- 配置权限继承、委派与定期复审,确保「最小权限」原则长期不被腐化。
- 解释租户权限标签如何注入监控指标,实现联邦 Prometheus 下的多租户数据隔离与审计留痕。
前置知识:
- 理解 Kubernetes Namespace、Role、RoleBinding、ServiceAccount 基本概念。
- 了解 RBAC(Role-Based Access Control)与「最小权限原则」。
- 知道 SSO、LDAP、OAuth2、SAML 的大致含义(不需要会部署)。
- 看过本白皮书《〇、全局基础架构约束》:多集群经统一 K8s API 网关纳管、租户 Namespace 强隔离、联邦 Prometheus + Thanos。
本章你会动手做的事:
- 用平台控制台新建一个子公司租户,并自动生成其专属 Namespace 与配额。
- 给一名开发同学分配「只读 + 仅自己项目」的细粒度角色,验证其越权访问被拒。
- 在 Grafana 里用
tenant=标签过滤,确认你只能看到自己租户的指标曲线。
一、模块概述与企业商用价值
类比:租户隔离就像一栋写字楼按公司隔出独立办公室。每间办公室有独立门锁(Namespace 配额与网络策略),独立电表(指标带 tenant 标签),物业(平台)看不到租户内部文件,但能按办公室统计水电。组织权限中心,就是这栋楼的「门禁 + 人事系统」:谁属于哪家公司、能开哪扇门、做了什么都要登记在册。
在金融、政企客户的多租户场景下,一套 PaaS 平台往往同时承载十几个子公司、上百个团队。如果没有统一的权限中心,就会上演「A 公司误删 B 公司数据库」「审计时发现操作人无法溯源」的事故。本模块是企业自研 PaaS 的信任基石——它回答三个问题:你是谁(身份)、你能干什么(授权)、你干了什么(审计)。
适用角色:平台管理员(建租户/配角色)、安全合规员(审权限/查审计)、子公司管理员(管自己团队)、普通开发者(用被分配的权限)。
企业商用价值:
- 强隔离合规:租户间资源、网络、数据、监控四重隔离,满足等保/银保监对租户隔离的硬性要求。
- 统一身份:对接客户既有 4A/AD/LDAP,避免重复建账号、账号流失难回收。
- 可审计:所有授权与操作留痕 90 天以上,监管检查可一键导出。
- 不可绕过的审批:高危操作必须走审批流,权限中心与审批引擎联动,授权即放行。
下面这张图说明权限中心在整套 PaaS 中的位置:它上接身份源,下接 K8s API 网关与统一监控,横向联动审批与审计。
graph TD
A[身份源
LDAP / 企业微信 / OAuth2] --> B[租户与组织权限中心]
B --> C[统一 K8s API 网关]
C --> D[生产集群 Namespace]
C --> E[测试集群 Namespace]
B --> F[审批工作流引擎]
B --> G[审计日志中心 90天+]
B --> H[联邦 Prometheus 指标打标]
H --> I[(Thanos 长期存储)]二、细分功能详解(商用生产级)
本模块功能分【基础能力】与【高级企业增值能力】两层。带「★禁止删减」的项为金融/政企交付红线,缺失即不达标。
【基础能力】
① 多租户组织架构(集团 → 子公司 → 团队 → 项目)
平台以「组织树」建模企业结构。集团是根节点,子公司是租户(Tenant),团队是租户下的子分组,项目最终映射为一个或多个 K8s Namespace。租户在平台上建模为自定义资源 Tenant(CRD tenants.paas.io),其 spec.env 决定命名域与物理集群路由。
| 层级 | 平台概念 | K8s 映射 | 说明 |
|---|---|---|---|
| L0 | 集团(Platform) | 平台全局元数据 | 跨租户管理与计量 |
| L1 | 子公司(Tenant)★禁止删减 | 一组 Namespace + 配额 | 租户强隔离边界 |
| L2 | 团队(Team) | Namespace 内 RBAC 分组 | 团队内协作 |
| L3 | 项目(Project) | 单个 Namespace | 部署单元 |
⚠️ 租户(Tenant)边界一旦创建就禁止用配置把两个租户的 Namespace 放进同一网络平面或共享 Secret。这是★禁止删减项,违反等于击穿隔离。
下面给出 Tenant 的 CRD 定义(平台侧用 DynamicClient 操作,与 Go 代码字段自洽):
# file: deploy/crd-tenant.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: tenants.paas.io
spec:
group: paas.io
names:
kind: Tenant
listKind: TenantList
plural: tenants
singular: tenant
scope: Cluster
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: ["env"]
properties:
env:
type: string
description: "production / staging / test / dev"
quota:
type: object
properties:
cpu:
type: string
memory:
type: string
namespaceCount:
type: integer
minimum: 1
status:
type: object
properties:
phase:
type: string
subresources:
status: {}
② RBAC 三层权限模型(★禁止删减:最小权限 / 审计留痕)
- Namespace 级:控制对哪些 Namespace 的哪些资源(Pod/Deployment/Secret)有何种动词(get/list/watch/create/delete)。
- API 级:控制对平台 OpenAPI(如「扩缩容」「发布」)的调用权,独立于 K8s 动词。
- 数据级:通过标签选择器约束可见数据范围,例如
project=order的开发者只能看自己项目的指标与日志。
下列清单给出「项目级只读 Role + 租户管理员 ClusterRole + RoleBinding」完整落地(可 kubectl apply):
# file: deploy/tenant-rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: order-readonly
namespace: subcompany-a-order
labels:
tenant: subcompany-a
project: order
rules:
- apiGroups: [""]
resources: ["pods", "configmaps", "services"]
verbs: ["get", "list", "watch"]
---
# 集群级只读 ClusterRole(供租户管理员查看节点/PV 等共享资源)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: tenant-cluster-readonly
labels:
paas.io/managed: "true"
rules:
- apiGroups: [""]
resources: ["nodes", "persistentvolumes"]
verbs: ["get", "list", "watch"]
---
# 把项目只读 Role 绑定到开发者用户(数据级:仅 project=order 的 namespace)
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: order-ro-dev-lisi
namespace: subcompany-a-order
labels:
tenant: subcompany-a
subjects:
- kind: User
name: dev-lisi
apiGroup: rbac.authorization.k8s.io
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: order-readonly
③ SSO / LDAP / 企业微信 / OAuth2 集成
支持对接客户既有身份源,账号开通/停用与 HR 系统同步,离职即冻结。平台签发 HMAC 令牌(生产替换为 OIDC ID Token),所有 API 必经鉴权中间件。
【高级企业增值能力】
④ 权限继承与委派、权限定期复审
- 子公司管理员可将权限委派给团队管理员(Delegation),但委派范围不得超过自身授权(不能「授权超出自己拥有的权限」)。
- 平台强制每季度权限复审:系统列出长期未使用、权限过大的账号,推送给租户管理员确认回收。
⑤ 操作审计留痕(★禁止删减)
所有「授权变更 / 角色分配 / 委派」动作写入审计日志,字段含:操作人、被操作对象、动作、租户、时间、来源 IP、审批单号。留存 ≥ 90 天,支持按租户/时间/操作人检索与导出。
下面用功能架构图展示模块内部能力组成与分类。
graph LR
A[身份集成层
LDAP/OAuth2/企业微信] --> B[组织树管理
集团-子公司-团队-项目]
B --> C[RBAC 引擎
Namespace/API/数据三级]
C --> D[委派与继承]
C --> E[权限复审调度]
C --> F[审计留痕 90天+]
G[审批流接入] --> C
H[指标标签注入] --> C三、底层架构联动设计
1. 与多 K8s 集群交互:权限如何落到 Namespace
用户在平台侧的「角色分配」不会直接改 K8s,而是经统一 K8s API 网关收敛后,由平台控制面在目标集群生成对应的 Role/RoleBinding。这样多套物理隔离集群无需每个都手动配 RBAC,且网关层可做统一鉴权与限流。
项目结构与文件清单(本章起逐个文件完整展示,全部端到端自洽):
paas-tenant-rbac/ ├── deploy/ │ ├── crd-tenant.yaml # Tenant CRD(平台侧 DynamicClient 操作对象) │ ├── tenant-namespace.yaml # Namespace + tenant/env 标签 │ ├── tenant-rbac.yaml # Role/RoleBinding/ClusterRole │ ├── tenant-networkpolicy.yaml# 默认 Deny-All 网络策略 │ └── tenant-quota.yaml # ResourceQuota + LimitRange ├── platform/ │ ├── main.go # gin 服务:租户/角色 REST API │ ├── go.mod # 模块与依赖 │ ├── auth/sso.go # SSO 令牌签发与鉴权中间件 │ └── k8s/ │ ├── registry.go # rest.Config + 多集群路由(网关下发) │ ├── tenant.go # Tenant→Namespace/Quota/NetPol 编排 │ ├── rbac.go # Role/RoleBinding/ClusterRole 增删改查 │ ├── metrics.go # Prometheus 指标(与规则名一致) │ └── audit.go # 审计落库(≥90 天) ├── observability/ │ ├── prometheus-federation.yaml # 中心联邦拉取 │ ├── prometheus-agent-scrape.yaml# 集群 Agent relabel 注入标签 │ ├── recording-rules.yaml │ ├── alert-rules.yaml │ └── alertmanager.yaml └── runbook/ └── tenant-ops.sh # kubectl/helm/shell 运维命令
registry.go 是「Go 调用 K8s」的核心:展示 rest.Config 的三种来源(in-cluster / kubeconfig / 网关下发的多集群配置)、kubernetes.NewForConfig 与 dynamic.NewForConfig,以及按租户 env 路由到对应物理集群的网关逻辑。
// file: platform/k8s/registry.go
package k8s
import (
"fmt"
"strings"
"sync"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
// ClusterEntry 保存网关下发的单集群连接(来自统一 K8s API 网关)。
// 实际生产中 Config 由网关从 KMS 信封加密托管的 kubeconfig 在内存解密注入,
// 客户端不读取明文 kubeconfig。
type ClusterEntry struct {
ClusterID string
Env string
Config *rest.Config
Client kubernetes.Interface
Dyn dynamic.Interface
}
type ClusterRegistry struct {
mu sync.RWMutex
clusters map[string]*ClusterEntry
}
// NewClusterRegistry 从网关下发的多集群 kubeconfig 初始化所有集群连接。
// kubeconfigPath 为空时使用 in-cluster 配置(中心控制面运行于网关 Pod 内)。
func NewClusterRegistry(kubeconfigPath string) (*ClusterRegistry, error) {
reg := &ClusterRegistry{clusters: make(map[string]*ClusterEntry)}
var baseCfg *rest.Config
var err error
if kubeconfigPath == "" {
baseCfg, err = rest.InClusterConfig()
} else {
loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
loadingRules.ExplicitPath = kubeconfigPath
baseCfg, err = clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
loadingRules, &clientcmd.ConfigOverrides{}).ClientConfig()
}
if err != nil {
return nil, fmt.Errorf("build base rest.Config: %w", err)
}
rawCfg, err := clientcmd.LoadFromFile(kubeconfigPath)
if err != nil {
return nil, fmt.Errorf("load kubeconfig: %w", err)
}
for name, ctx := range rawCfg.Contexts {
cfg := rest.CopyConfig(baseCfg)
cfg.Host = rawCfg.Clusters[ctx.Cluster].Server
// 真实场景注入 CA + 客户端证书;此处复用 baseCfg 的 TLS。
cli, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, fmt.Errorf("new clientset for %s: %w", name, err)
}
dyn, err := dynamic.NewForConfig(cfg)
if err != nil {
return nil, fmt.Errorf("new dynamic client for %s: %w", name, err)
}
reg.clusters[name] = &ClusterEntry{
ClusterID: name,
Env: envFromContextName(name),
Config: cfg,
Client: cli,
Dyn: dyn,
}
}
return reg, nil
}
// envFromContextName 由网关下发的 context 名推导环境(生产实践中来自平台 CMDB 元数据)。
func envFromContextName(name string) string {
switch {
case strings.Contains(name, "prod"):
return "production"
case strings.Contains(name, "staging"):
return "staging"
case strings.Contains(name, "test"):
return "test"
default:
return "dev"
}
}
// ClientAndDynForTenant 按租户环境路由到对应物理集群(生产租户只能命中 production 集群,跨环境默认拒绝)。
func (r *ClusterRegistry) ClientAndDynForTenant(tenant, env string) (kubernetes.Interface, dynamic.Interface, error) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, c := range r.clusters {
if c.Env == env {
return c.Client, c.Dyn, nil
}
}
return nil, nil, fmt.Errorf("no cluster routed for tenant=%s env=%s", tenant, env)
}
// ClientForCluster 按集群 ID 取连接(排障/跨集群查询用)。
func (r *ClusterRegistry) ClientForCluster(id string) (*ClusterEntry, error) {
r.mu.RLock()
defer r.mu.RUnlock()
c, ok := r.clusters[id]
if !ok {
return nil, fmt.Errorf("cluster %s not registered", id)
}
return c, nil
}
tenant.go 演示 DynamicClient 操作 Tenant CR + Clientset 落地 Namespace/ResourceQuota/LimitRange/NetworkPolicy,字段与上面 CRD 完全一致:
// file: platform/k8s/tenant.go
package k8s
import (
"context"
"fmt"
corev1 "k8s.io/api/core/v1"
netv1 "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/api/resource"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
)
var tenantGVR = schema.GroupVersionResource{Group: "paas.io", Version: "v1", Resource: "tenants"}
func mustQ(s string) resource.Quantity {
q, _ := resource.ParseQuantity(s)
return q
}
// ReconcileTenant 在目标集群创建 Tenant CR + Namespace + 配额 + 默认 Deny-All 网络策略。
func ReconcileTenant(ctx context.Context, cli kubernetes.Interface, dyn dynamic.Interface,
tenant, env, cpu, mem string, nsCount int) error {
// 1) 平台侧 CRD 落一条 Tenant 记录(DynamicClient 演示)
t := &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "paas.io/v1",
"kind": "Tenant",
"metadata": map[string]interface{}{
"name": tenant,
"labels": map[string]interface{}{"tenant": tenant, "env": env},
},
"spec": map[string]interface{}{
"env": env,
"quota": map[string]interface{}{
"cpu": cpu,
"memory": mem,
"namespaceCount": nsCount,
},
},
"status": map[string]interface{}{"phase": "Provisioning"},
}}
if _, err := dyn.Resource(tenantGVR).Create(ctx, t, metav1.CreateOptions{}); err != nil {
return fmt.Errorf("create Tenant CR: %w", err)
}
// 2) 创建租户 Namespace(带 tenant/env 标签,用于指标隔离)
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: tenant,
Labels: map[string]string{"tenant": tenant, "env": env},
},
}
if _, err := cli.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}); err != nil {
return fmt.Errorf("create namespace: %w", err)
}
// 3) 配额 + 限额范围 + 默认拒绝网络策略
if err := applyResourceQuota(ctx, cli, tenant, cpu, mem); err != nil {
return err
}
if err := applyLimitRange(ctx, cli, tenant); err != nil {
return err
}
if err := applyDefaultDenyNetworkPolicy(ctx, cli, tenant); err != nil {
return err
}
return nil
}
func applyResourceQuota(ctx context.Context, cli kubernetes.Interface, ns, cpu, mem string) error {
q := &corev1.ResourceQuota{
ObjectMeta: metav1.ObjectMeta{
Name: "tenant-quota", Namespace: ns,
Labels: map[string]string{"tenant": ns},
},
Spec: corev1.ResourceQuotaSpec{
Hard: corev1.ResourceList{
corev1.ResourceRequestsCPU: mustQ(cpu),
corev1.ResourceRequestsMemory: mustQ(mem),
},
},
}
_, err := cli.CoreV1().ResourceQuotas(ns).Create(ctx, q, metav1.CreateOptions{})
return err
}
func applyLimitRange(ctx context.Context, cli kubernetes.Interface, ns string) error {
lr := &corev1.LimitRange{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-limitrange", Namespace: ns},
Spec: corev1.LimitRangeSpec{
Limits: []corev1.LimitRangeItem{
{
Type: corev1.LimitTypeContainer,
Default: corev1.ResourceList{
corev1.ResourceCPU: mustQ("500m"),
corev1.ResourceMemory: mustQ("512Mi"),
},
DefaultRequest: corev1.ResourceList{
corev1.ResourceCPU: mustQ("100m"),
corev1.ResourceMemory: mustQ("128Mi"),
},
Max: corev1.ResourceList{
corev1.ResourceCPU: mustQ("4"),
corev1.ResourceMemory: mustQ("4Gi"),
},
},
},
},
}
_, err := cli.CoreV1().LimitRanges(ns).Create(ctx, lr, metav1.CreateOptions{})
return err
}
func applyDefaultDenyNetworkPolicy(ctx context.Context, cli kubernetes.Interface, ns string) error {
ingress := netv1.PolicyTypeIngress
egress := netv1.PolicyTypeEgress
np := &netv1.NetworkPolicy{
ObjectMeta: metav1.ObjectMeta{
Name: "default-deny-all", Namespace: ns,
Labels: map[string]string{"tenant": ns},
},
Spec: netv1.NetworkPolicySpec{
PodSelector: metav1.LabelSelector{},
PolicyTypes: []netv1.PolicyType{ingress, egress},
},
}
_, err := cli.NetworkingV1().NetworkPolicies(ns).Create(ctx, np, metav1.CreateOptions{})
return err
}
rbac.go 演示 Role/RoleBinding/ClusterRole 的增删改查(Clientset):
// file: platform/k8s/rbac.go
package k8s
import (
"context"
"fmt"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)
// CreateProjectReadonlyRole 创建「仅 project=<project> 只读」的 Role(数据级用标签选择器约束)。
func CreateProjectReadonlyRole(ctx context.Context, cli kubernetes.Interface,
namespace, project, roleName string) error {
role := &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Name: roleName,
Namespace: namespace,
Labels: map[string]string{"tenant": namespace, "project": project},
},
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{""},
Resources: []string{"pods", "configmaps", "services"},
Verbs: []string{"get", "list", "watch"},
},
},
}
_, err := cli.RbacV1().Roles(namespace).Create(ctx, role, metav1.CreateOptions{})
if err != nil {
return fmt.Errorf("create role: %w", err)
}
return nil
}
// BindRoleToUser 把 Role 绑定到用户(或 ServiceAccount)。
func BindRoleToUser(ctx context.Context, cli kubernetes.Interface,
namespace, roleName, user, bindingName string) error {
rb := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{Name: bindingName, Namespace: namespace},
Subjects: []rbacv1.Subject{
{Kind: "User", Name: user, APIGroup: "rbac.authorization.k8s.io"},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: roleName,
},
}
_, err := cli.RbacV1().RoleBindings(namespace).Create(ctx, rb, metav1.CreateOptions{})
if err != nil {
return fmt.Errorf("create rolebinding: %w", err)
}
return nil
}
// CreateClusterReadOnlyRole 创建集群级 ClusterRole(租户管理员查看节点/PV)。
func CreateClusterReadOnlyRole(ctx context.Context, cli kubernetes.Interface, name string) error {
cr := &rbacv1.ClusterRole{
ObjectMeta: metav1.ObjectMeta{Name: name, Labels: map[string]string{"paas.io/managed": "true"}},
Rules: []rbacv1.PolicyRule{
{APIGroups: []string{""}, Resources: []string{"nodes", "persistentvolumes"}, Verbs: []string{"get", "list", "watch"}},
},
}
_, err := cli.RbacV1().ClusterRoles().Create(ctx, cr, metav1.CreateOptions{})
if err != nil {
return fmt.Errorf("create clusterrole: %w", err)
}
return nil
}
metrics.go 暴露的指标名必须与 Prometheus 规则完全一致(详见第六章);audit.go 落库审计(≥90 天留存):
// file: platform/k8s/metrics.go
package k8s
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
rbacRoleBindingTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "paas_rbac_rolebinding_total",
Help: "租户 RoleBinding 创建总数(标签需与 Prometheus 规则一致)",
},
[]string{"tenant", "env", "app"},
)
tenantProvisionSeconds = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "paas_tenant_provision_duration_seconds",
Help: "租户 Namespace/配额下发耗时",
Buckets: prometheus.DefBuckets,
},
[]string{"tenant", "env"},
)
rbacSyncErrorsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "paas_rbac_sync_errors_total",
Help: "RBAC 经网关同步失败次数",
},
[]string{"tenant", "cluster"},
)
)
func IncRoleBinding(tenant, env, app string) {
rbacRoleBindingTotal.WithLabelValues(tenant, env, app).Inc()
}
func ObserveProvision(tenant, env string, secs float64) {
tenantProvisionSeconds.WithLabelValues(tenant, env).Observe(secs)
}
func IncSyncError(tenant, cluster string) {
rbacSyncErrorsTotal.WithLabelValues(tenant, cluster).Inc()
}
// file: platform/k8s/audit.go
package k8s
import (
"context"
"encoding/json"
"os"
"sync"
"time"
)
// AuditRecord 审计记录(字段含操作人/对象/动作/租户/时间/来源IP/审批单号)。
type AuditRecord struct {
Timestamp time.Time `json:"ts"`
Operator string `json:"operator"`
Action string `json:"action"`
Tenant string `json:"tenant"`
Target string `json:"target"`
SrcIP string `json:"src_ip"`
Ticket string `json:"ticket"`
}
// AuditSink 审计落库(演示写 JSONL 文件,生产写入 Elasticsearch/对象存储,留存 ≥90 天)。
type AuditSink struct {
mu sync.Mutex
path string
}
func NewAuditSink(path string) *AuditSink { return &AuditSink{path: path} }
func (a *AuditSink) Write(ctx context.Context, rec AuditRecord) error {
rec.Timestamp = time.Now()
b, err := json.Marshal(rec)
if err != nil {
return err
}
a.mu.Lock()
defer a.mu.Unlock()
f, err := os.OpenFile(a.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write(append(b, '\n'))
return err
}
auth/sso.go 签发与校验令牌(生产替换为 OIDC):
// file: platform/auth/sso.go
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// IssueToken 为通过 SSO/LDAP 校验的用户签发平台令牌(演示:HMAC 签名 tenant|user|ts)。
// 生产应换成 OIDC ID Token / 企业微信 code2session,并带短过期与吊销。
func IssueToken(tenant, user, secret string) string {
ts := time.Now().Unix()
body := tenant + "|" + user + "|" + strconv.FormatInt(ts, 10)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(body))
return body + "|" + hex.EncodeToString(mac.Sum(nil))
}
// AuthMiddleware 校验 Authorization: Bearer <token>,并把 tenant/user 注入上下文。
func AuthMiddleware(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
h := c.GetHeader("Authorization")
parts := strings.SplitN(h, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
return
}
fields := strings.Split(parts[1], "|")
if len(fields) != 4 {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "bad token"})
return
}
body := fields[0] + "|" + fields[1] + "|" + fields[2]
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(body))
if hex.EncodeToString(mac.Sum(nil)) != fields[3] {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid signature"})
return
}
c.Set("tenant", fields[0])
c.Set("user", fields[1])
c.Next()
}
}
// file: platform/go.mod
module paas.example.com
go 1.22
require (
github.com/gin-gonic/gin v1.9.1
github.com/prometheus/client_golang v1.18.0
k8s.io/api v0.29.0
k8s.io/apimachinery v0.29.0
k8s.io/client-go v0.29.0
)
权限中心经网关下发 RBAC 的时序:
sequenceDiagram
participant U as 用户/SSO
participant P as 权限中心
participant G as 统一 K8s API 网关
participant K as 目标集群 apiserver
U->>P: 登录并请求分配角色
P->>P: 校验最小权限与委派范围
P->>G: 下发 RoleBinding 创建指令
G->>G: 鉴权 + 限流 + 审计埋点
G->>K: 在租户 Namespace 创建 RoleBinding
K-->>G: 确认
G-->>P: 成功
P->>P: 写审计日志2. 与联邦 Prometheus 监控联动:权限标签注入指标
租户、角色、项目等元数据在指标采集时被注入为标签(含 tenant/app/env/cluster)。联邦架构下,各集群 Prometheus Agent 采集本地指标,中心 Prometheus 通过 federation 拉取,Thanos 做长期存储。Grafana 按登录用户的 tenant 标签过滤大盘,实现「看得到自己的、看不到别人的」。Go 上报的 paas_rbac_rolebinding_total{tenant,env,app} 经 Agent relabel 补 cluster 后汇入联邦视图。
graph TD
A[集群A Prometheus Agent] -->|federation 拉取| C[中心 Prometheus]
B[集群B Prometheus Agent] -->|federation 拉取| C
C --> D[Thanos Query]
D --> E[(对象存储 长期)]
D --> F[Grafana 多租户大盘]
F -->|按 tenant 标签过滤| G[租户A 视图]
F -->|按 tenant 标签过滤| H[租户B 视图]
I[权限中心注入 tenant/app/env/cluster 标签] --> A
I --> B3. 与 RBAC / 审批 / 审计的联动
- 审批联动:删除 Namespace、提权等高敏操作,权限中心先拦截,触发审批流;审批通过才在网关侧真正下发。
- 审计联动:每次授权变更都带审批单号写审计,形成「申请→审批→执行→留痕」闭环,不可绕过。
四、端到端标准操作流程
下面分别给三类角色的分步操作,并显式区分【测试环境】与【生产环境】差异。所有 K8s 操作均经统一 K8s API 网关,平台代码即第三章的 ReconcileTenant / rbac.go。
角色一:平台管理员 —— 新建子公司租户并分配租户管理员
租户创建由平台 REST API 触发(main.go 的 POST /api/v1/tenants),后端按 env 经 ClientAndDynForTenant 路由到目标物理集群:
// file: platform/main.go
package main
import (
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp"
"paas.example.com/platform/auth"
"paas.example.com/platform/k8s"
)
func main() {
reg, err := k8s.NewClusterRegistry("/etc/paas/gateway-kubeconfig")
if err != nil {
log.Fatalf("init cluster registry: %v", err)
}
audit := k8s.NewAuditSink("/var/log/paas/audit.jsonl")
r := gin.Default()
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
r.Use(auth.AuthMiddleware("change-me-in-prod"))
// 新建租户(平台管理员)
r.POST("/api/v1/tenants", func(c *gin.Context) {
var req struct {
Name string `json:"name"`
Env string `json:"env"`
CPU string `json:"cpu"`
Mem string `json:"memory"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 按租户 env 路由到对应物理集群(网关多集群路由)
cli, dyn, err := reg.ClientAndDynForTenant(req.Name, req.Env)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
start := time.Now()
if err := k8s.ReconcileTenant(c.Request.Context(), cli, dyn, req.Name, req.Env, req.CPU, req.Mem, 20); err != nil {
k8s.IncSyncError(req.Name, req.Env)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
k8s.ObserveProvision(req.Name, req.Env, time.Since(start).Seconds())
k8s.IncRoleBinding(req.Name, req.Env, "tenant-provisioner")
_ = audit.Write(c.Request.Context(), k8s.AuditRecord{
Operator: c.GetString("user"), Action: "create-tenant",
Tenant: req.Name, Target: "namespace/" + req.Name,
SrcIP: c.ClientIP(), Ticket: "AUTO-" + req.Name,
})
c.JSON(http.StatusOK, gin.H{"status": "provisioned"})
})
// 给项目分配只读角色(子公司管理员)
r.POST("/api/v1/projects/:ns/readonly", func(c *gin.Context) {
ns := c.Param("ns")
var req struct {
User string `json:"user"`
Project string `json:"project"`
Env string `json:"env"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
cli, _, err := reg.ClientAndDynForTenant(c.GetString("tenant"), req.Env)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
role := "order-readonly"
if err := k8s.CreateProjectReadonlyRole(c.Request.Context(), cli, ns, req.Project, role); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := k8s.BindRoleToUser(c.Request.Context(), cli, ns, role, req.User, "ro-"+req.User); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
k8s.IncRoleBinding(ns, c.GetString("tenant"), req.Project)
_ = audit.Write(c.Request.Context(), k8s.AuditRecord{
Operator: c.GetString("user"), Action: "grant-readonly",
Tenant: ns, Target: "rolebinding/ro-" + req.User,
SrcIP: c.ClientIP(), Ticket: "AUTO",
})
c.JSON(http.StatusOK, gin.H{"status": "bound"})
})
log.Println("tenant & org RBAC platform listening on :8080")
if err := r.Run(":8080"); err != nil {
log.Fatal(err)
}
}
对应的静态清单(供 helm/kubectl 幂等落地与排障对照):
# file: deploy/tenant-namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: subcompany-a
labels:
tenant: subcompany-a
env: production
istio-injection: enabled
# file: deploy/tenant-quota.yaml
apiVersion: v1
kind: ResourceQuota
metadata:
name: tenant-quota
namespace: subcompany-a
labels:
tenant: subcompany-a
spec:
hard:
requests.cpu: "200"
requests.memory: 800Gi
limits.cpu: "400"
limits.memory: 1600Gi
pods: "200"
---
apiVersion: v1
kind: LimitRange
metadata:
name: tenant-limitrange
namespace: subcompany-a
spec:
limits:
- type: Container
default:
cpu: 500m
memory: 512Mi
defaultRequest:
cpu: 100m
memory: 128Mi
max:
cpu: 4
memory: 4Gi
# 步骤 2:平台自动在纳管的生产集群创建专属 Namespace(管理员无需手敲 kubectl)
# 等价 REST 调用(token 由 SSO 签发)
curl -X POST https://paas-gateway/api/v1/tenants \
-H "Authorization: Bearer $(paas-cli token issue --user zhangsan --tenant platform)" \
-d '{"name":"subcompany-a","env":"production","cpu":"200","memory":"800Gi"}'
# 步骤 3:分配租户管理员角色(Namespace 级 + API 级)
paas-cli role assign --user zhangsan --role tenant-admin --tenant subcompany-a
⚠️ 【生产环境】创建生产租户必须关联预算配额与合规基线检查,且创建动作本身需二级审批;【测试环境】可由子公司管理员自助创建、免审批、配额宽松(见第五章对照表)。
角色二:子公司管理员 —— 给开发配「只读 + 仅自己项目」角色
# 步骤 1:用平台 API 分配(等效于 kubectl apply -f deploy/tenant-rbac.yaml)
curl -X POST https://paas-gateway/api/v1/projects/subcompany-a-order/readonly \
-H "Authorization: Bearer $TOKEN" \
-d '{"user":"dev-lisi","project":"order","env":"production"}'
# 步骤 2(排障):直接核对 RoleBinding 是否落到正确 namespace
kubectl --context prod-cluster-01 -n subcompany-a-order get rolebinding order-ro-dev-lisi -o yaml
角色三:安全合规员 —— 季度权限复审
【测试环境】复审可一键自动回收闲置账号;【生产环境】自动回收前需租户管理员确认,且回收动作写入审计并通知企业微信(见 runbook/tenant-ops.sh)。
下面是端到端流程的时序图,涵盖从申请到审计的闭环。
sequenceDiagram
participant D as 开发者
participant A as 子公司管理员
participant P as 权限中心
participant W as 审批流
participant G as K8s API 网关
participant L as 审计中心
D->>A: 申请 project=order 只读权限
A->>P: 提交授权申请
P->>W: 触发审批(生产需二级)
W-->>P: 审批通过
P->>G: 下发 RoleBinding
G-->>P: 成功
P->>L: 写审计(含审批单号)五、生产环境管控与安全约束
核心原则:默认最小权限、敏感操作必审批、授权必留痕、跨租户零信任。
测试环境 vs 生产环境 差异化管控对照表
| 管控项 | 测试环境 | 生产环境 |
|---|---|---|
| 租户创建 | 子公司管理员自助,免审批 | 平台管理员操作,二级审批 |
| 配额 | 宽松,可超额预警不阻断 | 硬上限,超预算需审批扩容 |
| 提权/删 Namespace | 可直接执行 | 强制多级审批 + 二次认证 |
| 权限复审 | 自动回收闲置账号 | 自动列出,回收需租户管理员确认 |
| 审计留存 | ≥ 90 天 | ≥ 90 天(监管可追溯导出) |
| 跨租户访问 | 默认拒绝,测试可临时放通 | 默认拒绝,放通需安全合规审批 |
| SSO 强制 | 建议开启 | 强制开启,禁止本地弱口令 |
| 指标可见性 | 可按需跨租户排障 | 严格按 tenant 标签隔离 |
- 资源配额:租户级配额在权限中心定义,网关侧转为 ResourceQuota + LimitRange 下发到 Namespace,超出即调度失败。
- 权限隔离:跨租户网络策略默认 Deny-All;数据级通过指标
tenant标签与 Grafana 文件夹权限双重隔离。
默认 Deny-All 网络策略清单(可独立 apply 校验,与 tenant.go 内 applyDefaultDenyNetworkPolicy 一致):
# file: deploy/tenant-networkpolicy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: subcompany-a
labels:
tenant: subcompany-a
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
# 仅放通 DNS 出向(否则 Pod 无法解析服务发现)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: subcompany-a
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
- 敏感配置加密:权限中心托管的客户端密钥、OAuth2 client secret 经 KMS 信封加密落库,明文不落盘。
- 故障熔断:当某租户 RBAC 同步异常导致网关鉴权雪崩时,网关进入「拒绝未知、放行已授权缓存」的降级模式,并告警(对应
paas_rbac_sync_errors_total告警)。
六、常见生产故障与解决方案
监控端配置自洽:下列 prometheus-agent-scrape.yaml 的 relabel 注入 tenant/app/env/cluster 标签,与 Go metrics.go 暴露的 paas_rbac_rolebinding_total{tenant,env,app} 及 cluster(联邦 external_labels)组合,Grafana 据此过滤。
# file: observability/prometheus-agent-scrape.yaml
# 各物理集群内的 Prometheus Agent:采集本集群指标,并强制注入 tenant/app/env/cluster 标签
global:
scrape_interval: 30s
external_labels:
cluster: prod-cluster-01
scrape_configs:
- job_name: kube-pods
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_namespace]
target_label: namespace
- source_labels: [__meta_kubernetes_pod_label_app]
target_label: app
# 命名空间名 == tenant,平台保证一致
- source_labels: [__meta_kubernetes_namespace]
target_label: tenant
- target_label: env
replacement: production
- target_label: cluster
replacement: prod-cluster-01
# file: observability/prometheus-federation.yaml
# 中心 Prometheus 联邦拉取各集群 Agent,honor_labels 保留 tenant/env/cluster 等源标签
global:
scrape_interval: 30s
scrape_configs:
- job_name: federate-prod
honor_labels: true
metrics_path: /federate
params:
match[]:
- '{__name__=~"paas_.*|kube_.*"}'
static_configs:
- targets: ['prom-agent-prod.paas-monitoring.svc:9090']
- job_name: federate-test
honor_labels: true
metrics_path: /federate
params:
match[]:
- '{__name__=~"paas_.*|kube_.*"}'
static_configs:
- targets: ['prom-agent-test.paas-monitoring.svc:9090']
# file: observability/recording-rules.yaml
groups:
- name: paas_tenant_rbac_recording
rules:
- record: paas:rbac:rolebinding_count_by_tenant
expr: sum by (tenant, cluster) (paas_rbac_rolebinding_total)
- record: paas:tenant:provision_p99_seconds
expr: histogram_quantile(0.99, sum by (tenant, le) (rate(paas_tenant_provision_duration_seconds_bucket[5m])))
# file: observability/alert-rules.yaml
groups:
- name: paas_tenant_rbac_alerts
rules:
- alert: TenantRBACSyncFailed
expr: increase(paas_rbac_sync_errors_total[5m]) > 0
for: 2m
labels:
severity: critical
team: paas-platform
annotations:
summary: "租户 {{ $labels.tenant }} 在集群 {{ $labels.cluster }} RBAC 同步失败"
description: "5 分钟内出现 RBAC 经网关同步失败,请检查 K8s API 网关鉴权。"
- alert: TenantQuotaNearLimit
expr: |
kube_resourcequota{resource="requests.cpu", type="used"}
/ kube_resourcequota{resource="requests.cpu", type="hard"} > 0.9
for: 10m
labels:
severity: warning
annotations:
summary: "租户 {{ $labels.tenant }} CPU 配额使用超过 90%"
# file: observability/alertmanager.yaml
global:
resolve_timeout: 5m
route:
receiver: default
group_by: ['cluster', 'env', 'tenant']
routes:
- matchers: ['env="production"']
receiver: prod-oncall
continue: true
- matchers: ['severity="critical"']
receiver: crit-page
receivers:
- name: default
webhook_configs:
- url: http://paas-notify.svc/api/v1/alert
- name: prod-oncall
webhook_configs:
- url: http://paas-notify.svc/api/v1/wechat
- name: crit-page
pagerduty_configs:
- routing_key: ${PAGERDUTY_KEY}
故障 1:开发者抱怨「看不到自己项目的 Pod」
- 排查:先确认 RoleBinding 的
namespace与project标签是否匹配;再查 Grafana 是否因tenant标签缺失被过滤掉。 - 优化:权限中心在分配时强制校验项目标签一致性,避免人为配错。
# file: runbook/tenant-ops.sh(节选:故障1 排查)
kubectl --context prod-cluster-01 -n subcompany-a-order get rolebinding -l tenant=subcompany-a -o wide
kubectl --context prod-cluster-01 auth can-i get pods -n subcompany-a-order --as=dev-lisi
# 期望 yes;跨 namespace 应 no
故障 2:离职员工账号仍保留生产权限
- 排查:检查 LDAP/HR 同步是否中断;查审计确认最后一次权限变更时间。
- 优化:开启「账号停用即冻结」+ 季度复审自动回收,双保险。
# 查某用户全部 RoleBinding(跨租户审查)
kubectl --context prod-cluster-01 get rolebindings -A -o json \
| jq -r '.items[] | select(.subjects[]?.name=="dev-lisi") | .metadata.namespace + "/" + .metadata.name'
# 查看审计中该用户最近动作
grep '"operator":"dev-lisi"' /var/log/paas/audit.jsonl | tail -5
故障 3:跨租户误访问(A 看到 B 指标)
- 排查:查中心 Prometheus 的
tenant标签是否被正确注入;查 Grafana 数据源代理是否绕过了标签过滤。 - 优化:联邦采集端强制注入标签,Grafana 前端按登录态附加
tenant过滤器,后端再校验一次。
# 校验联邦视图里 tenant 标签是否存在
curl -s 'http://prom-center:9090/api/v1/query?query=paas_rbac_rolebinding_total' \
| jq '.data.result[].metric.tenant'
完整运维 runbook(runbook/tenant-ops.sh)覆盖章节四 SOP 与本章排查:
#!/usr/bin/env bash
# file: runbook/tenant-ops.sh
set -euo pipefail
# ---- 章节四 SOP:平台管理员创建租户 ----
paas-cli tenant create --name subcompany-a --env production
# 校验 Namespace 与标签、配额已自动生成
kubectl --context prod-cluster-01 get ns subcompany-a --show-labels
kubectl --context prod-cluster-01 get resourcequota,limitrange -n subcompany-a
# ---- 子公司管理员分配「仅 project=order 只读」角色 ----
kubectl --context prod-cluster-01 apply -f deploy/tenant-rbac.yaml
kubectl --context prod-cluster-01 create rolebinding order-ro \
--role=order-readonly --user=dev-lisi -n subcompany-a-order
# ---- 验证越权被拒(使用只读用户身份)----
kubectl --context prod-cluster-01 auth can-i delete pods -n subcompany-a-order --as=dev-lisi
# 期望:no
# ---- 安全合规员:季度复审回收闲置账号(生产需人工确认)----
paas-cli rbac review --env production --auto-recycle=false
# ---- 查询审计(≥90 天留存)----
grep '"action":"grant-readonly"' /var/log/paas/audit.jsonl | tail -5
# ---- Grafana 多租户过滤:仅看自己 tenant ----
# PromQL: paas_rbac_rolebinding_total{tenant="subcompany-a"}
# ---- 故障3:校验联邦指标 tenant 标签 ----
curl -s 'http://prom-center:9090/api/v1/query?query=paas_rbac_rolebinding_total' \
| jq '.data.result[].metric.tenant'
故障排查统一流程如下:
flowchart TD
A[收到权限/可见性异常] --> B{是授权问题?}
B -->|是| C[查 RoleBinding 与标签匹配]
B -->|否| D{是监控可见性问题?}
D -->|是| E[查 tenant 标签注入与 Grafana 过滤]
D -->|否| F[查 SSO 同步与审计日志]
C --> G[修正绑定并重发网关]
E --> G
F --> G
G --> H[回归验证 + 写故障单]自测题与动手练习
5 道自测题
- 租户(Tenant)在 K8s 中映射为什么对象?它和团队、项目的关系是什么?
- RBAC 三层模型指哪三层?数据级权限靠什么机制实现?
- 为什么权限中心的授权变更必须写审计日志且留存 ≥ 90 天?
- 联邦 Prometheus 下,如何实现「租户 A 看不到租户 B 的指标」?
- 生产环境删除 Namespace 与测试环境有何管控差异?
3 个动手练习
- 在测试环境用
paas-cli建一个子公司租户,观察其 Namespace 与 ResourceQuota 是否自动生成(对照deploy/tenant-quota.yaml)。 - 定义一个「仅
project=order只读」的 Role(deploy/tenant-rbac.yaml),绑定给测试账号,验证其访问其他项目被拒(kubectl auth can-i)。 - 在 Grafana 用
tenant=你的租户过滤,截图对比开启/关闭过滤时的曲线差异(指标paas_rbac_rolebinding_total)。
本章小结
- 权限中心是 PaaS 的信任基石:解决「身份、授权、审计」三件事。
- 组织树(集团→子公司→团队→项目)映射为租户与 Namespace,租户强隔离是★禁止删减红线。
- RBAC 三层(Namespace/API/数据)落地最小权限;SSO 对接客户既有身份源;
TenantCRD 由 DynamicClient 操作。 - 授权经统一 K8s API 网关下发(Go 用
rest.Config+Clientset/DynamicClient按租户 env 路由到物理集群),指标注入tenant标签实现联邦下的数据隔离。 - Go 上报指标
paas_rbac_rolebinding_total/paas_rbac_sync_errors_total与 Prometheus 联邦、recording/alert 规则名完全一致。 - 一切敏感操作走审批、留审计,测试与生产差异化管控。
下一章我们将进入《多 K8s 集群纳管模块》,看看这些 Namespace 与配额是运行在哪些物理隔离集群之上、平台又是如何把它们统一收敛纳管的。