Skip to content

Users

commons.users

User management module providing authentication, authorization, and user identity classes.

This module implements a flexible user system with three levels of user identity and a role-based access control (RBAC) system for permission management.

User Hierarchy

The module defines three concrete user types, all implementing the User protocol:

  1. AnonymousUser: Represents unauthenticated visitors. Has a fixed ID of "__anonymous__" and no personal information. Use AnonymousUser.get() to obtain the singleton instance.

  2. AuthenticatedUser: Represents users authenticated via external systems (e.g., OAuth, SSO) who are not registered in the local database. Contains identity information (name, email) but no credentials. Created via AuthenticatedUser.create().

  3. RegisteredUser: Represents users with local accounts stored in the database. Has credentials (username/password) and can have role assignments. This is a SQLModel table class. Created via RegisteredUser.create(). A registered user may have no password (password_hash is None): they can't sign in with a password, only by other means such as an email link or code (commons.users.login_tokens).

Permission System

The RBAC system uses path-based scopes and permissions:

  • Permission: A capability like "/read" or "/admin/delete". Permissions use hierarchical paths where "/admin" implies "/admin/delete".

  • Role: A named collection of permissions (e.g., "editor" with ["/read", "/write"]). Roles are linked to permissions via the RolePermission junction table.

  • Scope: A resource boundary like "/projects/my-project". Scopes use hierarchical paths where "/projects" contains "/projects/my-project".

  • RoleAssignment: Links a RegisteredUser to a Role within a Scope. This is the primary mechanism for granting permissions to users. A user has a role on a scope at most once; the same role on another scope is a separate assignment. list_assignments(scope) and remove_assignments(scope) work on a scope and every scope below it (e.g. all roles in a tenant).

A role on a scope applies to every scope below it, so a typical multi-tenant setup is an admin role on "/app" for global admins and a tenant-admin role on "/app/tenants/<tenant-id>" for the admins of one tenant.

Paths

Scope and permission paths are normalized to one spelling wherever they enter (assigning, defining roles, checking): "/app/" and "app" are "/app", "/app//x" is "/app/x", "/foo/Foo Bar" is "/foo/foo-bar"; letters are lowercased, accents removed, other characters become -, and dashes (also double ones, "/lib--python-commons") are kept. Paths whose meaning normalizing would change raise ValueError: segments . and .., segments that are empty after normalizing, and paths without segments other than the root "/" (e.g. "").

Because different spellings become one scope, ids that differ only in case or special characters share their roles (Tenant_A and tenant-a): build scopes from generated ids (commons.id_operators.new_id), not from names.

Default Roles

Roles passed as default_roles to the repository are defined in code. They are synced into the database when the repository is created: stored with exactly the permissions of the code and marked as default role (Role.is_default_role), so they can be assigned like any other role; a role of the same name created by the app is taken over. A default role removed from the code is deleted on the next start, together with its assignments. Default roles can't be changed or deleted at runtime (upsert_role() and delete_role() raise ValueError); apps should show them as not editable.

Repository Pattern

User operations are performed through repository classes:

  • UsersRepository: Abstract base class defining the interface for user management, authentication, and permission checking.

  • SQLUsersRepository: Concrete implementation using SQLAlchemy/SQLModel for database persistence.

Command Line

python -m commons.users hash-password prints a bcrypt hash for a password, to be used as password_hash of users defined in the settings (see commons.settings.StandardUser).

Example Usage

::

from sqlalchemy import create_engine
from commons.users import SQLUsersRepository, RegisteredUser, Role

# Create repository
engine = create_engine("sqlite:///users.db")
repo = SQLUsersRepository(engine)

# Create a role with permissions
admin_role = Role.create("admin", ["/read", "/write", "/admin"])
repo.upsert_role(admin_role)

# Create a user
user = RegisteredUser.create(
    username="john.doe",
    password_hash=repo.hash_password("secret"),
    display_name="John Doe",
    firstname="John",
    lastname="Doe",
    email="john@example.com",
)
repo.upsert_user(user)

# Assign role to user
repo.add_assignment(user, "/projects/alpha", "admin")

# Check permission
if repo.has_permission(user, "/projects/alpha", "/write"):
    print("User can write!")

Modules:

Name Description
login_tokens

Passwordless login with a link or code sent to a registered user.

repository
sql

SQL-based implementation of the UsersRepository.

user

User identity and RBAC model classes.

Classes:

Name Description
UsersRepository

Abstract base class for user repository implementations.

SQLUsersRepository

SQL database implementation of UsersRepository using SQLAlchemy/SQLModel.

User

Protocol defining the common interface for all user types.

AnonymousUser

Represents an unauthenticated user (visitor).

AuthenticatedUser

Represents a user authenticated via an external system without a local account.

RegisteredUser

A user with a local account stored in the database.

Role

A named collection of permissions that can be assigned to users.

RoleAssignment

Links a registered user to a role within a specific scope.

RolePermission

Junction table linking roles to permissions (many-to-many relationship).

SystemUser

Represents a system itself.

__all__ module-attribute

__all__ = [
    "AnonymousUser",
    "AuthenticatedUser",
    "RegisteredUser",
    "Role",
    "RoleAssignment",
    "RolePermission",
    "SystemUser",
    "SQLUsersRepository",
    "User",
    "UsersRepository",
]

UsersRepository

UsersRepository(
    default_roles: list[Role] | None,
    anonymous_default_permissions: (
        list[DefaultRoleAssignment] | None
    ),
    authenticated_default_permissions: (
        list[DefaultRoleAssignment] | None
    ),
    registered_default_permissions: (
        list[DefaultRoleAssignment] | None
    ),
    password_hasher: PasswordHasher | None = None,
    password_verifier: PasswordVerifier | None = None,
)

Bases: ABC

Abstract base class for user repository implementations.

Provides user management, authentication, and permission checking functionality. Subclasses must implement the abstract methods to provide database-specific operations.

Parameters:

Name Type Description Default
default_roles list[Role] | None

Roles defined in code; see "Default Roles" in commons.users.

required
anonymous_default_permissions list[DefaultRoleAssignment] | None

Role assignments applied to anonymous (unauthenticated) users.

required
authenticated_default_permissions list[DefaultRoleAssignment] | None

Role assignments applied to authenticated but unregistered users.

required
registered_default_permissions list[DefaultRoleAssignment] | None

Role assignments applied to all registered users.

required
password_hasher PasswordHasher | None

Custom password hashing function. Defaults to bcrypt.

None
password_verifier PasswordVerifier | None

Custom password verification function. Defaults to bcrypt.

None

Methods:

Name Description
add_assignment

Add a role assignment to a user.

authenticate

Verify credentials and return the user if valid.

check_permission

Check if a user has a permission, raising an error if not.

delete_role

Delete a role.

delete_user

Delete a user.

find_role_by_name

Find a role by its name.

find_user_by_email

Find a user by their email address.

find_user_by_id

Find a user by their unique ID.

find_user_by_username

Find a user by their username.

get_role_by_name

Get a role by its name, raising an error if not found.

get_role_assignments

Fetch the role assignments assigned to a user.

get_user_by_email

Get a user by their email address, raising an error if not found.

get_user_by_id

Get a user by their unique ID, raising an error if not found.

get_user_by_username

Get a user by their username, raising an error if not found.

has_permission

Check whether a user has a specific permission in a scope.

hash_password

Hash a plain-text password using the configured hasher.

init_from_settings

Initialize users from settings configuration.

list_roles

List all roles from the database.

registered_user_has_permission

Check whether a registered user has a specific permission in a scope.

remove_assignment

Remove a role assignment.

role_has_permission

Check whether a role grants a specific permission within a scope.

update_password

Update a user's password.

upsert_role

Create or update a role.

upsert_user

Create or update a user.

list_assignments

List the role assignments on a scope and on every scope below it.

remove_assignments

Remove the role assignments on a scope and on every scope below it, e.g. when a tenant is deleted.

Attributes:

Name Type Description
default_roles
anonymous_default_permissions
authenticated_default_permissions
registered_default_permissions
Source code in src/commons/users/repository.py
def __init__(
    self,
    default_roles: list[Role] | None,
    anonymous_default_permissions: list[DefaultRoleAssignment] | None,
    authenticated_default_permissions: list[DefaultRoleAssignment] | None,
    registered_default_permissions: list[DefaultRoleAssignment] | None,
    password_hasher: PasswordHasher | None = None,
    password_verifier: PasswordVerifier | None = None,
):
    self.default_roles = default_roles or []
    self.anonymous_default_permissions = anonymous_default_permissions or []
    self.authenticated_default_permissions = authenticated_default_permissions or []
    self.registered_default_permissions = registered_default_permissions or []
    self._password_hasher = password_hasher or _default_hash_password
    self._password_verifier = password_verifier or _default_verify_password

