On this page0
Unfamiliar word? See Glossary: words used in these docs. Looking for the tasks in order? Begin at Start Here.
Reference
Included in: All plans. Which Plan Includes What
User Accounts
Create, update, and offboard the people in your directory — and verify the privilege controls that keep account creation from becoming a back door to administrator.
What It Does
The Users screen is under Administration → Users. You do not need the API for anything described in this section.
Invitations And Passwords
Basic Access Is Automatic
The Primary Administrator Is Sealed
Some Actions Are One Person At A Time
If something here is not working as you expect, start with When something goes wrong.
The plain explanation is above. This part is for IT and developers: the exact settings, permission names, API addresses and data fields behind it. Closed: select the heading above to open it. While it is closed, your browser’s find-on-page (Ctrl+F or Cmd+F) does not search this part.
One Gate, Two Invariants
Every write to the user directory is gated on the manage_users permission at the service router. But manage_users alone is deliberately not enough to create or grant elevated access: attaching roles or groups needs a second permission, and even then the caller can only confer permissions it already holds. Those two invariants are the controls an inspector checks.
The Endpoints
All paths are served through the API gateway under /api/v1/user-management and require an authenticated session (the gateway rejects an unauthenticated request before it reaches the service). The service then enforces the RBAC gate shown in the Auth column. The Auth value is the route-level gate; where a body field needs an additional permission, that is called out below the table.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /api/v1/user-management/users | Create a user (local or federated) | manage_users |
| GET | /api/v1/user-management/users/import/template | Download the CSV template for bulk import | manage_users |
| POST | /api/v1/user-management/users/import/preview | Validate a CSV and show what an import would create, without creating anything | manage_users |
| POST | /api/v1/user-management/users/import/commit | Create the users from a CSV (same create rules as a single user) | manage_users |
| POST | /api/v1/user-management/users/:id/invite/resend | Send a fresh set-password invitation (replaces the previous link) | manage_users |
| POST | /api/v1/user-management/users/:id/invite/revoke | Cancel an outstanding invitation so its link stops working | manage_users |
| POST | /api/v1/user-management/users/bulk | Apply one safe action to many selected users | manage_users |
| POST | /api/v1/user-management/users/:id/expire-password | Force a new password at next sign-in and end every live session | manage_users |
| POST | /api/v1/user-management/users/expire-password | The same for up to 500 users in one call | manage_users |
| GET | /api/v1/user-management/users/identity-availability | Check whether an email or account name is free, and get suggestions that follow the naming convention | execute_workflows | manage_users | view_users | view_dashboard |
| PUT | /api/v1/user-management/users/:id | Update an existing user (allowlisted fields; a password reset needs more) | manage_users (+ manage_security and sudo to reset a password) |
| DELETE | /api/v1/user-management/users/:id | Delete a user who is no longer active (erased, or anonymized when history references them) | manage_users |
| POST | /api/v1/user-management/users/:id/offboard | Start an offboarding run (revoke + deprovision) | manage_users |
GET /api/v1/user-management/users lists the directory and is reachable to a wider set of read permissions (it also powers app-wide people pickers), but non-privileged callers receive only a minimal identity card — full PII, roles, and groups are returned only to view_users/manage_users/manage_permissions. Directory read is out of scope for this page.
Creating A User
The full accepted body is below. Be precise about “required”. A user is either local (signs in with a Zonitix password) or federated (any other source, such as a directory sync), and the create is refused in these cases: 409 when the email, compared without regard to case, already belongs to an account, whether that account is active or not; 400 when sendInvite is used for a non-local source; 400 when a local source has neither sendInvite: true nor a password; 400 when a supplied sAMAccountName is already taken; and 400 when a supplied password fails the password policy. It does not itself reject a missing firstName, lastName, or email; the admin-panel form asks for them, but the endpoint will not 400 on their absence. Any field not listed here (for example MFA state or raw attributes-driven security columns) is not settable through this route.
// POST /api/v1/user-management/users — full accepted body
{
"firstName": "Ada",
"lastName": "Lovelace",
"email": "ada@your-company.cc",
"sAMAccountName": "alovelace", // if set, must be unique -> else 400
"sendInvite": true, // local only: email a set-password invitation; any password is ignored
"password": "S3cur3P@ss!", // local source needs this OR sendInvite
"position": "Staff Engineer",
"department": "Platform",
"managerId": 42, // "" is coerced to null
"status": "active", // defaults to "active"
"source": "local", // defaults to "local"
"employeeId": "E-10293",
"roleIds": [7], // needs manage_roles + subset check
"groupIds": [3], // needs manage_roles at creation
"personaId": 2 // "" / falsy is coerced to null
}
// Also accepted: phoneNumber, attributes. On success → 201 with the user row;
// with sendInvite it also carries invite: { sent, expiresAt, link? }.
Field Reference
sendInvite—truecreates the local account with no usable password and issues a single-use set-password invitation after the create commits. The link is valid for 72 hours (see Rate, Size & Time Limits for the other fixed lifetimes). The response’sinviteobject says whether the email was sent; when email is not set up or the send fails, it carries a one-timelinkfor the admin to pass on instead. Apasswordin the same body is ignored, and an invitation for a non-localsource is refused with 400.password— needed only whensourceislocaland no invitation is requested; omitting both returns 400 “Password is required for local users (or send an invitation instead).” When supplied, it is run through the instance password policy and rejected with 400 and the policy message if it fails.sAMAccountName— optional, but if present and already taken the create returns 400 naming the current holder.statusdefaults toactive;sourcedefaults tolocal.managerIdandpersonaIdaccept an empty string, which is stored asnull.roleIds/groupIds— optional arrays. Supplying either triggers the privilege gate described below; they are additive on top of the automatic baseline.
Naming Convention (Governed)
Usernames and email addresses aren’t ad-hoc. A single org-wide naming convention (set in Security Settings, default [first_name].[last_name]) drives the address the platform builds and suggests at account creation, combined with your configured email domain and a conflict strategy (e.g. numeric suffixes) so two people with the same name don’t collide. The availability check is GET /api/v1/user-management/users/identity-availability, with optional query parameters email, username, firstName, lastName and excludeUserId. It answers { available, usernameAvailable, suggestions, usernameSuggestions, onConvention, usernameOnConvention, convention, emailDomain, strategy }: whether the exact email and account name are free, up to three free addresses that follow the convention, and whether what was typed matches the pattern. If the lookup itself fails it still answers 200, with available: null and error: "lookup_unavailable". An administrator sets the pattern under Security Settings → Naming. An address an admin types by hand is still accepted even if it departs from the pattern — the convention governs what the system generates, and flags what it doesn’t.
Passwordless (Federated) Creates
A non-local account created without a password does not store null (the column is NOT NULL). The service writes an unusable random bcrypt hash — a value no real password can produce — and sets mustChangePassword: true. The account exists and can be assigned access, but cannot be signed into until a real credential is issued. To verify: create a source: "onboarding" user with no password, then confirm it cannot authenticate.
Automatic Baseline Access
Immediately after the row is created, and before the optional role/group assignment, every user is added to the Everyone group and given the standard_user role. Both are created on first use (findOrCreate) if missing. This step is unconditional and requires no extra permission; it is also non-blocking — if it fails, the create still succeeds and the failure is logged rather than surfaced. It is not affected by the privilege gate that governs explicit roleIds/groupIds.
The Privilege-Escalation Guard (Inspector’s Key Control)
This is the control worth testing directly. Creating an account is a third route to the same destination as editing roles or bulk-assigning them: mint a new account holding administrator, set its password, sign in as it. Two layered checks stop that.
1. The Coarse Gate
roleIds or groupIds, the caller must additionally hold manage_roles (or the administrator role). A manage_users-only operator that includes either array gets 403 “Assigning roles or groups at creation requires the manage_roles permission.” and the whole create is rolled back.2. The Subset Invariant
manage_roles, the requested roleIds and groupIds are checked against the caller’s own permission set — a group can be bound to roles, so membership confers their permissions just as a direct role does. If any role or group would confer a permission the caller does not personally hold, the create is rolled back with 403 “Privilege escalation blocked” and a ROLE_ESCALATION_BLOCKED audit entry naming the excess permissions or groups.The coarse gate lets the operation through; the subset invariant is what actually prevents escalation. The same subset check guards role and group assignment on PUT /users/:id and on the bulk addRole and addGroup actions, so there is no controller that reaches role or group assignment without it. On the update and bulk paths, a caller is additionally blocked from changing their own roles or group membership (on the single-user update, an administrator may still change their own groups).
// Verify the guard: as a manage_users + manage_roles operator who does NOT
// personally hold, say, manage_security, try to mint an account that would.
curl -sS -X POST https://<your-instance>-api.zonitix.com/api/v1/user-management/users \
-H "Authorization: Bearer $SESSION_JWT" \
-H "Content-Type: application/json" \
-d '{"firstName":"Test","lastName":"Admin","email":"t@your-company.cc",
"source":"local","password":"S3cur3P@ss!","roleIds":[1]}'
// Expected when role #1 confers a permission you lack:
HTTP/1.1 403 Forbidden
{ "error": "Privilege escalation blocked",
"message": "You cannot create an account holding permissions you do not hold: ..." }
// And an audit row: ROLE_ESCALATION_BLOCKED (level warn) with the excess permission list.
Update, Bulk-Update, Delete
Update — PUT /users/:id
Update writes from a strict allowlist of columns — firstName, lastName, email, sAMAccountName, position, department, managerId, personaId, photoUrl, employeeId, phoneNumber, terminationDate — plus the gated fields listed below. Anything outside those (MFA state, lockout counters, mustChangePassword, source/externalId, raw attributes) is silently ignored by this route. The Zonitix support account is refused here with 403; it is managed only from Security Settings → Support. A few fields need more than manage_users:
roleIdsrequiresmanage_roles, passes the subset invariant, and cannot target the caller’s own account.groupIdsrequiresmanage_groups(a group can carryrequiresMfa, so it is not plain user management), passes the same subset invariant (403 if a group confers a permission the caller lacks), and a non-administrator cannot change their own group membership.password— resetting someone’s password requiresmanage_securityand a fresh step-up (403 without the permission; 400 with"error": "SUDO_REQUIRED"without the step-up). The new password must pass the password policy and may not repeat a recent one (400). It is a one-time reset: the person must choose their own password at next sign-in, all their live sessions are ended, and any outstanding invitation is cancelled.status— a terminal value (offboarded, terminated and the like) is rejected with 400; use Offboard so access is actually revoked.- Reactivating a non-active account (status →
active) and settingisPrimaryAdminboth require the elevatedmanage_securitypermission; ordinary status transitions do not. managerIdchanges are checked for self-management and reporting-line cycles and rejected with 400 if they would create one.
Bulk — POST /users/bulk
One action applied to up to 500 selected userIds. Allowed actions: setManager, setDepartment, setPersona, setStatus, addGroup, removeGroup, addRole, removeRole. Role actions re-require manage_roles and group actions manage_groups, same as the single-user path. setStatus is an allow-list of active/inactive/disabled only — terminal states must go through Offboard so entitlements are actually revoked. The response is { updated, skipped }; the primary admin and your own account are skipped (never failed) on protective actions.
Expire Password — POST /users/:id/expire-password
Expiring a password is not a reset: no credential is set, so it needs only manage_users and no sudo step-up. It sets mustChangePassword, so every local sign-in path stops at a forced password change, and stamps sessionsInvalidatedAt, which ends every live session for the account at its next request. It applies only to accounts whose source is local or onboarding. The single-user call returns 200 { expired: true, id, email }, or refuses with 404 (no such user), 403 (the primary admin, or the Zonitix support account), 400 (your own account) or 409 (the account signs in through a directory). Each success writes a USER_PASSWORD_EXPIRED audit entry at warning level.
The batch form is POST /users/expire-password with { userIds: [...] }, at most 500 ids. It returns { expired: [{ id, email }], skipped: [{ id, reason }] }: an account that would be refused singly is skipped with the reason and the rest still go through. An empty list or more than 500 ids is 400. This flag is not reachable through PUT /users/:id, whose allowlist drops it.
// Move three people under a new manager in one call.
curl -sS -X POST https://<your-instance>-api.zonitix.com/api/v1/user-management/users/bulk \
-H "Authorization: Bearer $SESSION_JWT" \
-H "Content-Type: application/json" \
-d '{"userIds":[11,12,13],"action":"setManager","value":42}'
// 200 -> { "updated": 3, "skipped": [] }
// Cycle-forming or self-manager targets appear in skipped[] with a reason; they do not fail the batch.
Delete — DELETE /users/:id
Delete is for an account that has already left. An account whose status is still active is refused with 409 — offboard it first, which revokes access and creates the deprovisioning run, then delete. For a non-active account there are two successful outcomes. Normally the personal data is erased and the row is hard-deleted, returning 200 { deleted: true, erasure: { kept, failed } }, where erasure lists the other data stores that kept a record by design and those where the erasure failed, each with a reason. The audit entry is USER_DELETED at warning level; it names the user id and a pseudonymous subject reference, not the email being erased. If other records (audit history, tasks, approvals) still reference the user, the row cannot be removed, so it is anonymized instead: name, email and every other personal field are overwritten, roles and groups are stripped, status becomes deleted, and the call returns 200 { anonymized: true, message, crossServiceErasure, erasure }, audited as USER_ANONYMIZED. Errors: 404 if the id does not exist, 403 if the target is the primary admin, 409 SUPPORT_ACCOUNT_MANAGED for the Zonitix support account. Deletion is single-user only — there is no bulk delete. For a leaver you usually want POST /users/:id/offboard instead, which revokes access and creates a deprovisioning run rather than erasing the record; note it can return 202 (accepted but incomplete) if access was revoked but the deprovisioning run could not be created, and it refuses to offboard your own account or the primary admin.
Offboard — POST /users/:id/offboard
The body is optional: { lastDay, immediate, reason }. lastDay is the leaver’s last day. immediate: true is a termination with no notice period: the run’s revoke steps execute at once instead of waiting in a queue, and a reason of at least 5 characters is required (400 “An immediate termination needs a reason on the record.” otherwise). Success is 201 with the run. 202 means the request was accepted but is incomplete: either access was revoked and no run could be created, or an immediate termination left connected accounts that were not revoked automatically. Offboarding your own account is 403. The audit entry is USER_OFFBOARDING_STARTED, or USER_TERMINATED_IMMEDIATELY with the reason and who ordered it.
The Primary Administrator Cannot Be Modified Or Removed
Any account with isPrimaryAdmin set is sealed against all three write paths: PUT /users/:id returns 403 “System administrator account cannot be modified.”, DELETE /users/:id returns 403 “System administrator account cannot be deleted.”, and bulk setStatus/removeRole skip it with a “primary administrator is protected” reason. Offboarding refuses it too. Because the flag itself can only be set with the elevated manage_security permission, a delegated manage_users operator can neither create a protected account nor touch the existing one.
Where The Gate Lives
The route-level manage_users check is enforced by the service itself, not only the gateway. The gateway rejects unauthenticated calls; the service re-checks the permission on the identity it receives, and the role/group and elevated-permission gates run inside the controller. An inspector can confirm this by calling an endpoint with a session that has manage_users but not manage_roles and observing the create succeed without roles but 403 the moment a non-empty roleIds is added.