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:
┌────────┐ block ┌─────────┐ delete ┌─────────┐ │ active │ ───────────────►│ blocked │ ──────────────►│ deleted │ │ │◄─────────────── │ │ │ │ └────────┘ enable └─────────┘ └─────────┘
| Status | Description | Can Login? |
|---|---|---|
| active | Normal user account | Yes |
| 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
// 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:
- Generation — Random.secure() creates a cryptographically random password
- Hashing — Password is hashed with BCrypt before storage
- Flag — temporary_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
// 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 Doe → JD*** (initials only)
Detail endpoints return full information for admin use.
Organizations
Organizations group users together for collaborative function management.
Organization Operations
// 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
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
// 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
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
┌────────────────────────────────────────────────────────────┐ │ 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
// 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
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:
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:
// 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:
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():
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
- Read Admin Backend Overview for server architecture
- Read Admin API Reference for complete endpoint documentation
- Read Keycloak Authentication for auth system details
- Read Early Access Admin for early access management