Source code for genro_asgi.applications.server_sections.users_section

# Copyright 2025 Softwell S.r.l.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""The ``_server/users`` section: SUPERADMIN-gated user management.

``UsersSection`` is a ``RoutingClass`` the ``ServerApplication`` attaches under
``users`` (endpoints at ``/_server/users/...``). Every route is gated
``auth_rule="SUPERADMIN"`` — the section is ALWAYS declared (fixed structure,
D26), and each handler answers the ``{"error": ...}`` shape (coherent with
``login``) when the server has no ``user_store`` wired.

The credential invariant: ``password_hash`` NEVER crosses the wire. ``list``
and ``get`` strip it from the record; ``save`` never accepts it (it merges the
non-credential fields of the body over the stored record, preserving the hash);
a password enters the system only as plaintext through ``create_user`` and
``set_password``, hashed server-side via ``UserStore.hash_password``.

Route responsibilities are separated:

- ``create_user`` — births a NEW record (error if the identity exists): the
  body carries ``password``/``password_confirm`` (the server checks they match)
  plus the metadata and ``tags``;
- ``save`` — updates an EXISTING record only (error if absent): merges the
  body's metadata/tags over the stored record, ``password_hash`` untouched. The
  body is taken whole (``body_data``) so tomorrow's metadata fields need no
  signature change;
- ``set_password`` — changes the credential of an existing record only, with the
  same ``password``/``password_confirm`` server-side check;
- ``delete`` — removes a record.

Parent (dual relationship): the ServerApplication, stored as
``self.application``; the store is reached via ``self.application.server.user_store``.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

from genro_routes import RoutingClass, route

if TYPE_CHECKING:
    from ...auth.user_store import UserStore
    from ..server_app import ServerApplication

__all__ = ["UsersSection"]

NO_STORE_ERROR = {"error": "User management is not available"}


