Skip to content

Access control

Who may do what in the terminal (telnet, web terminal, local), who must use 2FA, and how long sessions and exports last. Passwords are still checked by ModernWMS and Part-DB; this decides what a signed-in user may do.

The model

  • A permission is area.action, e.g. lego.export or stock.adjust. wms access perms lists them all:
Area Actions
lego view, search, edit, delete, export
partdb view, edit, delete
stock view, check, adjust
ops view, asn, edit (ASN, inventory, outbound, master data)
sets check, stocktake (parts checks and recounts)
orders view, manage (orders for missing parts)
labels print
exports create, download (the QR download link)
bricklink view, price (live BrickLink calls)
scripts run
containers view, manage
users view, manage
access manage (this system)
settings view, edit
audit view
  • A group allows or denies permissions. A user belongs to groups and can override single permissions. Deny always wins; anything not set is not granted.
  • A user with no entry keeps their ModernWMS role's access, exactly as before. Nothing changes until you add someone.
  • What you can't use is hidden. A key pressed anyway says ACCESS DENIED — needs the lego.edit permission (not granted) and is written to the audit log (DENIED_PERMISSION).
  • A user is keyed source:username (e.g. partdb:checker, modernwms:checker) — and if the same username exists in both ModernWMS and Part-DB, signing in with it always resolves to the ModernWMS account (wms-go's auth tries ModernWMS first). Set the policy entry on that one (--source modernwms, the default), or the exemption/permissions you set on the Part-DB entry will simply never be read — wms users list shows both sources at once, so you can check which one you actually have before setting up a policy entry for it.

Starter groups

Group For Usable without 2FA
admin everything no
operator day-to-day stock, parts and LEGO work no
viewer read-only everywhere yes
builder LEGO only: browse, missing parts, what to build yes
exporter LEGO view plus exports and downloads yes
stock-clerk stock checks, Part-DB view, LEGO part/set search and export yes
checker checks new sets' parts, orders and missing-parts exports, records delivered parts yes
picker picks assigned orders, records what was taken yes

Edit any of them (or make your own) in Admin → Access Control → Groups, or with wms access groups set.

A group added to this list later (like checker) still reaches an install that already had a policy file — it's added once, the next time anything writes to the policy, same as a brand-new install. A starter group you've deliberately deleted is never brought back by this, whichever install it's on.

2FA, sign-in limits and timeouts

Per user:

  • 2FA: default (required over telnet and the web terminal, as before), required (also on the local console), or exempt.
  • An exempt account may only be in restricted groups, which can never hold users.manage, access.manage, settings.edit, scripts.run or containers.manage; wms refuses the change otherwise. Limit it further to networks (--cidr 192.168.1.0/24): from anywhere else 2FA is asked. The web terminal cannot see the client's address, so a network-limited exemption only applies over telnet.
  • Channels: which ways in are allowed (telnet, web, local).
  • Expiry: access ends at the start of that day.
  • Timeouts: the 2FA remember window, idle lock and maximum session length can be set per user; otherwise the global values apply.

Global values (Admin → Access Control → Security settings, or wms access settings):

Setting Default Meaning
2FA remember window 30 min the same address signs in again without a code
Idle lock 15 min 0 = never
Maximum session 0 (none) signed out after this many hours however active
Exports kept 7 days files in WMS_EXPORT_DIR older than this are deleted
Download links 15 min how long a QR download link works

Every sign-in decision is audited: LOGIN_2FA_EXEMPT, LOGIN_DENIED_EXPIRED, LOGIN_DENIED_CHANNEL, SESSION_MAX_AGE.

A real, persisted login lockout

5 wrong passwords in a row locks that username out for 15 minutes — and unlike a plain in-memory counter, it survives a reconnect: every telnet (or web terminal) sign-in is its own process, so a counter kept only on that session resets to zero the moment someone just reconnects. The lockout itself lives in its own small file (LOGIN_LOCK_FILE, alongside 2fa.json), keyed by username alone — at the point a password fails, which backend (ModernWMS or Part-DB) it would have belonged to isn't known yet, so this isn't tied to either one. A correct sign-in, or an admin resetting that user's password (Users → Reset Password, or wms users reset), clears it immediately — no separate "unlock" step.

Lockout policy across every surface

Several surfaces each lock out repeated failures on their own terms. They are not the same mechanism wearing different thresholds by accident — each guards a different thing, at a different point in the flow, so a wrong guess against one never counts against another:

Surface Trigger Threshold Lock duration Keyed by
Password sign-in (TUI/telnet/web terminal) wrong password 5 in a row 15 min flat username — see above
2FA code wrong code 5 in a row 5 min, doubling each further lock up to 1 hr username + source
Discord bot admin PIN wrong PIN 5 in a row 5 min flat the linked admin's policy entry
Telnet/mobile gateway connection throttle 3 failed sessions in an hour from one address 3 strikes 15 min, doubling up to 1 hr the client's network address, not an account
KC-Parts dashboard login (BetterAuth, separate service) wrong password 3 in a row 5 min flat email

Why they differ rather than share one number:

  • Password vs. 2FA are two separate gates on purpose. A password lockout (internal/loginlock) stops account guessing before 2FA is even reached; the 2FA lockout (internal/twofa) separately stops someone who already has a valid password from brute-forcing the second factor. Merging them would let a mistyped 2FA code start eating into the password-attempt budget, or vice versa — two unrelated mistakes punishing each other.
  • The 2FA lock doubles; the others don't. A code guesser who keeps coming back is a more deliberate attack than someone who mistypes a password once and gets distracted — the escalating backoff targets persistence specifically, where the flat 15 min on a password lock is already a long wait for a legitimate user who just needs an admin reset anyway.
  • The gateway throttle is address-keyed, not account-keyed — a different attacker model entirely (one address hammering many accounts) from the other four (repeated wrong guesses against one account). It has to stay separate: collapsing it into an account-level lockout would do nothing against credential stuffing across many usernames from one source.
  • The bot PIN gets the shortest, flattest lock because the credential it protects is the shortest (a 5-6 digit PIN on a bot-linked admin account, not a full password), and it has no 2FA-style "worth escalating" step behind it to protect.
  • The dashboard's 3 attempts / 5 minutes is deliberately stricter, not an unreconciled fourth number invented separately — it's the newest, most internet-exposed sign-in surface (the Go CLI/ telnet/mobile stack sits behind existing network/access controls the dashboard doesn't yet have), and it was specified explicitly in the dashboard's own requirements doc rather than inherited from this table. It lives entirely in kc-parts-auth-node (see that repo's src/lockout.ts) since BetterAuth owns dashboard identity on its own, separate user store — it can never interact with, lock, or unlock any of the four mechanisms above, which all protect the existing Go-side accounts.

None of the four Go-side mechanisms share a store or a counter; each is unlocked independently (password: a successful login or a password reset; 2FA: wms users 2fa unlock; bot PIN: a fresh PIN reset; gateway throttle: waiting it out, since it's not tied to an account at all).

Numeric login for handheld scanners

A Picker or Checker account created or reset from now on gets a short, numeric-only user ID (4-5 digits) and PIN (5-6 digits) instead of a free-form username and a mixed-character temp password — created via Users → Create New User / wms users create (reject a non-numeric ID for these roles with a clear message), reset via Users → Reset Password / wms users reset (a numeric username is itself the signal to issue a numeric PIN again). Admin accounts are untouched — they still sign in from a full keyboard. This is purely about the credential format, not new keyboard restrictions in the sign-on screen itself: a numeric ID/PIN is already everything a handheld barcode scanner's number keys need, with no awkward alpha-shift layer to hunt through. Existing Picker/Checker accounts keep working exactly as they are — this only applies the next time one is created or reset.

Renaming a numeric login ID

A numeric Picker/Checker ID is that account's permanent username — there's nowhere else for it to live, since an MC9090 (or any numeric-keypad-only handheld) can't type anything else. If one needs to change (a reissued device, a digit typo baked in early, two accounts clashing), rename it instead of recreating the account from scratch:

wms-go access user rename 7354 7421 --source local

This moves everything that follows the account: its policy entry (groups, permissions, 2FA setting, full name), its 2FA enrollment, its login-lockout state, and — in the LEGO database — every ticket, accuracy record, kanban note, backlog release, attendance entry and message it's attached to. A live session still signed in under the old ID is dropped (not silently left with a stale permission lookup) and has to reconnect.

Two things deliberately don't move:

  • The new ID must be digits only — the whole point is staying usable on a numeric keypad, so a rename refuses anything else.
  • The audit log is never rewritten. It's tamper-evident and hash-chained precisely so nothing — including an admin tool — can edit history after the fact. Sign-ins and actions from before the rename stay attributed to the old ID forever; finding that account's full history means checking for both IDs (grep the audit log, or wms audit head for the latest lines).

There's no TUI screen for this yet — wms access user rename is CLI-only, same as set-password and set-name.

Barcode badge sign-in

A username can be swapped for a scanned barcode at the sign-on screen: scan (or type) the badge into the Username / User ID field, then the password normally — the badge only ever substitutes what you'd have typed as a username; the password (and 2FA, if enrolled) is still required exactly as before. It's an unguessable, revocable token, never the literal username, so a lost badge is fixed by reprinting, not by changing anything the person actually signs in with.

wms-go access user badge alex                          # shows the current token (issuing one if there isn't yet)
wms-go access user badge alex --qr                      # also shows it as a scannable QR (many scanners read either)
wms-go access user badge alex --regenerate               # a new token — the old badge stops working immediately
wms-go access user badge alex --clear                    # badge sign-in stops working for this user

Print the token on a barcode (Code 128) however you already label things — a lost or shared badge is --regenerate, not a password reset.

In the terminal

Admin → 4 Access Control:

  1. Groups: ↑/↓ choose, Enter opens the permission grid (arrows move, Space cycles allow → deny → not set, S saves), N new, C copy, R restricted, D delete (refused while users are in it).
  2. Users: Enter edits groups, 2FA, networks, channels, expiry, timeouts and a note; P edits that user's permission overrides (not G — that's the global LEGO Collection jump everywhere else in the app, so this screen uses a different key to avoid the clash); B opens a Menu tabs checklist — Part-DB Hub, Operations (view-level), Script Hub and LEGO Collection toggle on/off for just this person (checker/picker already grant LEGO Collection and Part-DB Hub by default — see Starter groups below — so this is mainly for turning one off for someone, or on for a group that doesn't carry it). Admin is the one row this screen can only turn off — it reflects whichever admin-ish permission the person already has (role, group, or an override), but granting real admin access back stays a deliberate choice via P or Groups, never a single checkbox; N adds a user; X removes the entry (back to their ModernWMS role).
  3. Security settings: the global timings above.
  4. Check a user's effective permissions: every permission with why ("allowed by group exporter", "denied by user override").
  5. My remote bot link: link your Discord/Slack ID, set a bot PIN and a security question — see the remote-bot API guide for what that credential actually unlocks. You need a group of your own first; this screen refuses to run for an account with no policy entry at all.

You can't save a change that removes your own access.manage. Every change is audited as ACCESS_CHANGED with what changed.

Recipes

An export-only account that skips 2FA at home:

wms-go users create exportbot                       # a normal Part-DB/ModernWMS user
wms-go access user set-groups exportbot exporter --source partdb
wms-go access user 2fa exportbot exempt --cidr 192.168.1.0/24
wms-go access user channels exportbot telnet
wms-go access check exportbot lego.export            # ✓ allowed by group exporter

A LEGO-only account for a child, weekends only by expiry:

wms-go access user set-groups kid builder --source partdb
wms-go access user expires kid 2026-12-31
wms-go access user timeouts kid --idle 10 --max-hours 2

Tighten everyone:

wms-go access settings --idle-minutes 10 --max-session-hours 8 --grace-minutes 15 --export-days 3 --link-minutes 10

Back up or move the policy: wms-go access export > policy.json, wms-go access import policy.json. The file is /root/docker-server/wms/access.json (WMS_ACCESS_FILE), readable by root only.