User Management Architecture
🛡️ Permission/authz model: The permission model (actor model, permission checks, caching, visibility, links) is described on its own page: Permissions & RBAC →. This page covers operational user/agent/group/absence management.
User management covers roles and permissions (RBAC with 28+ modules, freely definable roles incl. data protection officer), agent groups with automatic assignment and capacity limits, absences with substitution, email invitations, two-factor sign-in (TOTP), ticket followers and the Entra ID connection.
System Overview
USER MANAGEMENT ARCHITECTURE
===============================================================================
User Model
* id, email, password, name, avatar
* roleId (FK to Role)
* roleChangedAt, roleChangedBy (Audit)
* theme, language (DE, EN, ES, FR, IT)
* twoFactorEnabled, twoFactorSecret (MFA/TOTP)
|
+-- 1:1 -> ManagedUser (END_USER profile)
| * firstName, lastName, phone, department, location
| * isActive, isArchived (Soft-delete)
| * lockSource: ADMIN, ENTRA_SYNC, SYSTEM (set while locked)
| * entraIDUserId (SSO mapping)
| * source: PORTAL, EMAIL, ASSET_CHECKOUT (origin snapshot)
| * emailOnlyContact, autoCreatedFromEmail
| * invitationStatus: PENDING / ACCEPTED
| * invitationSentAt, invitationSentBy
|
+-- 1:1 -> Agent (AGENT profile)
| * workload (Current assigned items)
| * maxWorkload (Capacity limit per agent)
| * lastActivity
| * N:M -> AgentGroup (via AgentGroupMember)
|
+-- 1:N -> UserAbsence (Absences)
| * type, startDate, endDate, substituteId
| * status (PENDING/APPROVED/REJECTED)
|
+-- N:M -> TicketParticipant (Follower System)
* role: CC, FOLLOWER, MENTIONED
* source: MANUAL, EMAIL_CC, MENTION, MERGE, FOLLOW,
CUSTOMER_CHANGE, SLA_ESCALATION
|
v
Role Model (Dynamic)
* id, name, displayName, description
* isSystem (system roles cannot be deactivated, deleted or moved), isActive
* priority (EntraID conflict resolution)
* permissions (JSONB) - 28+ feature modules
* entraIDRoleId (Azure AD Group mapping)
|
+-- System Roles (protected):
| END_USER (priority: 99000)
| AGENT (priority: 2000)
| ADMIN (priority: 1000)
| APPROVER (priority: 3000)
|
+-- Pre-installed Custom Role:
| DATA_PROTECTION_OFFICER / DSB (priority: 2500)
| - Supports the GDPR Art. 33 notification duty
| - Can confirm data breaches in incidents
| - Read-only access to all incidents
|
+-- Custom Roles:
Fully dynamic, created via admin UI
Full permission matrix configuration
|
v
Permission Loading (per request, by role)
* Read requests: role matrix from the cache (5 min, per role)
* Modifying requests (POST/PUT/PATCH/DELETE) and critical
actions: always fresh from the database
* A role change via the API clears the role's cache at once
|
v
AgentGroup (Assignment System)
* id, name, color, applicableEntityTypes[], assignmentStrategy
* lastAssignedAgentId (for ROUND_ROBIN)
* N:M -> Agent (via AgentGroupMember)
+-- isActive (per-group status)
+-- isTeamLead (read-only absence view, notifications; manages own group: skills/pause/workload)
+-- joinedAt
* Note: Users are assigned to AgentGroups via Agent profile directly
Absence-aware Assignment
* Absent or inactive agents are skipped by every strategy
* Assigning to an absent agent redirects to the substitute
* ignoreSubstitution bypasses the redirect (audited)
Capacity
* Agents at their maxWorkload are skipped by automatic assignment
* ASSIGNMENT_CAPACITY_REACHED notification to the group's team leads
* Dashboard widget for workload tracking
Role System (Dynamic)
Besides the 4 protected system roles, any number of custom roles can be created. The DATA_PROTECTION_OFFICER (DPO) role is also pre-installed for data protection tasks. Roles are available wherever permissions are assigned, e.g. in form management.
Pre-installed Roles (5)
| Role | ID | Priority | isSystem | Description |
|---|---|---|---|---|
| END_USER | role-system-enduser | 99000 | true | Basic user with ticket creation and view own |
| AGENT | role-system-agent | 2000 | true | Support agent with full ticket and problem management |
| ADMIN | role-system-admin | 1000 | true | Full access to all modules and settings |
| APPROVER | role-system-approver | 3000 | true | Approval permissions for changes and incidents |
| DATA_PROTECTION_OFFICER | role-custom-dsb | 2500 | false | Data protection officer (DPO): supports the GDPR Art. 33 notification duty, confirms data breaches in incidents, reads all incidents |
User Types & Data Models
User Model (Core)
interface User {
id: string; // CUID
email: string; // Unique
password?: string; // Optional (invitation flow, SSO)
name: string; // Full name
avatar?: string; // Compressed image
theme?: string; // light, dark, system
language?: string; // de, en, es, fr, it (5 languages)
timezone?: string; // system, local or e.g. Europe/Berlin
dateTimeFormat?: string; // system or e.g. dd.MM.yyyy HH:mm
// RBAC
roleId: string; // FK to Role
roleChangedAt?: Date;
roleChangedBy?: string; // User ID who changed
// MFA / Two-Factor
twoFactorEnabled: boolean; // TOTP enabled
twoFactorSecret?: string; // Encrypted via TWO_FACTOR_ENCRYPTION_KEY
// Relations
role?: Role;
managedProfile?: ManagedUser; // 1:1
agent?: Agent; // 1:1
absences?: UserAbsence[]; // 1:N
participants?: TicketParticipant[]; // N:M (follower/CC)
createdAt: Date;
updatedAt: Date;
}
Language, timezone and date format are personal settings of the user. They apply in the interface and equally to texts the server generates — email, push, Teams and Webex. Without a personal setting the system default applies; the full order of precedence is documented with the notifications.
ManagedUser (END_USER Profile)
interface ManagedUser {
userId: string; // Unique FK to User (1:1)
// Contact Info
firstName?: string;
lastName?: string;
phone?: string;
department?: string;
location?: string;
// Status
isActive: boolean; // false = locked (no sign-in)
isArchived: boolean; // archived = always locked
lockSource?: string; // ADMIN, ENTRA_SYNC, SYSTEM — set exactly while locked
lastLogin?: Date;
// Invitation Flow
invitationStatus?: string; // PENDING, ACCEPTED
invitationSentAt?: Date;
invitationSentBy?: string; // Admin user ID
// EntraID/SSO
entraIDUserId?: string; // Unique
entraIDSyncedAt?: Date;
isSyncedFromEntraID: boolean;
entraIDConflict: boolean; // true = user is in no mapped role group
// Source Tracking
source?: string; // PORTAL, EMAIL, ASSET_CHECKOUT — snapshot of how the account was created
emailOnlyContact: boolean; // TRUE = no portal login
autoCreatedFromEmail: boolean; // TRUE = auto-created from email
}
Agent Profile
interface Agent {
userId: string; // Unique FK to User (1:1)
avatar?: string; // Agent-specific avatar
isActive: boolean; // Global agent status
// Assignment & Capacity
workload: number; // Current assigned items count
maxWorkload?: number; // Capacity limit (1-999, nullable = unlimited)
lastActivity?: Date;
// Relations
memberships: AgentGroupMember[]; // N:M to groups
specialties: AgentOnSpecialty[]; // For SKILL_BASED
}
Invitation System (Invitation Flow)
New users can be invited via email. The admin creates the user, an invitation email is sent, and the user sets their password via the link.
INVITATION FLOW:
1. Admin creates user (POST /api/users, no password)
-> User created with no password
-> invitationStatus = null
2. Invitation is sent (POST /api/users with sendInvitation: true,
or later POST /api/users/:id/resend-invitation)
-> A one-time token is generated
(valid 24 h, configurable via INVITATION_TOKEN_TTL_HOURS)
-> Invitation email sent with password-setup link
-> invitationStatus = 'PENDING'
-> invitationSentAt = now()
-> invitationSentBy = adminUserId
3. User clicks link -> validates token
-> GET /api/auth/validate-invitation
-> Returns user info if token valid and account active
4. User sets password
-> POST /api/auth/setup-password
-> Password is hashed and stored
-> invitationStatus = 'ACCEPTED'
-> Token invalidated
-> User can now log in
User Lifecycle & GDPR
A user is retired in two steps: first ARCHIVED (isArchived; from then on neither sign-in nor API access is possible until an administrator unarchives the account with users.archive — a sign-in never changes the status), then optionally ANONYMIZED. Anonymization (Art. 17) irreversibly overwrites all personal data but leaves the row as a skeleton for foreign-key integrity and case history. Archiving is thus the deliberate precursor: departed users are automatically anonymized after 180 days (unless an active case blocks it or an erasure hold is set). The period runs from the last contact — the later of the archiving and the last email on that account; an archived account that is still being corresponded with is therefore not anonymized.
| Permission | Action |
|---|---|
users.dataExport | Data export of a person (Art. 15/20), without a quota; the self-service export runs without a permission via /privacy/export/me, at most one per 24 hours there |
users.erase | Pre-check, anonymization (archived accounts only) and erasure hold (at any time, with a reason); critical action, revalidated fresh from the DB |
Full flow, pre-check blockers, error codes, periods, erasure backlog and retention: Privacy & GDPR.
MFA / Two-Factor Authentication
Users can enable TOTP-based two-factor authentication in their security settings. The TOTP secret is stored encrypted with TWO_FACTOR_ENCRYPTION_KEY.
MFA SETUP FLOW: 1. User requests setup (POST /api/auth/2fa/setup) -> The server generates a TOTP secret -> Secret encrypted with TWO_FACTOR_ENCRYPTION_KEY (AES-256-GCM) -> Returns QR code + backup codes 2. User verifies with authenticator app -> POST /api/auth/2fa/verify-setup -> Validates TOTP code -> twoFactorEnabled = true 3. On login with MFA enabled: -> Normal login returns twoFactorPendingToken (partial session) -> POST /api/auth/2fa/complete-login -> Validates TOTP code -> full session ADMIN MANAGEMENT: POST /api/admin/users/:id/reset-2fa (Permission users.reset2FA) -> Admin can reset MFA for a user (e.g., lost device); self-reset blocked -> Audit-logged
Follower System (Ticket Participants)
Users can follow tickets and receive notifications on changes. The system supports three roles: CC (from email), FOLLOWER (actively followed), MENTIONED (mentioned). FOLLOWER has the highest priority and is never downgraded.
interface TicketParticipant {
ticketId: string;
userId: string;
role: 'CC' | 'FOLLOWER' | 'MENTIONED';
source: 'MANUAL' | 'EMAIL_CC' | 'MENTION' | 'MERGE' | 'FOLLOW' | 'CUSTOMER_CHANGE' | 'SLA_ESCALATION';
addedAt: Date;
}
// Hierarchy: FOLLOWER > CC > MENTIONED
// If user is CC and follows → upgraded to FOLLOWER
// If user is FOLLOWER and added as CC → stays FOLLOWER (no downgrade)
Role Permissions (RBAC)
Each role carries a permission matrix: per module (tickets, problems, changes, incidents, assets, workflows, settings, users, audit …) a set of boolean actions. A role change applies immediately to all holders of the role; modifying requests and critical actions (e.g. *.delete, changes.approve, settings.manageIntegrations) always check permissions fresh. When a user's role changes, the system writes a ROLE_CHANGE audit entry with the old and new role; it sits in the SHA-256 hash chain of the audit log, so later modifications are detectable.
The full permission catalog (all modules/actions), the system roles, the three check levels and permission loading and caching are documented centrally at Permissions & RBAC.
Agent Group Assignment System
Manager vs TeamLead
In AgentGroups there are two leadership roles with different permissions:
| Role | Absences | Group Management | Members |
|---|---|---|---|
| Manager | Full management (create, approve, reject) | Full access | Add/Remove |
| TeamLead | Read-only (can view absences, not manage) | No group CRUD | Own group: skills, pause/activate, workload limit (no add/remove/team-lead assignment) |
TeamLead capability (for members of their OWN active group): maintain specialties/proficiency, pause/activate membership (the membership's isActive) and set the workload limit (maxWorkload). Allowed with agents.manageGroups or as team lead of the group. Group CRUD, adding/removing members and team-lead assignment remain exclusively with agents.manageGroups.
Agent Capacity (maxWorkload)
Each agent can have an optional capacity limit (maxWorkload, 1–999); without an own limit the global setting applies. Agents whose workload has reached the limit are skipped by automatic assignment. If the assignment therefore finds no agent, the group's team leads receive an ASSIGNMENT_CAPACITY_REACHED notification. A dashboard widget shows the current workload status.
// Agent capacity configuration
interface Agent {
workload: number; // Current assigned items
maxWorkload?: number; // Limit (1-999, null = global setting)
}
Assignment Strategies
Before any strategy runs, absent and inactive agents, paused memberships and agents at their capacity limit are filtered out. If nobody is left, the item stays with the group but unassigned.
| Strategy | Selection |
|---|---|
FIRST_AVAILABLE | the first available agent of the group |
ROUND_ROBIN | in turn: the next available agent after the last one assigned |
LEAST_LOADED | the agent with the lowest current workload |
SKILL_BASED | the agent with the best specialty match for the category (proficiency, then workload); without a category or match, as LEAST_LOADED |
Absences
Timezone-Aware Absences
An approved absence applies either all day (allDay) or for part of the day (startTime/endTime). Partial-day absences are evaluated in the configured time zone (default Europe/Berlin).
EXAMPLES:
Szenario 1: All-Day Absence
Absence: { startDate: "2026-02-10", endDate: "2026-02-14", allDay: true }
Check at: 2026-02-12 14:30
Result: TRUE (absent all day)
Szenario 2: Partial-Day Absence
Absence: {
startDate: "2026-01-30",
endDate: "2026-01-30",
allDay: false,
startTime: "08:00",
endTime: "12:00"
}
Check at: 2026-01-30 10:30 (Europe/Berlin)
Result: TRUE (currently within 08:00-12:00)
Check at: 2026-01-30 14:30 (Europe/Berlin)
Result: FALSE (after 12:00)
Szenario 3: Timezone Conversion
at = 2026-01-30 09:00:00 UTC
Timezone: Europe/Berlin (UTC+1)
Local time: 10:00 (CET)
startTime: "08:00", endTime: "12:00"
Result: TRUE (10:00 within 08:00-12:00)
Substitute on Manual Assignment
If an item is assigned to an absent agent, it goes to their substitute; substitution chains are followed across at most three stations and cycles are detected. ignoreSubstitution bypasses the redirect; this is audited (SUBSTITUTE_BYPASS). Whoever ends up with the item is verified as available: if the absent agent has no substitute, or the chain ends on a person who is absent themselves, the item stays with the absent original — in the second case audited as SUBSTITUTE_UNAVAILABLE. This way the record visibly sits there instead of appearing to be worked on.
EntraID/Azure AD Integration
Group-to-Role Mapping
// Link role with EntraID group
PATCH /api/roles/:id
{
"entraIDRoleId": "azure-group-uuid"
}
// EntraID Sync Process (per base-group member):
// 1. Fetch the user's EntraID groups
// 2. Match groups to ACTIVE roles via entraIDRoleId
// (unique: one group maps to at most one role)
// 3. Exactly one match -> that role
// 4. Several matches -> lowest priority number wins (no conflict):
// - ADMIN: 1000
// - AGENT: 2000
// - DATA_PROTECTION_OFFICER: 2500
// - APPROVER: 3000
// - Custom roles: ~50000
// - END_USER: 99000
// 5. No match -> entraIDConflict = true:
// existing users keep their role as long as it is usable
// (an active role with a valid permission matrix),
// otherwise they get END_USER; new users get END_USER
Precedence across multiple groups
| Role | Priority | Result with multiple matches |
|---|---|---|
| ADMIN | 1000 | Always wins (highest priority) |
| AGENT | 2000 | Second priority |
| DATA_PROTECTION_OFFICER | 2500 | Data protection (DPO) |
| APPROVER | 3000 | Approval role |
| Custom roles | ~50000 | Medium priority |
| END_USER | 99000 | Lowest priority |
Conflict accounts (entraIDConflict = true): only for them is the role selection in user management editable; otherwise Entra manages the role — a role change via the API on a synchronized account without a conflict is rejected with 403 ROLE_MANAGED_BY_ENTRA_ID. A manually set role is kept by the sync as long as the account is in no mapped group and the role is usable (active and with a valid permission matrix); otherwise the sync sets END_USER so the account stays able to work. Once the account joins a mapped group, the next sync sets the role from the mapping.
Sync and account status
- The sync runs as a built-in daily job ("Entra ID User Sync", 01:00 UTC) and can additionally be triggered by hand at any time (POST /api/entra-id/sync, permission settings.editIntegrations); without an active integration it does nothing. Triggering it during a running job does not start a second one: the response is 409 ENTRA_ID_SYNC_ALREADY_RUNNING and names the running job along with its progress.
- Only the sync creates accounts from the base group. The Microsoft sign-in creates no accounts and does not check groups.
- Anyone who leaves the base group is locked on the next sync. Every lock records its origin: administration, Entra sync or system (lockSource: ADMIN, ENTRA_SYNC, SYSTEM). The origin sits on the account and is visible in user management — it explains why an account locked by the sync gets locked again on the next run after a reactivation as long as it is not in the base group.
- If an account returns to the base group, the sync lifts only its own lock. Locks set by administration or the system, as well as archived accounts, stay in place; the sync records this once per lock in the audit (ENTRAID / USER_LOCK_KEPT). This way a sync cannot undo a deliberate lock set by administration.
- Protection against mass locking: if the base group is empty, the sync locks nobody. If a run would lock more than 20 % of the active synchronized accounts AND more than 50 accounts, it aborts before the first lock.
- Accounts disabled in Entra are locked by the sync as well, with the origin Entra sync. If the account status cannot be read from Entra, it stays unchanged and the run records this in its result — a missing read permission therefore locks nobody by accident.
- If the sync does not take over an account completely, it states the reason as a code: NO_ROLE_GROUP (the account is in no mapped role group — an existing account keeps its usable role, otherwise END_USER), PROTECTED_ACCOUNT (a protected account the sync never links) or NO_MAIL (the directory provides no email address — such an account is not created). Every conflict row names the directory ID, the display name and, where present, the email address; this way the account can be found in the directory even without a mailbox.
- If the sync changes the role of an account, its holder is notified — on the same path as for a role change made by administration, and without a duplicate notification when both paths concern the same change. The time of the last role change sits on the account (roleChangedAt).
- If a contact created from email is in the base group, the sync turns it into a full user: the flags emailOnlyContact and autoCreatedFromEmail are cleared, the account appears under "Users" instead of "Email contacts" and takes part in internal notifications. The origin (source) stays unchanged — it describes how the account came into being.
- Synchronized accounts sign in via Microsoft only: when linking, the sync removes a local password along with local two-factor sign-in and ends open sessions. A password login then answers 401 ACCOUNT_REQUIRES_PASSWORD_SETUP, a password change 400 ACCOUNT_NO_PASSWORD.
- Every status change takes effect immediately — whether it comes from administration or from the sync: blocking ends all of the account's access (running sessions, refresh tokens, push subscriptions, open real-time connections), and the permission cache is cleared.
How blocked and archived accounts are rejected at sign-in: Authentication.
Deployment & Configuration
Environment Variables
# MFA / Two-Factor
TWO_FACTOR_ENCRYPTION_KEY=... # AES key for TOTP secret encryption (required for MFA)
# Invitations
INVITATION_TOKEN_TTL_HOURS=24 # Validity of invitation links (hours)
Initial Setup
# 1. System roles + DSB role automatically created on first start (seed)
# System: role-system-enduser, role-system-agent, role-system-admin, role-system-approver
# Custom default: role-custom-dsb (DATA_PROTECTION_OFFICER)
# 2. Create first admin user
POST /api/users
{
"email": "admin@company.com",
"name": "System Admin",
"roleId": "role-system-admin",
"password": "secure-initial-password"
}
# 3. Or invite users via email (omit the password -> invitation flow)
POST /api/users
{ "email": "user@company.com", "name": "New User", "roleId": "role-system-enduser" }
# -> Invitation email sent, user sets password via link
POST /api/users/:id/resend-invitation
# -> Sends the invitation again
# 4. Create agent groups
POST /api/agents/groups
{
"name": "IT Support Level 1",
"applicableEntityTypes": ["TICKET"],
"assignmentStrategy": "ROUND_ROBIN"
}
# 5. Add agents to groups (userId is the canonical id)
POST /api/agents/groups/:groupId/members/:userId
# ... and promote a member to team lead
PATCH /api/agents/groups/:groupId/members/:userId
{
"isTeamLead": true
}
# 6. Configure agent capacity (optional)
PATCH /api/agents/:userId
{
"maxWorkload": 25
}
Best Practices
- Role Design: Start with the 5 pre-installed roles (incl. DPO), create additional custom roles only when needed. Roles are dynamic and can be adjusted at any time.
- Permission Granularity: Use viewOwn for END_USER, viewAll for AGENT, editAll for ADMIN
- Agent Groups Setup: At least 2 groups (Level 1 + Level 2), GENERAL queue for fallback. Users are assigned directly via Agent profile.
- Manager vs TeamLead: Manager for full group management; TeamLead: read-only absences + notifications, plus skills/pause/workload of own group members
- Agent Capacity: Set maxWorkload per agent for fair distribution, monitor via dashboard widget
- Assignment Strategy: LEAST_LOADED for fair distribution (respects maxWorkload), ROUND_ROBIN for a predictable order
- Invitation Emails: Use the invitation flow for new users instead of manual password setup
- Enable MFA: Recommended for all admin and agent accounts, TWO_FACTOR_ENCRYPTION_KEY must be set
- Absence Planning: Plan absences 1-2 weeks ahead, always set substitute for >3 days
- Use Followers: Stakeholders can follow tickets instead of being added as CC - FOLLOWER is never downgraded
- Language Settings: Users can choose from 5 languages (DE, EN, ES, FR, IT), activities appear in the chosen language
- Email-Only Users: emailOnlyContact = true for external customers without portal access
- Permission Cache: The permission cache is cleared automatically on role changes; test critical actions after a role change
Related Documentation
- Users & Roles API - API reference, endpoints, examples
- Tickets API - Assignment integration, substitute system, followers/participants
- Problems API - Assignment to agent groups
- Incidents API - Escalation, assignment, DSB data breach handling
- SLA System - Absence integration, SLA pause
- Integrations - Email-only users (auto-created)
- Authentication - JWT, EntraID SSO, RBAC token integration, MFA/TOTP
- CronJobs API - Scheduled background jobs
- Audit System - Role changes, absence approvals logging