#!/usr/bin/env python3.14# -*- coding: utf-8 -*-"""The collaborator roster — `/etc/euler/roster/users.json`.A slug-keyed record of who exists and the X25519 public key each of them holds: the registry`key-rekey` re-issues against, `key-split` seals to, and `msg send` builds its recipient menufrom. **Host state, read by everyone and written by maintainers**, which is the shape thosethree readers actually need — the registry it replaced was an admin-plane read behind `sudo`,and a web shell can never obtain that.It was briefly a *tracked* file, which fixed the reading and broke the writing. `users` isadmin-gated and runs under `sudo`, but `user-authorize` and `key-split` are **maintainer**commands: a maintainer granting from their web shell wrote the tracked file in their own clone,on their own branch, where it reached nobody until a pull request that nobody opened — andcollided with the operator's copy at the next sync. One file both floors can write needs tolive where both floors can write it.**Who may write it, and why that matters.** `/etc/euler/roster/` is `root:euler-maint 2775`(setgid, so new files inherit the group) and the file is `0664` — world-readable, writable by`euler-maint`, whose membership the account verbs keep at maintainer-and-above. The floor isnot tidiness: `public_key` is the one field here that is *used* rather than displayed —:func:`~solver.crypto.keys._recipient_key` reads it to decide what a half gets sealed to — sowhoever can write it can redirect a future grant to a key they hold. Combined with host readaccess to the share (`config.share_file`) that is the whole master key, whichis why the group must be people who already have it. Every other field is advisory.**Public keys only. No key material.** Half the master key used to live here too, on theargument that a single share of a 2-of-2 split is a uniformly random point and reveals nothing.That is true and it was still wrong while this file was tracked: the repository is *public*, soa half anybody can clone is not a second factor.**No decisions.** `profile` is a **mirror for display and menus**; the system of record is`/etc/euler/authorizations.json` beside it, root-owned, and every `rank()` comparison stillreads that. The `euler-maint` group is a write-ACL for advisory data, not a second copy of theladder: a stale member can write public keys and dates on a host they still have an account on,and nothing else.**No e-mail addresses.** Records are keyed by :func:`~solver.auth.identity.system_slug`, thesame `u`+hash the uid, home, socket and branch already use — the standing decision not topublish who a collaborator is. The spool routes by that slug too (`box_of` leaves a non-addressunchanged), so a slug-keyed roster is enough to address a message, and the slug → identitymapping stays in `authorizations.json`.**Lifecycle: acts, not states.** The stamps here (`invited`, `provisioned`, `key_issued`,`removed`) record what the **operator did**, dated at the moment it did it. They cannot drift,because nothing but the operator writes them. The states a *user* drives (registration) andthe states enforcement depends on (`disabled`) deliberately stay on the host and are joined inat display time: a mirror of `disabled` reading `active` for a locked-out account is the kindof stale copy that gets trusted.The accounts that predate this file were migrated once, in August 2026, and their acts datedfrom what the host could still show — the registration record for `invited` / `provisioned`,the enc-key file's mtime for `key_issued` — falling back to the migration's own clock wherenothing could be deduced. An approximate date on an act that certainly happened beats an emptyfield, which reads as "never".**Writers**, all of them account acts and none of them a service: `users add` / `change` /`remove` (the operator), and `user-authorize` / `key-split` (any maintainer, recording the keythey just sealed to). Writes are whole-file and atomic — a temp file in the same directory,then `os.replace` — because several maintainers share one file and a half-written roster readsas "nobody holds a key", which is what a rotation would act on.Stdlib-only and free of any :mod:`solver.config` dependency, so :mod:`solver.crypto` (whichis on the git-filter path) and :mod:`euler_webshell.msg` (stdlib-only importable) can both read it."""from__future__importannotations__all__=['ROSTER_FILE_ENV','Account','Roster','UserEntry','accounts','public_keys','read_roster','roster_path','slug_of','slugs','stamp','upsert','user_entry','write_roster']importosfromdatetimeimportdatetime,timezonefromjsonimportdumps,loadsfrompathlibimportPathfromtempfileimportmkstempfromtypingimportTYPE_CHECKING,Any,Literal,NamedTuple,TypedDict,castfromsolver.auth.identityimportsystem_slugifTYPE_CHECKING:# only a shell ever renders an account; see Account.__choice__fromsolver.shell.dialogueimportChoicefromsolver.config.pathsimportrepo_root#: Environment override for the roster's location (tests, and any machine that keeps it#: elsewhere).ROSTER_FILE_ENV:str='EULER_ROSTER_FILE'#: The deployed location: its own directory under `/etc/euler`, because the *directory* is#: what has to be group-writable — an atomic replace writes a temp file beside the target and#: renames over it, which needs write permission on the directory, not the file. A#: group-writable file in a root-owned directory would force truncate-and-rewrite in place,#: and a crash mid-write would leave an empty roster: "nobody holds a key", which is what the#: next rotation would act on._SYSTEM_ROSTER:Path=Path('/etc/euler/roster/users.json')#: The schema version, so a reader can refuse a file it does not understand rather than#: guessing at it. Bump it only for a change a current reader could misread.VERSION:int=1classUserEntry(TypedDict,total=False):"""One collaborator, as the repository records them. Every field is optional: an entry is built up by the acts that touch it (an invite has no public key yet, a local os-login is never provisioned), and a reader must cope with any subset. `profile` is a **mirror** — see the module docstring. """public_key:str# X25519 public key hex, or '' — checkable, so safe herescope:Literal['web','local']# a web account with an instance, or a bare os-loginprofile:str# MIRROR of /etc/euler/authorizations.json; never a gateinvited:str# operator acts, ISO-8601 UTC, immutable once writtenprovisioned:strkey_issued:str# when the master key was last issued to `public_key`removed:str|None# the date they were removed, or null while they are notclassRoster(TypedDict,total=False):"""The whole file: a version and the slug-keyed records."""version:intusers:dict[str,UserEntry]defstamp()->str:"""UTC now in ISO-8601 seconds — how every act in this file is dated."""returndatetime.now(timezone.utc).isoformat(timespec='seconds')defroster_path()->Path:"""Where the roster lives: `$EULER_ROSTER_FILE`, the deployed host, or this machine. The same three-step resolution the share file uses, and for the same reason — one machine, one answer, whether the caller is a web service running from `/opt/euler/venv` or a shell in somebody's clone: 1. **`$EULER_ROSTER_FILE`** — the tests, and any machine that keeps it elsewhere. 2. **`/etc/euler/roster/users.json`** when `/etc/euler` exists: a deployed host. 3. **`~/.euler/roster.json`** otherwise — a plain checkout with no deployed tier, where the machine-local dot-directory beside the repo is already where such state lives. The *parent* of the deployed directory is probed rather than the file, so a first write on a deployed host lands in the deployed place rather than inventing a second copy in `~`. """override:str=os.environ.get(ROSTER_FILE_ENV,'').strip()ifoverride:returnPath(override)if_SYSTEM_ROSTER.parent.parent.is_dir():return_SYSTEM_ROSTERroot:Path=repo_root()returnroot.parent/f'.{root.name}'/'roster.json'defread_roster()->Roster:"""The roster, or an empty one — this never raises and never reports a problem. Absent, unreadable, unparseable and *from a future version* all read as empty, because every caller's fallback is the same and better: ask the host. A roster is a convenience that removes a `sudo` prompt, so a bad one must degrade to the old path rather than break a command. What must never happen is a *wrong* answer, and it cannot: everything here is checked by its consumer (a public key seals or it does not; a share verifies or it does not) or is advisory to begin with. """try:data=cast(dict[str,Any],loads(roster_path().read_text()))except(OSError,ValueError,TypeError):return{'version':VERSION,'users':{}}ifnotisinstance(data,dict)orint(data.get('version',0))>VERSION:return{'version':VERSION,'users':{}}users=data.get('users')return{'version':VERSION,'users':{str(slug):cast(UserEntry,entry)forslug,entryin(usersor{}).items()ifisinstance(entry,dict)}}#: The order a record's fields are written in: who they are, then what was done to them **in#: the order it happens**. Not alphabetical — this file is read by people, and a lifecycle#: listed `invited → provisioned → key_issued → removed` can be checked at a glance (each date#: should be no earlier than the one above it), where `invited, key_issued, provisioned,#: removed` cannot. Anything not named here is written after, in the order it arrived, so an#: older or newer schema's fields survive a round trip._FIELD_ORDER:tuple[str,...]=('public_key','scope','profile','invited','provisioned','key_issued','removed')def_ordered(entry:UserEntry)->UserEntry:"""One record with its fields in :data:`_FIELD_ORDER` — the shape a reader expects."""known={key:entry[key]forkeyin_FIELD_ORDERifkeyinentry}# type: ignore[literal-required]returncast(UserEntry,known|{key:valueforkey,valueinentry.items()ifkeynotinknown})def_record_order(item:tuple[str,UserEntry])->tuple[bool,str]:"""Sort key: **local logins first**, then web slugs, each alphabetically. Not a cosmetic choice. The local entries are the operators — the accounts that hold the master key and run the verbs that write this file — and the web ones are the roll they administer. Putting the short list of people who *act* above the longer list of people acted upon is what makes the file read top-down. Alphabetical within each group keeps a one-record change to a one-record diff. """slug,entry=itemreturnentry.get('scope')!='local',slugdefwrite_roster(roster:Roster)->Path:"""Serialise the roster and return the path written — for the caller to commit. Records in :func:`_record_order`, fields in lifecycle order, and a trailing newline: this file is reviewed in diffs and merged like any other tracked text, so a stable order is what keeps a one-field change to a one-line diff. It is also why the order lives here rather than at the call sites — `upsert` merges, so without a canonical order every new field would land wherever it was first written, and a shape somebody arranged by hand would survive exactly until the next grant. **Atomic**, because several maintainers share one file: the body goes to a temp file in the same directory and is renamed over the target, so a reader never sees a half-written roster and a crash mid-write cannot leave an empty one. `0664` and the directory's setgid group, which is what lets the next maintainer replace it in turn. Raises: OSError: If the file cannot be written — most likely a caller who is not in `euler-maint`. Callers that have already acted (a grant is sent by the time it records) report it rather than failing the act. """path:Path=roster_path()path.parent.mkdir(parents=True,exist_ok=True)body:Roster={'version':VERSION,'users':{slug:_ordered(entry)forslug,entryinsorted((roster.get('users')or{}).items(),key=_record_order)}}handle,staged=mkstemp(dir=path.parent,prefix=f'.{path.name}.')try:withos.fdopen(handle,'w',encoding='utf-8')asstream:stream.write(dumps(body,indent=2,sort_keys=False)+'\n')os.chmod(staged,0o664)# the group writes it next; setgid gave it the groupos.replace(staged,path)exceptBaseException:Path(staged).unlink(missing_ok=True)raisereturnpath# ==================================================================================================================== ## users# ==================================================================================================================== #defuser_entry(identity:str)->UserEntry|None:"""The record for *identity* — an e-mail or an already-slugged name — or None."""returnread_roster().get('users',{}).get(slug_of(identity))defslug_of(identity:str)->str:"""The roster key for *identity*: a slug stays itself, an address becomes its slug. Both forms reach this file — the operator types an address, a command that already has a box key passes the slug — and collapsing them here means no caller has to know which it holds. Matches :func:`euler_webshell.msg.identity.box_of` exactly, which is what lets a roster key be used as a message recipient unchanged. """name=identity.strip().lower()returnsystem_slug(name)if'@'innameelsenamedefupsert(identity:str,**fields:str|None)->Path:"""Merge *fields* into *identity*'s record and write the file; returns the path. Merge rather than replace: the acts that touch a record arrive at different times from different verbs (an invite, then a provision, then a key), and each knows only its own field. Passing an empty string clears a field — `removed=''` is how a re-invite un-retires somebody — but a field simply omitted is never disturbed. """roster:Roster=read_roster()users:dict[str,UserEntry]=roster.get('users')or{}entry:UserEntry=dict(users.get(slug_of(identity))or{})# type: ignore[assignment]entry.update(cast(UserEntry,{key:valueforkey,valueinfields.items()}))users[slug_of(identity)]=entryroster['users']=usersreturnwrite_roster(roster)classAccount(NamedTuple):"""One live collaborator, read out of the roster. The record every menu of people is built from — who exists, what they may do, and whether a key was ever issued to them. It carries the whole entry rather than the one field a given caller wants, because the three menus that used to read this file each read a *different* projection of it and drifted: one asked for slugs, one for `{slug: key}`, one for slugs in a scope, and only one of the three remembered that a removed record is not a person you may still send a key to. """slug:str#: `web` (an instance, and therefore a mailbox) or `local` (the operator at a terminal).scope:str#: The roster's **mirror** of the profile. Never a gate — see the module docstring.profile:str#: X25519 public key hex, or '' when none has been recorded.public_key:str#: The operator's own acts, ISO-8601 UTC, or '' where the act has not happened.invited:strprovisioned:strkey_issued:str@propertydefholds_key(self)->bool:"""Whether a share can be sealed to them — the filter `key-split`'s menu needs."""returnbool(self.public_key)def__choice__(self)->Choice:"""How the account reads in a menu: the slug is the value, the profile is the context. Not whether they hold a key — that is what *filters* `{holders}`, and a row has no business announcing the property that decides which list it is in. `Choice` is imported here rather than at module scope on purpose: this package is the authorization kernel, read by services that have no shell and no `rich`, and a menu row is the one thing about an account that only a shell ever wants. """fromsolver.shell.dialogueimportChoiceas_Choicereturn_Choice(self.slug,self.slug,' · '.join(partforpartin(self.profile,self.scope)ifpart))defaccounts(scope:str='')->list[Account]:"""Every **live** record, optionally filtered to a scope (`web` / `local`), sorted. Live means not removed, and that is the whole reason this is one function: a removed record's key was real, but issuing to somebody the operator retired is the mistake these lists must not make easy, and it was previously remembered in one of the three places that read them. """returnsorted((Account(slug=slug,scope=str(entry.get('scope','')),profile=str(entry.get('profile','')),public_key=str(entry.get('public_key','')),invited=str(entry.get('invited','')),provisioned=str(entry.get('provisioned','')),key_issued=str(entry.get('key_issued','')))forslug,entryin(read_roster().get('users')or{}).items()ifnotentry.get('removed')and(notscopeorentry.get('scope')==scope)),key=lambdaaccount:account.slug)defpublic_keys()->dict[str,str]:"""`{slug: public_key}` for every live record that has one. The registry a rotation re-issues against and `key-split` seals to. """return{account.slug:account.public_keyforaccountinaccounts()ifaccount.holds_key}defslugs(scope:str='')->list[str]:"""Every live slug, optionally filtered to a scope (`web` / `local`), sorted."""return[account.slugforaccountinaccounts(scope)]