default_roles instance-attribute

default_roles = default_roles or []

anonymous_default_permissions instance-attribute

anonymous_default_permissions = (
    anonymous_default_permissions or []
)

authenticated_default_permissions instance-attribute

authenticated_default_permissions = (
    authenticated_default_permissions or []
)

registered_default_permissions instance-attribute

registered_default_permissions = (
    registered_default_permissions or []
)

add_assignment abstractmethod

add_assignment(
    user: RegisteredUser | str, scope: str, role: str
) -> RoleAssignment

Add a role assignment to a user.

A user has a role on a scope at most once: if the assignment exists already, it is returned and nothing is written. The same role on another scope (also one below it) is a separate assignment.

Parameters:

Name Type Description Default
user RegisteredUser | str

The user instance or username to assign the role to.

required
scope str

The scope path (e.g., '/projects/my-project') where the role applies.

required
role str

The role name to assign.

required

Returns:

Type Description
RoleAssignment

The created (or existing) RoleAssignment.

Raises:

Type Description
KeyError

If the user or the role doesn't exist.

ValueError

If the scope is invalid.

Source code in src/commons/users/repository.py
@abc.abstractmethod
def add_assignment(self, user: RegisteredUser | str, scope: str, role: str) -> RoleAssignment:
    """
    Add a role assignment to a user.

    A user has a role on a scope at most once: if the assignment exists already, it is
    returned and nothing is written. The same role on another scope (also one below
    it) is a separate assignment.

    Args:
        user: The user instance or username to assign the role to.
        scope: The scope path (e.g., '/projects/my-project') where the role applies.
        role: The role name to assign.

    Returns:
        The created (or existing) RoleAssignment.

    Raises:
        KeyError: If the user or the role doesn't exist.
        ValueError: If the scope is invalid.
    """
    ...

authenticate

authenticate(
    username: str, password: str
) -> RegisteredUser | None

Verify credentials and return the user if valid.

A user without a password (password_hash is None or empty) is never authenticated, whatever password is given.

Parameters:

Name Type Description Default
username str

The username to authenticate.

required
password str

The plain-text password to verify.

required

Returns:

Type Description
RegisteredUser | None

The authenticated RegisteredUser if credentials are valid, None otherwise.

Source code in src/commons/users/repository.py
def authenticate(self, username: str, password: str) -> RegisteredUser | None:
    """
    Verify credentials and return the user if valid.

    A user without a password (``password_hash`` is ``None`` or empty) is never
    authenticated, whatever password is given.

    Args:
        username: The username to authenticate.
        password: The plain-text password to verify.

    Returns:
        The authenticated RegisteredUser if credentials are valid, None otherwise.
    """
    user = self.find_user_by_username(username)
    if not user or not user.password_hash:
        # A user without a password can never sign in with one.
        return None
    if self._password_verifier(password, user.password_hash):
        return user
    return None

check_permission

check_permission(
    user: User, scope: str, permission: str
) -> None

Check if a user has a permission, raising an error if not.

Parameters:

Name Type Description Default
user User

The user to check permissions for.

required
scope str

The scope path to check the permission in.

required
permission str

The permission path to check.

required

Raises:

Type Description
LookupError

If the user does not have the required permission.

Source code in src/commons/users/repository.py
def check_permission(self, user: User, scope: str, permission: str) -> None:
    """
    Check if a user has a permission, raising an error if not.

    Args:
        user: The user to check permissions for.
        scope: The scope path to check the permission in.
        permission: The permission path to check.

    Raises:
        LookupError: If the user does not have the required permission.
    """
    if not self.has_permission(user, scope, permission):
        raise LookupError(
            f"User `{user.display_name}` does not have the permission `{permission}` in scope `{scope}`."
        )

delete_role abstractmethod

delete_role(role: Role | str) -> None

Delete a role.

Parameters:

Name Type Description Default
role Role | str

The role instance or role name to delete.

required
Source code in src/commons/users/repository.py
@abc.abstractmethod
def delete_role(self, role: Role | str) -> None:
    """
    Delete a role.

    Args:
        role: The role instance or role name to delete.
    """
    ...

delete_user abstractmethod

delete_user(user: RegisteredUser | str) -> None

Delete a user.

Parameters:

Name Type Description Default
user RegisteredUser | str

The user instance or username to delete.

required
Source code in src/commons/users/repository.py
@abc.abstractmethod
def delete_user(self, user: RegisteredUser | str) -> None:
    """
    Delete a user.

    Args:
        user: The user instance or username to delete.
    """
    ...

find_role_by_name

find_role_by_name(name: str) -> Role | None

Find a role by its name.

Searches both default_roles and the database.

Parameters:

Name Type Description Default
name str

The role name to search for.

required

Returns:

Type Description
Role | None

The Role if found, None otherwise.

Source code in src/commons/users/repository.py
def find_role_by_name(self, name: str) -> Role | None:
    """
    Find a role by its name.

    Searches both default_roles and the database.

    Args:
        name: The role name to search for.

    Returns:
        The Role if found, None otherwise.
    """
    for role in self.default_roles:
        if role.name == name:
            return role

    return self._find_role_by_name(name)

find_user_by_email abstractmethod

find_user_by_email(email: str) -> RegisteredUser | None

Find a user by their email address.

The comparison is case-insensitive, as email addresses are commonly entered with varying case.

Parameters:

Name Type Description Default
email str

The user's email address.

required

Returns:

Type Description
RegisteredUser | None

The RegisteredUser if found, None otherwise.

Raises:

Type Description
ValueError

If more than one user is registered with the given email address.

Source code in src/commons/users/repository.py
@abc.abstractmethod
def find_user_by_email(self, email: str) -> RegisteredUser | None:
    """
    Find a user by their email address.

    The comparison is case-insensitive, as email addresses are commonly entered with varying case.

    Args:
        email: The user's email address.

    Returns:
        The RegisteredUser if found, None otherwise.

    Raises:
        ValueError: If more than one user is registered with the given email address.
    """
    ...

find_user_by_id abstractmethod

find_user_by_id(user_id: str) -> RegisteredUser | None

Find a user by their unique ID.

Parameters:

Name Type Description Default
user_id str

The user's unique identifier (UUID).

required

Returns:

Type Description
RegisteredUser | None

The RegisteredUser if found, None otherwise.

Source code in src/commons/users/repository.py
@abc.abstractmethod
def find_user_by_id(self, user_id: str) -> RegisteredUser | None:
    """
    Find a user by their unique ID.

    Args:
        user_id: The user's unique identifier (UUID).

    Returns:
        The RegisteredUser if found, None otherwise.
    """
    ...

find_user_by_username abstractmethod

find_user_by_username(
    username: str,
) -> RegisteredUser | None

Find a user by their username.

Parameters:

Name Type Description Default
username str

The user's unique username.

required

Returns:

Type Description
RegisteredUser | None

The RegisteredUser if found, None otherwise.

Source code in src/commons/users/repository.py
@abc.abstractmethod
def find_user_by_username(self, username: str) -> RegisteredUser | None:
    """
    Find a user by their username.

    Args:
        username: The user's unique username.

    Returns:
        The RegisteredUser if found, None otherwise.
    """
    ...

get_role_by_name

get_role_by_name(name: str) -> Role

Get a role by its name, raising an error if not found.

Parameters:

Name Type Description Default
name str

The role name to search for.

required

Returns:

Type Description
Role

The Role.

Raises:

Type Description
KeyError

If no role exists with the given name.

Source code in src/commons/users/repository.py
def get_role_by_name(self, name: str) -> Role:
    """
    Get a role by its name, raising an error if not found.

    Args:
        name: The role name to search for.

    Returns:
        The Role.

    Raises:
        KeyError: If no role exists with the given name.
    """
    role = self.find_role_by_name(name)

    if role:
        return role

    raise KeyError(f"No role found with name `{name}`.")

get_role_assignments abstractmethod

get_role_assignments(
    user: RegisteredUser | str,
) -> list[RoleAssignment]

Fetch the role assignments assigned to a user.

Parameters:

Name Type Description Default
user RegisteredUser | str

The user instance or username to get assignments for.

required

Returns:

Type Description
list[RoleAssignment]

