Skip to content

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

  1. Overview
  2. JWT Token Structure & Lifecycle
  3. RBAC Data Model
  4. System Roles
  5. Permissions Catalog
  6. Role → Permission Matrix
  7. Server-Side Enforcement
  8. Permission Resolution & Caching
  9. 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-ID HTTP header.
  • Permissions are cached in-process — 60-second TTL, invalidated on role changes.

2. JWT Token Structure & Lifecycle

Token Claims

json
{
  "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-ID header.

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:

http
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.

RoleDescription
super_adminSystem-wide administrator. Bypasses all tenant checks. Not scoped to any tenant.
tenant_adminFull control within a tenant — users, devices, plugins, rollouts.
operatorDevice operations — can read/write devices, fleet, shadow, rollouts; read plugins.
viewerRead-only access to devices, fleet, shadow, rollouts, and plugins.

5. Permissions Catalog

5.1 Platform Permissions

All resource:action pairs built into the platform:

ResourceActionDescription
tenantadminTenant-level administrative operations (create/delete tenants)
userreadList and view users within a tenant
userwriteCreate and update users, assign roles
userdeleteRemove users from a tenant
devicereadList and view device details
devicewriteRegister, update, and configure devices
devicedeleteDeregister/remove devices
fleetreadView device groups
fleetwriteCreate and manage device groups
shadowreadView device desired/reported state
shadowwriteUpdate device desired state
rolloutreadView rollout campaigns and status
rolloutwriteCreate and manage rollout campaigns
pluginreadView installed plugins and configuration
pluginwriteInstall and update plugins
plugindeleteRemove plugins
claimwriteGenerate device claim tokens
auditreadRead the immutable audit log
apikeyreadList API keys
apikeywriteCreate and revoke API keys
eventreadRead tenant event stream
eventwriteEmit tenant events
networkadminManage WireGuard subnets and VPN peers
metricsreadQuery 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.

ResourceActionDefault rolesDescription
terminal/sessionreadviewer, operator, tenant_adminList active terminal sessions
terminal/sessionopenoperator, tenant_adminOpen a WebSocket shell to a device
wireguard/networkreadviewer, operator, tenant_adminView WireGuard subnets
wireguard/networkcreatetenant_adminCreate a WireGuard subnet
wireguard/networkdeletetenant_adminDelete a WireGuard subnet
wireguard/peerreadviewer, operator, tenant_adminView VPN peers
wireguard/peeraddoperator, tenant_adminAdd a VPN peer
wireguard/peerremoveoperator, tenant_adminRemove a VPN peer
wireguard/relaymanagetenant_adminConfigure relay servers
wireguard/device_configpushoperator, tenant_adminPush WireGuard config to a device
telemetry/metricsreadviewer, operator, tenant_adminQuery device telemetry
logs/streamreadviewer, operator, tenant_adminQuery and stream device logs

6. Role → Permission Matrix

6.1 Platform Permissions

Permissionsuper_admintenant_adminoperatorviewer
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

Permissionsuper_admintenant_adminoperatorviewer
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 wrapper

1. AuthMiddleware (ports/middleware/auth.go)

  • Extracts Authorization: Bearer <token> header.
  • Validates JWT signature and expiry using the RSA public key.
  • Parses user_id from claims.
  • Stores user_id in the request context under UserIDKey.
  • Returns 401 if token is missing, invalid, or expired.

2. TenantAuthMiddleware (ports/middleware/tenant_auth.go)

  • Reads X-Tenant-ID header.
  • Checks if the user is super_admin (tenant_id = NULL in user_tenant_roles) — if so, sets IsSuperAdmin = true and skips all permission checks.
  • For non-super-admin users: resolves UserTenantPermissions from the PermissionCache (see §8).
  • Stores TenantIDKey and PermissionsKey in the request context.
  • Returns 403 if 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:

go
mux.Handle("/grpc.umoo.v1.admin.AdminService/DeleteDevice",
    RequirePermission("device", "delete")(adminHandler))
  • Reads PermissionsKey from context.
  • Calls HasPermission(resource, action) on the resolved UserTenantPermissions.
  • Super-admins always pass (IsSuperAdmin = true bypasses the check).
  • Returns 403 if 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 across user_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:

go
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

http
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:

http
GET /api/v1/auth/me/tenants
Authorization: Bearer <access_token>

Response:

json
[
  {
    "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

typescript
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:

tsx
// 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:

http
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 / ActionRequired Permission
View device listdevice:read
Register / edit devicedevice:write
Delete devicedevice:delete
View device groupsfleet:read
Create / edit device groupsfleet:write
View device shadow (desired/reported state)shadow:read
Edit device shadow (desired state)shadow:write
View rollout campaignsrollout:read
Create / manage rolloutsrollout:write
View installed pluginsplugin:read
Install / update pluginsplugin:write
Remove pluginsplugin:delete
Claim unclaimed devicesclaim:write
View users in tenantuser:read
Invite / edit usersuser:write
Remove usersuser:delete
Tenant administrationtenant:admin (super_admin only)
View audit logaudit:read
View / create API keysapikey:read + apikey:write
View eventsevent:read
Create eventsevent:write
View / manage WireGuard networknetwork:admin
View monitoring dashboardsmetrics:read
View active terminal sessionsterminal/session:read
Open device terminalterminal/session:open
View WireGuard subnetswireguard/network:read
Create / delete WireGuard subnetswireguard/network:create / wireguard/network:delete
View VPN peerswireguard/peer:read
Add / remove VPN peerswireguard/peer:add / wireguard/peer:remove
View device telemetrytelemetry/metrics:read
View / stream device logslogs/stream:read

Token Refresh

Access tokens expire (default 1 hour). Refresh silently:

typescript
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 = NULL in the database.
  • is_super_admin: true is returned in every entry of the /me/tenants response.
  • Server-side: IsSuperAdmin = true bypasses all RequirePermission checks entirely.
  • UI: treat is_super_admin: true as having all permissions everywhere. Show all controls, including cross-tenant administration panels.

Umoo — IoT Device Management Platform