[docs] class UsersSection(RoutingClass): """The ``_server/users`` mount: SUPERADMIN CRUD over the server's UserStore. Note: Parent (dual relationship): the ServerApplication, stored as ``self.application``. The store is ``self.application.server.user_store``. """
[docs] def __init__(self, application: ServerApplication) -> None: """Bind the section to its ServerApplication (dual relationship).""" self.application = application
@property def user_store(self) -> UserStore | None: """The server's UserStore, or ``None`` when identity is unconfigured.""" return getattr(self.application.server, "user_store", None)
[docs] def public_record(self, record: dict[str, Any]) -> dict[str, Any]: """A record without its ``password_hash`` — the wire-safe projection.""" return {k: v for k, v in record.items() if k != "password_hash"}
[docs] def matched_password(self, body: dict[str, Any]) -> str | None: """The password when it is present and matches its confirmation, else None. The caller distinguishes a mismatch (this returns None) from an absent password by checking the body itself: only ``create_user`` requires one. """ password = body.get("password") if password is None or password != body.get("password_confirm"): return None return password
[docs] @route(auth_rule="SUPERADMIN") def list(self) -> dict[str, Any]: """Every user record, ``password_hash`` stripped.""" store = self.user_store if store is None: return NO_STORE_ERROR return {"users": [self.public_record(r) for r in store.load_all()]}
[docs] @route(auth_rule="SUPERADMIN") def get(self, identity: str = "") -> dict[str, Any]: """One user record, ``password_hash`` stripped.""" store = self.user_store if store is None: return NO_STORE_ERROR record = store.get(identity) if record is None: return {"error": f"No such user: {identity}"} return self.public_record(record)
[docs] @route(auth_rule="SUPERADMIN", openapi_method="post") def create_user(self, identity: str = "", body_data: dict | None = None) -> dict[str, Any]: """Create a NEW user from the body (metadata + tags + password/confirm). Errors if the identity already exists. The password is validated against its confirmation and hashed server-side; ``password_hash`` never arrives pre-formed. Metadata and ``tags`` from the body land on the new record. A new record defaults to ``enabled: True`` and ``tags: []`` (creating a user with a password means letting them log in — the body can still say ``enabled: false`` to create it disabled); ``verify`` requires ``enabled`` and the login ``Avatar`` requires ``tags``, so a minimal body must still produce a working user. """ store = self.user_store if store is None: return NO_STORE_ERROR if not identity: return {"error": "Identity is required"} if store.get(identity) is not None: return {"error": f"User already exists: {identity}"} body = body_data or {} password = self.matched_password(body) if password is None: return {"error": "Password and confirmation are required and must match"} record = self.metadata_of(body) record.setdefault("enabled", True) record.setdefault("tags", []) record["identity"] = identity record["password_hash"] = store.hash_password(password) store.save(record) return self.public_record(record)
[docs] @route(auth_rule="SUPERADMIN", openapi_method="post") def save(self, identity: str = "", body_data: dict | None = None) -> dict[str, Any]: """Update an EXISTING record's metadata/tags; ``password_hash`` untouched. Errors if the user does not exist (creation is ``create_user``'s job). The body is merged whole over the stored record — new metadata fields persist with no signature change — but the credential never moves here. """ store = self.user_store if store is None: return NO_STORE_ERROR record = store.get(identity) if record is None: return {"error": f"No such user: {identity}"} record.update(self.metadata_of(body_data or {})) store.save(record) return self.public_record(record)
[docs] @route(auth_rule="SUPERADMIN", openapi_method="post") def set_password(self, identity: str = "", body_data: dict | None = None) -> dict[str, Any]: """Change an existing user's password (plaintext in, hashed server-side). The body carries ``password``/``password_confirm``; the server checks they match. Errors if the user does not exist. """ store = self.user_store if store is None: return NO_STORE_ERROR record = store.get(identity) if record is None: return {"error": f"No such user: {identity}"} password = self.matched_password(body_data or {}) if password is None: return {"error": "Password and confirmation are required and must match"} record["password_hash"] = store.hash_password(password) store.save(record) return {"identity": identity, "updated": True}
[docs] @route(auth_rule="SUPERADMIN", openapi_method="post") def delete(self, identity: str = "") -> dict[str, Any]: """Remove a user; reports whether a record was actually removed.""" store = self.user_store if store is None: return NO_STORE_ERROR return {"identity": identity, "deleted": store.delete(identity)}
[docs] def metadata_of(self, body: dict[str, Any]) -> dict[str, Any]: """The non-credential, non-key fields of a body (never password/hash/identity). Credential and key fields are owned by their dedicated routes, so they are dropped here: whatever else the body carries is user metadata. """ dropped = {"password", "password_confirm", "password_hash", "identity"} return {k: v for k, v in body.items() if k not in dropped}
if __name__ == "__main__": class _Store: def __init__(self) -> None: self._records: dict[str, dict[str, Any]] = {} def load_all(self) -> list[dict[str, Any]]: return list(self._records.values()) def get(self, identity: str) -> dict[str, Any] | None: return self._records.get(identity) def save(self, record: dict[str, Any]) -> None: self._records[record["identity"]] = record def delete(self, identity: str) -> bool: return self._records.pop(identity, None) is not None def hash_password(self, password: str) -> str: return f"hashed:{password}" class _Server: user_store = _Store() class _App: server = _Server() section = UsersSection(_App()) created = section.create_user( "bob", {"password": "pw", "password_confirm": "pw", "tags": ["ops"], "enabled": True} ) assert "password_hash" not in created and created["tags"] == ["ops"] assert section.get("bob")["enabled"] is True section.save("bob", {"tags": ["ops", "admin"], "email": "bob@x.io"}) assert section.get("bob")["tags"] == ["ops", "admin"] assert section.get("bob")["email"] == "bob@x.io" # new metadata, no signature change assert section.create_user("bob", {"password": "x", "password_confirm": "x"})["error"] assert section.save("ghost", {"tags": []})["error"] assert section.set_password("bob", {"password": "a", "password_confirm": "b"})["error"] assert section.delete("bob")["deleted"] is True