List of RoleAssignments for the user.

Source code in src/commons/users/repository.py
@abc.abstractmethod
def get_role_assignments(self, user: RegisteredUser | str) -> list[RoleAssignment]:
    """
    Fetch the role assignments assigned to a user.

    Args:
        user: The user instance or username to get assignments for.

    Returns:
        List of RoleAssignments for the user.
    """
    ...

get_user_by_email

get_user_by_email(email: str) -> RegisteredUser

Get a user by their email address, raising an error if not found.

Parameters:

Name Type Description Default
email str

The user's email address (compared case-insensitively).

required

Returns:

Type Description
RegisteredUser

The RegisteredUser.

Raises:

Type Description
KeyError

If no user exists with the given email address.

ValueError

If more than one user is registered with the given email address.

Source code in src/commons/users/repository.py
def get_user_by_email(self, email: str) -> RegisteredUser:
    """
    Get a user by their email address, raising an error if not found.

    Args:
        email: The user's email address (compared case-insensitively).

    Returns:
        The RegisteredUser.

    Raises:
        KeyError: If no user exists with the given email address.
        ValueError: If more than one user is registered with the given email address.
    """
    user = self.find_user_by_email(email)
    if not user:
        raise KeyError(f"No user found with email `{email}`.")
    return user

get_user_by_id

get_user_by_id(user_id: str) -> RegisteredUser

Get a user by their unique ID, raising an error if not found.

Parameters:

Name Type Description Default
user_id str

The user's unique identifier (UUID).

required

Returns:

Type Description
RegisteredUser

The RegisteredUser.

Raises:

Type Description
KeyError

If no user exists with the given ID.

Source code in src/commons/users/repository.py
def get_user_by_id(self, user_id: str) -> RegisteredUser:
    """
    Get a user by their unique ID, raising an error if not found.

    Args:
        user_id: The user's unique identifier (UUID).

    Returns:
        The RegisteredUser.

    Raises:
        KeyError: If no user exists with the given ID.
    """
    user = self.find_user_by_id(user_id)
    if not user:
        raise KeyError(f"No user found with id `{user_id}`.")
    return user

get_user_by_username

get_user_by_username(username: str) -> RegisteredUser

Get a user by their username, raising an error if not found.

Parameters:

Name Type Description Default
username str

The user's unique username.

required

Returns:

Type Description
RegisteredUser

The RegisteredUser.

Raises:

Type Description
KeyError

If no user exists with the given username.

Source code in src/commons/users/repository.py
def get_user_by_username(self, username: str) -> RegisteredUser:
    """
    Get a user by their username, raising an error if not found.

    Args:
        username: The user's unique username.

    Returns:
        The RegisteredUser.

    Raises:
        KeyError: If no user exists with the given username.
    """
    user = self.find_user_by_username(username)
    if not user:
        raise KeyError(f"No user found with username `{username}`.")
    return user

has_permission

has_permission(
    user: User, scope: str, permission: str
) -> bool

Check whether a user has a specific permission in a scope.

An entry (default permission or role assignment) whose role doesn't exist grants nothing: it is skipped with a warning in the log, and the other entries still count.

Permission checking is based on the user type: - AnonymousUser: Checks anonymous_default_permissions - AuthenticatedUser: Checks authenticated_default_permissions - RegisteredUser: Checks registered_default_permissions and user-specific assignments

Parameters:

Name Type Description Default
user User

The user to check permissions for.

required
scope str

The scope path to check the permission in (e.g., '/projects/my-project').

required
permission str

The permission path to check (e.g., '/read', '/admin/delete').

required

Returns:

Type Description
bool

True if the user has the permission, False otherwise.

Source code in src/commons/users/repository.py
def has_permission(self, user: User, scope: str, permission: str) -> bool:
    """
    Check whether a user has a specific permission in a scope.

    An entry (default permission or role assignment) whose role doesn't exist grants
    nothing: it is skipped with a warning in the log, and the other entries still count.

    Permission checking is based on the user type:
    - AnonymousUser: Checks anonymous_default_permissions
    - AuthenticatedUser: Checks authenticated_default_permissions
    - RegisteredUser: Checks registered_default_permissions and user-specific assignments

    Args:
        user: The user to check permissions for.
        scope: The scope path to check the permission in (e.g., '/projects/my-project').
        permission: The permission path to check (e.g., '/read', '/admin/delete').

    Returns:
        True if the user has the permission, False otherwise.
    """
    scope = _normalize_path("scope", scope)
    permission = _normalize_path("permission", permission)

    if isinstance(user, AnonymousUser):
        return any(self._grants(p.scope, p.role, scope, permission) for p in self.anonymous_default_permissions)
    elif isinstance(user, AuthenticatedUser):
        return any(self._grants(p.scope, p.role, scope, permission) for p in self.authenticated_default_permissions)
    elif isinstance(user, RegisteredUser):
        return self.registered_user_has_permission(user, scope, permission)
    else:
        return False

hash_password

hash_password(password: str) -> str

Hash a plain-text password using the configured hasher.

For hashes to put into the settings, use the command line helper python -m commons.users hash-password (default bcrypt hasher).

Parameters:

Name Type Description Default
password str

The plain-text password to hash.

required

Returns:

Type Description
str

The hashed password string.

Raises:

Type Description
ValueError

If the password is empty.

Source code in src/commons/users/repository.py
def hash_password(self, password: str) -> str:
    """
    Hash a plain-text password using the configured hasher.

    For hashes to put into the settings, use the command line helper
    ``python -m commons.users hash-password`` (default bcrypt hasher).

    Args:
        password: The plain-text password to hash.

    Returns:
        The hashed password string.

    Raises:
        ValueError: If the password is empty.
    """
    if not password:
        raise ValueError("Password must not be empty.")
    return self._password_hasher(password)

init_from_settings

init_from_settings(settings: StandardUserSettings) -> None

Initialize users from settings configuration.

Creates users defined in the settings which do not exist yet and assigns their configured roles. A configured password_hash is stored as-is; a plain password is hashed with the configured hasher; a user with neither is created without a password.

Users which already exist in the repository are left untouched (no password, profile or role changes), so e.g. passwords changed via update_password() are not reset on the next start.

Parameters:

Name Type Description Default
settings StandardUserSettings

The user settings containing user definitions.

required
Source code in src/commons/users/repository.py
def init_from_settings(self, settings: StandardUserSettings) -> None:
    """
    Initialize users from settings configuration.

    Creates users defined in the settings which do not exist yet and assigns their
    configured roles. A configured ``password_hash`` is stored as-is; a plain
    ``password`` is hashed with the configured hasher; a user with neither is
    created without a password.

    Users which already exist in the repository are left untouched (no password,
    profile or role changes), so e.g. passwords changed via ``update_password()``
    are not reset on the next start.

    Args:
        settings: The user settings containing user definitions.
    """
    for user_config in settings.users:
        if self.find_user_by_username(user_config.username):
            continue

        password_hash = user_config.password_hash
        if password_hash is None and user_config.password is not None:
            password_hash = self.hash_password(user_config.password)

        user = RegisteredUser.create(
            username=user_config.username,
            password_hash=password_hash,
            display_name=f"{user_config.firstname} {user_config.lastname}",
            firstname=user_config.firstname,
            lastname=user_config.lastname,
            email=user_config.email,
        )
        self.upsert_user(user)

        for role_config in user_config.roles:
            self.add_assignment(user, role_config.scope, role_config.role)

list_roles abstractmethod

list_roles() -> list[Role]

List all roles from the database.

Note: This does not include default_roles. Use default_roles attribute to access predefined roles.

Returns:

Type Description
list[Role]

List of all Role objects in the database.

Source code in src/commons/users/repository.py
@abc.abstractmethod
def list_roles(self) -> list[Role]:
    """
    List all roles from the database.

    Note: This does not include default_roles. Use default_roles attribute
    to access predefined roles.

    Returns:
        List of all Role objects in the database.
    """
    ...

registered_user_has_permission

registered_user_has_permission(
    user: RegisteredUser, scope: str, permission: str
) -> bool

Check whether a registered user has a specific permission in a scope.

Checks both registered_default_permissions (applied to all registered users) and the user's individual role assignments.

Parameters:

Name Type Description Default
user RegisteredUser

The registered user to check permissions for.

required
scope str

The scope path to check the permission in.

required
permission str

The permission path to check.

required

Returns:

Type Description
bool

True if the user has the permission, False otherwise.

