Skip to content

Helpdesk

The helpdesk lets operators reset a password, unlock an account, and disable or enable an account, only on the accounts their delegation covers. It also powers account creation (Onboarding and leavers), several bulk actions and LAPS.

It is off by default. While it is off, the helpdesk endpoints do not exist, the menu entries are hidden and the password service does not run.

How it works

Changing a password or an account's enabled state needs strong rights in AD. Steward keeps those rights out of the console: they belong to a dedicated password-reset account that only a separate container, the password service, knows.

operators ──HTTPS──> steward ──(internal network "helpdesk", HMAC-signed)──> steward-pwd ──LDAPS──> DCs
                     read + write accounts                                       password-reset account only
                     /data (read-write), /secrets                                /data (READ-ONLY), no /secrets

Both containers run from the same image:

steward (console) steward-pwd (password service)
Command default password-service
AD accounts read, write-attributes password-reset only (and the optional LAPS account)
Secrets master key and Data Protection keys (/secrets), link key link key, reset account's password, its pinned LDAPS CA
Trust anchors the DCs, CA and managed OUs stored by the setup wizard its own pins (DCs, CA, managed OUs, Tier OUs, sensitive groups) from .env and secrets/ on the host
Database read/write mounted read-only: domain name, VIP groups, and the settings it compares with its pins
Network published HTTPS port; helpdesk network helpdesk network (internal) and an egress network for LDAPS; no published port

The console never holds the reset account's password, and the password service cannot read the master key or the other accounts.

The link. Every request from the console carries an HMAC-SHA256 signature made with a shared link key, a timestamp (±60 seconds) and a nonce that is never accepted twice. Any failure gets a plain 401. A new password travels encrypted (AES-256-GCM) inside the request body.

Pinned trust anchors. The password service signs in to AD by simple bind with the reset (and LAPS) account: the server it connects to receives that password. So it never takes the DCs, the LDAPS CA or the managed OUs from the console's database, which a compromised console could rewrite. They are pinned on the host (.env and secrets/pwd-ca.pem of the instance, see Pin the password service). It connects to the pinned DCs only, trusts the pinned CA only, and when the database names other DCs, another CA or other managed OUs it refuses every operation (trust_mismatch), before any bind.

