认证与 RBAC
本文档涵盖 Umoo 完整的认证与授权系统——从 JWT Token 结构与生命周期、服务端中间件链,到 Web UI 如何利用已解析的权限控制组件可见性。
目录
1. 概述
Umoo 使用多租户 RBAC 模型。一个用户账号可以属于多个租户,并在每个租户中拥有不同的角色。权限根据调用方所在租户按请求进行解析。
关键设计决策:
- Token 仅携带
user_id— JWT 中不嵌入租户、角色或权限信息。 - 租户上下文为请求级 — 通过
X-Tenant-IDHTTP 请求头提供。 - 权限在进程内缓存 — 60 秒 TTL,角色变更时失效。
2. JWT Token 结构与生命周期
Token Claims
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"iat": 1700000000,
"exp": 1700003600
}Token 中不嵌入租户、角色或权限。 所有授权均在服务端通过
X-Tenant-ID请求头按请求解析。
算法
默认 RS256(可配置为 RS512)。使用 RSA 私钥签名,使用对应公钥验证。
Token 生命周期
POST /api/v1/auth/login
Body: { "email": "...", "password": "..." }
← 200: {
"access_token": "<jwt>",
"refresh_token": "<opaque>",
"expires_in": 3600
}POST /api/v1/auth/refresh
Body: { "refresh_token": "..." }
← 200: { "access_token": "<new jwt>", "expires_in": 3600 }Access Token 是短期有效的 JWT。Refresh Token 是存储在数据库(jwt_tokens 表)中的不透明字符串,用于在无需重新认证的情况下签发新的 Access Token。
发送请求
每个经过认证的 API 调用需要两个请求头:
Authorization: Bearer <access_token>
X-Tenant-ID: <tenant-uuid>缺少任一请求头将返回 401 Unauthorized 或 403 Forbidden。
3. RBAC 数据模型
users
└── user_tenant_roles
├── user_id → users.id
├── tenant_id → tenants.id (NULL = 系统级 / super_admin)
└── role_id → roles.id
└── role_permissions
└── permission_id → permissions.id
├── resource (例如 "device")
└── action (例如 "read")关键约束
- 一个用户在每个租户中最多只有一个角色(
UNIQUE NULLS NOT DISTINCT (user_id, tenant_id))。 user_tenant_roles.tenant_id = NULL→ 该用户是超级管理员,无租户边界限制。- 系统角色(
is_system = true)不可删除,在所有租户间共享(roles.tenant_id = NULL)。 - 租户可创建自定义角色(
roles.tenant_id = <tenant-uuid>)。
4. 系统角色
迁移时预置了四个内置系统角色,不可修改。
| 角色 | 说明 |
|---|---|
super_admin | 系统级管理员。绕过所有租户检查,不属于任何租户。 |
tenant_admin | 租户内完全控制——用户、设备、插件、发布。 |
operator | 设备运维——可读写设备、舰队、影子、发布;可读插件。 |
viewer | 设备、舰队、影子、发布和插件的只读访问。 |
5. 权限目录
5.1 平台权限
平台内置的所有 resource:action 对:
| 资源 | 操作 | 说明 |
|---|---|---|
tenant | admin | 租户级管理操作(创建/删除租户) |
user | read | 列出并查看租户内用户 |
user | write | 创建和更新用户,分配角色 |
user | delete | 从租户中移除用户 |
device | read | 列出并查看设备详情 |
device | write | 注册、更新和配置设备 |
device | delete | 注销/移除设备 |
fleet | read | 查看设备分组 |
fleet | write | 创建和管理设备分组 |
shadow | read | 查看设备期望/上报状态 |
shadow | write | 更新设备期望状态 |
rollout | read | 查看发布任务和状态 |
rollout | write | 创建和管理发布任务 |
plugin | read | 查看已安装插件和配置 |
plugin | write | 安装和更新插件 |
plugin | delete | 移除插件 |
claim | write | 生成设备认领 Token |
audit | read | 读取不可变审计日志 |
apikey | read | 列出 API 密钥 |
apikey | write | 创建和撤销 API 密钥 |
event | read | 读取租户事件流 |
event | write | 发送租户事件 |
network | admin | 管理 WireGuard 子网和 VPN 对等节点 |
metrics | read | 通过代理查询 Prometheus 指标 |
5.2 插件权限
插件权限以 {plugin-id}/{subresource} 格式命名,在启动时由 SDKPluginManager.Init() 自动写入。它们存储在同一 permissions 表中,由同一 PermissionCache 执行。
| 资源 | 操作 | 默认角色 | 说明 |
|---|---|---|---|
terminal/session | read | viewer, operator, tenant_admin | 列出活跃终端会话 |
terminal/session | open | operator, tenant_admin | 向设备开启 WebSocket Shell |
wireguard/network | read | viewer, operator, tenant_admin | 查看 WireGuard 子网 |
wireguard/network | create | tenant_admin | 创建 WireGuard 子网 |
wireguard/network | delete | tenant_admin | 删除 WireGuard 子网 |
wireguard/peer | read | viewer, operator, tenant_admin | 查看 VPN 对等节点 |
wireguard/peer | add | operator, tenant_admin | 添加 VPN 对等节点 |
wireguard/peer | remove | operator, tenant_admin | 移除 VPN 对等节点 |
wireguard/relay | manage | tenant_admin | 配置中继服务器 |
wireguard/device_config | push | operator, tenant_admin | 向设备推送 WireGuard 配置 |
telemetry/metrics | read | viewer, operator, tenant_admin | 查询设备遥测数据 |
logs/stream | read | viewer, operator, tenant_admin | 查询和流式传输设备日志 |
6. 角色 → 权限矩阵
6.1 平台权限
| 权限 | super_admin | tenant_admin | operator | viewer |
|---|---|---|---|---|
tenant:admin | ✅ | ❌ | ❌ | ❌ |
user:read | ✅ | ✅ | ❌ | ❌ |
user:write | ✅ | ✅ | ❌ | ❌ |
user:delete | ✅ | ✅ | ❌ | ❌ |
device:read | ✅ | ✅ | ✅ | ✅ |
device:write | ✅ | ✅ | ✅ | ❌ |
device:delete | ✅ | ✅ | ❌ | ❌ |
fleet:read | ✅ | ✅ | ✅ | ✅ |
fleet:write | ✅ | ✅ | ✅ | ❌ |
shadow:read | ✅ | ✅ | ✅ | ✅ |
shadow:write | ✅ | ✅ | ✅ | ❌ |
rollout:read | ✅ | ✅ | ✅ | ✅ |
rollout:write | ✅ | ✅ | ✅ | ❌ |
plugin:read | ✅ | ✅ | ✅ | ✅ |
plugin:write | ✅ | ✅ | ❌ | ❌ |
plugin:delete | ✅ | ✅ | ❌ | ❌ |
claim:write | ✅ | ✅ | ✅ | ❌ |
audit:read | ✅ | ✅ | ❌ | ❌ |
apikey:read | ✅ | ✅ | ❌ | ❌ |
apikey:write | ✅ | ✅ | ❌ | ❌ |
event:read | ✅ | ✅ | ✅ | ✅ |
event:write | ✅ | ❌ | ✅ | ❌ |
network:admin | ✅ | ✅ | ❌ | ❌ |
metrics:read | ✅ | ✅ | ✅ | ❌ |
6.2 插件权限
| 权限 | super_admin | tenant_admin | operator | viewer |
|---|---|---|---|---|
terminal/session:read | ✅ | ✅ | ✅ | ✅ |
terminal/session:open | ✅ | ✅ | ✅ | ❌ |
wireguard/network:read | ✅ | ✅ | ✅ | ✅ |
wireguard/network:create | ✅ | ✅ | ❌ | ❌ |
wireguard/network:delete | ✅ | ✅ | ❌ | ❌ |
wireguard/peer:read | ✅ | ✅ | ✅ | ✅ |
wireguard/peer:add | ✅ | ✅ | ✅ | ❌ |
wireguard/peer:remove | ✅ | ✅ | ✅ | ❌ |
wireguard/relay:manage | ✅ | ✅ | ❌ | ❌ |
wireguard/device_config:push | ✅ | ✅ | ✅ | ❌ |
telemetry/metrics:read | ✅ | ✅ | ✅ | ✅ |
logs/stream:read | ✅ | ✅ | ✅ | ✅ |
7. 服务端执行
中间件链
每个管理 API 请求按顺序经过以下链路:
RequestLogging
→ RateLimitMiddleware
→ AuthMiddleware
→ TenantAuthMiddleware
→ [路由处理器]
↑
RequirePermission("resource", "action") ← 可选的每路由包装器1. AuthMiddleware(ports/middleware/auth.go)
- 提取
Authorization: Bearer <token>请求头。 - 使用 RSA 公钥验证 JWT 签名和过期时间。
- 从 Claims 中解析
user_id。 - 将
user_id存入UserIDKey的请求上下文。 - Token 缺失、无效或过期时返回
401。
2. TenantAuthMiddleware(ports/middleware/tenant_auth.go)
- 读取
X-Tenant-ID请求头。 - 检查用户是否为
super_admin(user_tenant_roles中 tenant_id = NULL)——若是,设置IsSuperAdmin = true并跳过所有权限检查。 - 非超级管理员:从
PermissionCache解析UserTenantPermissions(见第 8 节)。 - 将
TenantIDKey和PermissionsKey存入请求上下文。 - 用户在请求的租户中无成员资格时返回
403。
3. RequirePermission(resource, action)(ports/middleware/tenant_auth.go)
应用于特定处理器的每路由中间件包装器:
mux.Handle("/grpc.umoo.v1.admin.AdminService/DeleteDevice",
RequirePermission("device", "delete")(adminHandler))- 从上下文读取
PermissionsKey。 - 对已解析的
UserTenantPermissions调用HasPermission(resource, action)。 - 超级管理员始终通过(
IsSuperAdmin = true绕过检查)。 - 权限不存在时返回
403。
8. 权限解析与缓存
PermissionCache(modules/auth/permission_cache.go)
以 "userID:tenantID"(超级管理员为 "userID:system")为键的进程内缓存。
- TTL:60 秒
- 缓存未命中:通过
ACLRepository查询数据库——跨user_tenant_roles → roles → role_permissions → permissions的 JOIN - 失效:
PermissionCache.Invalidate(userID)— 用户角色变更时调用
UserTenantPermissions(domain/permission.go)
每个请求存储在上下文中的已解析对象:
type UserTenantPermissions struct {
UserID uuid.UUID
TenantID uuid.UUID
Role string // 例如 "operator"
Permissions []Permission // 所有授予的 resource:action 对
IsSuperAdmin bool
}
func (p *UserTenantPermissions) HasPermission(resource, action string) bool {
if p.IsSuperAdmin {
return true
}
for _, perm := range p.Permissions {
if perm.Resource == resource && perm.Action == action {
return true
}
}
return false
}9. Web UI 集成指南
步骤 1:登录
POST /api/v1/auth/login
Content-Type: application/json
{ "email": "alice@example.com", "password": "secret" }存储返回的 access_token 和 refresh_token。Access Token 是 JWT,但不要解码它来获取权限——它只包含 user_id。
步骤 2:获取租户成员资格
登录后立即(在渲染任何受保护 UI 之前)调用:
GET /api/v1/auth/me/tenants
Authorization: Bearer <access_token>响应:
[
{
"tenant_id": "550e8400-...",
"tenant_name": "Acme Corp",
"role_name": "operator",
"permissions": [
{ "resource": "device", "action": "read" },
{ "resource": "device", "action": "write" },
{ "resource": "fleet", "action": "read" },
{ "resource": "fleet", "action": "write" },
{ "resource": "shadow", "action": "read" },
{ "resource": "shadow", "action": "write" },
{ "resource": "rollout","action": "read" },
{ "resource": "rollout","action": "write" },
{ "resource": "plugin", "action": "read" }
],
"is_super_admin": false
}
]步骤 3:在前端状态中存储权限
interface Permission {
resource: string;
action: string;
}
interface TenantMembership {
tenant_id: string;
tenant_name: string;
role_name: string;
permissions: Permission[];
is_super_admin: boolean;
}
// 构建 Set 以实现 O(1) 查找
function buildPermissionSet(membership: TenantMembership): Set<string> {
if (membership.is_super_admin) {
return new Set(['*']); // 超级管理员拥有一切权限
}
return new Set(
membership.permissions.map(p => `${p.resource}:${p.action}`)
);
}
function can(permSet: Set<string>, resource: string, action: string): boolean {
return permSet.has('*') || permSet.has(`${resource}:${action}`);
}步骤 4:控制 UI 组件可见性
使用 can() 辅助函数(或等效的 React context / hook)条件性渲染组件:
// 仅当用户有 device:delete 权限时渲染"删除设备"按钮
{can(perms, 'device', 'delete') && (
<Button onClick={handleDeleteDevice}>删除设备</Button>
)}
// 仅对 tenant_admin 及以上角色显示用户管理区域
{can(perms, 'user', 'read') && <UserManagementPanel />}
// 仅对 tenant_admin+ 显示插件管理
{can(perms, 'plugin', 'write') && <InstallPluginButton />}
// 仅当用户可认领时显示认领设备流程
{can(perms, 'claim', 'write') && <ClaimDeviceWizard />}步骤 5:切换租户
用户切换租户时,所有后续 API 调用需包含 X-Tenant-ID:
GET /grpc.umoo.v1.admin.AdminService/ListDevices
Authorization: Bearer <access_token>
X-Tenant-ID: <selected-tenant-id>从已存储的成员资格列表中重新派生所选租户的权限集——无需额外 API 调用(步骤 2 已获取所有租户成员资格)。
组件可见性参考
| UI 组件 / 操作 | 所需权限 |
|---|---|
| 查看设备列表 | device:read |
| 注册/编辑设备 | device:write |
| 删除设备 | device:delete |
| 查看设备分组 | fleet:read |
| 创建/编辑设备分组 | fleet:write |
| 查看设备影子(期望/上报状态) | shadow:read |
| 编辑设备影子(期望状态) | shadow:write |
| 查看发布任务 | rollout:read |
| 创建/管理发布 | rollout:write |
| 查看已安装插件 | plugin:read |
| 安装/更新插件 | plugin:write |
| 移除插件 | plugin:delete |
| 认领未认领设备 | claim:write |
| 查看租户内用户 | user:read |
| 邀请/编辑用户 | user:write |
| 移除用户 | user:delete |
| 租户管理 | tenant:admin(仅超级管理员) |
| 查看审计日志 | audit:read |
| 查看/创建 API 密钥 | apikey:read + apikey:write |
| 查看事件 | event:read |
| 创建事件 | event:write |
| 查看/管理 WireGuard 网络 | network:admin |
| 查看监控仪表板 | metrics:read |
| 查看活跃终端会话 | terminal/session:read |
| 打开设备终端 | terminal/session:open |
| 查看 WireGuard 子网 | wireguard/network:read |
| 创建/删除 WireGuard 子网 | wireguard/network:create / wireguard/network:delete |
| 查看 VPN 对等节点 | wireguard/peer:read |
| 添加/移除 VPN 对等节点 | wireguard/peer:add / wireguard/peer:remove |
| 查看设备遥测 | telemetry/metrics:read |
| 查看/流式传输设备日志 | logs/stream:read |
Token 刷新
Access Token 会过期(默认 1 小时)。静默刷新:
async function refreshAccessToken(refreshToken: string): Promise<string> {
const res = await fetch('/api/v1/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refresh_token: refreshToken }),
});
const { access_token } = await res.json();
return access_token;
}收到任意 API 调用的 401 响应时,刷新 Token 并重试一次。
附录:超级管理员行为
超级管理员(role = "super_admin")无租户边界:
- 数据库中
user_tenant_roles.tenant_id = NULL。 /me/tenants响应的每条记录均返回is_super_admin: true。- 服务端:
IsSuperAdmin = true完全绕过所有RequirePermission检查。 - UI:将
is_super_admin: true视为拥有所有权限。显示所有控件,包括跨租户管理面板。