Source code in src/commons/users/repository.py
def registered_user_has_permission(self, user: RegisteredUser, scope: str, permission: str) -> bool:
    """
    Check whether a registered user has a specific permission in a scope.

    Checks both registered_default_permissions (applied to all registered users)
    and the user's individual role assignments.

    Args:
        user: The registered user to check permissions for.
        scope: The scope path to check the permission in.
        permission: The permission path to check.

    Returns:
        True if the user has the permission, False otherwise.
    """
    scope = _normalize_path("scope", scope)
    permission = _normalize_path("permission", permission)

    if any(self._grants(p.scope, p.role, scope, permission) for p in self.registered_default_permissions):
        return True

    return any(
        self._grants(ra.scope_name, ra.role if ra.role is not None else ra.role_name, scope, permission)
        for ra in self.get_role_assignments(user)
    )

remove_assignment abstractmethod

remove_assignment(assignment: RoleAssignment | str) -> None

Remove a role assignment.

Parameters:

Name Type Description Default
assignment RoleAssignment | str

The RoleAssignment instance or assignment ID (UUID) to remove.

required
Source code in src/commons/users/repository.py
@abc.abstractmethod
def remove_assignment(self, assignment: RoleAssignment | str) -> None:
    """
    Remove a role assignment.

    Args:
        assignment: The RoleAssignment instance or assignment ID (UUID) to remove.
    """
    ...

role_has_permission

role_has_permission(
    scope: str,
    role: Role | str,
    scope_to_check: str,
    permission_to_check: str,
) -> bool

Check whether a role grants a specific permission within a scope.

Uses path-based matching: a role with permission '/admin' also grants '/admin/delete', and a role assigned to scope '/projects' also applies to '/projects/my-project'.

Parameters:

Name Type Description Default
scope str

The scope path where the role is assigned.

required
role Role | str

The role instance or role name to check.

required
scope_to_check str

The scope path to verify access for.

required
permission_to_check str

The permission path to verify.

required

Returns:

Type Description
bool

True if the role grants the permission in the scope, False otherwise.

Source code in src/commons/users/repository.py
def role_has_permission(self, scope: str, role: Role | str, scope_to_check: str, permission_to_check: str) -> bool:
    """
    Check whether a role grants a specific permission within a scope.

    Uses path-based matching: a role with permission '/admin' also grants '/admin/delete',
    and a role assigned to scope '/projects' also applies to '/projects/my-project'.

    Args:
        scope: The scope path where the role is assigned.
        role: The role instance or role name to check.
        scope_to_check: The scope path to verify access for.
        permission_to_check: The permission path to verify.

    Returns:
        True if the role grants the permission in the scope, False otherwise.
    """
    scope = _normalize_path("scope", scope)
    scope_to_check = _normalize_path("scope", scope_to_check)
    permission_to_check = _normalize_path("permission", permission_to_check)

    if not self._scope_contains_scope(scope, scope_to_check):
        return False

    if isinstance(role, str):
        role = self.get_role_by_name(role)

    return any(self._permission_contains_permission(p.name, permission_to_check) for p in role.permissions)

update_password

update_password(
    user: RegisteredUser | str, new_password: str
) -> RegisteredUser

Update a user's password.

Parameters:

Name Type Description Default
user RegisteredUser | str

The user instance or username whose password should be updated.

required
new_password str

The new plain-text password (will be hashed before storage); must not be empty.

required

Returns:

Type Description
RegisteredUser

The updated RegisteredUser with the new password hash.

Raises:

Type Description
KeyError

If a username is provided and no user exists with that username.

ValueError

If the new password is empty.

Source code in src/commons/users/repository.py
def update_password(self, user: RegisteredUser | str, new_password: str) -> RegisteredUser:
    """
    Update a user's password.

    Args:
        user: The user instance or username whose password should be updated.
        new_password: The new plain-text password (will be hashed before storage); must not be empty.

    Returns:
        The updated RegisteredUser with the new password hash.

    Raises:
        KeyError: If a username is provided and no user exists with that username.
        ValueError: If the new password is empty.
    """
    if isinstance(user, str):
        user = self.get_user_by_username(user)
    new_hash = self.hash_password(new_password)
    updated_user = RegisteredUser.create(
        username=user.username,
        password_hash=new_hash,
        display_name=user.display_name,
        id=user.id,
        firstname=user.firstname,
        lastname=user.lastname,
        email=user.email,
        locale=user.locale,
    )
    return self.upsert_user(updated_user)

upsert_role abstractmethod

upsert_role(role: Role) -> Role

Create or update a role.

If a role with the same name exists, it is updated. Otherwise, a new role is created.

Parameters:

Name Type Description Default
role Role

The role to create or update.

required

Returns:

Type Description
Role

The created or updated Role.

Source code in src/commons/users/repository.py
@abc.abstractmethod
def upsert_role(self, role: Role) -> Role:
    """
    Create or update a role.

    If a role with the same name exists, it is updated. Otherwise, a new role is created.

    Args:
        role: The role to create or update.

    Returns:
        The created or updated Role.
    """
    ...

upsert_user abstractmethod

upsert_user(user: RegisteredUser) -> RegisteredUser

Create or update a user.

If a user with the same ID exists, it is updated. Otherwise, a new user is created.

Parameters:

Name Type Description Default
user RegisteredUser

The user to create or update.

required

Returns:

Type Description
RegisteredUser

The created or updated RegisteredUser.

Source code in src/commons/users/repository.py
@abc.abstractmethod
def upsert_user(self, user: RegisteredUser) -> RegisteredUser:
    """
    Create or update a user.

    If a user with the same ID exists, it is updated. Otherwise, a new user is created.

    Args:
        user: The user to create or update.

    Returns:
        The created or updated RegisteredUser.
    """
    ...

list_assignments abstractmethod

list_assignments(scope: str) -> list[RoleAssignment]

List the role assignments on a scope and on every scope below it.

Assignments on scopes above it (which also apply to it) are not included; list the parent scopes to find them.

Parameters:

Name Type Description Default
scope str

The scope path, e.g. "/app/tenants/<id>" for everyone with a role in a tenant.

required

Returns:

Type Description
list[RoleAssignment]

The assignments, each with its user, role and scope loaded.

Source code in src/commons/users/repository.py
@abc.abstractmethod
def list_assignments(self, scope: str) -> list[RoleAssignment]:
    """
    List the role assignments on a scope and on every scope below it.

    Assignments on scopes above it (which also apply to it) are not included; list
    the parent scopes to find them.

    Args:
        scope: The scope path, e.g. ``"/app/tenants/<id>"`` for everyone with a role in a tenant.

    Returns:
        The assignments, each with its ``user``, ``role`` and ``scope`` loaded.
    """
    ...

remove_assignments abstractmethod

remove_assignments(scope: str) -> int

Remove the role assignments on a scope and on every scope below it, e.g. when a tenant is deleted.

Scope entries left without assignments are removed, too. Assignments on scopes above it are kept.

Parameters:

Name Type Description Default
scope str

The scope path.

required

Returns:

Type Description
int

The number of removed assignments.

Source code in src/commons/users/repository.py
@abc.abstractmethod
def remove_assignments(self, scope: str) -> int:
    """
    Remove the role assignments on a scope and on every scope below it, e.g. when a tenant is deleted.

    Scope entries left without assignments are removed, too. Assignments on scopes
    above it are kept.

    Args:
        scope: The scope path.

    Returns:
        The number of removed assignments.
    """
    ...

SQLUsersRepository

SQLUsersRepository(
    engine: Engine,
    default_roles: list[Role] | None = None,
    anonymous_default_permissions: (
        list[DefaultRoleAssignment] | None
    ) = None,
    authenticated_default_permissions: (
        list[DefaultRoleAssignment] | None
    ) = None,
    registered_default_permissions: (
        list[DefaultRoleAssignment] | None
    ) = None,
    password_hasher: PasswordHasher | None = None,
    password_verifier: PasswordVerifier | None = None,
)

Bases: UsersRepository

SQL database implementation of UsersRepository using SQLAlchemy/SQLModel.

This repository persists users, roles, permissions, and role assignments to a SQL database. It automatically creates the required tables on initialization if they don't exist.

Existing tables are not migrated: a database created before commons 1.4 (without registered_users.locale) or before commons 1.2 (without roles.is_default_role and the unique role assignments) must be recreated.

Supported databases include SQLite, PostgreSQL, MySQL, and any other database supported by SQLAlchemy.

Parameters:

Name Type Description Default
engine Engine

