jmri_server.auth.store

Load / save the users.yaml file at <settings-root>/users.yaml.

YAML shape::

users:
  - username: admin
    password_hash: "$2b$12$..."
    role: admin
    token: "8f3a..."   # optional; empty string when not present
  - username: alice
    password_hash: "$2b$12$..."
    role: operator
    token: ""

Load / save round-trip preserves user order so a human editor doesn't see their carefully-organised file reshuffle on the first save. Atomic write (temp file + replace) so a crashed save can't leave a truncated file.

  1"""Load / save the ``users.yaml`` file at ``<settings-root>/users.yaml``.
  2
  3YAML shape::
  4
  5    users:
  6      - username: admin
  7        password_hash: "$2b$12$..."
  8        role: admin
  9        token: "8f3a..."   # optional; empty string when not present
 10      - username: alice
 11        password_hash: "$2b$12$..."
 12        role: operator
 13        token: ""
 14
 15Load / save round-trip preserves user order so a human editor doesn't
 16see their carefully-organised file reshuffle on the first save. Atomic
 17write (temp file + replace) so a crashed save can't leave a truncated
 18file.
 19"""
 20from __future__ import annotations
 21
 22import logging
 23import os
 24import pathlib
 25import tempfile
 26from typing import Any
 27
 28import yaml
 29from pydantic import BaseModel, Field
 30
 31from jmri_server.auth.models import User
 32
 33_log = logging.getLogger(__name__)
 34
 35
 36class UsersFile(BaseModel):
 37    """Top-level ``users.yaml`` shape."""
 38
 39    users: list[User] = Field(default_factory=list)
 40
 41    def by_username(self, username: str) -> User | None:
 42        """Case-sensitive lookup. Returns ``None`` for unknown user.
 43
 44        JMRI usernames are treated as opaque identifiers; we do not
 45        lowercase for lookup because doing so silently hides the
 46        real difference between ``Admin`` and ``admin`` and
 47        confuses log-line audits."""
 48        for u in self.users:
 49            if u.username == username:
 50                return u
 51        return None
 52
 53    def by_token(self, token: str) -> User | None:
 54        """Constant-time-ish lookup by API token.
 55
 56        Bounded by user count and skips empty tokens (a user with
 57        no token gets no matches). ``hmac.compare_digest`` guards
 58        against timing side channels on the compare — matters when
 59        the caller can flood the endpoint with guesses."""
 60        if not token:
 61            return None
 62        import hmac  # noqa: PLC0415
 63        for u in self.users:
 64            if u.token and hmac.compare_digest(u.token, token):
 65                return u
 66        return None
 67
 68
 69def load_users_file(path: str | pathlib.Path) -> UsersFile:
 70    """Load *path* into a :class:`UsersFile`. Missing → empty file.
 71
 72    Malformed YAML raises ``yaml.YAMLError``; the caller decides
 73    whether to keep starting (empty users file → bootstrap admin)
 74    or crash out (edited file corrupt → refuse to start with auth
 75    on, since silently ignoring would be worse than a loud stop).
 76    """
 77    p = pathlib.Path(path)
 78    if not p.is_file():
 79        return UsersFile(users=[])
 80    raw: Any = yaml.safe_load(p.read_text(encoding="utf-8"))
 81    if raw is None:
 82        return UsersFile(users=[])
 83    if not isinstance(raw, dict):
 84        raise ValueError(
 85            f"{path}: expected a mapping at the top level, got {type(raw).__name__}"
 86        )
 87    return UsersFile.model_validate(raw)
 88
 89
 90def save_users_file(path: str | pathlib.Path, file: UsersFile) -> None:
 91    """Atomically write *file* to *path* with mode 0600 on POSIX.
 92
 93    Uses temp file + rename so a crashed save can't leave the
 94    file half-written. On Windows, the ``chmod`` is a no-op (the
 95    OS ACL isn't representable through the same mode bits); the
 96    settings root should be user-only anyway.
 97    """
 98    p = pathlib.Path(path)
 99    p.parent.mkdir(parents=True, exist_ok=True)
100    # ``mode="json"`` converts the ``Role`` enum to its string value —
101    # pyyaml's safe_dump can't represent StrEnum instances directly,
102    # and even if it could, we want the on-disk shape to be plain
103    # strings so a human editor doesn't see ``!!python/object:``
104    # references.
105    payload = {"users": [u.model_dump(mode="json") for u in file.users]}
106    fd, tmp = tempfile.mkstemp(
107        prefix=".users.yaml.", dir=str(p.parent),
108    )
109    try:
110        with os.fdopen(fd, "w", encoding="utf-8") as f:
111            yaml.safe_dump(payload, f, sort_keys=False)
112        # Best-effort mode 0600 — matters on multi-user hosts, no-op
113        # on Windows where the ACL model differs.
114        try:
115            os.chmod(tmp, 0o600)
116        except (OSError, PermissionError) as e:  # pragma: no cover
117            _log.debug("chmod on %s ignored: %s", tmp, e)
118        os.replace(tmp, p)
119    except Exception:
120        try:
121            os.unlink(tmp)
122        except OSError:
123            pass
124        raise
class UsersFile(pydantic.main.BaseModel):
37class UsersFile(BaseModel):
38    """Top-level ``users.yaml`` shape."""
39
40    users: list[User] = Field(default_factory=list)
41
42    def by_username(self, username: str) -> User | None:
43        """Case-sensitive lookup. Returns ``None`` for unknown user.
44
45        JMRI usernames are treated as opaque identifiers; we do not
46        lowercase for lookup because doing so silently hides the
47        real difference between ``Admin`` and ``admin`` and
48        confuses log-line audits."""
49        for u in self.users:
50            if u.username == username:
51                return u
52        return None
53
54    def by_token(self, token: str) -> User | None:
55        """Constant-time-ish lookup by API token.
56
57        Bounded by user count and skips empty tokens (a user with
58        no token gets no matches). ``hmac.compare_digest`` guards
59        against timing side channels on the compare — matters when
60        the caller can flood the endpoint with guesses."""
61        if not token:
62            return None
63        import hmac  # noqa: PLC0415
64        for u in self.users:
65            if u.token and hmac.compare_digest(u.token, token):
66                return u
67        return None

