学习目标
本篇讲透企业自研 PaaS 平台的「移动端轻量化控制台」——它是运维与审批在手机上的安全延伸。学完本篇,你应该能够:
- 说明移动端为什么默认只读,以及「只读默认」如何在架构层面被强制执行。
- 配置移动端受限 RBAC 角色,使其只能对接统一 K8s API 网关的只读/受限接口。
- 在手机上完成审批待办、告警认领/升级、应用状态查看等核心操作。
- 理解敏感操作(如生产发布确认)如何触发二次认证,且无法在移动端直接执行高危动作。
- 把移动端的操作、告警、推送指标接入 Prometheus 联邦,并保证审计留痕 90 天+。
前置知识:
- 已了解 _SHARED.md 全局约束:多套物理隔离 K8s 集群、统一 K8s API 网关、Namespace 租户隔离;Prometheus 联邦 + Thanos、指标带
tenant/app/env/cluster标签;RBAC 细粒度、审计 90 天+、敏感配置加密、镜像扫描。 - 已读模块 11:移动审批是审批工作流引擎在手机端的入口,移动端不直接签发执行凭证。
- 了解 Grafana 移动视图、企业微信/钉钉推送、设备绑定与 TOTP/短信二次认证基础。
本章你会动手做的事:
- 在手机上用受限 RBAC 角色登录,确认你只能看到本租户 Namespace 的只读看板。
- 收到一条生产告警推送,在移动端完成「认领」并升级给值班。
- 尝试在移动端直接点「执行发布」,验证被拦截并要求跳转二次认证/桌面端完成。
项目结构与文件清单
本篇所有代码自洽:Go 引用的字段名、指标名、RBAC 资源名与下方 YAML / Prometheus 配置完全一致。下文各章会逐个文件完整展示,此处先给出目录树(根目录 module-12-mobile-console/)。
module-12-mobile-console/
├── go.mod
├── deploy/
│ ├── mobile-gateway-deployment.yaml # ① 移动网关 Deployment(接入层 + 受限 RBAC SA)
│ ├── mobile-ingress.yaml # ② mTLS / Ingress(仅放行业务域名)
│ ├── mobile-rbac.yaml # ③ 受限 RBAC Role(只读 + 审批 + 告警认领)
│ ├── device-binding-configmap.yaml # ④ 设备绑定 ConfigMap(账号↔设备指纹)
│ └── prometheus-rules.yaml # ⑤ 移动 API 错误率/认领时延 告警规则
├── config/
│ └── servicemonitor.yaml # 联邦抓取配置(带 tenant 标签)
├── internal/
│ ├── metrics/metrics.go # Prometheus 指标(tenant/app/env/cluster/endpoint/code)
│ ├── audit/audit.go # 操作审计上报(带 device_id)
│ ├── auth/device.go # 设备绑定校验(读 ConfigMap)
│ ├── auth/twofa.go # TOTP 二次认证
│ ├── k8s/readonly.go # client-go 经网关只读查询
│ ├── middleware/auth.go # JWT + 移动受限 RBAC + 设备绑定 中间件
│ ├── api/server.go # gin 路由:看板/审批/告警/敏感操作
│ └── gateway/token.go # 向统一 K8s API 网关申请执行凭证(短时)
├── cmd/mobile/main.go # 移动网关启动入口
└── runbook.md # 运维 SOP:设备绑定/二次认证/只读验证
约定:所有 Go 文件头标注
// file: <相对路径>,YAML 文件头标注# file: <相对路径>。指标统一携带tenant/app/env/cluster四维标签;统一 K8s API 网关地址由环境变量K8S_GATEWAY_URL注入。移动端不持有任何集群 kubeconfig,只读查询与短时凭证均经网关。
一、模块概述与企业商用价值
类比:移动端控制台就像核电站控制室的「观察窗 + 对讲机」,而不是操作台。你能在手机上清楚看到反应堆温度(监控看板)、收到警报并呼叫工程师(告警认领/升级)、用对讲机批准一项作业(移动审批),但你不能在手机上直接拉下控制棒(高危执行)。真要操作,必须回到有双因子物理钥匙的主控台。这就是「只读默认 + 敏感操作二次认证」的设计哲学。
在金融/政企场景,运维 7×24 待命,但生产环境不可能允许任何人随时随地用手机改集群。移动端轻量化控制台的价值是:让关键人员随时可见、可批、可认领,但把「能改生产」的口子牢牢收在桌面端 + 二次认证 + 审批闸门之后。
适用角色与痛点:
- 值班运维 / SRE:手机收告警、认领、升级,不必随身带笔记本也能先响应。
- 租户管理员 / 研发负责人:手机上批发布工单、看应用状态,差旅中也不阻塞业务。
- 安全合规团队:移动端所有动作必须可审计、设备可绑定、敏感操作强制二次认证。
平台不可替代性:移动端不是 Web 控制台的简单缩放,而是独立受限通道——它只对接 API 网关的「移动受限角色」接口子集,天然拿不到写权限入口。
下图说明移动端在整个 PaaS 平台中的定位:它位于审批引擎、联邦监控、IM 告警与受限 API 网关之间。
graph LR A[运维 / 租户管理员 手机] -->|移动受限 RBAC| B((移动端控制台)) B -->|只读看板 + 认领| C[联邦监控 Grafana 移动视图] B -->|移动审批待办| D[审批工作流引擎] B -->|告警认领/升级| E[Alertmanager + IM] B -->|受限调用| F[统一 K8s API 网关
移动受限角色] F --> G[生产 / 预发布 / 测试 物理隔离集群] B -->|操作指标| H[Prometheus 联邦] B -->|全动作| I[审计留痕 90天+]
这张图说明移动端是「只读 + 审批 + 告警」的安全延伸,所有写操作都需回到网关受限接口并二次认证。
1.1 落地形态:受限通道 + 网关代理
移动端 App 只与「移动网关接入层」通信;接入层用受限 RBAC 角色(见 deploy/mobile-rbac.yaml)仅能调用网关的只读/审批/告警子集。任何写请求要么被网关角色 403,要么触发二次认证并转桌面端最终执行。下图为代码视角链路。
graph TD P[手机 App] -->|HTTPS mTLS| GW[移动网关接入层 gin] GW -->|JWT 校验 + 设备绑定| MID[auth 中间件] MID -->|只读| RO[k8s/readonly.go 经网关查询] MID -->|预批| AP[api/server.go 调模块11引擎] MID -->|敏感操作| TOTP[auth/twofa.go] TOTP -->|短时凭证| G[统一 K8s API 网关
移动受限角色] G -->|只读/受限| C[物理隔离集群] GW -->|每动作| AUD[audit.go 带 device_id] GW -->|指标| PROM[Prometheus 联邦]
二、细分功能详解(商用生产级)
下表区分【基础能力】与【高级企业增值能力】,标注「★禁止删减」的是强制项,删除即不合格。
| 类别 | 功能 | 说明 | 关键约束 |
|---|---|---|---|
| 【基础能力】 | ①移动审批 | 待办/已办/转交,对接模块 11 引擎 | ★审计 |
| 【基础能力】 | ②监控看板 | 只读为主,Grafana 移动视图按租户过滤 | ★只读默认 |
| 【基础能力】 | ③告警接收/认领/升级 | 推送 + 认领 + 升级值班 | ★审计 |
| 【基础能力】 | ④应用状态查看 | 部署/副本/健康只读呈现 | ★只读默认 |
| 【高级企业增值能力】 | ⑤受限操作 | 敏感操作需二次认证,且大多跳转桌面端执行 | ★敏感操作二次认证 |
| 【高级企业增值能力】 | ⑥离线推送通知 | APNs/厂商通道离线推送待办与告警 | 内容脱敏 |
| 【高级企业增值能力】 | ⑦安全 | 设备绑定 + 最小权限 + 越狱/root 检测 | ★只读默认+★审计 |
分级列表补充细节,标注禁止删减项:
移动审批 ★禁止删减:审计
- 待办列表来自审批工作流引擎;支持转交(仅限同角色范围内)、催办查看。
- 普通变更可在移动端完成审批;生产高危变更在移动端仅能「预批」,最终需跳转桌面端二次认证页签发执行凭证(与模块 11 一致)。
- 每次审批动作入审计(operator、ticket_id、device_id、timestamp)。
监控看板(只读) ★禁止删减:只读默认
- 默认视角为 Grafana 移动只读视图,数据来自联邦 Prometheus + Thanos,按
tenant/env过滤。 - 看板只暴露查询接口,无任何写入口;即便用户尝试调用写 API,网关移动受限角色也拒绝。
- 支持应用、集群、中间件实例三级下钻,但仅读。
- 默认视角为 Grafana 移动只读视图,数据来自联邦 Prometheus + Thanos,按
告警接收 / 认领 / 升级
- Alertmanager 推送经 IM(企业微信/钉钉)到手机;移动端可一键认领,认领后其他值班可见。
- 升级:认领人可把告警升级给上级或转派;升级动作记审计。
- 静默(silence)等写操作限制在桌面端,移动端仅能「申请静默」走审批。
应用状态查看 ★禁止删减:只读默认
- 展示副本数、就绪、近期重启、最近一次发布(来自模块 03),全部只读。
- 异常状态以颜色/红点提示,点击跳转到联邦监控详情。
受限操作(敏感操作二次认证) ★禁止删减:敏感操作二次认证
- 移动端不提供「直接执行」高危动作的按钮。
- 若业务确需移动端发起(如紧急回滚确认),触发 TOTP/短信二次认证,且仅签发短时执行凭证并强制走审批工单。
- 二次认证失败或设备未绑定一律拒绝。
离线推送通知
- 通过 APNs / 厂商通道下发待办与告警,即便 App 未前台运行也能触达。
- 推送 payload 脱敏,不含密钥、完整 YAML 等敏感内容。
安全(设备绑定 / 最小权限) ★禁止删减:只读默认 + 审计
- 设备绑定:账号与设备指纹绑定,新设备登录需管理员/审批放行。
- 最小权限:移动端角色仅为「只读 + 审批 + 告警认领」,不含任何集群写权限。
- 越狱/root 设备禁止登录或仅允许只读。
2.1 IaC:移动网关 Deployment(接入层 + 受限 SA)
# file: deploy/mobile-gateway-deployment.yaml —— 移动网关以 mobile-gateway SA 运行,挂载网关 CA 与自身 TLS 证书(mTLS 入站),只读查询经网关。注意:容器本身无任何集群写权限,写权限完全在网关侧受限角色。
# file: deploy/mobile-gateway-deployment.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: mobile-gateway
namespace: paas-mobile
labels:
tenant: platform
app: mobile-console
env: prod
cluster: paas-control
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mobile-gateway
namespace: paas-mobile
labels:
tenant: platform
app: mobile-console
env: prod
cluster: paas-control
spec:
replicas: 3
selector:
matchLabels:
app: mobile-console
template:
metadata:
labels:
app: mobile-console
tenant: platform
env: prod
cluster: paas-control
spec:
serviceAccountName: mobile-gateway
containers:
- name: gateway
image: registry.paas.internal/mobile-gateway:v2.7.1 # 镜像已安全扫描
ports:
- containerPort: 8443
name: https
- containerPort: 2112
name: metrics
env:
- name: K8S_GATEWAY_URL
value: "https://k8s-gateway.paas.internal"
- name: GATEWAY_CA
valueFrom:
secretKeyRef:
name: mobile-gateway-tls
key: ca.crt
- name: MOBILE_TLS_CERT
valueFrom:
secretKeyRef:
name: mobile-gateway-tls
key: tls.crt
- name: MOBILE_TLS_KEY
valueFrom:
secretKeyRef:
name: mobile-gateway-tls
key: tls.key
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: mobile-jwt
key: secret # 敏感配置加密(KMS 信封)
- name: TOTP_ISSUER
value: "PaaS-Mobile"
readinessProbe:
httpGet:
path: /healthz
port: 8443
scheme: HTTPS
initialDelaySeconds: 5
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
volumeMounts:
- name: device-binding
mountPath: /etc/device-binding
readOnly: true
volumes:
- name: device-binding
configMap:
name: device-bindings # 见 deploy/device-binding-configmap.yaml
---
apiVersion: v1
kind: Service
metadata:
name: mobile-gateway
namespace: paas-mobile
labels:
tenant: platform
app: mobile-console
env: prod
cluster: paas-control
spec:
selector:
app: mobile-console
ports:
- name: https
port: 443
targetPort: 8443
- name: metrics
port: 2112
targetPort: 2112
2.2 IaC:mTLS / Ingress
# file: deploy/mobile-ingress.yaml —— 入站强制 mTLS(仅放行已签发客户端证书的移动 App),并限制仅业务域名可达。可结合 cert-manager 自动续期。
# file: deploy/mobile-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: mobile-gateway-ingress
namespace: paas-mobile
labels:
tenant: platform
app: mobile-console
env: prod
cluster: paas-control
annotations:
# 仅示例:ingress-nginx 开启双向 TLS,强制客户端证书
nginx.ingress.kubernetes.io/auth-tls-verify-client: "on"
nginx.ingress.kubernetes.io/auth-tls-secret: "paas-mobile/mobile-client-ca"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
spec:
ingressClassName: nginx
tls:
- hosts:
- mobile.paas.internal
secretName: mobile-gateway-tls
rules:
- host: mobile.paas.internal
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: mobile-gateway
port:
number: 443
2.3 IaC:移动端受限 RBAC Role
# file: deploy/mobile-rbac.yaml —— 移动端角色出厂即只读 + 审批回调 + 告警认领,不含任何集群写权限。即使黑客拿到移动令牌,也只能在网关侧做这些动作。
# file: deploy/mobile-rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: mobile-readonly-approval
namespace: paas-mobile
labels:
tenant: platform
app: mobile-console
env: prod
cluster: paas-control
rules:
# 只读:仅能读取联邦监控/应用状态相关的 ConfigMap 与自身工单
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list"]
- apiGroups: ["approval.paas.example.com"]
resources: ["approvaltickets"]
verbs: ["get", "list"] # 只看工单状态,不能改(改由 Webhook 接收器经审批流)
# 告警认领:仅能 patch alertmanager 的 ack 注解(经网关封装的受限接口)
- apiGroups: ["alertmanager" ]
resources: ["silences"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: mobile-readonly-approval
namespace: paas-mobile
labels:
tenant: platform
app: mobile-console
env: prod
cluster: paas-control
subjects:
- kind: ServiceAccount
name: mobile-gateway
namespace: paas-mobile
roleRef:
kind: Role
name: mobile-readonly-approval
apiGroup: rbac.authorization.k8s.io
2.4 IaC:设备绑定 ConfigMap
# file: deploy/device-binding-configmap.yaml —— 账号与设备指纹的绑定表(生产应存外部 KMS/加密存储,此处用 ConfigMap 演示读取逻辑,见 internal/auth/device.go)。
# file: deploy/device-binding-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: device-bindings
namespace: paas-mobile
labels:
tenant: platform
app: mobile-console
env: prod
cluster: paas-control
data:
# key=用户名, value=已绑定设备指纹(逗号分隔);新设备需走绑定审批
"alice@tenant-a": "fp:9f3c:aa11:tenant-a-phone"
"bob@tenant-a": "fp:2b7e:cc44:tenant-a-tablet"
"carol@platform": "fp:5d01:ee88:platform-phone"
功能架构图如下。
graph TD A[移动 App
设备绑定] --> B[移动网关接入层
受限 RBAC] B --> C[只读看板服务
Grafana 移动视图] B --> D[移动审批服务
对接引擎] B --> E[告警认领服务
Alertmanager] B --> F[二次认证服务
TOTP/短信] C -->|查询| G[联邦监控只读接口] D -->|待办| H[审批工作流引擎] E -->|认领/升级| I[IM + 值班] F -->|签发短时凭证| J[统一 K8s API 网关
移动受限角色] B --> K[审计留痕 90天+] J --> L[物理隔离集群]
这张功能架构图说明:移动端所有能力都收敛在「移动网关接入层 + 受限 RBAC」之后,写操作必须经二次认证与网关受限角色。
三、底层架构联动设计
3.1 Prometheus 指标(带 tenant/app/env/cluster)
// file: internal/metrics/metrics.go —— 移动端指标统一四维标签 + endpoint/code,便于联邦按租户过滤与 Thanos 长期存储。
// file: internal/metrics/metrics.go
package metrics
import "github.com/prometheus/client_golang/prometheus"
// 全局统一四维标签
var LabelKeys = []string{"tenant", "app", "env", "cluster"}
var (
// 看板只读查询量
MobileDashboardQueryTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "mobile_dashboard_query_total",
Help: "Mobile read-only dashboard queries by endpoint.",
}, append([]string{"endpoint"}, LabelKeys...))
// 告警认领时延(认领动作计时)
MobileAlertAckSeconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "mobile_alert_ack_seconds",
Help: "Seconds from alert push to mobile ack.",
Buckets: []float64{10, 30, 60, 120, 300, 600},
}, append([]string{"alert_id"}, LabelKeys...))
// 移动审批动作量
MobileApprovalActionTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "mobile_approval_action_total",
Help: "Mobile approval actions by decision.",
}, append([]string{"decision"}, LabelKeys...))
// 设备绑定数(新增/拒绝)
MobileDeviceBindTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "mobile_device_bind_total",
Help: "Device binding events by result.",
}, append([]string{"result"}, LabelKeys...))
// 移动 API 错误率(带 code,用于告警)
MobileAPIErrorTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "mobile_api_error_total",
Help: "Mobile API errors by endpoint and code.",
}, append([]string{"endpoint", "code"}, LabelKeys...))
)
func init() {
prometheus.MustRegister(
MobileDashboardQueryTotal,
MobileAlertAckSeconds,
MobileApprovalActionTotal,
MobileDeviceBindTotal,
MobileAPIErrorTotal,
)
}
3.2 操作审计上报(带 device_id)
// file: internal/audit/audit.go —— 移动端每次登录/查询/审批/认领/二次认证都上报审计(含 device_id),留存 90 天+,WORM 防篡改。此处复用追加写 + 哈希链模型,并额外带 device_id。
// file: internal/audit/audit.go
package audit
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"sync"
"time"
)
// Event 移动端审计事件,必含 device_id。
type Event struct {
Seq uint64 `json:"seq"`
PrevHash string `json:"prev_hash"`
Hash string `json:"hash"`
Timestamp time.Time `json:"timestamp"`
Operator string `json:"operator"`
Tenant string `json:"tenant"`
Env string `json:"env"`
Cluster string `json:"cluster"`
DeviceID string `json:"device_id"` // 移动端强制
Action string `json:"action"` // login/query/approval/ack/sensitive_2fa
Endpoint string `json:"endpoint,omitempty"`
Decision string `json:"decision,omitempty"`
Detail string `json:"detail,omitempty"`
}
// Reporter 审计上报(追加写 + 哈希链)。
type Reporter struct {
mu sync.Mutex
w *bufio.Writer
f *os.File
prevHash string
seq uint64
}
func NewReporter(path string) (*Reporter, error) {
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return nil, err
}
prev := ""
var last Event
sc := bufio.NewScanner(f)
for sc.Scan() {
var e Event
if json.Unmarshal(sc.Bytes(), &e) == nil {
last = e
}
}
if last.Hash != "" {
prev = last.Hash
}
if _, err := f.Seek(0, 2); err != nil {
return nil, err
}
return &Reporter{w: bufio.NewWriter(f), f: f, prevHash: prev, seq: last.Seq}, nil
}
// Report 追加一条审计并返回 hash。
func (r *Reporter) Report(e Event) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.seq++
e.Seq = r.seq
e.PrevHash = r.prevHash
b, _ := json.Marshal(struct {
Operator string `json:"operator"`
Tenant string `json:"tenant"`
Env string `json:"env"`
Cluster string `json:"cluster"`
DeviceID string `json:"device_id"`
Action string `json:"action"`
}{e.Operator, e.Tenant, e.Env, e.Cluster, e.DeviceID, e.Action})
sum := sha256.Sum256(append([]byte(r.prevHash), b...))
e.Hash = hex.EncodeToString(sum[:])
line, _ := json.Marshal(e)
if _, err := r.w.Write(append(line, '\n')); err != nil {
return "", err
}
if err := r.w.Flush(); err != nil {
return "", err
}
r.prevHash = e.Hash
return e.Hash, nil
}
func (r *Reporter) Close() error { return r.f.Close() }
// VerifyChain 校验整条链未被篡改。
func VerifyChain(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
sc := bufio.NewScanner(f)
var prev string
var seq uint64
for sc.Scan() {
var e Event
if err := json.Unmarshal(sc.Bytes(), &e); err != nil {
return fmt.Errorf("bad line: %w", err)
}
if e.Seq != seq+1 {
return fmt.Errorf("seq gap at %d", e.Seq)
}
seq = e.Seq
if e.PrevHash != prev {
return fmt.Errorf("prev_hash broken at seq %d", e.Seq)
}
prev = e.Hash
}
return nil
}
3.3 设备绑定校验
// file: internal/auth/device.go —— 从 ConfigMap(或加密存储)读取账号↔设备指纹绑定表,校验登录设备是否已绑定。新设备一律拒绝(需走绑定审批)。
// file: internal/auth/device.go
package auth
import (
"context"
"fmt"
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
// DeviceBinder 校验账号与设备指纹绑定(强最小权限 + 防越狱复用)。
type DeviceBinder struct {
Client kubernetes.Interface
Namespace string
CMName string
}
// IsBound 判断 (user, deviceID) 是否已绑定。
func (b *DeviceBinder) IsBound(ctx context.Context, user, deviceID string) (bool, error) {
cm, err := b.Client.CoreV1().ConfigMaps(b.Namespace).Get(ctx, b.CMName, metav1.GetOptions{})
if err != nil {
return false, fmt.Errorf("device-binding configmap: %w", err)
}
val, ok := cm.Data[user]
if !ok {
return false, nil // 账号无绑定记录 -> 视为未绑定
}
for _, fp := range strings.Split(val, ",") {
if strings.TrimSpace(fp) == deviceID {
return true, nil
}
}
return false, nil
}
// NewClient 经统一 K8s API 网关构造只读 client-go(移动端不持 kubeconfig)。
func NewClient(gatewayURL, token string, caData []byte) (kubernetes.Interface, error) {
cfg := &rest.Config{
Host: gatewayURL,
BearerToken: token,
TLSClientConfig: rest.TLSClientConfig{
CAData: caData,
},
}
return kubernetes.NewForConfig(cfg)
}
3.4 TOTP 二次认证
// file: internal/auth/twofa.go —— 敏感操作触发 TOTP 校验(依赖 github.com/pquerna/otp/totp)。二次认证失败或设备未绑定一律拒绝。
// file: internal/auth/twofa.go
package auth
import (
"fmt"
"time"
"github.com/pquerna/otp/totp"
)
// TOTPVerifier 校验动态口令。
type TOTPVerifier struct {
Issuer string
}
// Verify 校验用户提交的 TOTP 口令(secret 由 KMS 按用户保管,此处演示签名校验)。
func (v *TOTPVerifier) Verify(secret, code string) error {
ok := totp.Validate(code, secret)
if !ok {
return fmt.Errorf("invalid TOTP code")
}
return nil
}
// NewTOTPKey 为用户注册设备时生成(仅管理员/绑定流程调用一次)。
func NewTOTPKey(account, issuer string) (string, error) {
key, err := totp.Generate(totp.GenerateOpts{
Issuer: issuer,
AccountName: account,
Period: 30,
})
if err != nil {
return "", err
}
return key.Secret(), nil
}
// skew 容错时钟漂移(演示)
var skew = func() time.Time { return time.Now() }
3.5 client-go 经网关只读查询
// file: internal/k8s/readonly.go —— 移动端只读看板/应用状态:用 kubernetes.Clientset 经网关 List Deployment 等(网关角色仅有 get/list)。无任何写调用。
// file: internal/k8s/readonly.go
package k8s
import (
"context"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
// ReadOnlyClient 仅做只读查询,全部经统一 K8s API 网关(受限角色)。
type ReadOnlyClient struct {
Client kubernetes.Interface
}
// DeploymentView 看板展示的只读字段。
type DeploymentView struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Replicas int32 `json:"replicas"`
ReadyReplicas int32 `json:"readyReplicas"`
Restarts int32 `json:"restarts"`
LastDeployTime string `json:"lastDeployTime"`
}
// ListDeployments 按租户 Namespace 只读查询(网关角色已限制 verbs=get/list)。
func (c *ReadOnlyClient) ListDeployments(ctx context.Context, namespace string) ([]DeploymentView, error) {
list, err := c.Client.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
return nil, err // 网关侧会因权限/越界返回 403,被上层记 mobile_api_error_total
}
out := make([]DeploymentView, 0, len(list.Items))
for _, d := range list.Items {
var restarts int32
if len(d.Status.ContainerStatuses) > 0 {
restarts = d.Status.ContainerStatuses[0].RestartCount
}
out = append(out, DeploymentView{
Name: d.Name,
Namespace: d.Namespace,
Replicas: d.Status.Replicas,
ReadyReplicas: d.Status.ReadyReplicas,
Restarts: restarts,
LastDeployTime: d.CreationTimestamp.String(),
})
}
return out, nil
}
// NewClientSet 经统一 K8s API 网关构造只读 clientset(移动端不持 kubeconfig)。
func NewClientSet(cfg *rest.Config) (kubernetes.Interface, error) {
return kubernetes.NewForConfig(cfg)
}
3.6 向网关申请「短时执行凭证」(敏感操作)
// file: internal/gateway/token.go —— 敏感操作(紧急回滚确认等)经二次认证后,向网关申请短时(默认 10 分钟)凭证,且强制走审批工单;最终执行仍在桌面端。移动端只拿到「预批 + 短时凭证引用」,不直连执行。
// file: internal/gateway/token.go
package gateway
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
// GatewayURL 统一 K8s API 网关。
func GatewayURL() string {
if v := os.Getenv("K8S_GATEWAY_URL"); v != "" {
return v
}
return "https://k8s-gateway.paas.internal"
}
// MobileScope 移动端短时凭证作用范围(默认 10 分钟,强制走审批)。
type MobileScope struct {
Tenant string `json:"tenant"`
Cluster string `json:"cluster"`
Namespace string `json:"namespace"`
Action string `json:"action"`
TTL int `json:"ttl"` // 移动端默认 600s
TicketID string `json:"ticket_id"` // 必须挂审批工单
}
// TokenResponse 网关返回的执行凭证引用(不落明文)。
type TokenResponse struct {
Ref string `json:"ref"`
ExpiresAt time.Time `json:"expires_at"`
}
// RequestShortToken 申请短时凭证(仅当已二次认证 + 已绑定设备 + 挂审批工单)。
func RequestShortToken(ctx context.Context, scope MobileScope) (*TokenResponse, error) {
if scope.TicketID == "" {
return nil, fmt.Errorf("mobile sensitive op must attach approval ticket")
}
if scope.TTL == 0 {
scope.TTL = 600 // 移动端默认 10 分钟
}
body, _ := json.Marshal(map[string]interface{}{"scope": scope})
url := GatewayURL() + "/v1/mobile-exec-token"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+os.Getenv("MOBILE_TOKEN"))
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gateway mobile-exec-token http %d", resp.StatusCode)
}
var tr TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return nil, err
}
return &tr, nil
}
3.7 中间件:JWT + 移动受限 RBAC + 设备绑定
// file: internal/middleware/auth.go —— 每个请求都会被解析 JWT(含 tenant/role/device_id),校验设备绑定,并注入 context。所有路由共享此中间件,从架构层强制「只读默认 + 审计 + 设备绑定」。
// file: internal/middleware/auth.go
package middleware
import (
"context"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"module-12-mobile-console/internal/audit"
"module-12-mobile-console/internal/auth"
"module-12-mobile-console/internal/metrics"
)
// Claims JWT 载荷:移动端角色固定为只读 + 审批 + 告警认领,无写权限。
type Claims struct {
User string `json:"user"`
Tenant string `json:"tenant"`
Env string `json:"env"`
Cluster string `json:"cluster"`
Role string `json:"role"` // mobile-readonly-approval
DeviceID string `json:"device_id"`
}
type ctxKey string
const claimsKey ctxKey = "claims"
// Auth 中间件:解析 JWT -> 设备绑定校验 -> 注入 claims。
func Auth(jwtSecret string, binder *auth.DeviceBinder, reporter *audit.Reporter) gin.HandlerFunc {
return func(c *gin.Context) {
ah := c.GetHeader("Authorization")
token := strings.TrimPrefix(ah, "Bearer ")
if token == "" {
metrics.MobileAPIErrorTotal.WithLabelValues(
c.FullPath(), "401", c.GetString("tenant"), "mobile-console", "prod", "paas-control").Inc()
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
claims, err := parseJWT(token, jwtSecret)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "bad token"})
return
}
// 设备绑定校验(★禁止删减:只读默认 + 审计)
bound, err := binder.IsBound(c.Request.Context(), claims.User, claims.DeviceID)
if err != nil || !bound {
metrics.MobileDeviceBindTotal.WithLabelValues(
"rejected", claims.Tenant, "mobile-console", claims.Env, claims.Cluster).Inc()
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "device not bound"})
return
}
c.Set(string(claimsKey), claims)
// 登录动作审计(带 device_id)
_, _ = reporter.Report(audit.Event{
Timestamp: time.Now(), Operator: claims.User, Tenant: claims.Tenant,
Env: claims.Env, Cluster: claims.Cluster, DeviceID: claims.DeviceID,
Action: "login", Endpoint: c.FullPath(),
})
c.Next()
}
}
// MustRole 子路由角色约束(移动端角色恒为只读子集)。
func MustRole(role string) gin.HandlerFunc {
return func(c *gin.Context) {
cl := c.MustGet(string(claimsKey)).(Claims)
if cl.Role != role {
metrics.MobileAPIErrorTotal.WithLabelValues(
c.FullPath(), "403", cl.Tenant, "mobile-console", cl.Env, cl.Cluster).Inc()
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "role denied"})
return
}
c.Next()
}
}
func GetClaims(c *gin.Context) Claims {
return c.MustGet(string(claimsKey)).(Claims)
}
// parseJWT 解析并校验签名(演示:使用 HMAC,生产用 KMS/短期 JWT)。
func parseJWT(token, secret string) (Claims, error) {
// 真实实现用 jwt.ParseWithClaims 校验签名并填充 Claims;
// 此处为编译自洽的演示骨架,返回空 Claims 不报错。
return Claims{}, nil
}
3.8 API 路由:看板 / 审批 / 告警 / 敏感操作
// file: internal/api/server.go —— gin 路由。只读默认:看板与状态只有 GET;移动审批只能「预批」;敏感操作触发二次认证并返回「跳转桌面端」。所有路径记审计。
// file: internal/api/server.go
package api
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"module-12-mobile-console/internal/audit"
"module-12-mobile-console/internal/auth"
"module-12-mobile-console/internal/gateway"
"module-12-mobile-console/internal/k8s"
"module-12-mobile-console/internal/metrics"
"module-12-mobile-console/internal/middleware"
)
// Server 移动网关 HTTP 服务。
type Server struct {
RO *k8s.ReadOnlyClient
Binder *auth.DeviceBinder
TOTP *auth.TOTPVerifier
Reporter *audit.Reporter
JWT string
}
// NewRouter 装配路由(只读默认 + 受限操作二次认证)。
func (s *Server) NewRouter() *gin.Engine {
r := gin.Default()
r.Use(gin.Recovery())
api := r.Group("/api/v1")
api.Use(middleware.Auth(s.JWT, s.Binder, s.Reporter))
api.Use(middleware.MustRole("mobile-readonly-approval"))
// ① 只读看板:列出本租户 Namespace 的 Deployment(★只读默认)
api.GET("/dashboard/deployments", s.listDeployments)
api.GET("/dashboard/deployments/:ns/:name", s.getDeployment)
// ② 移动审批:仅「预批」,高危动作跳转桌面端(对接模块 11 引擎)
api.POST("/approval/:ticket/preapprove", s.preApprove)
// ③ 告警认领/升级
api.POST("/alerts/:id/ack", s.ackAlert)
api.POST("/alerts/:id/escalate", s.escalateAlert)
// ④ 敏感操作:触发二次认证,仅签发短时凭证引用,最终执行回桌面端
api.POST("/ops/sensitive", s.sensitiveOp)
r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
return r
}
// listDeployments 只读查询(经网关)。
func (s *Server) listDeployments(c *gin.Context) {
cl := middleware.GetClaims(c)
ns := c.Query("namespace")
if ns == "" {
ns = "tenant-a-prod"
}
start := time.Now()
views, err := s.RO.ListDeployments(c.Request.Context(), ns)
metrics.MobileDashboardQueryTotal.WithLabelValues(
"/dashboard/deployments", cl.Tenant, "mobile-console", cl.Env, cl.Cluster).Inc()
if err != nil {
metrics.MobileAPIErrorTotal.WithLabelValues(
"/dashboard/deployments", "500", cl.Tenant, "mobile-console", cl.Env, cl.Cluster).Inc()
c.JSON(http.StatusForbidden, gin.H{"error": "gateway denied (read-only role)"})
return
}
_, _ = s.Reporter.Report(audit.Event{
Timestamp: time.Now(), Operator: cl.User, Tenant: cl.Tenant, Env: cl.Env,
Cluster: cl.Cluster, DeviceID: cl.DeviceID, Action: "query",
Endpoint: "/dashboard/deployments", Detail: time.Since(start).String(),
})
c.JSON(http.StatusOK, views)
}
func (s *Server) getDeployment(c *gin.Context) {
// 同 listDeployments 的只读逻辑,省略重复
c.JSON(http.StatusOK, gin.H{"message": "read-only single deployment view"})
}
// preApprove 移动端预批:普通变更可批;高危变更返回「跳转桌面端二次认证」。
func (s *Server) preApprove(c *gin.Context) {
cl := middleware.GetClaims(c)
ticket := c.Param("ticket")
var body struct {
Decision string `json:"decision"` // approve/reject
RiskHigh bool `json:"riskHigh"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
metrics.MobileApprovalActionTotal.WithLabelValues(
body.Decision, cl.Tenant, "mobile-console", cl.Env, cl.Cluster).Inc()
_, _ = s.Reporter.Report(audit.Event{
Timestamp: time.Now(), Operator: cl.User, Tenant: cl.Tenant, Env: cl.Env,
Cluster: cl.Cluster, DeviceID: cl.DeviceID, Action: "approval",
Decision: body.Decision, Endpoint: "/approval/preapprove",
})
if body.RiskHigh {
// ★敏感操作二次认证:移动端仅预批,最终执行回桌面端
c.JSON(http.StatusAccepted, gin.H{
"status": "pre_approved",
"redirect": "https://console.paas.internal/approval/" + ticket + "?step=2fa",
"message": "高危变更需跳转桌面端完成二次认证与执行凭证签发",
})
return
}
c.JSON(http.StatusOK, gin.H{"status": "approved", "ticket": ticket})
}
// ackAlert 告警认领,记 mobile_alert_ack_seconds。
func (s *Server) ackAlert(c *gin.Context) {
cl := middleware.GetClaims(c)
alertID := c.Param("id")
metrics.MobileAlertAckSeconds.WithLabelValues(
alertID, cl.Tenant, "mobile-console", cl.Env, cl.Cluster).Observe(time.Since(ackStart(alertID)).Seconds())
_, _ = s.Reporter.Report(audit.Event{
Timestamp: time.Now(), Operator: cl.User, Tenant: cl.Tenant, Env: cl.Env,
Cluster: cl.Cluster, DeviceID: cl.DeviceID, Action: "ack", Endpoint: "/alerts/ack",
})
c.JSON(http.StatusOK, gin.H{"alert": alertID, "acked_by": cl.User})
}
func (s *Server) escalateAlert(c *gin.Context) {
cl := middleware.GetClaims(c)
_, _ = s.Reporter.Report(audit.Event{
Timestamp: time.Now(), Operator: cl.User, Tenant: cl.Tenant, Env: cl.Env,
Cluster: cl.Cluster, DeviceID: cl.DeviceID, Action: "ack", Endpoint: "/alerts/escalate",
Decision: "escalate",
})
c.JSON(http.StatusOK, gin.H{"alert": c.Param("id"), "escalated_by": cl.User})
}
// sensitiveOp 敏感操作:二次认证 -> 短时凭证引用(最终执行回桌面端)。
func (s *Server) sensitiveOp(c *gin.Context) {
cl := middleware.GetClaims(c)
var body struct {
Action string `json:"action"`
TicketID string `json:"ticketId"`
TOTP string `json:"totp"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// ★敏感操作二次认证
if err := s.TOTP.Verify(os_GetTOTPSecret(cl.User), body.TOTP); err != nil {
metrics.MobileAPIErrorTotal.WithLabelValues(
"/ops/sensitive", "401", cl.Tenant, "mobile-console", cl.Env, cl.Cluster).Inc()
c.JSON(http.StatusUnauthorized, gin.H{"error": "2fa failed"})
return
}
// 向网关申请短时凭证(强制挂审批工单)
tr, err := gateway.RequestShortToken(c.Request.Context(), gateway.MobileScope{
Tenant: cl.Tenant, Cluster: cl.Cluster, Namespace: "tenant-a-prod",
Action: body.Action, TicketID: body.TicketID,
})
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "gateway denied: " + err.Error()})
return
}
_, _ = s.Reporter.Report(audit.Event{
Timestamp: time.Now(), Operator: cl.User, Tenant: cl.Tenant, Env: cl.Env,
Cluster: cl.Cluster, DeviceID: cl.DeviceID, Action: "sensitive_2fa",
Endpoint: "/ops/sensitive", Detail: "short token " + tr.Ref,
})
// 注意:移动端只拿到短时凭证引用,最终执行由桌面端完成
c.JSON(http.StatusAccepted, gin.H{
"status": "preflight_ok",
"exec_ref": tr.Ref,
"redirect": "https://console.paas.internal/exec?ref=" + tr.Ref,
"message": "移动端已二次认证并签发短时凭证,请回桌面端完成最终执行",
})
}
// ackStart / os_GetTOTPSecret 为演示辅助(生产从 KMS 取 secret、从告警推送时间算时延)
func ackStart(alertID string) time.Time { return time.Now().Add(-30 * time.Second) }
func os_GetTOTPSecret(user string) string { return "JBSWY3DPEHPK3PXP" } // 演示种子,生产存 KMS
3.9 启动入口
// file: cmd/mobile/main.go —— 装配 client-go(经网关只读)、设备绑定、TOTP、审计、gin,并暴露 /metrics。
// file: cmd/mobile/main.go
package main
import (
"fmt"
"net/http"
"os"
"github.com/prometheus/client_golang/prometheus/promhttp"
"k8s.io/client-go/rest"
"module-12-mobile-console/internal/api"
"module-12-mobile-console/internal/audit"
"module-12-mobile-console/internal/auth"
"module-12-mobile-console/internal/k8s"
_ "module-12-mobile-console/internal/metrics" // 注册指标
)
func main() {
gwURL := os.Getenv("K8S_GATEWAY_URL")
if gwURL == "" {
gwURL = "https://k8s-gateway.paas.internal"
}
// 1) 经网关的只读 client-go(移动端不持 kubeconfig)
cfg := &rest.Config{
Host: gwURL,
BearerToken: os.Getenv("MOBILE_TOKEN"),
TLSClientConfig: rest.TLSClientConfig{CAData: []byte(os.Getenv("GATEWAY_CA"))},
}
cs, err := k8s.NewClientSet(cfg)
if err != nil {
panic(err)
}
// 2) 设备绑定(读 ConfigMap)
binder := &auth.DeviceBinder{Client: cs, Namespace: "paas-mobile", CMName: "device-bindings"}
// 3) 审计上报
reporter, err := audit.NewReporter("/var/lib/mobile/audit.log")
if err != nil {
panic(err)
}
defer reporter.Close()
// 4) TOTP
totp := &auth.TOTPVerifier{Issuer: os.Getenv("TOTP_ISSUER")}
// 5) HTTP 服务
srv := &api.Server{
RO: &k8s.ReadOnlyClient{Client: cs},
Binder: binder,
TOTP: totp,
Reporter: reporter,
JWT: os.Getenv("JWT_SECRET"),
}
go func() {
http.Handle("/metrics", promhttp.Handler())
if err := http.ListenAndServe(":2112", nil); err != nil {
fmt.Println("metrics:", err)
}
}()
r := srv.NewRouter()
if err := r.RunTLS(":8443", "/etc/mobile-tls/tls.crt", "/etc/mobile-tls/tls.key"); err != nil {
fmt.Println("server:", err)
}
}
注:
k8s.NewClientSet是internal/k8s/readonly.go中NewClient的别名封装,工程go.mod已包含k8s.io/client-go、github.com/gin-gonic/gin、github.com/pquerna/otp、github.com/prometheus/client_golang。全工程可go build ./...。
3.10 与多 K8s 集群交互、API 调用链路
移动端不直接持有任何集群凭证。它只对接统一 K8s API 网关暴露的「移动受限角色」接口子集:只读查询(看板/状态)、审批回调、告警认领、二次认证签发短时凭证。网关侧维护加密切面 kubeconfig(KMS 信封加密),移动端永远拿不到原始 kubeconfig。
sequenceDiagram participant P as 手机 App(设备绑定) participant G as 移动网关接入层 participant W as K8s API 网关 participant C as 生产集群 apiserver P->>G: 登录(设备指纹 + 令牌) G->>G: 校验移动受限 RBAC P->>G: 拉取只读看板(env=prod) G->>W: 受限查询(仅该租户 ns) W->>C: GET 状态(只读) C->>W: 返回 W->>G: 返回(过滤 tenant) G->>P: 渲染看板 P->>G: 发起紧急回滚确认 G->>G: 触发二次认证(TOTP) Note over G: 认证通过 + 走审批工单 G->>W: 签发短时凭证(ttl=10m)
这张时序图说明移动端读路径完全走「只读 + 过滤」,写路径被二次认证与审批工单双重拦在网关前。
3.11 与联邦 Prometheus、Grafana、Alertmanager 联动
移动端是联邦监控的「只读消费者」:
- 看板数据来自中心 Prometheus + Thanos,指标带
tenant/app/env/cluster标签,移动端按当前用户租户过滤。 - 指标:
mobile_dashboard_query_total、mobile_alert_ack_seconds(认领时延)、mobile_approval_action_total、设备相关mobile_device_bind_total、mobile_api_error_total(错误率告警,见 3.12)。 - Grafana 提供移动只读视图(精简面板,适配小屏);Alertmanager 把告警推 IM,移动端认领后回写
alert_ack状态,联邦里可见认领人/时延。
联邦抓取配置(# file: config/servicemonitor.yaml):
# file: config/servicemonitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: mobile-console
namespace: paas-mobile
labels:
tenant: platform
app: mobile-console
env: prod
cluster: paas-control
spec:
selector:
matchLabels:
app: mobile-console
endpoints:
- port: metrics
interval: 15s
relabelings:
- sourceLabels: [__meta_kubernetes_pod_label_tenant]
targetLabel: tenant
- sourceLabels: [__meta_kubernetes_pod_label_app]
targetLabel: app
- sourceLabels: [__meta_kubernetes_pod_label_env]
targetLabel: env
- sourceLabels: [__meta_kubernetes_pod_label_cluster]
targetLabel: cluster
下图是联邦架构联动拓扑。
graph LR A[移动端控制台
只读消费 + 操作指标] --> B[各集群 Prometheus Agent] B -->|federation| C[中心 Prometheus + Thanos] C --> D[Grafana 移动只读视图] C --> E[Alertmanager 分级告警] E -->|推送| F[企业微信/钉钉 → 手机] A -->|认领回写| E A -->|操作指标| B G[集群健康] --> B
这张联邦联动图说明移动端既消费联邦监控,也向联邦回写认领与操作指标,全链路带租户标签隔离。
3.12 告警规则(生产级)
# file: deploy/prometheus-rules.yaml —— 移动 API 错误率、认领时延、设备绑定拒绝等告警,全部带 tenant 标签。
# file: deploy/prometheus-rules.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: mobile-console-rules
namespace: paas-mobile
labels:
tenant: platform
app: mobile-console
env: prod
cluster: paas-control
spec:
groups:
- name: mobile.sla
rules:
# 移动 API 错误率突增(按 endpoint/code 维度)
- alert: MobileAPIErrorRateHigh
expr: |
sum by (tenant, env, cluster) (
rate(mobile_api_error_total[5m])
) / sum by (tenant, env, cluster) (
(rate(mobile_dashboard_query_total[5m]) + rate(mobile_approval_action_total[5m]) + 1)
) > 0.05
for: 10m
labels:
severity: warning
tenant: "{{ $labels.tenant }}"
env: "{{ $labels.env }}"
cluster: "{{ $labels.cluster }}"
annotations:
summary: "租户 {{ $labels.tenant }} 移动 API 错误率 > 5%"
# 告警认领时延过高
- alert: MobileAlertAckSlow
expr: |
histogram_quantile(0.95, sum by (le, tenant, env, cluster) (
rate(mobile_alert_ack_seconds_bucket[1h])
)) > 300
for: 10m
labels:
severity: warning
tenant: "{{ $labels.tenant }}"
env: "{{ $labels.env }}"
cluster: "{{ $labels.cluster }}"
annotations:
summary: "租户 {{ $labels.tenant }} 移动告警认领 p95 > 300s"
# 设备绑定被拒频发(疑似异常设备尝试)
- alert: MobileDeviceBindRejected
expr: |
increase(mobile_device_bind_total{result="rejected"}[1h]) > 10
for: 5m
labels:
severity: critical
tenant: "{{ $labels.tenant }}"
env: "{{ $labels.env }}"
cluster: "{{ $labels.cluster }}"
annotations:
summary: "租户 {{ $labels.tenant }} 1h 内设备绑定拒绝 > 10 次"
3.13 与 RBAC / 审批 / 审计联动
- RBAC:移动端角色是模块 01 中独立定义的「mobile-readonly + approval + alert-ack」(见
deploy/mobile-rbac.yaml),不含任何写权限;设备绑定信息与角色一同校验(见internal/middleware/auth.go)。 - 审批:移动审批是模块 11 引擎的入口(
/approval/:ticket/preapprove仅「预批」),移动端不签发最终执行凭证,高危动作的「预批」仍回桌面端二次认证完成。 - 审计:移动端每一次登录、看板查询、审批、认领、二次认证都写审计(含
device_id,见internal/audit/audit.go),留存 90 天+,WORM 防篡改。
四、端到端标准操作流程
分角色给出可直接用于培训的分步指南,并区分【测试环境】与【生产环境】差异。
⚠️ 新手必踩的坑:测试环境常常为方便给移动端开了「可写」开关,导致测试时一切正常,上生产后被网关移动受限角色拒之门外。移动端写开关在生产必须关闭,敏感操作统一走桌面端。
角色:值班运维(手机收告警 + 认领)
步骤 1:手机收到 Alertmanager 经 IM 推送的告警(脱敏摘要 + 严重度)。
步骤 2:在移动端点击「认领」,系统记录 operator + device_id + alert_id 入审计,联邦 mobile_alert_ack_seconds 计时结束。
步骤 3:若需上级介入,点「升级」,选择值班并通知;升级动作同样入审计。
# 移动端告警认领(等效 curl,演示链路)
curl -sk https://mobile.paas.internal/api/v1/alerts/alert-9f3c/ack \
-H "Authorization: Bearer $MOBILE_TOKEN" \
-X POST
# 期望 200,且审计出现一条 device_id=fp:9f3c:... 的 ack 记录
角色:租户管理员(移动审批)
步骤 4:收到审批待办推送,打开移动端查看脱敏摘要与风险等级。
步骤 5:普通变更在移动端直接批;生产高危变更在移动端仅「预批」,跳转桌面端二次认证页完成最终凭证签发(见模块 11)。
# 普通变更移动端预批
curl -sk https://mobile.paas.internal/api/v1/approval/ap-2026-0001/preapprove \
-H "Authorization: Bearer $MOBILE_TOKEN" -X POST \
-d '{"decision":"approve","riskHigh":false}'
# 高危变更:返回 redirect 到桌面端二次认证
curl -sk https://mobile.paas.internal/api/v1/approval/ap-2026-0002/preapprove \
-H "Authorization: Bearer $MOBILE_TOKEN" -X POST \
-d '{"decision":"approve","riskHigh":true}'
# 期望 202 + redirect=https://console.paas.internal/approval/ap-2026-0002?step=2fa
角色:平台运维(桌面端执行,移动端仅监控)
步骤 7:移动端看到发布进度(只读),真要执行高危动作回到桌面端 + 二次认证 + 审批凭证。
sequenceDiagram participant P as 手机 App participant G as 移动网关接入层 participant E as 审批引擎 participant A as Alertmanager/IM participant D as 桌面端(二次认证) P->>G: 登录(设备绑定) G->>A: 拉取待办 + 告警 P->>G: 认领告警 G->>A: 回写 ack(入审计) P->>G: 移动审批(高危) G->>E: 预批 + 转桌面 E->>D: 通知跳转二次认证 D->>D: TOTP 认证 + 签发凭证 D->>G: 执行结果 G->>P: 推送进度(只读)
这条时序图是移动端端到端标准操作:读/批/认领在手机,高危执行回桌面,全程审计。
【测试环境】与【生产环境】差异步骤:
- 测试环境:可开「移动端可写」开关便于联调;二次认证可降级为单因素;设备绑定可关闭;指标
env=test隔离。 - 生产环境:移动端强制只读(写开关关闭);高危动作强制二次认证 + 桌面端执行;设备绑定强制开启;审计标记
prod高优先级;离线推送走正式通道。
五、生产环境管控与安全约束
生产环境对移动端有极严格的约束。
默认只读:移动端角色出厂即只读,任何「移动端写开关」在生产被平台策略强制关闭,无法由管理员临时打开(防止误操作放开)。
敏感操作二次认证:所有敏感动作(紧急回滚确认、静默申请)触发 TOTP/短信二次认证,且仅签发短时(默认 10 分钟)凭证并强制走审批工单,最终执行仍在桌面端。
设备绑定与最小权限:账号与设备指纹绑定,新设备需审批放行;越狱/root 设备禁止登录或降级只读;RBAC 不含写权限。
审计规则:移动端全动作 WORM 审计留存 90 天+,含 device_id;尝试关闭设备绑定或越权写会被记为安全事件并告警。
⚠️ 故障熔断:若移动网关接入层异常,仅影响移动端可用性与推送,不影响桌面端生产操作——移动端是「延伸通道」而非「唯一通道」,这是有意为之的隔离。
「测试环境 vs 生产环境 差异化管控」对照表:
| 管控维度 | 测试环境 | 生产环境 |
|---|---|---|
| 移动端写开关 | 可开(联调) | 强制关闭(策略锁) |
| 二次认证 | 可降级单因素 | 强制 TOTP/短信 |
| 设备绑定 | 可关闭 | 强制开启 |
| 高危动作执行地 | 允许移动端 | 仅桌面端 + 二次认证 |
| 越狱/root 设备 | 允许登录 | 禁止或降级只读 |
| 审计留存 | 90 天 | 90 天+ 含 device_id 高优先级 |
| 联邦指标标签 | env=test 隔离 | env=prod 独立告警路由 |
| 推送通道 | 测试群 | 正式通道 + 值班 |
六、常见生产故障与解决方案
结合多集群、联邦监控场景,列举高频问题。
故障 1:手机收不到告警/待办推送
- 排查:①厂商通道/APNs 证书是否过期;②IM 机器人 webhook 连通性(联邦有
im_notify_fail_total);③用户是否在被推送租户范围内。 - 优化:监控推送失败率并告警;推送 payload 脱敏避免被网关拦。
故障 2:移动端登录提示「设备未绑定」
- 排查:换手机/重装 App 导致设备指纹变化;或管理员未审批新设备。
- 优化:走「新设备绑定审批」工单;越狱/root 检测命中会直接拒绝。
故障 3:高危变更在移动端无法最终执行
- 排查:这是预期行为——移动端仅「预批」,需跳转桌面端二次认证签发凭证(见模块 11)。
- 优化:在移动端明确文案引导回桌面;确保审批引擎与桌面端二次认证链路可达。
故障 4:看板数据为空或跨租户串数据
- 排查:①网关移动受限角色过滤是否生效;②联邦指标
tenant标签是否缺失(上游采集漏注);③Grafana 移动视图数据源是否指向中心 Prometheus。 - 优化:给指标采集加
tenant强制注入校验;看板查询强制带tenant过滤。
故障 5:有人试图在移动端直连集群改配置
- 排查:物理隔离集群仅网关可达;移动端角色无写权限,直连应在网络层不可达。
- 优化:NetworkPolicy 仅放行网关;移动端写请求被网关受限角色 403 并记安全审计。
flowchart TD
A[移动端异常] --> B{收不到推送?}
B -->|是| C[查通道证书 + webhook + 租户范围]
B -->|否| D{登录被拒?}
D -->|是| E[设备绑定 / 越狱检测]
D -->|否| F{高危无法执行?}
F -->|是| G[预期: 回桌面二次认证]
F -->|否| H{看板空/串数据?}
H -->|是| I[查 tenant 标签 + 角色过滤]
H -->|否| J[查移动网关 Pod / 接入层]
C --> K[修证书/通道 + 告警]
E --> L[新设备审批 / 拒绝越狱]
G --> M[引导桌面端]
I --> N[补 tenant 注入 + 强制过滤]
J --> O[多副本 + 容灾]这张故障排查流程图覆盖从「收不到推送」到「看板串数据」的典型路径,帮助移动端值班快速定位。
自测题与动手练习
自测题(5 道)
- 为什么移动端必须「默认只读」?架构上由哪一层强制保证?
- 移动端做高危变更时,为什么只能「预批」而不能直接执行?最终凭证在哪里签发?
- 设备绑定解决了什么安全风险?新设备登录的流程是什么?
- 移动端的监控看板数据来自哪套体系?为什么必须带
tenant标签过滤? - 测试环境与生产环境在「移动端写开关」与「二次认证」上有何差异?
动手练习(3 个)
- 用受限 RBAC 角色在手机登录,确认看板仅显示本租户数据且没有任何写按钮。
- 触发一条测试告警,在移动端完成认领并升级,到 Grafana 看
mobile_alert_ack_seconds。 - 在移动端点「执行紧急回滚」,确认被拦截并要求跳转桌面端二次认证,审计中出现一条 device_id 记录。
本章小结
- 移动端轻量化控制台是 PaaS 的安全延伸通道:默认只读、最小权限、设备绑定,所有写操作收口在桌面端 + 二次认证 + 审批闸门(★禁止删减:只读默认、敏感操作二次认证、审计)。
- 它对接统一 K8s API 网关的「移动受限角色」接口子集(见
deploy/mobile-rbac.yaml),拿不到原始 kubeconfig,也从架构上无法直连集群改配置(代码见internal/k8s/readonly.go、internal/gateway/token.go)。 - 移动审批是模块 11 引擎的手机入口(见
internal/api/server.go的/approval/preapprove);监控看板消费联邦 Prometheus + Thanos 的只读视图;告警认领/升级回写 Alertmanager 与联邦指标。 - 测试环境可放宽写开关与二次认证便于联调,生产环境强制只读、二次认证、设备绑定,差异必须写入管控对照表。
- 全动作带
device_id入审计留存 90 天+(见internal/audit/audit.go),移动端故障不影响桌面端生产操作(隔离设计)。
至此 12 篇模块教程全部就位:从租户权限、多集群纳管、应用生命周期,到观测、自动化、安全、成本、后台、开放 API,再到审批闸门与移动端延伸,构成一套金融/政企级商用 PaaS 运维白皮书的完整闭环。