#!/usr/bin/env python3.14# -*- coding: utf-8 -*-"""The admin-plane CLI: run **under sudo** by the `users` shell command.The admin plane is gated by **wheel/sudo**, not a bespoke group. Running as root(via `sudo`) this CLI does two things the operator's ordinary uid cannot:- **writes the authorization system of record** — `/etc/euler/authorizations.json` (`root:euler-authz 0640`) — for profile assignment (`add` / `change` / the map side of `remove`), and with it each account's own row under `/etc/euler/profiles/` (`sync-rungs` re-publishes those alone, for the setup kits);- **reaches the euler-auth admin socket** (`0600`, token in root-readable `/etc/euler/auth.env`) for SRP operations — minting invites, enable / disable / remove, session revocation, and the full roster listing.Identity is a **web email** (`@`) or a **local OS login** (bare). A bare name getsa direct map entry (no invite, no SRP record — a local login authenticates by beingthat OS user); an email additionally mints an emailed invite / drives SRP state.Invocation (see :mod:`euler_webshell.users` for the shell wrapper):: sudo <venv-python> -m euler_site.auth.admin \ <list|add|change|enable|disable|remove|requests-json|dismiss|sync-rungs> [identity] [profile]`list` now folds in the invite-request queue (the login page's "Request aninvite" form). `requests-json` dumps that queue as JSON for the interactive`users process-requests` orchestrator, and `dismiss <email>` drops one — bothreach the same admin socket; the queue itself is a euler-auth-private store, neverthe SoR."""from__future__importannotations__all__=['main','revoke_all']importgrpimportjsonimportosimportpwdimportsubprocessimportsysimporttempfilefrompathlibimportPathfromtypingimportAnyfromsolver.auth.authorizationsimportRUNG_DIRfromsolver.auth.identityimportsystem_slugfromsolver.config.envimportload_specfromeuler_site.auth.clientimportrequestfromeuler_site.envfileimportenv_file_values_ACTIONS=('list','add','change','enable','disable','remove','roster-json','requests-json','dismiss','sync-rungs')_NO_IDENTITY=('list','roster-json','requests-json','sync-rungs')# these take no identity_WEB_PROFILES=('reader','contributor','maintainer')# admin is local-only_ALL_PROFILES=_WEB_PROFILES+('admin',)_AUTHZ_PATH=os.environ.get('EULER_AUTHZ_FILE','/etc/euler/authorizations.json')#: The unix group that may write the roster, and the profiles that belong in it._MAINT_GROUP='euler-maint'_MAINT_PROFILES=('maintainer','admin')def_fail(message:str,code:int=1)->int:print(f'error: {message}',file=sys.stderr)returncode# ── authorizations.json (the SoR — root-write) ──────────────────────────────────────def_authz_save(data:dict[str,Any])->None:"""Write the policy back atomically, keeping the file's group and mode. The map names every collaborator, so it is `root:euler-authz 0640` (auth.sh) — readable by the services that need it and the operator, by no per-user uid. A rewrite through a temp file would otherwise land with the temp file's own owner and mode, so both are carried over from the file being replaced (`0640` if there is none yet). """path=Path(_AUTHZ_PATH)try:current=path.stat()gid,mode=current.st_gid,current.st_mode&0o777exceptOSError:gid,mode=-1,0o640_write_json(path,data,gid,mode)_rungs_sync(data)def_write_json(path:Path,data:dict[str,Any],gid:int,mode:int)->None:"""Replace *path* with *data* atomically, born with group *gid* and *mode*."""fd,tmp=tempfile.mkstemp(dir=path.parent,prefix=f'.{path.name}.')try:withos.fdopen(fd,'w',encoding='utf-8')ashandle:json.dump(data,handle,indent=2,sort_keys=True)handle.write('\n')os.chown(tmp,-1,gid)os.chmod(tmp,mode)os.replace(tmp,path)exceptBaseException:os.unlink(tmp)raisedef_rungs_sync(data:dict[str,Any])->None:"""Publish each mapped account's own row beside the map, and drop every row that is stale. A per-user uid may not read the map, yet a `solver` it runs — claude-solve's headless Claude, a nested `solver "…"` — resolves its rung from it (solver.auth.identity). So every account gets `profiles/<account>`: its row in the map's own shape, `root:<account> 0640`, readable by that uid alone. The account is the e-mail's system slug, or a bare login as is; only one that exists with its own private group gets a file — an account not yet provisioned is skipped, and `user.sh provision` runs `sync-rungs` once it exists. """directory=Path(_AUTHZ_PATH).parent/RUNG_DIRdirectory.mkdir(mode=0o755,exist_ok=True)published:set[str]=set()foridentity,profilein(data.get('users')or{}).items():account=system_slug(identity)if'@'inidentityelseidentitytry:gid=grp.getgrnam(account).gr_gidifpwd.getpwnam(account).pw_gid!=gid:continue# a shared group would show it to othersexceptKeyError:continue_write_json(directory/account,{'users':{identity:profile}},gid,0o640)published.add(account)forstaleindirectory.iterdir():ifstale.namenotinpublished:stale.unlink()def_authz_set(identity:str,profile:str)->None:data=json.loads(Path(_AUTHZ_PATH).read_text(encoding='utf-8'))data.setdefault('users',{})[identity.strip().lower()]=profile_authz_save(data)def_authz_remove(identity:str)->bool:data=json.loads(Path(_AUTHZ_PATH).read_text(encoding='utf-8'))ifdata.get('users',{}).pop(identity.strip().lower(),None)isNone:returnFalse_authz_save(data)returnTrue# ── the roster write-ACL group ──────────────────────────────────────────────────────def_sync_maint_group(identity:str,profile:str,is_web:bool)->None:"""Add or remove *identity*'s unix account from `euler-maint`, tracking the profile. That group is the write-ACL on `/etc/euler/roster/` — the roster is read by everyone and written by maintainers, and unix has no notion of a profile, so membership is what carries the floor down to the filesystem. It is **not** a second copy of the ladder: it grants "may write an advisory file" and nothing else, so a stale member can record public keys and dates on a host they still have an account on, and that is the whole of it. Best-effort, and deliberately: the account act itself has already succeeded by the time this runs, and a missing group (a host where the roster kit was never laid down) must not turn a successful `users change` into a failure. It reports instead. Membership is read at login, so a promoted maintainer's **running** service keeps the old groups until it restarts — `users redeploy` is what re-lays them. """account=system_slug(identity)ifis_webelseidentitywanted=profilein_MAINT_PROFILEStry:members=grp.getgrnam(_MAINT_GROUP).gr_memexceptKeyError:print(f'note: group {_MAINT_GROUP} is absent — run the auth kit to lay the roster down',file=sys.stderr)returnifwanted==(accountinmembers):returndone=subprocess.run(['gpasswd','-a'ifwantedelse'-d',account,_MAINT_GROUP],capture_output=True,text=True)ifdone.returncode!=0:print(f'note: could not {"add"ifwantedelse"remove"}{account} 'f'{"to"ifwantedelse"from"}{_MAINT_GROUP}: {done.stderr.strip()}',file=sys.stderr)returnprint(f'{"added"ifwantedelse"removed"}{account} 'f'{"to"ifwantedelse"from"}{_MAINT_GROUP} (roster write access)')# ── euler-auth admin socket (SRP operations) ────────────────────────────────────────def_api(method:str,path:str,*,body:dict[str,Any]|None=None,timeout:float=10.0)->tuple[int,dict[str,Any]|str]:"""One call to the euler-auth admin socket (raises SystemExit-style on failure)."""env_file=env_file_values(Path(os.environ.get('EULER_AUTH_ENV','/etc/euler/auth.env')))token=os.environ.get('EULER_ADMIN_TOKEN')orenv_file.get('EULER_ADMIN_TOKEN','')admin_socket=load_spec('auth').entries['admin_socket_path']socket_path=(os.environ.get(admin_socket.env_var)orenv_file.get(admin_socket.env_var)oradmin_socket.default)ifnottoken:raiseRuntimeError('EULER_ADMIN_TOKEN not found (is /etc/euler/auth.env deployed, and are you root?)')returnrequest(socket_path,method,path,body=body,headers={'X-Admin-Token':token},timeout=timeout)defrevoke_all()->int:"""Drop every session and remember-me token; return how many. Raises on a refusal."""status,data=_api('POST','/admin/revoke-all')ifstatus!=200ornotisinstance(data,dict):raiseRuntimeError(f'admin API: {status}{data}')returnint(data.get('revoked',0))# ── dispatch ────────────────────────────────────────────────────────────────────────defmain(argv:list[str])->int:ifnotargvorargv[0]notin_ACTIONS:print(f'usage: python -m euler_site.auth.admin {{{"|".join(_ACTIONS)}}} [identity] [profile]',file=sys.stderr)return2action=argv[0]identity=argv[1]iflen(argv)>1else''profile=argv[2]iflen(argv)>2else'reader'is_web='@'inidentityifaction=='sync-rungs':_rungs_sync(json.loads(Path(_AUTHZ_PATH).read_text(encoding='utf-8')))return0ifactionnotin_NO_IDENTITYandnotidentity:return_fail(f'users {action} requires an email or os-login')ifactionin('enable','disable')andnotis_web:return_fail(f'users {action} applies to web accounts only (SRP state)')ifactionin('add','change'):allowed=_WEB_PROFILESifis_webelse_ALL_PROFILESifprofilenotinallowed:return_fail(f'profile for {"a web account"ifis_webelse"a local login"} 'f'must be one of {allowed}')try:ifaction=='list':status,data=_api('GET','/admin/users')ifstatus!=200ornotisinstance(data,dict):return_fail(f'admin API: {status}{data}')_print_listing(data)status,queue=_api('GET','/admin/requests')# the invite-request queue, folded inifstatus!=200ornotisinstance(queue,dict):return_fail(f'admin API: {status}{queue}')_print_requests(queue)return0ifaction=='roster-json':# The roster as data, for `key-rekey`: every identity on the map with its# profile, its per-user slug and its registration state. Machine-readable# sibling of `list`, exactly as `requests-json` is for the queue — purge has# to *decide* from the roster, not read it off a terminal.status,data=_api('GET','/admin/users')ifstatus!=200ornotisinstance(data,dict):return_fail(f'admin API: {status}{data}')print(json.dumps(data.get('roster',[])))return0ifaction=='requests-json':status,data=_api('GET','/admin/requests')ifstatus!=200ornotisinstance(data,dict):return_fail(f'admin API: {status}{data}')print(json.dumps(data.get('requests',[])))# machine-readable, for `users process-requests`return0ifaction=='dismiss':status,data=_api('DELETE',f'/admin/requests/{identity}')ifstatus==200:print(f'dismissed request from {identity}')return0return_fail(f'{status}{data}')ifaction=='add':_authz_set(identity,profile)# SoR write (both paths)_sync_maint_group(identity,profile,is_web)ifnotis_web:print(f'mapped local login {identity} → {profile}')return0status,data=_api('POST','/admin/users',body={'email':identity,'profile':profile},timeout=60.0)# invite mail can take secondsifstatus==201andisinstance(data,dict):print(f'invited {identity} ({profile}) — link emailed, valid {data.get("expires")}')return0_authz_remove(identity)# roll back the map entry on failurereturn_fail(f'invite failed ({status}{data}); map entry reverted')ifaction=='change':_authz_set(identity,profile)_sync_maint_group(identity,profile,is_web)ifis_web:_api('POST',f'/admin/users/{identity}/revoke')# new profile takes effect on re-loginprint(f'changed {identity} → {profile}')return0ifaction=='remove':_sync_maint_group(identity,'reader',is_web)# drop the roster write-ACL firstremoved_map=_authz_remove(identity)removed_srp=Falseifis_web:status,_=_api('DELETE',f'/admin/users/{identity}')removed_srp=status==200ifnotremoved_mapandnotremoved_srp:return_fail(f'no such user or invite: {identity}')print(f'removed {identity}')return0# enable | disable (web SRP state)status,data=_api('POST',f'/admin/users/{identity}/{action}')ifstatus==200:print(f'{action}d {identity}')return0return_fail(f'{status}{data}')exceptTimeoutError:return_fail('timed out waiting for the auth service (the action may still have completed — ''check with `users list`)')except(OSError,RuntimeError)asexc:return_fail(f'auth admin plane error ({exc}) — is euler-auth.service running, and are you root?')def_pending_note(record:dict[str,Any])->str:"""The in-flight invite / reset, as a suffix to whatever row it belongs to."""returnf'pending {record.get("kind")}/{record.get("state")}, expires in {record.get("expires_in_h")}h'def_print_listing(data:dict[str,Any])->None:"""Render the roster — every identity in authorizations.json (web + local) with its profile and registration state — plus in-flight invites. One identity is one row: a pending record for an identity already on the roster is an *attribute* of that account (mid-registration, or a reset in flight), not a second account, so it folds into that row's state column. Only a pending record with no roster entry — an invite whose map entry has since gone — stands alone. """roster=data.get('roster',[])# Wide enough for the longest unix name present (a slug is 17 characters today), so a# blank one — a local login has none — still pads the columns after it into line.slug_width=max([len(str(entry.get('slug')or''))forentryinroster]+[1])pending:dict[str,list[dict[str,Any]]]={}forrecordindata.get('pending',[]):pending.setdefault(str(record.get('email')),[]).append(record)forentryinroster:# The unix name (the per-user uid/home/socket) for a web account; a local# OS login has no per-user instance, so its column is blank.notes=[_pending_note(record)forrecordinpending.pop(str(entry.get('user')),[])]state=' — '.join([str(entry.get('state')),*notes])print(f' {entry.get("user"):40}{entry.get("slug")or"":{slug_width}}{entry.get("profile"):18} 'f'{entry.get("scope"):6}{state}')foremail,recordsinpending.items():# orphans: no account, no map entryforrecordinrecords:# No account exists — hence no unix name — so the slug column is a placeholder,# keeping the fields aligned with the roster rows above.print(f' {email:40}{"—":{slug_width}}{record.get("profile"):18} 'f'web {_pending_note(record)}')ifnotrosterandnotdata.get('pending'):print('no accounts or pending invites')def_print_requests(data:dict[str,Any])->None:"""Render the invite-request queue — prospective collaborators from the public form. Printed as its own section under `users list`; work through it with `users process-requests`. """requests=data.get('requests',[])print(f'\ninvite requests ({len(requests)}) — process with `users process-requests`:')forrecordinrequests:submissions=int(record.get('submissions',1))seen=f' (×{submissions})'ifsubmissions>1else''print(f' {record.get("email"):40}{(record.get("name")or"")[:28]:28} 'f'{record.get("created","")}{seen}')forlineinstr(record.get('remarks')or'').splitlines():print(f' {line}')ifnotrequests:print(' (none)')if__name__=='__main__':raiseSystemExit(main(sys.argv[1:]))