Two checks before anything changes. The console authorizes each request: delegation, VIP and tier gates, a recent step-up for a reset, a disable or an enable, and an explicit confirmation (never the operator's own account for disable / enable). Then the password service checks again, so a compromised console alone is not enough:

  1. the console's configuration matches its pins (DCs, CA, managed OUs);
  2. the target, looked up by GUID, is under a pinned managed OU;
  3. it is still at the DN the console decided on;
  4. the shared classifier says it is a standard account (privileged or unclassifiable targets are refused);
  5. a per-target rate limit: at most 5 operations per 10 minutes, shared by reset, unlock, disable and enable (PasswordService:MaxOperationsPerTarget, PasswordService:RateWindow).

Finally AD checks the reset account's own rights. A password never appears in a log, an audit row or an error. Each operation is audited by the console and logged as one JSON line on the password service's output.

Turn the helpdesk on

  1. Create the reset account in AD: a dedicated, standard user account (not the read or write account), with a long random password that does not expire. Do not add it to any group.
  2. Install or update the instance with the helpdesk. The guided install asks for it ("Enable the helpdesk?", then the account's UPN and its password, typed hidden). With options:

    sudo install -m 600 /dev/null /root/steward-pwd.txt && sudo vi /root/steward-pwd.txt   # the password, one line
    sudo deploy/install.sh --name acme --hostname steward.acme-client.ch \
         --helpdesk --reset-upn svc-steward-reset@corp.acme-client.ch --reset-password-file /root/steward-pwd.txt
    sudo shred -u /root/steward-pwd.txt
    

    The script:

    • generates the link key once (32 random bytes) in <instance>/secrets/pwd-link;
    • copies the password to <instance>/secrets/ad-pwd-reset;
    • makes both files mode 0600, owned by the container user, in a 0700 directory;
    • writes COMPOSE_PROFILES=helpdesk, STEWARD_HELPDESK_ENABLED=true and the reset UPN to <instance>/.env;
    • starts both containers and waits until both are healthy.

    Re-running the script keeps the link key, the password and the helpdesk state. Pass --reset-password-file again only to change the password.

  3. Give operators the actions. In Delegations, give the operators who need them a delegation with users.password.reset, users.unlock, users.disable and/or users.enable (users.disable does not imply users.enable).

  4. Apply the AD rights. In Configuration > Diagnostics, export the AD delegation and have a domain administrator apply it (Delegation model and AD rights). For the reset account, on each OU of those delegations, it adds:

    • the Reset Password extended right and Write property on pwdLastSet ("must change at next logon"), for users.password.reset;
    • Write property on lockoutTime, for users.unlock;
    • Write property on userAccountControl, for users.disable / users.enable (user objects only, see below).

    The script refuses a privileged reset account, and a reset account that is also the write account. 5. Pin the password service once the setup wizard is completed (domain, DCs, LDAPS CA, managed OUs): see Pin the password service. Until then every reset, unlock, disable, enable and LAPS read answers not pinned (trust_not_pinned). 6. Check the diagnostics. With the helpdesk on, they add: - Password service: reachable, and the reset account signs in; - per OU: pwdLastSet / lockoutTime writable by the reset account (read from allowedAttributesEffective); - per OU: Reset Password right present. AD offers no read-only way to prove an extended right is effective, so this line says "present, not proven". The proof is a real reset on a test account.

Pin the password service

The password service's trust anchors and scope are pinned on the host, by an administrator, never by the console:

sudo deploy/install.sh --name acme --pin-password-service

It reads the console's current configuration (read-only, with the password service image), shows it for review: the DCs, the SHA-256 fingerprint of the LDAPS CA, each managed OU with its DN and GUID, the Tier OUs and the extra sensitive groups, and asks for confirmation (in a non-interactive run the option itself is the confirmation). Then it writes them to the instance:

Pin Where Password service setting
Domain (DNS name; fixes the naming context, LDAPS port 636 only) .env: STEWARD_PWD_DOMAIN PasswordService:DomainFqdn
Domain controllers .env: STEWARD_PWD_DC_FQDNS PasswordService:DcFqdns
LDAPS CA secrets/pwd-ca.pem (Docker secret of steward-pwd only) PasswordService:CaPemFile
Managed OUs (objectGUIDs) .env: STEWARD_PWD_MANAGED_OU_GUIDS PasswordService:ManagedOuGuids
Tier OUs (objectGUIDs) .env: STEWARD_PWD_TIER_OU_GUIDS PasswordService:TierOuGuids
Extra sensitive groups (SIDs) .env: STEWARD_PWD_SENSITIVE_SIDS PasswordService:SensitiveGroupSids

and recreates steward-pwd. Compare what is shown with what you configured: pinning values that someone else stored would hand them the pins. You can also give the four required pins yourself, e.g. before the setup wizard: --pwd-domain corp.acme-client.ch --pwd-dcs "dc1.corp.acme-client.ch dc2.corp.acme-client.ch" --pwd-ca-file ldaps-ca.pem --pwd-managed-ous <guid>,<guid>.

Run it again after every change of domain, LDAPS port, DCs, LDAPS CA or managed OUs on the console's Configuration page: until then the password service refuses to work (trust_mismatch), the operators see the directory configuration differs from the one pinned on the server, the audit row says failed / trust_mismatch, and the service logs which part differs (domain, port, dcs, ca, managed_ous). Tier OUs and sensitive groups added in the console apply at once (they only protect more); removing one takes effect in the password service after the next pin.

Reset a password

  1. Find the account: Users, or press Ctrl+K (⌘K on a Mac) anywhere and type a name or login (see Find the caller fast).
  2. Verify the caller before anything else and record how (How did you verify the caller?): callback on a number already known (the directory number shown next to the choices, never the one the caller gives), confirmation by the manager (shown when you may read it), in person with an identity document, or Other (then explain it in the ticket). Only the method is stored in the audit. Required by default (Configuration > Helpdesk).
  3. Choose the new password:
    • Random (16 characters, the strongest), or
    • Passphrase: four common words and three digits, for example Tigre-Jardin-Piano-Nuage-472 (about 48 bits): easy to dictate, always to be changed at the next sign-in. The words follow your console language;
    • or type one (AD's password policy applies).
  4. Enter the ticket (INC-12345, "call from the user"...), required if the instance says so. Never put a password or personal data in it.
  5. Confirm. A step-up is asked when the last one is older than 5 minutes: AD operators type a code from their authenticator app (set up once on the Security page); Teleport operators are not asked (their Teleport session is the step-up, audited stepup:teleport).
  6. The result shows the password once, large and grouped. Spell it reads it in the radio alphabet; Copy the message copies a ready-to-send text (editable first) with the password and the must-change notice, or without the password if the instance turned that off (then dictate it). Send it only over a channel you trust.

Unlock an account and find the lockout source

Unlock (profile header, users.unlock) clears the lockout.

Before unlocking, find what keeps locking the account: on a locked (or failing) profile, the Lockout source card asks each domain controller for its bad-password counter and time (these are not replicated between DCs). The DC with the most recent failure is the one nearest to the device that still uses the old password: phone mail profile, mapped drive, saved RDP or Wi-Fi credentials, scheduled task or service. Fix that device, then unlock. Needs users.read and users.unlock on the account. A DC that does not answer within a few seconds is shown as such.

Disable and enable an account

Disable on a profile blocks the account's sign-in; Enable lifts it. Nothing else changes (password, groups and attributes stay). Both need a delegation with the action, a recent step-up and an explicit confirmation, and exist only while the helpdesk is on (the profile says so otherwise). A disabled account that is also a console operator loses its console sessions at once. Audited as users.disable / users.enable with the state before and after.

To close an account completely (disable, random password, expiry, groups...), use the leaver flow.

Why it goes through the password service. Write property on userAccountControl lets its holder set ANY account flag, for example "Kerberos pre-authentication not required" (AS-REP roasting) or "password not required". AD has no right for "only the disable bit". So the right goes to the reset account in the isolated steward-pwd container, never to the console's write account (the delegation script refuses that), and the service:

  1. reads userAccountControl as the reset account, by GUID, at the DN the console decided on;
  2. computes the new value with ONLY the disable bit (0x2) changed, and refuses anything else;
  3. writes it as a compare-and-set: one modify that deletes the value read and adds the new one. If anything changed meanwhile, AD refuses it and the console reports changed in the meantime; nothing is written. An account already in the wanted state is left alone.

Find the caller fast

  • Press Ctrl+K (⌘K on a Mac), or use the search box of the top bar, anywhere in the console. Type a name or login: the palette lists the users you may read (the same authorized search as the Users page), your recently viewed users, groups (with groups.read) and pages. ↑ ↓ move, Enter opens, → (or Tab) on a user lists the actions the server allows on that user: open, unlock, reset, disable / enable, expiry date, history. Esc goes back, then closes.
  • An action from the palette opens the profile and the same dialog as the profile button: confirmation, ticket and step-up are never skipped.
  • The recently viewed list (palette and dashboard) is kept in this browser only, per operator (at most 10 accounts) and is erased at sign-out.

Tickets on every change

Reset, unlock, disable / enable, expiry date, profile edits and group membership changes accept a ticket (at most 100 characters, one line). It is stored with the audit row, searchable on the Audit page, shown in the profile's History and in the CSV export. With Ticket required for helpdesk actions (Configuration > Helpdesk), the console refuses those changes without one.

Helpdesk settings

Configuration > Helpdesk (settings.manage, identity confirmation, audited settings.helpdesk):

  • ticket required;
  • caller verification required (default on) and the methods offered;
  • generated password proposed by default (random or passphrase);
  • password included in the ready-to-send message (default on).

Move or rename an account

These use the write account, not the password service: they work with the helpdesk off.

  • Move (profile header, users.move): pick the destination among the OUs offered (only those you may move this account into). If an object with the same name already exists there, the console proposes a free name such as "Jane Doe (2)"; accepting it needs users.rename. Group memberships follow the account (AD keeps them by reference).
  • Rename (profile header, users.rename): change the name shown in AD (cn), the display name, first and last name; the preview shows the resulting DN. Changing the sign-in name (sAMAccountName, UPN) needs users.rename.login and a step-up: tell the person their new sign-in name, and remember that saved credentials, mapped drives and some applications use the old one.
  • Groups (groups.create, groups.attributes.write, groups.delete): New group on the Groups page; Edit / Delete on a group. Deleting a group that still has members lists them first and asks for confirmation. A delegation with groups.owned.members.write lets a person manage the members of the groups whose manager they are (useful for team leads).

More on moves, renames and groups: Operations.

Temporary group membership

Adding a member (Groups page, a user's Groups tab, a bulk Add to group, or the groups of an onboarding template) accepts an optional until date and time. It uses the write account, not the password service: it works with the helpdesk off.

  • Bounds: at least one minute ahead, at most 365 days (TemporaryMemberships:MaxDays, 1 to 3650). A date out of bounds is refused before anything is decided or written (until_too_soon, until_too_far).
  • Where it is kept: in the console's database (group and member by objectGUID, expiry, who added it, ticket), never in AD. The add is audited with the expiry (membership.until). An "until" on someone who already is a member changes nothing: a permanent membership is never turned into a temporary one behind your back.
  • What you see: an "until ..." badge on the member (group page) and on the group (user's Groups tab), and the list of upcoming expirations (Groups > Temporary memberships). Change date and Make permanent need exactly what adding the member needs (groups.members.write on the group, or groups.owned.members.write on a group you manage, and the member gate); both are audited with the old and new date.
  • At expiry: a background service (every minute, TemporaryMemberships:CheckInterval) removes the member through the same checked path as a manual removal, on fresh facts, with the write account. The removal is audited as groups.members.remove, provider system, attributed to the operator who added it, with the basis:

    • operator_grant: that operator's current delegation still covers the change;
    • temporary_grant: it no longer does (role changed, operator disabled or deleted, group or member moved out of their scope...): the member is removed anyway, because taking back a temporary grant only reduces access. The audit row says why.

    A failed removal (AD refused, DC unavailable...) is retried after 1 min, 5 min, 15 min, 1 h, then every 6 h, and notified (temporary_membership_failed) after 3 attempts (TemporaryMemberships:NotifyAfterAttempts). A success is notified as temporary_membership_expired. - Removed elsewhere: removing the member through the console closes the record. If someone removes the member directly in AD, the record is closed at the next presence check (every 15 minutes, TemporaryMemberships:PresenceInterval), audited as groups.members.temporary.closed. If the console is down at the expiry time, the removal happens when it is back. - Why not AD's own TTL: forests with the Privileged Access Management optional feature can hold a membership with a TTL in AD itself. Enabling that feature is irreversible and Samba does not support it, so Steward does not use it: temporary memberships work on every supported DC and stay visible, changeable and audited in one place.

Turn the helpdesk off

sudo deploy/install.sh --name acme --hostname steward.acme-client.ch --no-helpdesk

The script stops and removes the password service and turns the feature off in the console. The secret files stay in <instance>/secrets; delete them if you do not plan to turn the helpdesk on again. Then remove the reset account's rights in AD (or disable the account): the delegation script never removes rights.

Good to know

  • Old password grace (Windows): after a reset, a Windows DC may keep accepting the previous password for a short time (NTLM/Kerberos grace period). This is AD behavior, not a failed reset.
  • Uncertain outcome: if the password service changed the password but its answer was lost (timeout, restart), the console shows Outcome uncertain and the audit row says unknown/outcome_unknown. A generated password is still displayed, because AD may already hold it. Check the user before resetting again.
  • Rate limit: each account accepts a limited number of resets, unlocks, disables and enables per window. Attempts that AD refused (password policy, missing right, directory unavailable) do not count.

Residual risks

Risk Mitigation Remaining exposure
Console compromised The service re-checks OU, DN, tier and rate with its own pinned DCs, CA and scope; AD checks the reset account's rights An attacker can reset passwords of, disable or enable standard accounts in the pinned managed OUs, within the rate limit, until detected. The service log and the audit show every operation. It cannot set any other account flag, point the service at another LDAPS server (the reset password would leak in the bind) or widen its scope: a changed database is refused
Reset account password stolen (root on the host) File permissions; no published port With Write property on userAccountControl, the holder can set any flag on accounts of the delegated OUs (e.g. no pre-authentication). Grant users.disable / users.enable only where needed; monitor userAccountControl changes in AD
Link key stolen Only useful from inside the helpdesk network; replay refused Rotate it (below)
Root on the host File permissions Root can read every secret, including the reset account's password
Write account password stolen, users.create granted Create user objects only on the delegated OUs; OWNER RIGHTS removes the creator's implicit permission changes (Windows) The holder can create accounts there. See Onboarding
Reset Password right present but not effective (deny ACE, AdminSDHolder) Diagnostics say "present, not proven"; AD refuses and the console reports ad_denied Found at the first reset
Protected accounts (adminCount=1) Classified privileged: always refused, by the console and by the service None by design

Rotation

  • Reset account password: change it in AD, write it to a root-only file, then re-run install.sh with --reset-password-file <file>. The password service restarts with the new password. Run the diagnostics.
  • Link key: delete <instance>/secrets/pwd-link (as root) and re-run install.sh with --helpdesk. A new key is generated and both containers restart with it.