Top-level users.yaml shape.

users: list[jmri_server.auth.User] = PydanticUndefined
def by_username(self, username: str) -> jmri_server.auth.User | None:
42    def by_username(self, username: str) -> User | None:
43        """Case-sensitive lookup. Returns ``None`` for unknown user.
44
45        JMRI usernames are treated as opaque identifiers; we do not
46        lowercase for lookup because doing so silently hides the
47        real difference between ``Admin`` and ``admin`` and
48        confuses log-line audits."""
49        for u in self.users:
50            if u.username == username:
51                return u
52        return None

Case-sensitive lookup. Returns None for unknown user.

JMRI usernames are treated as opaque identifiers; we do not lowercase for lookup because doing so silently hides the real difference between Admin and admin and confuses log-line audits.

def by_token(self, token: str) -> jmri_server.auth.User | None:
54    def by_token(self, token: str) -> User | None:
55        """Constant-time-ish lookup by API token.
56
57        Bounded by user count and skips empty tokens (a user with
58        no token gets no matches). ``hmac.compare_digest`` guards
59        against timing side channels on the compare — matters when
60        the caller can flood the endpoint with guesses."""
61        if not token:
62            return None
63        import hmac  # noqa: PLC0415
64        for u in self.users:
65            if u.token and hmac.compare_digest(u.token, token):
66                return u
67        return None

Constant-time-ish lookup by API token.

Bounded by user count and skips empty tokens (a user with no token gets no matches). hmac.compare_digest guards against timing side channels on the compare — matters when the caller can flood the endpoint with guesses.

def load_users_file(path: str | pathlib._local.Path) -> UsersFile:
70def load_users_file(path: str | pathlib.Path) -> UsersFile:
71    """Load *path* into a :class:`UsersFile`. Missing → empty file.
72
73    Malformed YAML raises ``yaml.YAMLError``; the caller decides
74    whether to keep starting (empty users file → bootstrap admin)
75    or crash out (edited file corrupt → refuse to start with auth
76    on, since silently ignoring would be worse than a loud stop).
77    """
78    p = pathlib.Path(path)
79    if not p.is_file():
80        return UsersFile(users=[])
81    raw: Any = yaml.safe_load(p.read_text(encoding="utf-8"))
82    if raw is None:
83        return UsersFile(users=[])
84    if not isinstance(raw, dict):
85        raise ValueError(
86            f"{path}: expected a mapping at the top level, got {type(raw).__name__}"
87        )
88    return UsersFile.model_validate(raw)

Load path into a UsersFile. Missing → empty file.

Malformed YAML raises yaml.YAMLError; the caller decides whether to keep starting (empty users file → bootstrap admin) or crash out (edited file corrupt → refuse to start with auth on, since silently ignoring would be worse than a loud stop).

def save_users_file( path: str | pathlib._local.Path, file: UsersFile) -> None:
 91def save_users_file(path: str | pathlib.Path, file: UsersFile) -> None:
 92    """Atomically write *file* to *path* with mode 0600 on POSIX.
 93
 94    Uses temp file + rename so a crashed save can't leave the
 95    file half-written. On Windows, the ``chmod`` is a no-op (the
 96    OS ACL isn't representable through the same mode bits); the
 97    settings root should be user-only anyway.
 98    """
 99    p = pathlib.Path(path)
100    p.parent.mkdir(parents=True, exist_ok=True)
101    # ``mode="json"`` converts the ``Role`` enum to its string value —
102    # pyyaml's safe_dump can't represent StrEnum instances directly,
103    # and even if it could, we want the on-disk shape to be plain
104    # strings so a human editor doesn't see ``!!python/object:``
105    # references.
106    payload = {"users": [u.model_dump(mode="json") for u in file.users]}
107    fd, tmp = tempfile.mkstemp(
108        prefix=".users.yaml.", dir=str(p.parent),
109    )
110    try:
111        with os.fdopen(fd, "w", encoding="utf-8") as f:
112            yaml.safe_dump(payload, f, sort_keys=False)
113        # Best-effort mode 0600 — matters on multi-user hosts, no-op
114        # on Windows where the ACL model differs.
115        try:
116            os.chmod(tmp, 0o600)
117        except (OSError, PermissionError) as e:  # pragma: no cover
118            _log.debug("chmod on %s ignored: %s", tmp, e)
119        os.replace(tmp, p)
120    except Exception:
121        try:
122            os.unlink(tmp)
123        except OSError:
124            pass
125        raise

Atomically write file to path with mode 0600 on POSIX.

Uses temp file + rename so a crashed save can't leave the file half-written. On Windows, the chmod is a no-op (the OS ACL isn't representable through the same mode bits); the settings root should be user-only anyway.