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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user