SQLAlchemy Engine connected to the database.

required
default_roles list[Role] | None

Roles defined in code. They are synced into the database on creation (marked as default roles, with exactly these permissions); default roles no longer listed are deleted with their assignments. They can't be changed or deleted at runtime. See "Default Roles" in commons.users.

None
anonymous_default_permissions list[DefaultRoleAssignment] | None

Role assignments automatically applied to anonymous (unauthenticated) users.

None
authenticated_default_permissions list[DefaultRoleAssignment] | None

Role assignments automatically applied to authenticated users without local accounts.

None
registered_default_permissions list[DefaultRoleAssignment] | None

Role assignments automatically applied to all registered users (in addition to their individual assignments).

None
password_hasher PasswordHasher | None

Custom password hashing function. Defaults to bcrypt.

None
password_verifier PasswordVerifier | None

Custom password verification function. Defaults to bcrypt.

None

Example::

from sqlalchemy import create_engine
from commons.users import SQLUsersRepository, Role

# Create with SQLite
engine = create_engine("sqlite:///users.db")
repo = SQLUsersRepository(engine)

# Create with PostgreSQL and default roles
engine = create_engine("postgresql://user:pass@localhost/db")
viewer_role = Role.create("viewer", ["/read"])
repo = SQLUsersRepository(
    engine,
    default_roles=[viewer_role],
    registered_default_permissions=[DefaultRoleAssignment(scope="/", role="viewer")],
)
See Also

UsersRepository: Abstract base class defining the interface.

Methods:

Name Description
add_assignment
delete_role
delete_user
find_user_by_email
find_user_by_id
find_user_by_username
get_role_assignments
list_assignments
list_roles
remove_assignment
remove_assignments
upsert_role
upsert_user
Source code in src/commons/users/sql.py
def __init__(
    self,
    engine: Engine,
    default_roles: list[Role] | None = None,
    anonymous_default_permissions: list[DefaultRoleAssignment] | None = None,
    authenticated_default_permissions: list[DefaultRoleAssignment] | None = None,
    registered_default_permissions: list[DefaultRoleAssignment] | None = None,
    password_hasher: PasswordHasher | None = None,
    password_verifier: PasswordVerifier | None = None,
):
    super().__init__(
        default_roles,
        anonymous_default_permissions,
        authenticated_default_permissions,
        registered_default_permissions,
        password_hasher,
        password_verifier,
    )
    self._engine = engine
    self._create_session = sessionmaker(bind=self._engine)

    SQLModel.metadata.create_all(self._engine, checkfirst=True)

    for role in self.default_roles:
        role.is_default_role = True
        for permission in role.permissions:
            permission.name = _normalize_path("permission", permission.name)

    try:
        self._sync_default_roles()
    except IntegrityError:
        # Another process (e.g. a second worker starting) inserted the same rows at the same moment;
        # running again finds them and only updates them.
        self._sync_default_roles()

add_assignment

add_assignment(
    user: RegisteredUser | str, scope: str, role: str
) -> RoleAssignment
Source code in src/commons/users/sql.py
def add_assignment(self, user: RegisteredUser | str, scope: str, role: str) -> RoleAssignment:
    if isinstance(user, str):
        user = self.get_user_by_username(user)
    scope = _normalize_path("scope", scope)
    self.get_role_by_name(role)  # KeyError for an unknown role, before anything is written

    with self._create_session() as session:
        existing = session.execute(
            select(RoleAssignment).where(
                RoleAssignment.user_id == user.id,  # type: ignore[arg-type]
                RoleAssignment.scope_name == scope,  # type: ignore[arg-type]
                RoleAssignment.role_name == role,  # type: ignore[arg-type]
            )
        ).scalar_one_or_none()
        if existing:
            session.expunge(existing)
            return existing

        existing_scope = session.execute(
            select(Scope).where(Scope.name == scope)  # type: ignore[arg-type]
        ).scalar_one_or_none()
        if not existing_scope:
            session.add(Scope(name=scope))

        assignment = RoleAssignment(
            scope_name=scope,
            role_name=role,
            user_id=user.id,
        )
        session.add(assignment)
        session.commit()
        session.refresh(assignment)
        session.expunge(assignment)
        return assignment

delete_role

delete_role(role: Role | str) -> None
Source code in src/commons/users/sql.py
def delete_role(self, role: Role | str) -> None:
    self._reject_default_role(role)

    with self._create_session() as session:
        if isinstance(role, str):
            db_role = session.execute(
                select(Role).where(Role.name == role)  # type: ignore[arg-type]
            ).scalar_one_or_none()
        else:
            db_role = session.merge(role)

        if db_role:
            session.delete(db_role)
            session.commit()

delete_user

delete_user(user: RegisteredUser | str) -> None
Source code in src/commons/users/sql.py
def delete_user(self, user: RegisteredUser | str) -> None:
    with self._create_session() as session:
        if isinstance(user, str):
            user = self.get_user_by_username(user)

        db_user = session.merge(user)
        session.delete(db_user)
        session.commit()

find_user_by_email

find_user_by_email(email: str) -> RegisteredUser | None
Source code in src/commons/users/sql.py
def find_user_by_email(self, email: str) -> RegisteredUser | None:
    with self._create_session() as session:
        results = list(
            session.execute(
                select(RegisteredUser).where(
                    func.lower(RegisteredUser.email_) == email.lower()  # type: ignore[arg-type]
                )
            )
            .scalars()
            .all()
        )
        if len(results) > 1:
            raise ValueError(f"Multiple users found with email `{email}`.")
        for user in results:
            session.expunge(user)
        return results[0] if results else None

find_user_by_id

find_user_by_id(user_id: str) -> RegisteredUser | None
Source code in src/commons/users/sql.py
def find_user_by_id(self, user_id: str) -> RegisteredUser | None:
    with self._create_session() as session:
        result = session.execute(
            select(RegisteredUser).where(RegisteredUser.id_ == user_id)  # type: ignore[arg-type]
        ).scalar_one_or_none()
        if result:
            session.expunge(result)
        return result

find_user_by_username

find_user_by_username(
    username: str,
) -> RegisteredUser | None
Source code in src/commons/users/sql.py
def find_user_by_username(self, username: str) -> RegisteredUser | None:
    with self._create_session() as session:
        result = session.execute(
            select(RegisteredUser).where(RegisteredUser.username_ == username)  # type: ignore[arg-type]
        ).scalar_one_or_none()
        if result:
            session.expunge(result)
        return result

get_role_assignments

get_role_assignments(
    user: RegisteredUser | str,
) -> list[RoleAssignment]
Source code in src/commons/users/sql.py
def get_role_assignments(self, user: RegisteredUser | str) -> list[RoleAssignment]:
    with self._create_session() as session:
        if isinstance(user, str):
            user = self.get_user_by_username(user)

        results = list(
            session.execute(
                select(RoleAssignment).where(RoleAssignment.user_id == user.id)  # type: ignore[arg-type]
            )
            .scalars()
            .all()
        )
        for ra in results:
            session.expunge(ra)
        return results

list_assignments

list_assignments(scope: str) -> list[RoleAssignment]
Source code in src/commons/users/sql.py
def list_assignments(self, scope: str) -> list[RoleAssignment]:
    scope = _normalize_path("scope", scope)
    with self._create_session() as session:
        results = list(
            session.execute(
                select(RoleAssignment)
                .where(_in_subtree(col(RoleAssignment.scope_name), scope))
                .options(
                    selectinload(RoleAssignment.user),  # type: ignore[arg-type]
                    selectinload(RoleAssignment.role),  # type: ignore[arg-type]
                    selectinload(RoleAssignment.scope),  # type: ignore[arg-type]
                )
            )
            .scalars()
            .all()
        )
        session.expunge_all()
        return results

list_roles

list_roles() -> list[Role]
Source code in src/commons/users/sql.py
def list_roles(self) -> list[Role]:
    with self._create_session() as session:
        results = list(session.execute(select(Role)).scalars().all())
        for role in results:
            session.expunge(role)
        return results

remove_assignment

remove_assignment(assignment: RoleAssignment | str) -> None
Source code in src/commons/users/sql.py
def remove_assignment(self, assignment: RoleAssignment | str) -> None:
    with self._create_session() as session:
        if isinstance(assignment, str):
            db_assignment = session.execute(
                select(RoleAssignment).where(RoleAssignment.id == assignment)  # type: ignore[arg-type]
            ).scalar_one_or_none()
        else:
            db_assignment = session.merge(assignment)

        if db_assignment:
            session.delete(db_assignment)
            session.commit()

