jmri_server.services.config_user

ConfigUserService — user CRUD + self-service password/token.

Wire shape (rewrite-specific extension — JMRI Java doesn't have this topic since its Swing UI edits users through a native dialog)::

method=list                            → array of {username, role, has_token, self}
method=get, data={"username": "..."}    → one user projection
method=put,  data={"username": "...",
                   "password": "...",
                   "role": "viewer|operator|admin"}
    → creates or updates. On existing user, updates only the
      fields you passed. Admin-only.

method=delete, data={"username": "..."} → remove user. Admin only.
                                         Refuses self-delete + last-admin.

method=post, data={"action": "change_password",
                   "username": "...",
                   "new_password": "..."}
    → change password. Self only for non-admins; admins can
      change anyone's.

method=post, data={"action": "regenerate_token",
                   "username": "..."}
    → mint new API token, returns {"token": "..."}. Self only
      for non-admins; admins can regenerate anyone's.

Never returns the password_hash in list/get/put — that would leak the bcrypt salt across the wire even if hashed, and there's no legitimate client-side use for it. Callers change passwords via the change_password action, not by reading the current hash and writing a modified one.

  1"""``ConfigUserService`` — user CRUD + self-service password/token.
  2
  3Wire shape (rewrite-specific extension — JMRI Java doesn't have
  4this topic since its Swing UI edits users through a native dialog)::
  5
  6    method=list                            → array of {username, role, has_token, self}
  7    method=get, data={"username": "..."}    → one user projection
  8    method=put,  data={"username": "...",
  9                       "password": "...",
 10                       "role": "viewer|operator|admin"}
 11        → creates or updates. On existing user, updates only the
 12          fields you passed. Admin-only.
 13
 14    method=delete, data={"username": "..."} → remove user. Admin only.
 15                                             Refuses self-delete + last-admin.
 16
 17    method=post, data={"action": "change_password",
 18                       "username": "...",
 19                       "new_password": "..."}
 20        → change password. Self only for non-admins; admins can
 21          change anyone's.
 22
 23    method=post, data={"action": "regenerate_token",
 24                       "username": "..."}
 25        → mint new API token, returns {"token": "..."}. Self only
 26          for non-admins; admins can regenerate anyone's.
 27
 28**Never returns the password_hash** in list/get/put — that would
 29leak the bcrypt salt across the wire even if hashed, and there's
 30no legitimate client-side use for it. Callers change passwords
 31via the ``change_password`` action, not by reading the current
 32hash and writing a modified one.
 33"""
 34from __future__ import annotations
 35
 36import logging
 37from typing import Any
 38
 39from jmri_server.auth.models import Role, User
 40from jmri_server.service import JsonService, JsonServiceError
 41
 42_log = logging.getLogger(__name__)
 43
 44
 45def _project(user: User, *, self_flag: bool = False) -> dict[str, Any]:
 46    """Wire projection of a User. Excludes password_hash and the
 47    token itself; only reports whether one exists. The token is
 48    returned only on regenerate."""
 49    return {
 50        "username": user.username,
 51        "role": user.role.value,
 52        "has_token": bool(user.token),
 53        "self": self_flag,
 54    }
 55
 56
 57class ConfigUserService(JsonService):
 58    topic = "configUser"
 59
 60    # User CRUD is admin-only for read + mutate. The self-service
 61    # POST actions do their own finer-grained checks (a viewer can
 62    # change their own password, not anyone else's) so ``post``
 63    # is gated at operator — the action handler enforces the rest.
 64    REQUIRED_ROLE = {
 65        "list": Role.ADMIN,
 66        "get": Role.ADMIN,
 67        "put": Role.ADMIN,
 68        "delete": Role.ADMIN,
 69        "post": Role.VIEWER,   # self-service always available
 70    }
 71
 72    # ---- helpers -------------------------------------------------------
 73
 74    def _auth(self):
 75        auth = getattr(self.ctx, "auth", None)
 76        if auth is None:
 77            raise JsonServiceError(
 78                503,
 79                "auth is disabled on this server (JMRI_AUTH_ENABLED not set)",
 80            )
 81        return auth
 82
 83    def _current_username(self) -> str | None:
 84        return getattr(self.ctx, "_current_username", None)
 85
 86    def _current_role(self) -> Role:
 87        """The current role is passed through dispatch to the JsonService
 88        signature, not to instances — but we can infer from the
 89        current user. When auth is off the middleware sets role=admin;
 90        we mirror that here by treating "no user" as admin."""
 91        current = self._current_username()
 92        if current is None:
 93            return Role.ADMIN
 94        user = self._auth().by_username(current)
 95        return user.role if user else Role.VIEWER
 96
 97    # ---- CRUD ----------------------------------------------------------
 98
 99    async def on_list(
100        self, data: dict[str, Any] | None,
101    ) -> list[dict[str, Any]]:
102        auth = self._auth()
103        current = self._current_username()
104        return [
105            _project(u, self_flag=(u.username == current))
106            for u in auth.users
107        ]
108
109    async def on_get(
110        self, data: dict[str, Any] | None,
111    ) -> dict[str, Any]:
112        data = data or {}
113        username = data.get("username")
114        if not isinstance(username, str) or not username:
115            raise JsonServiceError(400, "configUser.get requires 'username'")
116        auth = self._auth()
117        user = auth.by_username(username)
118        if user is None:
119            raise JsonServiceError(404, f"no user {username!r}")
120        return _project(
121            user, self_flag=(user.username == self._current_username()),
122        )
123
124    async def on_put(
125        self, data: dict[str, Any] | None,
126    ) -> dict[str, Any]:
127        """Create or update.
128
129        For a new user: ``password`` is required, ``role`` defaults
130        to viewer. For an existing user: only the fields supplied
131        are updated (role and/or password). Changing your own role
132        would trigger the self-demotion guard — see ``set_role``.
133        """
134        data = data or {}
135        username = data.get("username")
136        password = data.get("password")
137        role_raw = data.get("role")
138        if not isinstance(username, str) or not username:
139            raise JsonServiceError(400, "configUser.put requires 'username'")
140        role: Role | None = None
141        if role_raw is not None:
142            try:
143                role = Role(role_raw)
144            except ValueError:
145                raise JsonServiceError(
146                    400,
147                    f"invalid role {role_raw!r}; must be one of "
148                    f"{[r.value for r in Role]}",
149                ) from None
150
151        auth = self._auth()
152        current = self._current_username() or ""
153        existing = auth.by_username(username)
154        if existing is None:
155            if not isinstance(password, str) or not password:
156                raise JsonServiceError(
157                    400, "creating a user requires 'password'",
158                )
159            user = auth.add_user(
160                username, password,
161                role=role if role is not None else Role.VIEWER,
162            )
163            return _project(user, self_flag=(username == current))
164
165        # Existing user — update only what's supplied.
166        if isinstance(password, str) and password:
167            auth.set_password(username, password)
168        if role is not None and role is not existing.role:
169            try:
170                auth.set_role(
171                    username, role, current_username=current,
172                )
173            except ValueError as e:
174                raise JsonServiceError(409, str(e)) from None
175        return _project(
176            auth.by_username(username),
177            self_flag=(username == current),
178        )
179
180    async def on_delete(
181        self, data: dict[str, Any] | None,
182    ) -> dict[str, Any]:
183        data = data or {}
184        username = data.get("username")
185        if not isinstance(username, str) or not username:
186            raise JsonServiceError(400, "configUser.delete requires 'username'")
187        auth = self._auth()
188        current = self._current_username() or ""
189        try:
190            auth.remove_user(username, current_username=current)
191        except LookupError as e:
192            raise JsonServiceError(404, str(e)) from None
193        except ValueError as e:
194            raise JsonServiceError(409, str(e)) from None
195        return {"username": username, "deleted": True}
196
197    # ---- actions -------------------------------------------------------
198
199    async def on_post(
200        self, data: dict[str, Any] | None,
201    ) -> dict[str, Any]:
202        data = data or {}
203        action = data.get("action")
204        if action == "change_password":
205            return await self._change_password(data)
206        if action == "regenerate_token":
207            return await self._regenerate_token(data)
208        raise JsonServiceError(
209            400, f"unknown configUser action: {action!r}",
210        )
211
212    async def _change_password(
213        self, data: dict[str, Any],
214    ) -> dict[str, Any]:
215        username = data.get("username")
216        new_password = data.get("new_password")
217        if not isinstance(username, str) or not username:
218            raise JsonServiceError(400, "change_password requires 'username'")
219        if not isinstance(new_password, str) or not new_password:
220            raise JsonServiceError(400, "change_password requires 'new_password'")
221        current = self._current_username() or ""
222        if username != current and self._current_role() is not Role.ADMIN:
223            raise JsonServiceError(
224                403,
225                "only admins can change other users' passwords",
226            )
227        auth = self._auth()
228        try:
229            auth.set_password(username, new_password)
230        except LookupError as e:
231            raise JsonServiceError(404, str(e)) from None
232        return {"username": username, "changed": True}
233
234    async def _regenerate_token(
235        self, data: dict[str, Any],
236    ) -> dict[str, Any]:
237        username = data.get("username")
238        if not isinstance(username, str) or not username:
239            raise JsonServiceError(
240                400, "regenerate_token requires 'username'",
241            )
242        current = self._current_username() or ""
243        if username != current and self._current_role() is not Role.ADMIN:
244            raise JsonServiceError(
245                403,
246                "only admins can regenerate other users' tokens",
247            )
248        auth = self._auth()
249        try:
250            token = auth.regenerate_token(username)
251        except LookupError as e:
252            raise JsonServiceError(404, str(e)) from None
253        return {"username": username, "token": token}
class ConfigUserService(jmri_server.service.JsonService):
 58class ConfigUserService(JsonService):
 59    topic = "configUser"
 60
 61    # User CRUD is admin-only for read + mutate. The self-service
 62    # POST actions do their own finer-grained checks (a viewer can
 63    # change their own password, not anyone else's) so ``post``
 64    # is gated at operator — the action handler enforces the rest.
 65    REQUIRED_ROLE = {
 66        "list": Role.ADMIN,
 67        "get": Role.ADMIN,
 68        "put": Role.ADMIN,
 69        "delete": Role.ADMIN,
 70        "post": Role.VIEWER,   # self-service always available
 71    }
 72
 73    # ---- helpers -------------------------------------------------------
 74
 75    def _auth(self):
 76        auth = getattr(self.ctx, "auth", None)
 77        if auth is None:
 78            raise JsonServiceError(
 79                503,
 80                "auth is disabled on this server (JMRI_AUTH_ENABLED not set)",
 81            )
 82        return auth
 83
 84    def _current_username(self) -> str | None:
 85        return getattr(self.ctx, "_current_username", None)
 86
 87    def _current_role(self) -> Role:
 88        """The current role is passed through dispatch to the JsonService
 89        signature, not to instances — but we can infer from the
 90        current user. When auth is off the middleware sets role=admin;
 91        we mirror that here by treating "no user" as admin."""
 92        current = self._current_username()
 93        if current is None:
 94            return Role.ADMIN
 95        user = self._auth().by_username(current)
 96        return user.role if user else Role.VIEWER
 97
 98    # ---- CRUD ----------------------------------------------------------
 99
100    async def on_list(
101        self, data: dict[str, Any] | None,
102    ) -> list[dict[str, Any]]:
103        auth = self._auth()
104        current = self._current_username()
105        return [
106            _project(u, self_flag=(u.username == current))
107            for u in auth.users
108        ]
109
110    async def on_get(
111        self, data: dict[str, Any] | None,
112    ) -> dict[str, Any]:
113        data = data or {}
114        username = data.get("username")
115        if not isinstance(username, str) or not username:
116            raise JsonServiceError(400, "configUser.get requires 'username'")
117        auth = self._auth()
118        user = auth.by_username(username)
119        if user is None:
120            raise JsonServiceError(404, f"no user {username!r}")
121        return _project(
122            user, self_flag=(user.username == self._current_username()),
123        )
124
125    async def on_put(
126        self, data: dict[str, Any] | None,
127    ) -> dict[str, Any]:
128        """Create or update.
129
130        For a new user: ``password`` is required, ``role`` defaults
131        to viewer. For an existing user: only the fields supplied
132        are updated (role and/or password). Changing your own role
133        would trigger the self-demotion guard — see ``set_role``.
134        """
135        data = data or {}
136        username = data.get("username")
137        password = data.get("password")
138        role_raw = data.get("role")
139        if not isinstance(username, str) or not username:
140            raise JsonServiceError(400, "configUser.put requires 'username'")
141        role: Role | None = None
142        if role_raw is not None:
143            try:
144                role = Role(role_raw)
145            except ValueError:
146                raise JsonServiceError(
147                    400,
148                    f"invalid role {role_raw!r}; must be one of "
149                    f"{[r.value for r in Role]}",
150                ) from None
151
152        auth = self._auth()
153        current = self._current_username() or ""
154        existing = auth.by_username(username)
155        if existing is None:
156            if not isinstance(password, str) or not password:
157                raise JsonServiceError(
158                    400, "creating a user requires 'password'",
159                )
160            user = auth.add_user(
161                username, password,
162                role=role if role is not None else Role.VIEWER,
163            )
164            return _project(user, self_flag=(username == current))
165
166        # Existing user — update only what's supplied.
167        if isinstance(password, str) and password:
168            auth.set_password(username, password)
169        if role is not None and role is not existing.role:
170            try:
171                auth.set_role(
172                    username, role, current_username=current,
173                )
174            except ValueError as e:
175                raise JsonServiceError(409, str(e)) from None
176        return _project(
177            auth.by_username(username),
178            self_flag=(username == current),
179        )
180
181    async def on_delete(
182        self, data: dict[str, Any] | None,
183    ) -> dict[str, Any]:
184        data = data or {}
185        username = data.get("username")
186        if not isinstance(username, str) or not username:
187            raise JsonServiceError(400, "configUser.delete requires 'username'")
188        auth = self._auth()
189        current = self._current_username() or ""
190        try:
191            auth.remove_user(username, current_username=current)
192        except LookupError as e:
193            raise JsonServiceError(404, str(e)) from None
194        except ValueError as e:
195            raise JsonServiceError(409, str(e)) from None
196        return {"username": username, "deleted": True}
197
198    # ---- actions -------------------------------------------------------
199
200    async def on_post(
201        self, data: dict[str, Any] | None,
202    ) -> dict[str, Any]:
203        data = data or {}
204        action = data.get("action")
205        if action == "change_password":
206            return await self._change_password(data)
207        if action == "regenerate_token":
208            return await self._regenerate_token(data)
209        raise JsonServiceError(
210            400, f"unknown configUser action: {action!r}",
211        )
212
213    async def _change_password(
214        self, data: dict[str, Any],
215    ) -> dict[str, Any]:
216        username = data.get("username")
217        new_password = data.get("new_password")
218        if not isinstance(username, str) or not username:
219            raise JsonServiceError(400, "change_password requires 'username'")
220        if not isinstance(new_password, str) or not new_password:
221            raise JsonServiceError(400, "change_password requires 'new_password'")
222        current = self._current_username() or ""
223        if username != current and self._current_role() is not Role.ADMIN:
224            raise JsonServiceError(
225                403,
226                "only admins can change other users' passwords",
227            )
228        auth = self._auth()
229        try:
230            auth.set_password(username, new_password)
231        except LookupError as e:
232            raise JsonServiceError(404, str(e)) from None
233        return {"username": username, "changed": True}
234
235    async def _regenerate_token(
236        self, data: dict[str, Any],
237    ) -> dict[str, Any]:
238        username = data.get("username")
239        if not isinstance(username, str) or not username:
240            raise JsonServiceError(
241                400, "regenerate_token requires 'username'",
242            )
243        current = self._current_username() or ""
244        if username != current and self._current_role() is not Role.ADMIN:
245            raise JsonServiceError(
246                403,
247                "only admins can regenerate other users' tokens",
248            )
249        auth = self._auth()
250        try:
251            token = auth.regenerate_token(username)
252        except LookupError as e:
253            raise JsonServiceError(404, str(e)) from None
254        return {"username": username, "token": token}

Base class for a topic's service handlers.

Subclasses set topic and override the methods they support; unsupported methods raise 405 by default. Subclasses can also override REQUIRED_ROLE to change per-method role gates.

topic = 'configUser'
REQUIRED_ROLE = {'list': <Role.ADMIN: 'admin'>, 'get': <Role.ADMIN: 'admin'>, 'put': <Role.ADMIN: 'admin'>, 'delete': <Role.ADMIN: 'admin'>, 'post': <Role.VIEWER: 'viewer'>}
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
100    async def on_list(
101        self, data: dict[str, Any] | None,
102    ) -> list[dict[str, Any]]:
103        auth = self._auth()
104        current = self._current_username()
105        return [
106            _project(u, self_flag=(u.username == current))
107            for u in auth.users
108        ]
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
110    async def on_get(
111        self, data: dict[str, Any] | None,
112    ) -> dict[str, Any]:
113        data = data or {}
114        username = data.get("username")
115        if not isinstance(username, str) or not username:
116            raise JsonServiceError(400, "configUser.get requires 'username'")
117        auth = self._auth()
118        user = auth.by_username(username)
119        if user is None:
120            raise JsonServiceError(404, f"no user {username!r}")
121        return _project(
122            user, self_flag=(user.username == self._current_username()),
123        )
async def on_put(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
125    async def on_put(
126        self, data: dict[str, Any] | None,
127    ) -> dict[str, Any]:
128        """Create or update.
129
130        For a new user: ``password`` is required, ``role`` defaults
131        to viewer. For an existing user: only the fields supplied
132        are updated (role and/or password). Changing your own role
133        would trigger the self-demotion guard — see ``set_role``.
134        """
135        data = data or {}
136        username = data.get("username")
137        password = data.get("password")
138        role_raw = data.get("role")
139        if not isinstance(username, str) or not username:
140            raise JsonServiceError(400, "configUser.put requires 'username'")
141        role: Role | None = None
142        if role_raw is not None:
143            try:
144                role = Role(role_raw)
145            except ValueError:
146                raise JsonServiceError(
147                    400,
148                    f"invalid role {role_raw!r}; must be one of "
149                    f"{[r.value for r in Role]}",
150                ) from None
151
152        auth = self._auth()
153        current = self._current_username() or ""
154        existing = auth.by_username(username)
155        if existing is None:
156            if not isinstance(password, str) or not password:
157                raise JsonServiceError(
158                    400, "creating a user requires 'password'",
159                )
160            user = auth.add_user(
161                username, password,
162                role=role if role is not None else Role.VIEWER,
163            )
164            return _project(user, self_flag=(username == current))
165
166        # Existing user — update only what's supplied.
167        if isinstance(password, str) and password:
168            auth.set_password(username, password)
169        if role is not None and role is not existing.role:
170            try:
171                auth.set_role(
172                    username, role, current_username=current,
173                )
174            except ValueError as e:
175                raise JsonServiceError(409, str(e)) from None
176        return _project(
177            auth.by_username(username),
178            self_flag=(username == current),
179        )

Create or update.

For a new user: password is required, role defaults to viewer. For an existing user: only the fields supplied are updated (role and/or password). Changing your own role would trigger the self-demotion guard — see set_role.

async def on_delete(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
181    async def on_delete(
182        self, data: dict[str, Any] | None,
183    ) -> dict[str, Any]:
184        data = data or {}
185        username = data.get("username")
186        if not isinstance(username, str) or not username:
187            raise JsonServiceError(400, "configUser.delete requires 'username'")
188        auth = self._auth()
189        current = self._current_username() or ""
190        try:
191            auth.remove_user(username, current_username=current)
192        except LookupError as e:
193            raise JsonServiceError(404, str(e)) from None
194        except ValueError as e:
195            raise JsonServiceError(409, str(e)) from None
196        return {"username": username, "deleted": True}
async def on_post(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
200    async def on_post(
201        self, data: dict[str, Any] | None,
202    ) -> dict[str, Any]:
203        data = data or {}
204        action = data.get("action")
205        if action == "change_password":
206            return await self._change_password(data)
207        if action == "regenerate_token":
208            return await self._regenerate_token(data)
209        raise JsonServiceError(
210            400, f"unknown configUser action: {action!r}",
211        )