Eviworx
Docs

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.

🏗️
Features
✓ RBAC (28+ modules, 350+ permissions)
✓ Permission cache per role (5 min)
✓ Dynamic roles (4 system roles + DPO)
✓ Email invitation (password via link)
✓ Two-factor sign-in (TOTP)
✓ GDPR data export and anonymization
✓ Agent capacity (maxWorkload)
✓ 4 assignment strategies (e.g. ROUND_ROBIN)
✓ Substitute for absent agents
✓ EntraID sync (group → role)

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_USERrole-system-enduser99000trueBasic user with ticket creation and view own
AGENTrole-system-agent2000trueSupport agent with full ticket and problem management
ADMINrole-system-admin1000trueFull access to all modules and settings
APPROVERrole-system-approver3000trueApproval permissions for changes and incidents
DATA_PROTECTION_OFFICERrole-custom-dsb2500falseData 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.

PermissionAction
users.dataExportData 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.erasePre-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
ManagerFull management (create, approve, reject)Full accessAdd/Remove
TeamLeadRead-only (can view absences, not manage)No group CRUDOwn 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.

StrategySelection
FIRST_AVAILABLEthe first available agent of the group
ROUND_ROBINin turn: the next available agent after the last one assigned
LEAST_LOADEDthe agent with the lowest current workload
SKILL_BASEDthe 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
ADMIN1000Always wins (highest priority)
AGENT2000Second priority
DATA_PROTECTION_OFFICER2500Data protection (DPO)
APPROVER3000Approval role
Custom roles~50000Medium priority
END_USER99000Lowest 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

  1. 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.
  2. Permission Granularity: Use viewOwn for END_USER, viewAll for AGENT, editAll for ADMIN
  3. Agent Groups Setup: At least 2 groups (Level 1 + Level 2), GENERAL queue for fallback. Users are assigned directly via Agent profile.
  4. Manager vs TeamLead: Manager for full group management; TeamLead: read-only absences + notifications, plus skills/pause/workload of own group members
  5. Agent Capacity: Set maxWorkload per agent for fair distribution, monitor via dashboard widget
  6. Assignment Strategy: LEAST_LOADED for fair distribution (respects maxWorkload), ROUND_ROBIN for a predictable order
  7. Invitation Emails: Use the invitation flow for new users instead of manual password setup
  8. Enable MFA: Recommended for all admin and agent accounts, TWO_FACTOR_ENCRYPTION_KEY must be set
  9. Absence Planning: Plan absences 1-2 weeks ahead, always set substitute for >3 days
  10. Use Followers: Stakeholders can follow tickets instead of being added as CC - FOLLOWER is never downgraded
  11. Language Settings: Users can choose from 5 languages (DE, EN, ES, FR, IT), activities appear in the chosen language
  12. Email-Only Users: emailOnlyContact = true for external customers without portal access
  13. Permission Cache: The permission cache is cleared automatically on role changes; test critical actions after a role change

Related Documentation