remove_assignments

remove_assignments(scope: str) -> int
Source code in src/commons/users/sql.py
def remove_assignments(self, scope: str) -> int:
    scope = _normalize_path("scope", scope)
    with self._create_session() as session:
        result = session.execute(delete(RoleAssignment).where(_in_subtree(col(RoleAssignment.scope_name), scope)))
        still_used = select(col(RoleAssignment.scope_name))
        session.execute(
            delete(Scope).where(_in_subtree(col(Scope.name), scope), col(Scope.name).not_in(still_used))
        )
        session.commit()
        return cast(CursorResult[Any], result).rowcount

upsert_role

upsert_role(role: Role) -> Role
Source code in src/commons/users/sql.py
def upsert_role(self, role: Role) -> Role:
    self._reject_default_role(role)

    for permission in role.permissions:
        permission.name = _normalize_path("permission", permission.name)

    with self._create_session() as session:
        # Built in the session instead of merging the given objects: merging cascades the role's
        # permissions, and stored ones would be inserted a second time.
        permissions = [session.get(Permission, p.name) or Permission(name=p.name) for p in role.permissions]
        db_role = session.get(Role, role.name)
        if db_role is None:
            db_role = Role(name=role.name, permissions=permissions)
            session.add(db_role)
        else:
            db_role.permissions = permissions

        session.commit()
        session.refresh(db_role)
        session.expunge(db_role)
        return db_role

upsert_user

upsert_user(user: RegisteredUser) -> RegisteredUser
Source code in src/commons/users/sql.py
def upsert_user(self, user: RegisteredUser) -> RegisteredUser:
    with self._create_session() as session:
        merged_user = session.merge(user)
        session.commit()
        session.refresh(merged_user)
        session.expunge(merged_user)
        return merged_user

User

Bases: Protocol

Protocol defining the common interface for all user types.

This protocol enables polymorphic handling of different user types (AnonymousUser, AuthenticatedUser, RegisteredUser) in permission checks and other operations.

All concrete user classes implement this protocol, allowing code to work with any user type without knowing the specific implementation.

Example::

def greet_user(user: User) -> str:
    return f"Hello, {user.name('casual')}!"


# Works with any user type
greet_user(AnonymousUser.get())
greet_user(AuthenticatedUser.create("John Doe"))
greet_user(registered_user)

Methods:

Name Description
name

Get a formatted name for the user.

Attributes:

Name Type Description
id str

Unique identifier for the user.

display_name str

Human-readable display name for the user.

firstname str | None

User's first name, if available.

lastname str | None

User's last name, if available.

email str | None

User's email address, if available.

id property

id: str

Unique identifier for the user.

display_name property

display_name: str

Human-readable display name for the user.

firstname property

firstname: str | None

User's first name, if available.

lastname property

lastname: str | None

User's last name, if available.

email property

email: str | None

User's email address, if available.

name

name(type: UserNameType = 'casual') -> str

Get a formatted name for the user.

Parameters:

Name Type Description Default
type UserNameType

The name format to return. See UserNameType for options.

'casual'

Returns:

Type Description
str

The formatted name string.

Source code in src/commons/users/user.py
def name(self, type: UserNameType = "casual") -> str:
    """
    Get a formatted name for the user.

    Args:
        type: The name format to return. See ``UserNameType`` for options.

    Returns:
        The formatted name string.
    """
    ...

AnonymousUser

Bases: BaseModel

Represents an unauthenticated user (visitor).

Anonymous users have no personal information and a fixed identity. They are used to represent visitors who have not logged in. Permission checks for anonymous users use the repository's anonymous_default_permissions.

This class implements the User protocol.

Attributes:

Name Type Description
id str

Always "__anonymous__".

display_name str

Defaults to "Anonymous User".

firstname str | None

Always None.

lastname str | None

Always None.

email str | None

Always None.

Example::

# Get the anonymous user instance
anon = AnonymousUser.get()

# Check permissions
if repo.has_permission(anon, "/public", "/read"):
    show_public_content()

Methods:

Name Description
get

Get an AnonymousUser instance.

name

Get a formatted name for the anonymous user.

id_ class-attribute instance-attribute

id_: Literal["__anonymous__"] = Field(
    default="__anonymous__",
    validation_alias="id",
    serialization_alias="id",
)

display_name_ class-attribute instance-attribute

display_name_: str = Field(
    default="Anonymous User",
    validation_alias="display_name",
    serialization_alias="display_name",
)

id property

id: str

display_name property

display_name: str

firstname property

firstname: str | None

lastname property

lastname: str | None

email property

email: str | None

get staticmethod

get() -> AnonymousUser

Get an AnonymousUser instance.

Returns:

Type Description
AnonymousUser

An AnonymousUser with default values.

Source code in src/commons/users/user.py
@staticmethod
def get() -> "AnonymousUser":
    """
    Get an AnonymousUser instance.

    Returns:
        An AnonymousUser with default values.
    """
    return AnonymousUser()

name

name(type: UserNameType = 'casual') -> str

Get a formatted name for the anonymous user.

Parameters:

Name Type Description Default
type UserNameType

The name format (ignored for anonymous users).

'casual'

Returns:

Type Description
str

Always returns display_name since anonymous users have no personal info.

Source code in src/commons/users/user.py
def name(self, type: UserNameType = "casual") -> str:
    """
    Get a formatted name for the anonymous user.

    Args:
        type: The name format (ignored for anonymous users).

    Returns:
        Always returns ``display_name`` since anonymous users have no personal info.
    """
    return self.display_name

AuthenticatedUser

Bases: BaseModel

Represents a user authenticated via an external system without a local account.

Authenticated users are typically created from external identity providers (OAuth, SSO, SAML, etc.). They have identity information but no local credentials. Permission checks use the repository's authenticated_default_permissions.

This class implements the User protocol.

Use AuthenticatedUser.create() to construct instances with proper defaults.

Attributes:

Name Type Description
id str

Unique identifier (UUID), auto-generated if not provided.

display_name str

Human-readable name (required).

firstname str | None

First name (optional).

lastname str | None

Last name (optional).

email str | None

Email address (optional).

Example::

# Create from OAuth callback data
user = AuthenticatedUser.create(
    display_name="Jane Smith",
    id=oauth_user_id,
    firstname="Jane",
    lastname="Smith",
    email="jane@example.com",
)

# Check permissions
if repo.has_permission(user, "/documents", "/read"):
    show_documents()
See Also

RegisteredUser: For users with local accounts and credentials.

Methods:

Name Description
name

Get a formatted name for the user.

create

Create an AuthenticatedUser with the given attributes.

model_config class-attribute instance-attribute

model_config = ConfigDict(
    populate_by_name=True, serialize_by_alias=True
)

id_ class-attribute instance-attribute

id_: str = Field(
    default_factory=new_id,
    validation_alias="id",
    serialization_alias="id",
)

display_name_ class-attribute instance-attribute

display_name_: str = Field(
    validation_alias="display_name",
    serialization_alias="display_name",
)

firstname_ class-attribute instance-attribute

firstname_: str | None = Field(
    default=None,
    validation_alias="firstname",
    serialization_alias="firstname",
)

lastname_ class-attribute instance-attribute

lastname_: str | None = Field(
    default=None,
    validation_alias="lastname",
    serialization_alias="lastname",
)

email_ class-attribute instance-attribute

email_: str | None = Field(
    default=None,
    validation_alias="email",
    serialization_alias="email",
)

id property

id: str

display_name property

display_name: str

firstname property

firstname: str | None

lastname property

lastname: str | None

email property

email: str | None

name

name(type: UserNameType = 'casual') -> str

Get a formatted name for the user.

Parameters:

Name Type Description Default
type UserNameType

The name format to return.

'casual'

Returns:

Type Description
str
  • "technical": Returns the user's UUID.
str
  • "casual": Returns firstname if available, otherwise display_name.
str
  • "formal": Returns "Lastname, Firstname" if both available, otherwise display_name.
str
  • "full": Returns "Firstname Lastname" if both available, otherwise display_name.
