Authentication & RBAC
This document covers the complete authentication and authorization system in Umoo — from JWT token structure and lifecycle, through the server-side middleware chain, to how a Web UI should use the resolved permissions to gate components.
Table of Contents
- Overview
- JWT Token Structure & Lifecycle
- RBAC Data Model
- System Roles
- Permissions Catalog
- Role → Permission Matrix
- Server-Side Enforcement
- Permission Resolution & Caching
- Web UI Integration Guide
1. Overview
Umoo uses a multi-tenant RBAC model. A single user account can belong to multiple tenants and hold a different role in each. Permissions are resolved per-request based on which tenant the caller is operating in.
Key design decisions:
- Tokens carry only
user_id— no tenant, no role, no permissions are embedded in the JWT. - Tenant context is request-scoped — supplied via the
X-Tenant-IDHTTP header. - Permissions are cached in-process — 60-second TTL, invalidated on role changes.
2. JWT Token Structure & Lifecycle
Token Claims
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"iat": 1700000000,
"exp": 1700003600
}No tenant, role, or permissions are embedded in the token. All authorization is resolved server-side per request using the
X-Tenant-IDheader.
Algorithm
RS256 by default (RS512 configurable). Signed with an RSA private key; verified with the corresponding public key.
Token Lifecycle
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 tokens are short-lived JWTs. Refresh tokens are opaque strings stored in the database (jwt_tokens table) and used to issue new access tokens without re-authentication.
Sending Requests
Every authenticated API call requires two headers:
Authorization: Bearer <access_token>
X-Tenant-ID: <tenant-uuid>Omitting either header results in a 401 Unauthorized or 403 Forbidden response.
3. RBAC Data Model
users
└── user_tenant_roles
├── user_id → users.id
├── tenant_id → tenants.id (NULL = system-level / super_admin)
└── role_id → roles.id
└── role_permissions
└── permission_id → permissions.id
├── resource (e.g. "device")
└── action (e.g. "read")Key constraints
- A user has at most one role per tenant (
UNIQUE NULLS NOT DISTINCT (user_id, tenant_id)). user_tenant_roles.tenant_id = NULL→ the user is a super_admin with no tenant boundary.- System roles (
is_system = true) cannot be deleted and are shared across all tenants (roles.tenant_id = NULL). - Tenants can create custom roles (
roles.tenant_id = <tenant-uuid>).
4. System Roles
Four built-in system roles are seeded at migration time and cannot be modified.
| Role | Description |
|---|---|
super_admin | System-wide administrator. Bypasses all tenant checks. Not scoped to any tenant. |
tenant_admin | Full control within a tenant — users, devices, plugins, rollouts. |
operator | Device operations — can read/write devices, fleet, shadow, rollouts; read plugins. |
viewer | Read-only access to devices, fleet, shadow, rollouts, and plugins. |
5. Permissions Catalog
5.1 Platform Permissions
All resource:action pairs built into the platform:
| Resource | Action | Description |
|---|---|---|
tenant | admin | Tenant-level administrative operations (create/delete tenants) |
user | read | List and view users within a tenant |
user | write | Create and update users, assign roles |
user | delete | Remove users from a tenant |
device | read | List and view device details |
device | write | Register, update, and configure devices |
device | delete | Deregister/remove devices |
fleet | read | View device groups |
fleet | write | Create and manage device groups |
shadow | read | View device desired/reported state |
shadow | write | Update device desired state |
rollout | read | View rollout campaigns and status |
rollout | write | Create and manage rollout campaigns |
plugin | read | View installed plugins and configuration |
plugin | write | Install and update plugins |
plugin | delete | Remove plugins |
claim | write | Generate device claim tokens |
audit | read | Read the immutable audit log |
apikey | read | List API keys |
apikey | write | Create and revoke API keys |
event | read | Read tenant event stream |
event | write | Emit tenant events |
network | admin | Manage WireGuard subnets and VPN peers |
metrics | read | Query Prometheus metrics via the proxy |
5.2 Plugin Permissions
Plugin permissions are namespaced as {plugin-id}/{subresource} and seeded automatically at startup by SDKPluginManager.Init(). They are stored in the same permissions table and enforced by the same PermissionCache.
| Resource | Action | Default roles | Description |
|---|---|---|---|
terminal/session | read | viewer, operator, tenant_admin | List active terminal sessions |
terminal/session | open | operator, tenant_admin | Open a WebSocket shell to a device |
wireguard/network | read | viewer, operator, tenant_admin | View WireGuard subnets |
wireguard/network | create | tenant_admin | Create a WireGuard subnet |
wireguard/network | delete | tenant_admin | Delete a WireGuard subnet |
wireguard/peer | read | viewer, operator, tenant_admin | View VPN peers |
wireguard/peer | add | operator, tenant_admin | Add a VPN peer |
wireguard/peer | remove | operator, tenant_admin | Remove a VPN peer |
wireguard/relay | manage | tenant_admin | Configure relay servers |
wireguard/device_config | push | operator, tenant_admin | Push WireGuard config to a device |
telemetry/metrics | read | viewer, operator, tenant_admin | Query device telemetry |
logs/stream | read | viewer, operator, tenant_admin | Query and stream device logs |
6. Role → Permission Matrix
6.1 Platform Permissions
| Permission | 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 Plugin Permissions
| Permission | 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. Server-Side Enforcement
Middleware Chain
Every request to the admin API passes through this chain (in order):
RequestLogging
→ RateLimitMiddleware
→ AuthMiddleware
→ TenantAuthMiddleware
→ [route handler]
↑
RequirePermission("resource", "action") ← optional per-route wrapper1. AuthMiddleware (ports/middleware/auth.go)
- Extracts
Authorization: Bearer <token>header. - Validates JWT signature and expiry using the RSA public key.
- Parses
user_idfrom claims. - Stores
user_idin the request context underUserIDKey. - Returns
401if token is missing, invalid, or expired.
2. TenantAuthMiddleware (ports/middleware/tenant_auth.go)
- Reads
X-Tenant-IDheader. - Checks if the user is
super_admin(tenant_id = NULL inuser_tenant_roles) — if so, setsIsSuperAdmin = trueand skips all permission checks. - For non-super-admin users: resolves
UserTenantPermissionsfrom thePermissionCache(see §8). - Stores
TenantIDKeyandPermissionsKeyin the request context. - Returns
403if the user has no membership in the requested tenant.
3. RequirePermission(resource, action) (ports/middleware/tenant_auth.go)
A per-route middleware wrapper applied to specific handlers:
mux.Handle("/grpc.umoo.v1.admin.AdminService/DeleteDevice",
RequirePermission("device", "delete")(adminHandler))- Reads
PermissionsKeyfrom context. - Calls
HasPermission(resource, action)on the resolvedUserTenantPermissions. - Super-admins always pass (
IsSuperAdmin = truebypasses the check). - Returns
403if the permission is absent.
8. Permission Resolution & Caching
PermissionCache (modules/auth/permission_cache.go)
An in-process cache keyed by "userID:tenantID" (or "userID:system" for super-admins).
- TTL: 60 seconds
- Cache miss: queries the database via
ACLRepository— a JOIN acrossuser_tenant_roles → roles → role_permissions → permissions - Invalidation:
PermissionCache.Invalidate(userID)— called whenever a user's role is changed
UserTenantPermissions (domain/permission.go)
The resolved object stored in context per request:
type UserTenantPermissions struct {
UserID uuid.UUID
TenantID uuid.UUID
Role string // e.g. "operator"
Permissions []Permission // all granted resource:action pairs
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 Integration Guide
Step 1: Login
POST /api/v1/auth/login
Content-Type: application/json
{ "email": "alice@example.com", "password": "secret" }Store the returned access_token and refresh_token. The access token is a JWT, but do not decode it to get permissions — it only contains user_id.
Step 2: Fetch Tenant Memberships
Immediately after login (before rendering any protected UI), call:
GET /api/v1/auth/me/tenants
Authorization: Bearer <access_token>Response:
[
{
"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
}
]Step 3: Store Permissions in Frontend State
interface Permission {
resource: string;
action: string;
}
interface TenantMembership {
tenant_id: string;
tenant_name: string;
role_name: string;
permissions: Permission[];
is_super_admin: boolean;
}
// Build a Set for O(1) lookups
function buildPermissionSet(membership: TenantMembership): Set<string> {
if (membership.is_super_admin) {
return new Set(['*']); // super admin has everything
}
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}`);
}Step 4: Gate UI Components
Use the can() helper (or a React context / hook equivalent) to conditionally render components:
// Only render "Delete Device" button if user has device:delete
{can(perms, 'device', 'delete') && (
<Button onClick={handleDeleteDevice}>Delete Device</Button>
)}
// Only show User Management section for tenant_admin and above
{can(perms, 'user', 'read') && <UserManagementPanel />}
// Only show Plugin management for tenant_admin+
{can(perms, 'plugin', 'write') && <InstallPluginButton />}
// Show claim device flow only when user can claim
{can(perms, 'claim', 'write') && <ClaimDeviceWizard />}Step 5: Tenant Switching
When the user switches tenants, include X-Tenant-ID on all subsequent API calls:
GET /grpc.umoo.v1.admin.AdminService/ListDevices
Authorization: Bearer <access_token>
X-Tenant-ID: <selected-tenant-id>Re-derive the permission set from the stored membership list for the selected tenant — no additional API call is needed (you already have all tenant memberships from Step 2).
Component Visibility Reference
| UI Component / Action | Required Permission |
|---|---|
| View device list | device:read |
| Register / edit device | device:write |
| Delete device | device:delete |
| View device groups | fleet:read |
| Create / edit device groups | fleet:write |
| View device shadow (desired/reported state) | shadow:read |
| Edit device shadow (desired state) | shadow:write |
| View rollout campaigns | rollout:read |
| Create / manage rollouts | rollout:write |
| View installed plugins | plugin:read |
| Install / update plugins | plugin:write |
| Remove plugins | plugin:delete |
| Claim unclaimed devices | claim:write |
| View users in tenant | user:read |
| Invite / edit users | user:write |
| Remove users | user:delete |
| Tenant administration | tenant:admin (super_admin only) |
| View audit log | audit:read |
| View / create API keys | apikey:read + apikey:write |
| View events | event:read |
| Create events | event:write |
| View / manage WireGuard network | network:admin |
| View monitoring dashboards | metrics:read |
| View active terminal sessions | terminal/session:read |
| Open device terminal | terminal/session:open |
| View WireGuard subnets | wireguard/network:read |
| Create / delete WireGuard subnets | wireguard/network:create / wireguard/network:delete |
| View VPN peers | wireguard/peer:read |
| Add / remove VPN peers | wireguard/peer:add / wireguard/peer:remove |
| View device telemetry | telemetry/metrics:read |
| View / stream device logs | logs/stream:read |
Token Refresh
Access tokens expire (default 1 hour). Refresh silently:
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;
}On a 401 response to any API call, refresh the token and retry once.
Appendix: Super Admin Behaviour
Super admins (role = "super_admin") have no tenant boundary:
user_tenant_roles.tenant_id = NULLin the database.is_super_admin: trueis returned in every entry of the/me/tenantsresponse.- Server-side:
IsSuperAdmin = truebypasses allRequirePermissionchecks entirely. - UI: treat
is_super_admin: trueas having all permissions everywhere. Show all controls, including cross-tenant administration panels.