jmri_server.auth.models

Auth data models — Role enum, User record, env constant.

Kept in their own module so tests + downstream code can import them without pulling bcrypt / YAML transitively. Password hashing lives in ~jmri_server.auth.passwords; the YAML file layer lives in ~jmri_server.auth.store.

 1"""Auth data models — Role enum, User record, env constant.
 2
 3Kept in their own module so tests + downstream code can import them
 4without pulling bcrypt / YAML transitively. Password hashing lives
 5in :mod:`~jmri_server.auth.passwords`; the YAML file layer lives in
 6:mod:`~jmri_server.auth.store`.
 7"""
 8from __future__ import annotations
 9
10from enum import StrEnum
11
12from pydantic import BaseModel, ConfigDict
13
14
15# Env var name kept as a module constant so tests can flip it via
16# ``monkeypatch.setenv`` without importing anything heavy.
17AUTH_ENABLED_ENV = "JMRI_AUTH_ENABLED"
18
19
20class Role(StrEnum):
21    """Access role for one user.
22
23    Ordered by privilege (viewer < operator < admin). The
24    comparison operators are defined via :meth:`_priority` so
25    ``request.state.role >= Role.OPERATOR`` reads naturally on the
26    dispatch path.
27    """
28
29    VIEWER = "viewer"
30    OPERATOR = "operator"
31    ADMIN = "admin"
32
33    def _priority(self) -> int:
34        return {"viewer": 0, "operator": 1, "admin": 2}[self.value]
35
36    def __ge__(self, other: object) -> bool:  # type: ignore[override]
37        if not isinstance(other, Role):
38            return NotImplemented
39        return self._priority() >= other._priority()
40
41    def __gt__(self, other: object) -> bool:  # type: ignore[override]
42        if not isinstance(other, Role):
43            return NotImplemented
44        return self._priority() > other._priority()
45
46    def __le__(self, other: object) -> bool:  # type: ignore[override]
47        if not isinstance(other, Role):
48            return NotImplemented
49        return self._priority() <= other._priority()
50
51    def __lt__(self, other: object) -> bool:  # type: ignore[override]
52        if not isinstance(other, Role):
53            return NotImplemented
54        return self._priority() < other._priority()
55
56
57class User(BaseModel):
58    """One user record — matches one entry in ``users.yaml``.
59
60    ``password_hash`` is bcrypt-encoded; verify via
61    :func:`~jmri_server.auth.passwords.verify_password`. ``token``
62    is the long-lived personal API token — regenerated on demand,
63    never derived from the password so a password change doesn't
64    invalidate mobile clients.
65
66    ``token`` may be empty for a user that only logs in via the
67    web UI. The bootstrap admin always gets one so JMRI mobile
68    clients can be connected without a browser round-trip.
69    """
70
71    model_config = ConfigDict(frozen=False, extra="ignore")
72
73    username: str
74    password_hash: str
75    role: Role = Role.VIEWER
76    token: str = ""
AUTH_ENABLED_ENV = 'JMRI_AUTH_ENABLED'
class Role(enum.StrEnum):
21class Role(StrEnum):
22    """Access role for one user.
23
24    Ordered by privilege (viewer < operator < admin). The
25    comparison operators are defined via :meth:`_priority` so
26    ``request.state.role >= Role.OPERATOR`` reads naturally on the
27    dispatch path.
28    """
29
30    VIEWER = "viewer"
31    OPERATOR = "operator"
32    ADMIN = "admin"
33
34    def _priority(self) -> int:
35        return {"viewer": 0, "operator": 1, "admin": 2}[self.value]
36
37    def __ge__(self, other: object) -> bool:  # type: ignore[override]
38        if not isinstance(other, Role):
39            return NotImplemented
40        return self._priority() >= other._priority()
41
42    def __gt__(self, other: object) -> bool:  # type: ignore[override]
43        if not isinstance(other, Role):
44            return NotImplemented
45        return self._priority() > other._priority()
46
47    def __le__(self, other: object) -> bool:  # type: ignore[override]
48        if not isinstance(other, Role):
49            return NotImplemented
50        return self._priority() <= other._priority()
51
52    def __lt__(self, other: object) -> bool:  # type: ignore[override]
53        if not isinstance(other, Role):
54            return NotImplemented
55        return self._priority() < other._priority()

Access role for one user.

Ordered by privilege (viewer < operator < admin). The comparison operators are defined via _priority() so request.state.role >= Role.OPERATOR reads naturally on the dispatch path.

VIEWER = <Role.VIEWER: 'viewer'>
OPERATOR = <Role.OPERATOR: 'operator'>
ADMIN = <Role.ADMIN: 'admin'>
class User(pydantic.main.BaseModel):
58class User(BaseModel):
59    """One user record — matches one entry in ``users.yaml``.
60
61    ``password_hash`` is bcrypt-encoded; verify via
62    :func:`~jmri_server.auth.passwords.verify_password`. ``token``
63    is the long-lived personal API token — regenerated on demand,
64    never derived from the password so a password change doesn't
65    invalidate mobile clients.
66
67    ``token`` may be empty for a user that only logs in via the
68    web UI. The bootstrap admin always gets one so JMRI mobile
69    clients can be connected without a browser round-trip.
70    """
71
72    model_config = ConfigDict(frozen=False, extra="ignore")
73
74    username: str
75    password_hash: str
76    role: Role = Role.VIEWER
77    token: str = ""

One user record — matches one entry in users.yaml.

password_hash is bcrypt-encoded; verify via ~jmri_server.auth.passwords.verify_password(). token is the long-lived personal API token — regenerated on demand, never derived from the password so a password change doesn't invalidate mobile clients.

token may be empty for a user that only logs in via the web UI. The bootstrap admin always gets one so JMRI mobile clients can be connected without a browser round-trip.

username: str = PydanticUndefined
password_hash: str = PydanticUndefined
role: Role = <Role.VIEWER: 'viewer'>
token: str = ''