Source code in src/commons/users/user.py
def name(self, type: UserNameType = "casual") -> str:
    """
    Get a formatted name for the user.

    Args:
        type: The name format to return.

    Returns:
        - ``"technical"``: Returns the user's UUID.
        - ``"casual"``: Returns firstname if available, otherwise display_name.
        - ``"formal"``: Returns "Lastname, Firstname" if both available, otherwise display_name.
        - ``"full"``: Returns "Firstname Lastname" if both available, otherwise display_name.
    """
    if type == "technical":
        return self.id
    elif type == "casual":
        return self.firstname or self.display_name
    elif type == "formal":
        if self.lastname and self.firstname:
            return f"{self.lastname}, {self.firstname}"
        return self.display_name
    else:  # full
        if self.firstname and self.lastname:
            return f"{self.firstname} {self.lastname}"
        return self.display_name

create staticmethod

create(
    display_name: str,
    *,
    id: str | None = None,
    firstname: str | None = None,
    lastname: str | None = None,
    email: str | None = None
) -> AuthenticatedUser

Create an AuthenticatedUser with the given attributes.

Parameters:

Name Type Description Default
display_name str

Human-readable display name (required).

required
id str | None

Unique identifier. Auto-generated UUID if not provided.

None
firstname str | None

First name (optional).

None
lastname str | None

Last name (optional).

None
email str | None

Email address (optional).

None

Returns:

Type Description
AuthenticatedUser

A new AuthenticatedUser instance.

Source code in src/commons/users/user.py
@staticmethod
def create(
    display_name: str,
    *,
    id: str | None = None,
    firstname: str | None = None,
    lastname: str | None = None,
    email: str | None = None,
) -> "AuthenticatedUser":
    """
    Create an AuthenticatedUser with the given attributes.

    Args:
        display_name: Human-readable display name (required).
        id: Unique identifier. Auto-generated UUID if not provided.
        firstname: First name (optional).
        lastname: Last name (optional).
        email: Email address (optional).

    Returns:
        A new AuthenticatedUser instance.
    """
    return AuthenticatedUser(
        id_=id or new_id(),
        display_name_=display_name,
        firstname_=firstname,
        lastname_=lastname,
        email_=email,
    )

RegisteredUser

Bases: SQLModel

A user with a local account stored in the database.

Registered users have credentials (username and password hash) and can have role assignments granting specific permissions. They are authenticated via UsersRepository.authenticate() and can be assigned roles via UsersRepository.add_assignment().

This class implements the User protocol and is a SQLModel table class.

Use RegisteredUser.create() to construct instances with proper defaults. Use UsersRepository.hash_password() to generate password hashes in code, or python -m commons.users hash-password on the command line (e.g. for password_hash of users configured in the settings).

Attributes:

Name Type Description
id str

Unique identifier (UUID), auto-generated if not provided.

username str

Unique login username.

password_hash str | None

Bcrypt-hashed password (never store plain passwords), or None for a user without a password, who can't sign in with a password (e.g. users who sign in with an email link or code only).

display_name str

Human-readable name for display.

firstname str | None

First name (optional).

lastname str | None

Last name (optional).

email str | None

Email address (optional).

locale str | None

The language the user chose (a language tag such as "de" or "de-CH"), or None if they never chose one. pyhx uses it to pick the language of pages and login mails; it can be changed and saved with upsert_user.

Example::

# Create a new user
user = RegisteredUser.create(
    username="john.doe",
    password_hash=repo.hash_password("secret123"),
    display_name="John Doe",
    firstname="John",
    lastname="Doe",
    email="john@example.com",
)
repo.upsert_user(user)

# Authenticate
authenticated = repo.authenticate("john.doe", "secret123")

# Assign roles
repo.add_assignment(user, "/projects/alpha", "admin")

# Check permissions
if repo.has_permission(user, "/projects/alpha", "/admin/delete"):
    delete_project()
See Also

AnonymousUser: For unauthenticated visitors. AuthenticatedUser: For externally authenticated users without local accounts. RoleAssignment: For granting permissions to registered users.

Methods:

Name Description
name

Get a formatted name for the user.

create

Create a RegisteredUser with the given attributes.

__tablename__ class-attribute instance-attribute

__tablename__ = 'registered_users'

id_ class-attribute instance-attribute

id_: str = Field(
    default_factory=new_id,
    validation_alias="id",
    serialization_alias="id",
    sa_column=Column("id", String(64), primary_key=True),
    description="Unique id of the user.",
)

username_ class-attribute instance-attribute

username_: str = Field(
    validation_alias="username",
    serialization_alias="username",
    sa_column=Column("username", String(64), unique=True),
)

password_hash_ class-attribute instance-attribute

password_hash_: str | None = Field(
    default=None,
    validation_alias="password_hash",
    serialization_alias="password_hash",
    sa_column=Column("password_hash", String(256)),
)

display_name_ class-attribute instance-attribute

display_name_: str = Field(
    validation_alias="display_name",
    serialization_alias="display_name",
    sa_column=Column("display_name", Unicode(128)),
)

firstname_ class-attribute instance-attribute

firstname_: str | None = Field(
    default=None,
    validation_alias="firstname",
    serialization_alias="firstname",
    sa_column=Column("firstname", Unicode(64)),
)

lastname_ class-attribute instance-attribute

lastname_: str | None = Field(
    default=None,
    validation_alias="lastname",
    serialization_alias="lastname",
    sa_column=Column("lastname", Unicode(64)),
)

email_ class-attribute instance-attribute

email_: str | None = Field(
    default=None,
    validation_alias="email",
    serialization_alias="email",
    sa_column=Column("email", String(128)),
)

locale_ class-attribute instance-attribute

locale_: str | None = Field(
    default=None,
    validation_alias="locale",
    serialization_alias="locale",
    sa_column=Column("locale", String(35)),
)

id property

id: str

username property

username: str

password_hash property

password_hash: str | None

display_name property

display_name: str

firstname property

firstname: str | None

lastname property

lastname: str | None

email property

email: str | None

locale property writable

locale: str | None

name

name(type: UserNameType = 'casual') -> str

Get a formatted name for the user.

Parameters:

Name Type Description Default
type UserNameType

The name format to return.

'casual'

Returns:

Type Description
str
  • "technical": Returns the username.
str
  • "casual": Returns firstname if available, otherwise display_name.
str
  • "formal": Returns "Lastname, Firstname" if both available, otherwise display_name.
str
  • "full": Returns "Firstname Lastname" if both available, otherwise display_name.
Source code in src/commons/users/user.py
def name(self, type: UserNameType = "casual") -> str:
    """
    Get a formatted name for the user.

    Args:
        type: The name format to return.

    Returns:
        - ``"technical"``: Returns the username.
        - ``"casual"``: Returns firstname if available, otherwise display_name.
        - ``"formal"``: Returns "Lastname, Firstname" if both available, otherwise display_name.
        - ``"full"``: Returns "Firstname Lastname" if both available, otherwise display_name.
    """
    if type == "technical":
        return self.username
    elif type == "casual":
        return self.firstname or self.display_name
    elif type == "formal":
        if self.lastname and self.firstname:
            return f"{self.lastname}, {self.firstname}"
        return self.display_name
    else:  # full
        if self.firstname and self.lastname:
            return f"{self.firstname} {self.lastname}"
        return self.display_name

create staticmethod

create(
    username: str,
    password_hash: str | None,
    display_name: str,
    *,
    id: str | None = None,
    firstname: str | None = None,
    lastname: str | None = None,
    email: str | None = None,
    locale: str | None = None
) -> RegisteredUser

Create a RegisteredUser with the given attributes.

Parameters:

Name Type Description Default
username str

Unique login username (required).

required
password_hash str | None

Pre-hashed password, or None for a user without a password. Use UsersRepository.hash_password() to generate this value.

required
display_name str

Human-readable display name (required).

required
id str | None

Unique identifier. Auto-generated UUID if not provided.

None
firstname str | None

First name (optional).

None
lastname str | None

Last name (optional).

None
email str | None

Email address (optional).

None
locale str | None

The language the user chose, e.g. "de" (optional).

None

Returns:

Type Description
RegisteredUser

A new RegisteredUser instance.

Example::

