Admin User Management

The admin backend provides comprehensive user lifecycle management including organizations, service tiers, and feature role assignment via Keycloak.

Users

User Status Lifecycle

Users have a status field with three possible states:

Diagram
┌────────┐     block      ┌─────────┐     delete     ┌─────────┐
│ active │ ───────────────►│ blocked │ ──────────────►│ deleted │
│        │◄─────────────── │         │                │         │
└────────┘     enable      └─────────┘                └─────────┘
StatusDescriptionCan Login?
activeNormal user accountYes
blocked Temporarily disabled No (status checked on login)
deleted Soft-deleted No (status checked on login)

Soft-Delete Design

Users are never physically removed from the database. Instead:

  • disableUser() sets status = 'blocked' — temporary suspension
  • deleteUser() sets status = 'deleted' — permanent archival
  • Both preserve all user data, function ownership, and execution history
  • The platform backend validates user status on login and rejects blocked/deleted users

User Operations

dart
// List users with search and status filtering
GET /api/admin/users?page=1&page_size=20&search=john&status=active

// Get detailed user info
GET /api/admin/users/<userUuid>

// Update user fields
PATCH /api/admin/users/<userUuid>

// Status management (soft actions)
POST /api/admin/users/<userUuid>/disable
POST /api/admin/users/<userUuid>/enable
DELETE /api/admin/users/<userUuid>  // Soft-delete

// Temporary password
POST /api/admin/users/<userUuid>/send-temporary-password

Temporary Passwords

Admins can generate one-time passwords for users:

  • GenerationRandom.secure() creates a cryptographically random password
  • Hashing — Password is hashed with BCrypt before storage
  • Flagtemporary_password = true set on the user record
  • Delivery — Password is emailed to the user via ForwardEmail
  • Expiry — User is prompted to change password on next login
dart
// Service-level generation
final password = UsersService.generateTemporaryPassword();
final hash = BCrypt.hashpw(password, BCrypt.gensalt());
await Database.update('users', {
  'password_hash': hash,
  'temporary_password': true,
}, where: {'uuid': userUuid});
await emailClient.sendTemporaryPassword(email: userEmail, password: password);

Email & Name Masking

List responses mask PII for privacy:

  • Emails: [email protected]jo***@example.com (first 2 chars + *** + domain)
  • Names: John DoeJD*** (initials only)

Detail endpoints return full information for admin use.

Organizations

Organizations group users together for collaborative function management.

Organization Operations

dart
// List organizations
GET /api/admin/organizations?page=1&page_size=20&search=acme

// Create organization
POST /api/admin/organizations
Body: { "name": "Acme Corp", "owner_uuid": "user-uuid" }

// Get organization with members
GET /api/admin/organizations/<orgUuid>

// Update organization
PATCH /api/admin/organizations/<orgUuid>
Body: { "name": "Acme Corp Updated" }

// Manage members
POST /api/admin/organizations/<orgUuid>/members
Body: { "user_uuid": "user-uuid", "role": "member" }

DELETE /api/admin/organizations/<orgUuid>/members/<userUuid>

Database Schema

