UID allocation — one register instead of four mirrors
Date: August 2026 · Status: built 2026-08-05 — the register is
provisioning/uid/uid-map.csv, issued and checked by uid-alloc.py. Dated operations on it are on
the history page; open items in the ops todo.
Scope: the numeric UIDs/GIDs that NFS uses to authorise access to the student homes on the
FS2500, and every consumer that has to agree with them.
1. Why
Nothing in the fleet issued a UID. Each consumer rediscovered one from whichever list was nearest —
a Calypso node's passwd, the Ansible conf/users/uid-*.yml, a home's ownership on the NAS — and
the lists drifted apart. Measured 2026-08-05: five accounts carried a node number that disagreed
with the owner of their own home, and the nodes did not even agree with each other, one of them
holding a different number for the same person than the other two.
The failure is silent. A lab VM built from a wrong number creates the account, mounts the home,
and reports success; the student then cannot read their own ~/nas_home, because the directory is
mode 750 owned by a number nobody assigned them. There is no error to notice — no login fails, no
mount fails, the files are simply not there.
A variant is just as quiet: where only the uid was renumbered and the gid was left behind, the person
matches their home's group rather than its owner, so 750 lets them read it and not write to it.
Any design that looks a number up at provisioning time inherits this. The number has to be issued once and thereafter only read back.
2. The principle
Allocate, don't discover. One thing issues the number and owns the range; everything else is a reader. A UID, once issued, is permanent and never reused — it is baked into file ownership on the NAS, so a freed number is not free.
3. The register
provisioning/uid/uid-map.csv is the source of truth, one row per number:
uid,gid,nasname,email,note
1004,1004,firstname.lastname,firstname.lastname@hevs.ch,
10003,10003,someone.wholeft,,
25301,25301,,,drifted duplicate on the calypso nodes
An empty email means the number is taken but nobody currently holds it — someone who left whose
home still exists, or an account that owns files elsewhere. It is burned, not free. nasname is the
directory under /volume1/calypso_homes/homes; empty means the number owns files somewhere other
than the NAS.
uid-alloc.py beside it:
| Mode | What |
|---|---|
--seed | Once, from stat on the NAS homes. Refuses to run twice, and flags a home owned by uid 0 rather than importing it |
| (default) | Allocates for anyone in roster.csv without a number, in the band for their role. Prints the plan; writes only with --commit |
--verify | Compares every row against the NAS and reports each discrepancy |
--check-ansible | Lists the accounts a playbook run would renumber — read it before running one |
create-home.sh makes a home with the number the register holds, and refuses a name the register
does not know, a number nobody on the roster holds, or an existing directory whose ownership
disagrees. It works through calypsomaster, the only host the NAS exports to without root
squashing, and applies no ACL — the homes are plain POSIX 750.
Why a committed file and not Keycloak
Keycloak was the obvious candidate — it already holds the roster and the identities — and it was rejected. What an allocation needs, git gives and an IdP attribute does not:
| git | Keycloak attribute | |
|---|---|---|
| Append-only, never silently reassigned | visible in the diff | nothing prevents it |
| "Who got this number, and when" | git log | no attribute history |
| Reviewed before it takes effect | the diff | a click in a console |
| Survives losing the machine | yes | no — the realm export omits users, so the numbers would live only in CT 110 |
| Readable while everything is down | yes | no |
The last two decide it. Provisioning must work with the directory down, so a committed file had to exist either way — and then the Keycloak copy would have been a second copy of one fact, which is the disease this page exists to cure.
Keycloak may still become a reader later: an SSSD/LDAP bridge or a SLURM integration wanting
posixAccount semantics would need uidNumber in the token. That is a push from the register, and
the direction is what matters.
The bands
Sized so that "never reuse" stays affordable for the life of the school — the earlier
10000–10500 sketch gave out after 500 students, which is a few years, and its two student bands
overlapped at 10500.
| Range | Who | Size |
|---|---|---|
1000–1023 | Legacy staff — closed, no new allocations | eight accounts |
1024–1099 | Reserved — Synology DSM local accounts | never issue |
1100–9999 | Teachers & staff | 8 900 |
10000–39999 | ISC students | 30 000 |
40000–49999 | Other students (exchange, external, other programmes) | 10 000 |
50000–59999 | Project & shared group accounts | 10 000 |
60000+ | Off-limits | see below |
Why the ceiling is 60000. useradd stops there by default, systemd hands out dynamic users from
61184, nobody is 65534, and SLURM already sits at 64030. Above that, two ranges are actively
dangerous: Synology package accounts start at 100000 (FileStation 179652, HybridShare
298119 …) and unprivileged LXC maps subuid from 100000 — a UID up there can collide with a DSM
package account on the very device that stores the homes.
Why 1024–1099 is a hole. DSM allocates its own local accounts from 1024 upward — admin 1024,
guest 1025, then its administrator accounts — and guest is also the anonuid that
root squashing maps to. A person allocated a number in that range
is indistinguishable, to the NAS, from one of its own administrators: ls -l renders their home as
owned by the admin account, and an admin session has owner rights over it. One home sat inside the
range and was renumbered out.
The roster holds who is currently authorized, so it is always smaller than the set of homes: 21 lines against 66 homes as of 2026-08-05, because a departed student keeps their home until it is deliberately removed. An allocator reading only the roster would see 45 numbers as free that already own files, and would hand a future student a number whose directories belong to someone else — silently, and in exactly the way this whole design exists to prevent.
So the seed burns every UID that owns a home, rostered or not, plus the ones that own files only on the Calypso nodes. Once the file is complete, "absent from the file" is a sufficient test — which is why the allocator takes the lowest free number in the band rather than working above a high-water mark. Retiring a home frees nothing: the number stays spent.
The seed found this the hard way — it first burned only the numbers with a NAS home, and promptly offered one that a node account already held. What that cost.
Joining a roster line to a home
The seed reads ownership off the NAS, so every rostered person has to be matched to a directory
under /volume1/calypso_homes/homes. The roster keys on the edu-ID email; the homes are named
firstname.lastname. The join is the optional third roster.csv column, nasname, defaulting to
the email local-part — correct for every home that follows the naming, and filled in only where it
does not. Three of the 66 homes need it because they predate the convention, and two of the current
cohort because the directory truncates a double surname while the email keeps it whole.
roster-sync.sh rejects two people claiming the same home, because that would hand them one number
and one home between them.
4. The guarantee
A home directory and a lab-VM account draw their number from the same issuance, so a 750 mount
cannot silently deny. Before the register the two were derived independently and agreed by luck.
The guarantee is only as good as the file matching the disk, so uid-alloc.py --verify is the check
that matters: the NAS is the terrain, the register is the map.
5. What reads it
| Consumer | State |
|---|---|
| Ansible, on the ISC³ rack | reads it since 2026-08-05 — its number wins over any id: in conf/users |
create-home.sh | reads it |
carnaval-lab-vm.sh | still stats the NAS home, which stays correct for the same reason the seed does |
| The research machines | deliberately not wired — they share the same conf/users lists with their own numbering |
Why the id: columns survive in conf/users rather than being deleted: the same lists serve the
research machines, where the register is off. Emptying them would leave those machines with no
number at all. Unifying the two namespaces means renumbering and chowning one side, which is a
decision rather than a side effect of a playbook run — ops todo.