user = RegisteredUser.create(
    username="jane.doe",
    password_hash=repo.hash_password("password123"),
    display_name="Jane Doe",
    firstname="Jane",
    lastname="Doe",
)
Source code in src/commons/users/user.py
@staticmethod
def create(
    username: str,
    password_hash: str | None,
    display_name: str,
    *,
    id: str | None = None,
    firstname: str | None = None,
    lastname: str | None = None,
    email: str | None = None,
    locale: str | None = None,
) -> "RegisteredUser":
    """
    Create a RegisteredUser with the given attributes.

    Args:
        username: Unique login username (required).
        password_hash: Pre-hashed password, or ``None`` for a user without a password.
            Use ``UsersRepository.hash_password()``
            to generate this value.
        display_name: Human-readable display name (required).
        id: Unique identifier. Auto-generated UUID if not provided.
        firstname: First name (optional).
        lastname: Last name (optional).
        email: Email address (optional).
        locale: The language the user chose, e.g. ``"de"`` (optional).

    Returns:
        A new RegisteredUser instance.

    Example::

        user = RegisteredUser.create(
            username="jane.doe",
            password_hash=repo.hash_password("password123"),
            display_name="Jane Doe",
            firstname="Jane",
            lastname="Doe",
        )
    """
    return RegisteredUser(
        id_=id or new_id(),
        username_=username,
        password_hash_=password_hash,
        display_name_=display_name,
        firstname_=firstname,
        lastname_=lastname,
        email_=email,
        locale_=locale,
    )

Role

Bases: SQLModel

A named collection of permissions that can be assigned to users.

Roles group related permissions together for easier management. For example, an "editor" role might include "/read" and "/write" permissions, while an "admin" role might include "/admin" (covering all admin sub-permissions).

Roles are assigned to users within a scope via RoleAssignment.

Attributes:

Name Type Description
name str

Unique role name (e.g., "admin", "editor", "viewer").

permissions list[Permission]

List of permissions granted by this role.

is_default_role bool

Whether the role is a default role, defined in code (default_roles of the repository) rather than created by the app. Default roles are synced into the database when the repository is created and can't be changed or deleted at runtime, so apps should show them as not editable.

Example::

# Create a role with permissions
admin_role = Role.create("admin", ["/read", "/write", "/admin"])

# Save to database
repo.upsert_role(admin_role)

# Assign to a user within a scope
repo.add_assignment(user, "/projects/alpha", "admin")
See Also

Permission: Individual capabilities granted by roles. RoleAssignment: Links users to roles within scopes. Scope: Resource boundaries where roles apply.

Methods:

Name Description
create

Create a Role with the given permissions.

__tablename__ class-attribute instance-attribute

__tablename__ = 'roles'

name class-attribute instance-attribute

name: str = Field(
    sa_column=Column(String(64), primary_key=True)
)

is_default_role class-attribute instance-attribute

is_default_role: bool = Field(
    default=False,
    sa_column=Column(
        Boolean, nullable=False, default=False
    ),
)

permissions class-attribute instance-attribute

permissions: list[Permission] = Relationship(
    back_populates="roles",
    link_model=RolePermission,
    sa_relationship_kwargs={"lazy": "selectin"},
)

create staticmethod

create(name: str, permissions: list[str]) -> Role

Create a Role with the given permissions.

Parameters:

Name Type Description Default
name str

Unique role name.

required
permissions list[str]

List of permission paths (e.g., ["/read", "/write"]).

required

Returns:

Type Description
Role

A new Role instance with Permission objects created for each path.

Source code in src/commons/users/user.py
@staticmethod
def create(name: str, permissions: list[str]) -> "Role":
    """
    Create a Role with the given permissions.

    Args:
        name: Unique role name.
        permissions: List of permission paths (e.g., ``["/read", "/write"]``).

    Returns:
        A new Role instance with Permission objects created for each path.
    """
    normalized = dict.fromkeys(_normalize_path("permission", p) for p in permissions)
    return Role(name=name, permissions=[Permission(name=p) for p in normalized])

RoleAssignment

Bases: SQLModel

Links a registered user to a role within a specific scope.

Role assignments are the primary mechanism for granting permissions to users. Each assignment grants a user all permissions defined in the role, but only within the specified scope (and its child scopes).

Attributes:

Name Type Description
id str

Unique identifier for the assignment (UUID).

scope_name str

The scope path where the role applies.

role_name str

The name of the assigned role.

user_id str

The ID of the user receiving the assignment.

scope Scope

The Scope object (relationship).

role Role

The Role object (relationship).

user RegisteredUser

The RegisteredUser object (relationship).

Example::

# Assign a role to a user (via repository)
assignment = repo.add_assignment(user, "/projects/alpha", "editor")

# The user now has all "editor" permissions within /projects/alpha
repo.has_permission(user, "/projects/alpha", "/write")  # True
repo.has_permission(user, "/projects/beta", "/write")  # False (different scope)

# Remove the assignment
repo.remove_assignment(assignment)
See Also

Role: Defines what permissions the assignment grants. Scope: Defines where the assignment applies. RegisteredUser: The user receiving the assignment.

__tablename__ class-attribute instance-attribute

__tablename__ = 'role_assignments'

__table_args__ class-attribute instance-attribute

__table_args__ = (
    UniqueConstraint("user_id", "scope_name", "role_name"),
)

id class-attribute instance-attribute

id: str = Field(
    default_factory=new_id,
    sa_column=Column("id", String(64), primary_key=True),
    description="Unique id of the role assignment.",
)

scope_name class-attribute instance-attribute

scope_name: str = Field(
    sa_column=Column(
        String(128), ForeignKey("scopes.name")
    ),
    description="The scope on which the role has been assigned.",
)

role_name class-attribute instance-attribute

role_name: str = Field(
    sa_column=Column(String(64), ForeignKey("roles.name")),
    description="The role which has been assigned to the user.",
)

user_id class-attribute instance-attribute

user_id: str = Field(
    sa_column=Column(
        String(64), ForeignKey("registered_users.id")
    ),
    description="The user to whom the role has been assigned.",
)

scope class-attribute instance-attribute

scope: Scope = Relationship(
    sa_relationship_kwargs={"lazy": "selectin"}
)

role class-attribute instance-attribute

role: Role = Relationship(
    sa_relationship_kwargs={"lazy": "selectin"}
)

user class-attribute instance-attribute

user: RegisteredUser = Relationship()

RolePermission

Bases: SQLModel

Junction table linking roles to permissions (many-to-many relationship).

This is an internal model used by SQLAlchemy to represent the relationship between Role and Permission. You typically don't interact with this class directly; instead, use Role.permissions to access a role's permissions.

Attributes:

Name Type Description
permission_name str

Foreign key to the permission.

role_name str

Foreign key to the role.

See Also

Role: The role that has the permission. Permission: The permission granted to the role.

__tablename__ class-attribute instance-attribute

__tablename__ = 'role_permissions'

__table_args__ class-attribute instance-attribute

__table_args__ = (
    PrimaryKeyConstraint("permission_name", "role_name"),
)

permission_name class-attribute instance-attribute

permission_name: str = Field(
    sa_column=Column(
        String(128), ForeignKey("permissions.name")
    )
)

role_name class-attribute instance-attribute

role_name: str = Field(
    sa_column=Column(String(64), ForeignKey("roles.name"))
)

SystemUser

Bases: BaseModel

Represents a system itself.

System users should be used when an applications does something autonomously (e.g., seeding, running background processes), which are not on behalf of other users.

This class implements the User protocol.

Methods:

Name Description
get

Get n SystemUser instance.

name

Get a formatted name for the anonymous user.

Attributes:

Name Type Description
id_ Literal['__system__']
display_name_ str
id str
display_name str
firstname str | None
lastname str | None
email str | None

id_ class-attribute instance-attribute

id_: Literal["__system__"] = Field(
    default="__system__",
    validation_alias="id",
    serialization_alias="id",
)

display_name_ class-attribute instance-attribute

display_name_: str = Field(
    default="System",
    validation_alias="display_name",
    serialization_alias="display_name",
)

id property

id: str

display_name property

display_name: str

firstname property

firstname: str | None

lastname property

lastname: str | None

email property

email: str | None

get staticmethod

get() -> SystemUser

Get n SystemUser instance.

Returns:

Type Description
SystemUser

A SystemUser with default values.

Source code in src/commons/users/user.py
@staticmethod
def get() -> "SystemUser":
    """
    Get n SystemUser instance.

    Returns:
        A SystemUser with default values.
    """
    return SystemUser()

name

name(type: UserNameType = 'casual') -> str

Get a formatted name for the anonymous user.

Parameters:

Name Type Description Default
type UserNameType

The name format (ignored for anonymous users).

'casual'

Returns:

Type Description
str

Always returns display_name since anonymous users have no personal info.

Source code in src/commons/users/user.py
def name(self, type: UserNameType = "casual") -> str:
    """
    Get a formatted name for the anonymous user.

    Args:
        type: The name format (ignored for anonymous users).

    Returns:
        Always returns ``display_name`` since anonymous users have no personal info.
    """
    return self.display_name