sql
CREATE TABLE organizations (
  uuid UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  name VARCHAR(255) NOT NULL,
  owner_id UUID REFERENCES users(uuid),
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE organization_members (
  uuid UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  organization_uuid UUID REFERENCES organizations(uuid),
  user_uuid UUID REFERENCES users(uuid),
  role VARCHAR(50) DEFAULT 'member',
  joined_at TIMESTAMP DEFAULT NOW(),
  UNIQUE(organization_uuid, user_uuid)
);

Tiers

Tiers define service levels for users, controlling feature access and limits.

Tier Operations

dart
// List tiers
GET /api/admin/tiers

// Create tier
POST /api/admin/tiers
Body: {
  "name": "Pro",
  "description": "Professional tier",
  "metadata": { "max_functions": 50, "max_memory": 512 }
}

// Update tier
PATCH /api/admin/tiers/<tierId>

// Delete tier
DELETE /api/admin/tiers/<tierId>

// Assign/unassign users
POST /api/admin/tiers/<tierId>/assign-user
Body: { "user_uuid": "user-uuid" }

DELETE /api/admin/tiers/<tierId>/assign-user/<userUuid>

Database Schema

sql
CREATE TABLE tiers (
  uuid UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  name VARCHAR(100) NOT NULL UNIQUE,
  description TEXT,
  metadata JSONB DEFAULT '{}',
  is_active BOOLEAN DEFAULT true,
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE user_tiers (
  uuid UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  user_uuid UUID REFERENCES users(uuid),
  tier_id UUID REFERENCES tiers(uuid),
  assigned_at TIMESTAMP DEFAULT NOW(),
  UNIQUE(user_uuid)
);

Feature Roles

Feature roles control granular access to specific platform features. They are managed via Keycloak's client-level role system.

Role Architecture

Diagram
┌────────────────────────────────────────────────────────────┐
│  Default Roles (always applied)                            │
│  ├── functions:read                                        │
│  ├── functions:write                                       │
│  ├── sites:read                                            │
│  └── sites:write                                           │
├────────────────────────────────────────────────────────────┤
│  Grantable Roles (admin-managed)                           │
│  ├── container:read                                        │
│  ├── container:write                                       │
│  ├── webhook:read                                          │
│  └── webhook:write                                         │
└────────────────────────────────────────────────────────────┘
  • Default roles are automatically applied to all users
  • Grantable roles are individually assigned by admins
  • The grantable allowlist prevents accidental assignment of system-level roles
  • Admins can only grant/remove roles from the _grantableRoles allowlist

Role Operations

yaml
// Get user's feature roles
GET /api/admin/keycloak-users/<keycloakUserId>/feature-roles
Response: {
  "assigned_roles": ["container:read"],
  "default_roles": ["functions:read", ...],
  "grantable_roles": ["container:read", ...],
  "effective_roles": ["functions:read", ..., "container:read"]
}

// Grant roles (only grantable roles allowed)
POST /api/admin/keycloak-users/<keycloakUserId>/feature-roles/grant
Body: { "roles": ["container:read", "webhook:write"] }

// Remove roles
POST /api/admin/keycloak-users/<keycloakUserId>/feature-roles/remove
Body: { "roles": ["container:read"] }

// Get role statistics across all users
GET /api/admin/feature-roles/stats
Response: {
  "roles": {
    "container:read": { "assigned_count": 42 },
    "container:write": { "assigned_count": 15 },
    "webhook:read": { "assigned_count": 38 },
    "webhook:write": { "assigned_count": 12 }
  },
  "total_users": 150,
  "total_full_admins": 3
}

Role Stats Design

The role stats endpoint:

  • Dynamically fetches all client roles from Keycloak (not just grantable ones)
  • Counts users assigned to each role
  • Excludes full admins from all counts (they skew statistics)
  • Returns total normal user count and full admin count separately
dart
Future<Map<String, dynamic>> getRoleStats() async {
  // 1. Identify full admins (exclude from all stats)
  final fullAdminIds = await _getFullAdminUserIds();

  // 2. Count normal users (exclude full admins)
  final totalUsers = await _countNormalUsers(excludeIds: fullAdminIds);

  // 3. For each Keycloak client role, count assigned normal users
  final clientRoles = await _keycloakClient.listClientRoles(clientId: _clientId);
  for (final role in clientRoles) {
    final users = await _keycloakClient.listClientRoleUsers(...);
    final filtered = users.where((u) => !fullAdminIds.contains(u['id']));
    stats[roleName] = { 'assigned_count': filtered.length };
  }

  return {
    'roles': stats,
    'total_users': totalUsers,
    'total_full_admins': fullAdminIds.length,
  };
}

User Database Schema

The users table includes both the database package schema and admin-specific fields:

sql
CREATE TABLE users (
  uuid UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  email VARCHAR(255) UNIQUE NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  first_name VARCHAR(100),
  last_name VARCHAR(100),
  status VARCHAR(20) DEFAULT 'active'
    CHECK (status IN ('active', 'blocked', 'deleted')),
  temporary_password BOOLEAN DEFAULT false,
  keycloak_user_id VARCHAR(255),
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

Design Patterns

Handler-Service Separation

Handlers parse requests and format responses. Services contain business logic:

dart
// Handler: request parsing + response formatting
class UsersHandler {
  Future<Response> listUsers(Request request) async {
    final page = request.intParam('page', default: 1);
    final search = request.url.queryParameters['search'];
    final status = request.url.queryParameters['status'];
    final result = await _usersService.listUsers(page: page, search: search, status: status);
    return jsonOk(result);
  }
}

// Service: business logic + database access
class UsersService {
  Future<Map<String, dynamic>> listUsers({
    required int page,
    String? search,
    String? status,
  }) async {
    // Build query, filter, paginate, mask emails
  }
}

Status Validation on Login

The platform backend (dart_cloud_backend) checks user status during authentication:

dart
final user = await Database.find('users', where: {'email': email});
if (user == null) return Unauthorized;
if (user['status'] == 'blocked') return Forbidden('Account has been blocked');
if (user['status'] == 'deleted') return Forbidden('Account has been deleted');
// ... proceed with password verification

Error Handling

All errors in handlers and services are captured by ErrorReporting.captureException():

dart
try {
  await _usersService.deleteUser(userUuid);
  return jsonOk({'success': true});
} catch (e, stack) {
  ErrorReporting.captureException(e, stack);
  return jsonError('Failed to delete user: ${e.toString()}');
}

Errors are logged to console and reported to Sentry (when configured via DSN_SENTRY).

Next Steps