Give identity its due: native enrollment, honest deletion, and sign-in that stays home

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 18:55:38 -04:00
parent 5a0643357f
commit 4ec8bd94d9
25 changed files with 4713 additions and 407 deletions
+124 -14
View File
@@ -13,14 +13,17 @@ accountsservice over D-Bus from inside this process. It is never an argument:
argv is world-readable through /proc, so a password -- or even its hash --
passed that way is published to every process on the machine.
panama-accounts snapshot
panama-accounts set-real-name USER NAME
panama-accounts set-icon USER PATH [X Y SIZE]
panama-accounts set-account-type USER standard|administrator
panama-accounts set-automatic-login USER true|false
panama-accounts set-password USER (new password on stdin)
panama-accounts create-user USERNAME REALNAME standard|administrator
panama-accounts delete-user USERNAME [keep-files|remove-files]
panama-users snapshot
panama-users stock-avatars
panama-users set-real-name USER NAME
panama-users set-icon USER PATH [X Y SIZE] (PATH "" clears the picture)
panama-users set-account-type USER standard|administrator
panama-users set-automatic-login USER true|false
panama-users set-locked USER true|false
panama-users set-password USER (new password on stdin)
panama-users reset-password USER (no password material at all)
panama-users create-user USERNAME REALNAME standard|administrator
panama-users delete-user USERNAME keep|remove
"""
from __future__ import annotations
@@ -39,6 +42,16 @@ USER_INTERFACE = "org.freedesktop.Accounts.User"
# Account types as accountsservice numbers them.
STANDARD, ADMINISTRATOR = 0, 1
# Password modes, likewise. 1 is "the account has no usable password and must
# choose one at the next sign-in" -- which is why resetting a password here
# involves no password at all, not even one this process saw for a moment.
PASSWORD_MODE_SET_AT_LOGIN = 1
# Where a distribution keeps the pictures its login screen offers. Fedora ships
# fifteen; a machine with none is normal and yields an empty list.
STOCK_AVATAR_DIR = "/usr/share/pixmaps/faces"
STOCK_AVATAR_SUFFIXES = (".png", ".jpg", ".jpeg", ".webp", ".svg")
USERNAME = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
@@ -137,6 +150,40 @@ def snapshot() -> dict:
}
def stock_avatars() -> dict:
"""The pictures the distribution ships, as {name, path} pairs.
Absolute paths, because the caller draws them straight from disk and then
hands the same path back to set-icon. Sorted by name so the gallery does
not reshuffle itself between openings.
"""
entries = []
try:
names = os.listdir(STOCK_AVATAR_DIR)
except OSError:
# No such directory is an ordinary machine, not a failure worth a row.
names = []
for name in names:
if not name.lower().endswith(STOCK_AVATAR_SUFFIXES):
continue
path = os.path.join(STOCK_AVATAR_DIR, name)
if not os.path.isfile(path):
continue
entries.append({"name": _avatar_label(name), "path": path})
entries.sort(key=lambda entry: entry["name"].lower())
return {"avatars": entries, "error": ""}
def _avatar_label(filename: str) -> str:
""""coffee2.jpg" -> "Coffee 2". A file name is not a caption, but it is the
only thing these pictures carry, so it is tidied rather than invented."""
stem = os.path.splitext(filename)[0]
words = re.sub(r"(\d+)$", r" \1", stem.replace("-", " ").replace("_", " ")).strip()
return words[:1].upper() + words[1:]
def set_real_name(username: str, name: str) -> None:
from gi.repository import GLib
@@ -195,6 +242,14 @@ def crop_square(path: str, x: int, y: int, size: int) -> str:
def set_icon(username: str, path: str, region: tuple[int, int, int] | None = None) -> None:
from gi.repository import GLib
# An empty path is how accountsservice is told to forget the picture: the
# same call, with nothing in it. There is no separate "clear" method, and
# inventing a verb for it here would only hide that.
if path == "":
call(user_path(username), USER_INTERFACE, "SetIconFile",
GLib.Variant("(s)", ("",)))
return
if not os.path.isfile(path):
raise BoundaryError("That picture no longer exists.")
@@ -221,7 +276,21 @@ def set_account_type(username: str, kind: str) -> None:
if kind not in ("standard", "administrator"):
raise BoundaryError("That is not an account type.")
call(user_path(username), USER_INTERFACE, "SetAccountType",
path = user_path(username)
# The same reason deleting the last administrator is refused: a machine
# whose only administrator has just been demoted cannot be administered,
# and the demotion itself is the last thing that needed authorization.
if kind == "standard":
state = snapshot()
target = next((user for user in state["users"]
if user["userName"] == username), None)
if target is not None and target["administrator"] \
and state["administratorCount"] <= 1:
raise BoundaryError(
"That is the only administrator; the machine would have none.")
call(path, USER_INTERFACE, "SetAccountType",
GLib.Variant("(i)", (ADMINISTRATOR if kind == "administrator" else STANDARD,)))
@@ -232,6 +301,29 @@ def set_automatic_login(username: str, enabled: bool) -> None:
GLib.Variant("(b)", (enabled,)))
def set_locked(username: str, locked: bool) -> None:
"""Lock or unlock an account. A locked account cannot sign in at all, which
is what someone is looking at when a user row says nothing works for them."""
from gi.repository import GLib
call(user_path(username), USER_INTERFACE, "SetLocked",
GLib.Variant("(b)", (locked,)))
def reset_password(username: str) -> None:
"""Require a new password at the next sign-in.
Deliberately not "set a password for them": no password is chosen, typed,
hashed, or transmitted. accountsservice is told the account's password mode
is "set at login", and the login screen collects the new one from the person
who is going to use it.
"""
from gi.repository import GLib
call(user_path(username), USER_INTERFACE, "SetPasswordMode",
GLib.Variant("(i)", (PASSWORD_MODE_SET_AT_LOGIN,)))
def set_password(username: str) -> None:
"""Set a new password, read from stdin and never named on a command line."""
secret = sys.stdin.buffer.read()
@@ -269,10 +361,17 @@ def create_user(username: str, real_name: str, kind: str) -> None:
"(o)")
# What to do with the home directory, spelled either way. "keep"/"remove" is
# what the page says out loud; the longer pair is what this helper has always
# taken, and callers older than the page still pass it.
KEEP_FILES = ("keep", "keep-files")
REMOVE_FILES = ("remove", "remove-files")
def delete_user(username: str, files: str) -> None:
from gi.repository import GLib
if files not in ("keep-files", "remove-files"):
if files not in KEEP_FILES + REMOVE_FILES:
raise BoundaryError("Say whether to keep or remove the home directory.")
if username == (os.environ.get("USER") or ""):
raise BoundaryError("You cannot delete the account you are signed in to.")
@@ -285,7 +384,7 @@ def delete_user(username: str, files: str) -> None:
raise BoundaryError("That is the only administrator; the machine would have none.")
call(ACCOUNTS_PATH, ACCOUNTS, "DeleteUser",
GLib.Variant("(xb)", (target["uid"], files == "remove-files")))
GLib.Variant("(xb)", (target["uid"], files in REMOVE_FILES)))
def main(arguments: list[str]) -> int:
@@ -294,6 +393,12 @@ def main(arguments: list[str]) -> int:
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
# Its own shape rather than a snapshot: this asks the filesystem what
# pictures exist, which has nothing to do with who has an account.
if arguments == ["stock-avatars"]:
print(json.dumps(stock_avatars(), separators=(",", ":")))
return 0
if len(arguments) == 3 and arguments[0] == "set-real-name":
set_real_name(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "set-icon":
@@ -308,19 +413,24 @@ def main(arguments: list[str]) -> int:
set_account_type(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "set-automatic-login":
set_automatic_login(arguments[1], arguments[2] == "true")
elif len(arguments) == 3 and arguments[0] == "set-locked":
set_locked(arguments[1], arguments[2] == "true")
elif len(arguments) == 2 and arguments[0] == "set-password":
set_password(arguments[1])
elif len(arguments) == 2 and arguments[0] == "reset-password":
reset_password(arguments[1])
elif len(arguments) == 4 and arguments[0] == "create-user":
create_user(arguments[1], arguments[2], arguments[3])
elif len(arguments) == 3 and arguments[0] == "delete-user":
delete_user(arguments[1], arguments[2])
else:
raise BoundaryError(
"Usage: panama-accounts snapshot | set-real-name USER NAME | "
"Usage: panama-users snapshot | stock-avatars | set-real-name USER NAME | "
"set-icon USER PATH [X Y SIZE] | set-account-type USER standard|administrator | "
"set-automatic-login USER true|false | set-password USER | "
"set-automatic-login USER true|false | set-locked USER true|false | "
"set-password USER | reset-password USER | "
"create-user USERNAME REALNAME standard|administrator | "
"delete-user USERNAME keep-files|remove-files")
"delete-user USERNAME keep|remove")
except BoundaryError as error:
# Answers with the fresh state plus the message, so a page never has to
# ask twice to find out what happened.