Repository: indico/indico
Pull request #6779: Add UserLogEntry
Author: login: tomako
Labels: name: alembic
description: Contains database changes
Created: 2025-02-26T23:10:28Z | Merged: 2025-03-21T16:36:23Z

This PR is about adding `UserLogEntry` for logging events related to user records.

Highlights:
* The Logs menu is placed in User Profile side-menu and visible only for admins
* In this PR only the user creation is logged
* The user log records are deleted during user anonymization


Diff:
diff --git a/CHANGES.rst b/CHANGES.rst
index 9dfecd812af..23acc332c57 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -76,6 +76,8 @@ Improvements
   :user:`amCap1712`)
 - Allow to configure a restrictive set of allowed contribution keywords (:pr:`6778`, thanks
   :user:`tomako, unconventionaldotdev`)
+- Add a log for user actions, similar to that in events and categories (:pr:`6779`, thanks
+  :user:`tomako`)
 
 Bugfixes
 ^^^^^^^^
diff --git a/indico/migrations/versions/20250320_1800_4615aff776e0_add_user_logs.py b/indico/migrations/versions/20250320_1800_4615aff776e0_add_user_logs.py
new file mode 100644
index 00000000000..3ad7c4bb13f
--- /dev/null
+++ b/indico/migrations/versions/20250320_1800_4615aff776e0_add_user_logs.py
@@ -0,0 +1,58 @@
+"""Add user logs
+
+Revision ID: 4615aff776e0
+Revises: 3fbd5d4ffb11
+Create Date: 2025-02-24 18:32:20.784460
+"""
+
+from enum import Enum
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+from indico.core.db.sqlalchemy import PyIntEnum, UTCDateTime
+
+
+# revision identifiers, used by Alembic.
+revision = '4615aff776e0'
+down_revision = '3fbd5d4ffb11'
+branch_labels = None
+depends_on = None
+
+
+class _UserLogRealm(int, Enum):
+    user = 1
+    management = 2
+
+
+class _LogKind(int, Enum):
+    other = 1
+    positive = 2
+    change = 3
+    negative = 4
+
+
+def upgrade():
+    op.create_table('logs',
+        sa.Column('id', sa.Integer(), nullable=False),
+        sa.Column('logged_dt', UTCDateTime, nullable=False),
+        sa.Column('kind', PyIntEnum(_LogKind), nullable=False),
+        sa.Column('module', sa.String(), nullable=False),
+        sa.Column('type', sa.String(), nullable=False),
+        sa.Column('summary', sa.String(), nullable=False),
+        sa.Column('data', postgresql.JSON(astext_type=sa.Text()), nullable=False),
+        sa.Column('meta', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
+        sa.Column('target_user_id', sa.Integer(), nullable=False, index=True),
+        sa.Column('realm', PyIntEnum(_UserLogRealm), nullable=False),
+        sa.Column('user_id', sa.Integer(), nullable=True, index=True),
+        sa.ForeignKeyConstraint(['target_user_id'], ['users.users.id']),
+        sa.ForeignKeyConstraint(['user_id'], ['users.users.id']),
+        sa.PrimaryKeyConstraint('id'),
+        schema='users'
+    )
+    op.create_index(None, 'logs', ['meta'], unique=False, schema='users', postgresql_using='gin')
+
+
+def downgrade():
+    op.drop_table('logs', schema='users')
diff --git a/indico/modules/categories/models/categories.py b/indico/modules/categories/models/categories.py
index 35487d37554..19f664f8179 100644
--- a/indico/modules/categories/models/categories.py
+++ b/indico/modules/categories/models/categories.py
@@ -285,7 +285,7 @@ def __table_args__(cls):
     # - events (Event.category)
     # - favorite_of (User.favorite_categories)
     # - legacy_mapping (LegacyCategoryMapping.category)
-    # - log_entries (CategoryLogEntry.event)
+    # - log_entries (CategoryLogEntry.category)
     # - parent (Category.children)
     # - receipt_templates (ReceiptTemplate.category)
     # - roles (CategoryRole.category)
diff --git a/indico/modules/logs/__init__.py b/indico/modules/logs/__init__.py
index 58389b73da2..2f7e07048e8 100644
--- a/indico/modules/logs/__init__.py
+++ b/indico/modules/logs/__init__.py
@@ -26,6 +26,13 @@ def _extend_event_management_menu(sender, event, **kwargs):
     return SideMenuItem('logs', _('Logs'), url_for('logs.event', event), section='reports')
 
 
+@signals.menu.items.connect_via('user-profile-sidemenu')
+def _extend_profile_sidemenu(sender, user, **kwargs):
+    if not session.user.is_admin:
+        return
+    return SideMenuItem('logs', _('Logs'), url_for('logs.user'), -100)
+
+
 @signals.users.merged.connect
 def _merge_users(target, source, **kwargs):
     EventLogEntry.query.filter_by(user_id=source.id).update({EventLogEntry.user_id: target.id})
diff --git a/indico/modules/logs/blueprint.py b/indico/modules/logs/blueprint.py
index df0766ecbe9..5969f3911c6 100644
--- a/indico/modules/logs/blueprint.py
+++ b/indico/modules/logs/blueprint.py
@@ -5,7 +5,10 @@
 # modify it under the terms of the MIT License; see the
 # LICENSE file for more details.
 
-from indico.modules.logs.controllers import RHCategoryLogs, RHCategoryLogsJSON, RHEventLogs, RHEventLogsJSON
+from flask import request
+
+from indico.modules.logs.controllers import (RHCategoryLogs, RHCategoryLogsJSON, RHEventLogs, RHEventLogsJSON,
+                                             RHUserLogs, RHUserLogsJSON)
 from indico.web.flask.wrappers import IndicoBlueprint
 
 
@@ -18,3 +21,14 @@
 # Categories
 _bp.add_url_rule('/category/<int:category_id>/manage/logs/', 'category', RHCategoryLogs)
 _bp.add_url_rule('/category/<int:category_id>/manage/logs/api/logs', 'api_category_logs', RHCategoryLogsJSON)
+
+# Users
+with _bp.add_prefixed_rules('/user/<int:user_id>', '/user'):
+    _bp.add_url_rule('/logs/', 'user', RHUserLogs)
+    _bp.add_url_rule('/logs/api/logs', 'api_user_logs', RHUserLogsJSON)
+
+
+@_bp.url_defaults
+def _add_user_id(endpoint, values):
+    if endpoint in {'logs.user', 'logs.api_user_logs'} and 'user_id' not in values:
+        values['user_id'] = request.view_args.get('user_id')
diff --git a/indico/modules/logs/client/style/logs.scss b/indico/modules/logs/client/style/logs.scss
index 834f23bff74..9e9bdf7c8dd 100644
--- a/indico/modules/logs/client/style/logs.scss
+++ b/indico/modules/logs/client/style/logs.scss
@@ -74,6 +74,10 @@
     @include icon-before('icon-users');
   }
 
+  li.log-realm-user span.log-module span.log-icon i.log-realm {
+    @include icon-before('icon-user');
+  }
+
   li.log-realm-management span.log-module span.log-icon i.log-realm {
     @include icon-before('icon-medal');
   }
diff --git a/indico/modules/logs/controllers.py b/indico/modules/logs/controllers.py
index 4a0a5c45df4..8dc2a5ca3bc 100644
--- a/indico/modules/logs/controllers.py
+++ b/indico/modules/logs/controllers.py
@@ -5,15 +5,20 @@
 # modify it under the terms of the MIT License; see the
 # LICENSE file for more details.
 
-from flask import jsonify, request
+from babel.dates import get_timezone
+from flask import jsonify, request, session
+from werkzeug.exceptions import Forbidden
 
+from indico.core.config import config
 from indico.core.db import db
 from indico.core.db.sqlalchemy.util.queries import preprocess_ts_string
 from indico.modules.categories.controllers.base import RHManageCategoryBase
 from indico.modules.events.management.controllers import RHManageEventBase
-from indico.modules.logs.models.entries import CategoryLogEntry, CategoryLogRealm, EventLogEntry, EventLogRealm
+from indico.modules.logs.models.entries import (CategoryLogEntry, CategoryLogRealm, EventLogEntry, EventLogRealm,
+                                                UserLogEntry, UserLogRealm)
 from indico.modules.logs.util import serialize_log_entry
-from indico.modules.logs.views import WPCategoryLogs, WPEventLogs
+from indico.modules.logs.views import WPCategoryLogs, WPEventLogs, WPUserLogs
+from indico.modules.users.controllers import RHUserBase
 from indico.web.flask.util import url_for
 
 
@@ -52,6 +57,22 @@ def _process(self):
                                               logs_api_url=url_for('.api_category_logs', self.category))
 
 
+class RHUserLogs(RHUserBase):
+    """Show the modification/action log for the user."""
+
+    def _check_access(self):
+        RHUserBase._check_access(self)
+        if not session.user.is_admin:
+            raise Forbidden
+
+    def _process(self):
+        metadata_query = _get_metadata_query()
+        realms = {realm.name: realm.title for realm in UserLogRealm}
+        return WPUserLogs.render_template('logs.html', 'logs', user=self.user,
+                                          realms=realms, metadata_query=metadata_query,
+                                          logs_api_url=url_for('.api_user_logs', self.user))
+
+
 class LogsAPIMixin:
     model = None
     realm_enum = None
@@ -60,6 +81,10 @@ class LogsAPIMixin:
     def object(self):
         raise NotImplementedError
 
+    @property
+    def object_tzinfo(self):
+        raise NotImplementedError
+
     def _process(self):
         page = int(request.args.get('page', 1))
         filters = request.args.getlist('filters')
@@ -91,7 +116,8 @@ def _process(self):
             query = query.filter(self.model.meta.contains(metadata_query))
 
         query = query.paginate(page=page, per_page=LOG_PAGE_SIZE)
-        entries = [dict(serialize_log_entry(entry), index=index, html=entry.render())
+        tzinfo = self.object_tzinfo
+        entries = [dict(serialize_log_entry(entry, tzinfo), index=index, html=entry.render())
                    for index, entry in enumerate(query.items)]
         return jsonify(current_page=page, pages=list(query.iter_pages()), total_page_count=query.pages, entries=entries)
 
@@ -104,6 +130,10 @@ class RHEventLogsJSON(LogsAPIMixin, RHManageEventBase):
     def object(self):
         return self.event
 
+    @property
+    def object_tzinfo(self):
+        return self.event.tzinfo
+
 
 class RHCategoryLogsJSON(LogsAPIMixin, RHManageCategoryBase):
     model = CategoryLogEntry
@@ -112,3 +142,20 @@ class RHCategoryLogsJSON(LogsAPIMixin, RHManageCategoryBase):
     @property
     def object(self):
         return self.category
+
+    @property
+    def object_tzinfo(self):
+        return self.category.tzinfo
+
+
+class RHUserLogsJSON(LogsAPIMixin, RHUserBase):
+    model = UserLogEntry
+    realm_enum = UserLogRealm
+
+    @property
+    def object(self):
+        return self.user
+
+    @property
+    def object_tzinfo(self):
+        return get_timezone(config.DEFAULT_TIMEZONE)
diff --git a/indico/modules/logs/models/entries.py b/indico/modules/logs/models/entries.py
index 2c4a7b3bcc4..a1c445156b9 100644
--- a/indico/modules/logs/models/entries.py
+++ b/indico/modules/logs/models/entries.py
@@ -33,6 +33,12 @@ class CategoryLogRealm(RichIntEnum):
     events = 2
 
 
+class UserLogRealm(RichIntEnum):
+    __titles__ = (None, _('User'), _('Management'))
+    user = 1
+    management = 2
+
+
 class LogKind(IndicoIntEnum):
     other = 1
     positive = 2
@@ -122,7 +128,8 @@ def user(cls):
             backref=db.backref(
                 cls.user_backref_name,
                 lazy='dynamic'
-            )
+            ),
+            foreign_keys=[cls.user_id]
         )
 
     @property
@@ -192,14 +199,14 @@ class CategoryLogEntry(LogEntryBase):
         index=True,
         nullable=False
     )
-    #: The general area of the event the entry comes from
+    #: The general area of the category the entry comes from
     realm = db.Column(
         PyIntEnum(CategoryLogRealm),
         nullable=False
     )
 
     #: The Category this log entry is associated with
-    event = db.relationship(
+    category = db.relationship(
         'Category',
         lazy=True,
         backref=db.backref(
@@ -207,3 +214,35 @@ class CategoryLogEntry(LogEntryBase):
             lazy='dynamic'
         )
     )
+
+
+class UserLogEntry(LogEntryBase):
+    """Log entries for users."""
+
+    __auto_table_args = {'schema': 'users'}
+    user_backref_name = 'user_log_entries'
+    link_fk_name = 'user_id'
+
+    #: The ID of the user
+    target_user_id = db.Column(
+        db.Integer,
+        db.ForeignKey('users.users.id'),
+        index=True,
+        nullable=False
+    )
+    #: The general area of the user the entry comes from
+    realm = db.Column(
+        PyIntEnum(UserLogRealm),
+        nullable=False
+    )
+
+    #: The User this log entry is associated with
+    target_user = db.relationship(
+        'User',
+        lazy=True,
+        backref=db.backref(
+            'log_entries',
+            lazy='dynamic'
+        ),
+        foreign_keys=[target_user_id]
+    )
diff --git a/indico/modules/logs/templates/logs.html b/indico/modules/logs/templates/logs.html
index bfb8e405177..afa16831582 100644
--- a/indico/modules/logs/templates/logs.html
+++ b/indico/modules/logs/templates/logs.html
@@ -1,4 +1,10 @@
-{% extends 'categories/management/base.html' if category|default(none) else 'events/management/base.html' %}
+{% if category|default(none) %}
+    {% extends 'categories/management/base.html' %}
+{% elif event|default(none) %}
+    {% extends 'events/management/base.html' %}
+{% else %}
+    {% extends 'users/base.html' %}
+{% endif %}
 
 {% block title %}
     {%- trans %}Logs{% endtrans -%}
diff --git a/indico/modules/logs/util.py b/indico/modules/logs/util.py
index 91374290f9e..be54cae2177 100644
--- a/indico/modules/logs/util.py
+++ b/indico/modules/logs/util.py
@@ -167,7 +167,7 @@ def _diff_list(a, b):
     return Markup(', ').join(output)
 
 
-def serialize_log_entry(entry):
+def serialize_log_entry(entry, tzinfo):
     return {
         'id': entry.id,
         'type': entry.type,
@@ -176,7 +176,7 @@ def serialize_log_entry(entry):
         'module': entry.module,
         'description': entry.summary,
         'meta': entry.meta,
-        'time': entry.logged_dt.astimezone(entry.event.tzinfo).isoformat(),
+        'time': entry.logged_dt.astimezone(tzinfo).isoformat(),
         'payload': entry.data,
         'user': {
             'fullName': entry.user.full_name if entry.user else None,
diff --git a/indico/modules/logs/views.py b/indico/modules/logs/views.py
index 43020429bb6..055c70c7f24 100644
--- a/indico/modules/logs/views.py
+++ b/indico/modules/logs/views.py
@@ -7,6 +7,7 @@
 
 from indico.modules.categories.views import WPCategoryManagement
 from indico.modules.events.management.views import WPEventManagement
+from indico.modules.users.views import WPUser
 
 
 class WPEventLogs(WPEventManagement):
@@ -18,3 +19,8 @@ class WPEventLogs(WPEventManagement):
 class WPCategoryLogs(WPCategoryManagement):
     bundles = ('module_logs.js', 'module_logs.css')
     template_prefix = 'logs/'
+
+
+class WPUserLogs(WPUser):
+    bundles = ('module_logs.js', 'module_logs.css')
+    template_prefix = 'logs/'
diff --git a/indico/modules/users/models/users.py b/indico/modules/users/models/users.py
index 7e256df5b91..edc6d644bd8 100644
--- a/indico/modules/users/models/users.py
+++ b/indico/modules/users/models/users.py
@@ -28,6 +28,7 @@
 from indico.core.db.sqlalchemy.custom.unaccent import define_unaccented_lowercase_index
 from indico.core.db.sqlalchemy.principals import PrincipalType
 from indico.core.db.sqlalchemy.util.models import get_default_values
+from indico.modules.logs.models.entries import UserLogEntry
 from indico.modules.users.models.affiliations import Affiliation
 from indico.modules.users.models.emails import UserEmail
 from indico.modules.users.models.favorites import favorite_category_table, favorite_event_table, favorite_user_table
@@ -476,6 +477,7 @@ class User(PersonMixin, db.Model):
     # - judged_papers (PaperRevision.judge)
     # - layout_reviewer_for_contributions (Contribution.paper_layout_reviewers)
     # - local_groups (LocalGroup.members)
+    # - log_entries (UserLogEntry.target_user)
     # - merged_from_users (User.merged_into_user)
     # - moderated_event_move_requests (EventMoveRequest.moderator)
     # - modified_abstract_comments (AbstractComment.modified_by)
@@ -495,6 +497,7 @@ class User(PersonMixin, db.Model):
     # - review_comments (PaperReviewComment.user)
     # - static_sites (StaticSite.creator)
     # - survey_submissions (SurveySubmission.user)
+    # - user_log_entries (UserLogEntry.user)
     # - vc_rooms (VCRoom.created_by_user)
 
     @staticmethod
@@ -609,6 +612,34 @@ def __contains__(self, user):
     def __repr__(self):
         return format_repr(self, 'id', 'email', is_deleted=False, is_pending=False, _text=self.full_name)
 
+    def log(self, realm, kind, module, summary, user=None, type_='simple', data=None, meta=None):
+        """Create a new log entry for the user.
+
+        :param realm: A value from :class:`.UserLogRealm` indicating
+                      the realm of the action.
+        :param kind: A value from :class:`.LogKind` indicating
+                     the kind of the action that was performed.
+        :param module: A human-friendly string describing the module
+                       related to the action.
+        :param summary: A one-line summary describing the logged action.
+        :param user: The user who performed the action.
+        :param type_: The type of the log entry. This is used for custom
+                      rendering of the log message/data
+        :param data: JSON-serializable data specific to the log type.
+        :param meta: JSON-serializable data that won't be displayed.
+        :return: The newly created `UserLogEntry`
+
+        In most cases the ``simple`` log type is fine. For this type,
+        any items from data will be shown in the detailed view of the
+        log entry.  You may either use a dict (which will be sorted)
+        alphabetically or a list of ``key, value`` pairs which will
+        be displayed in the given order.
+        """
+        entry = UserLogEntry(user=user, realm=realm, kind=kind, module=module, type=type_, summary=summary,
+                             data=(data or {}), meta=(meta or {}))
+        self.log_entries.append(entry)
+        return entry
+
     def can_be_modified(self, user):
         """If this user can be modified by the given user."""
         return self == user or user.is_admin
diff --git a/indico/modules/users/operations.py b/indico/modules/users/operations.py
index 9b53687d528..d4b4d1ebfb5 100644
--- a/indico/modules/users/operations.py
+++ b/indico/modules/users/operations.py
@@ -5,9 +5,12 @@
 # modify it under the terms of the MIT License; see the
 # LICENSE file for more details.
 
+from flask import session
+
 from indico.core import signals
 from indico.core.config import config
 from indico.core.db import db
+from indico.modules.logs.models.entries import LogKind, UserLogRealm
 from indico.modules.users import User
 
 
@@ -58,5 +61,10 @@ def create_user(email, data, identity=None, settings=None, other_emails=None, fr
     user.settings.set_multi(settings)
     db.session.flush()
     signals.users.registered.send(user, from_moderation=from_moderation, identity=identity)
+    data = {'Moderated': from_moderation}
+    if identity:
+        data |= {'Identity provider': identity.provider,
+                 'Identifier': identity.identifier}
+    user.log(UserLogRealm.user, LogKind.positive, 'User', 'User created', session.user if session else None, data=data)
     db.session.flush()
     return user
diff --git a/indico/modules/users/util.py b/indico/modules/users/util.py
index c9808a015e0..09c59f1097a 100644
--- a/indico/modules/users/util.py
+++ b/indico/modules/users/util.py
@@ -464,6 +464,7 @@ def anonymize_user(user):
 
     user.event_roles.clear()
     user.category_roles.clear()
+    user.log_entries.delete()
     user.suggested_categories.order_by(None).delete()
 
     # Unlink registrations + persons (those hold personal data and are linked by email which no longer matches)


Reviews:
user: login: ThiefMaster
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-26T23:37:35Z
user: login: tomako
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T00:19:30Z
user: login: tomako
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T00:19:45Z
user: login: ThiefMaster
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T17:29:19Z

Review comments:
diff_hunk: @@ -0,0 +1,66 @@
+"""Add user logs
+
+Revision ID: 4615aff776e0
+Revises: 9251bc3e2106
+Create Date: 2025-02-24 18:32:20.784460
+"""
+
+from enum import Enum
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+from indico.core.db.sqlalchemy import PyIntEnum, UTCDateTime
+
+
+# revision identifiers, used by Alembic.
+revision = '4615aff776e0'
+down_revision = '9251bc3e2106'
+branch_labels = None
+depends_on = None
+
+
+class _UserLogRealm(int, Enum):
+    user = 1
+
+
+class _LogKind(int, Enum):
+    other = 1
+    positive = 2
+    change = 3
+    negative = 4
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('logs',
+        sa.Column('id', sa.Integer(), nullable=False),
+        sa.Column('logged_dt', UTCDateTime, nullable=False),
+        sa.Column('kind', PyIntEnum(_LogKind), nullable=False),
+        sa.Column('module', sa.String(), nullable=False),
+        sa.Column('type', sa.String(), nullable=False),
+        sa.Column('summary', sa.String(), nullable=False),
+        sa.Column('data', postgresql.JSON(astext_type=sa.Text()), nullable=False),
+        sa.Column('meta', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
+        sa.Column('target_user_id', sa.Integer(), nullable=False),
+        sa.Column('realm', PyIntEnum(_UserLogRealm), nullable=False),
+        sa.Column('user_id', sa.Integer(), nullable=True),
+        sa.ForeignKeyConstraint(['target_user_id'], ['users.users.id'], name=op.f('fk_logs_target_user_id_users')),
+        sa.ForeignKeyConstraint(['user_id'], ['users.users.id'], name=op.f('fk_logs_user_id_users')),
+        sa.PrimaryKeyConstraint('id', name=op.f('pk_logs')),
+        schema='users'
+    )
+    op.create_index(op.f('ix_logs_meta'), 'logs', ['meta'], unique=False, schema='users', postgresql_using='gin')
+    op.create_index(op.f('ix_logs_target_user_id'), 'logs', ['target_user_id'], unique=False, schema='users')
+    op.create_index(op.f('ix_logs_user_id'), 'logs', ['user_id'], unique=False, schema='users')
path: indico/migrations/versions/20250224_1832_4615aff776e0_add_user_logs.py
user: login: ThiefMaster
body: Check e.g. `20210802_1514_4b097412a8d9_add_category_logs.py` to see how this can be simplified (`index=True` on the columns)
created_at: 2025-02-26T23:27:36Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,66 @@
+"""Add user logs
+
+Revision ID: 4615aff776e0
+Revises: 9251bc3e2106
+Create Date: 2025-02-24 18:32:20.784460
+"""
+
+from enum import Enum
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+from indico.core.db.sqlalchemy import PyIntEnum, UTCDateTime
+
+
+# revision identifiers, used by Alembic.
+revision = '4615aff776e0'
+down_revision = '9251bc3e2106'
+branch_labels = None
+depends_on = None
+
+
+class _UserLogRealm(int, Enum):
+    user = 1
+
+
+class _LogKind(int, Enum):
+    other = 1
+    positive = 2
+    change = 3
+    negative = 4
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('logs',
+        sa.Column('id', sa.Integer(), nullable=False),
+        sa.Column('logged_dt', UTCDateTime, nullable=False),
+        sa.Column('kind', PyIntEnum(_LogKind), nullable=False),
+        sa.Column('module', sa.String(), nullable=False),
+        sa.Column('type', sa.String(), nullable=False),
+        sa.Column('summary', sa.String(), nullable=False),
+        sa.Column('data', postgresql.JSON(astext_type=sa.Text()), nullable=False),
+        sa.Column('meta', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
+        sa.Column('target_user_id', sa.Integer(), nullable=False),
+        sa.Column('realm', PyIntEnum(_UserLogRealm), nullable=False),
+        sa.Column('user_id', sa.Integer(), nullable=True),
+        sa.ForeignKeyConstraint(['target_user_id'], ['users.users.id'], name=op.f('fk_logs_target_user_id_users')),
+        sa.ForeignKeyConstraint(['user_id'], ['users.users.id'], name=op.f('fk_logs_user_id_users')),
+        sa.PrimaryKeyConstraint('id', name=op.f('pk_logs')),
+        schema='users'
+    )
+    op.create_index(op.f('ix_logs_meta'), 'logs', ['meta'], unique=False, schema='users', postgresql_using='gin')
+    op.create_index(op.f('ix_logs_target_user_id'), 'logs', ['target_user_id'], unique=False, schema='users')
+    op.create_index(op.f('ix_logs_user_id'), 'logs', ['user_id'], unique=False, schema='users')
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_index(op.f('ix_logs_user_id'), table_name='logs', schema='users')
+    op.drop_index(op.f('ix_logs_target_user_id'), table_name='logs', schema='users')
+    op.drop_index(op.f('ix_logs_meta'), table_name='logs', schema='users', postgresql_using='gin')
path: indico/migrations/versions/20250224_1832_4615aff776e0_add_user_logs.py
user: login: ThiefMaster
body: not needed, deleting the table deletes its indexes as well
created_at: 2025-02-26T23:27:49Z
author_association: MEMBER
diff_hunk: @@ -192,18 +199,50 @@ class CategoryLogEntry(LogEntryBase):
         index=True,
         nullable=False
     )
-    #: The general area of the event the entry comes from
+    #: The general area of the category the entry comes from
     realm = db.Column(
         PyIntEnum(CategoryLogRealm),
         nullable=False
     )
 
     #: The Category this log entry is associated with
-    event = db.relationship(
+    category = db.relationship(
path: indico/modules/logs/models/entries.py
user: login: ThiefMaster
body: oops, nice catch! :D
created_at: 2025-02-26T23:28:50Z
author_association: MEMBER
diff_hunk: @@ -52,6 +57,21 @@ def _process(self):
                                               logs_api_url=url_for('.api_category_logs', self.category))
 
 
+class RHUserLogs(RHUserBase):
+    """Show the modification/action log for the user."""
+
+    def _check_access(self):
+        if not session.user.is_admin:
+            raise Forbidden
path: indico/modules/logs/controllers.py
user: login: ThiefMaster
body: To be consistent with other admin-only user endpoints:

```suggestion
        RHUserBase._check_access(self)
        if not session.user.is_admin:
            raise Forbidden
```
created_at: 2025-02-26T23:32:08Z
author_association: MEMBER
diff_hunk: @@ -58,5 +61,10 @@ def create_user(email, data, identity=None, settings=None, other_emails=None, fr
     user.settings.set_multi(settings)
     db.session.flush()
     signals.users.registered.send(user, from_moderation=from_moderation, identity=identity)
+    data = {'Moderated': from_moderation}
+    if identity:
+        data |= {'Identity provider': identity.provider,
+                 'Identifier': identity.identifier}
+    user.log(UserLogRealm.user, LogKind.positive, 'User', 'User created', session.user, data=data)
path: indico/modules/users/operations.py
user: login: ThiefMaster
body: Test what happens when you create a user with the `indico user create` CLI where no flask session exists.
created_at: 2025-02-26T23:32:55Z
author_association: MEMBER
diff_hunk: @@ -1,4 +1,4 @@
-{% extends 'categories/management/base.html' if category|default(none) else 'events/management/base.html' %}
+{% extends 'categories/management/base.html' if category|default(none) else ('events/management/base.html' if event|default(none) else 'users/base.html') %}
path: indico/modules/logs/templates/logs.html
user: login: ThiefMaster
body: Ugh this is getting quite ugly. I think it's time to change this to separate extends commands and `if/elif/...`
created_at: 2025-02-26T23:34:26Z
author_association: MEMBER
diff_hunk: @@ -26,6 +26,13 @@ def _extend_event_management_menu(sender, event, **kwargs):
     return SideMenuItem('logs', _('Logs'), url_for('logs.event', event), section='reports')
 
 
+@signals.menu.items.connect_via('user-profile-sidemenu')
+def _extend_profile_sidemenu(sender, user, **kwargs):
+    if not session.user.is_admin:
+        return
+    return SideMenuItem('logs', _('Logs'), url_for('logs.user'))
path: indico/modules/logs/__init__.py
user: login: ThiefMaster
body: Set the weight to something like `-100` so the entry shows up last. (Data export is weight 0, everything else is positive)
created_at: 2025-02-26T23:35:55Z
author_association: MEMBER
diff_hunk: @@ -58,5 +61,10 @@ def create_user(email, data, identity=None, settings=None, other_emails=None, fr
     user.settings.set_multi(settings)
     db.session.flush()
     signals.users.registered.send(user, from_moderation=from_moderation, identity=identity)
+    data = {'Moderated': from_moderation}
+    if identity:
+        data |= {'Identity provider': identity.provider,
+                 'Identifier': identity.identifier}
+    user.log(UserLogRealm.user, LogKind.positive, 'User', 'User created', session.user, data=data)
path: indico/modules/users/operations.py
user: login: tomako
body: Oops, that's true...
created_at: 2025-02-27T00:19:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,4 +1,4 @@
-{% extends 'categories/management/base.html' if category|default(none) else 'events/management/base.html' %}
+{% extends 'categories/management/base.html' if category|default(none) else ('events/management/base.html' if event|default(none) else 'users/base.html') %}
path: indico/modules/logs/templates/logs.html
user: login: tomako
body: It is indeed :)
created_at: 2025-02-27T00:19:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+"""Add user logs
+
+Revision ID: 4615aff776e0
+Revises: b212f6d17229
+Create Date: 2025-02-24 18:32:20.784460
+"""
+
+from enum import Enum
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+from indico.core.db.sqlalchemy import PyIntEnum, UTCDateTime
+
+
+# revision identifiers, used by Alembic.
+revision = '4615aff776e0'
+down_revision = 'b212f6d17229'
+branch_labels = None
+depends_on = None
+
+
+class _UserLogRealm(int, Enum):
+    user = 1
path: indico/migrations/versions/20250224_1832_4615aff776e0_add_user_logs.py
user: login: ThiefMaster
body: This does not match the UserLogRealm enum (`management = 2` is missing) in the model
created_at: 2025-03-20T17:08:40Z
author_association: MEMBER
diff_hunk: @@ -609,6 +612,34 @@ def __contains__(self, user):
     def __repr__(self):
         return format_repr(self, 'id', 'email', is_deleted=False, is_pending=False, _text=self.full_name)
 
+    def log(self, realm, kind, module, summary, user=None, type_='simple', data=None, meta=None):
+        """Create a new log entry for the user.
+
+        :param realm: A value from :class:`.UserLogRealm` indicating
+                      the realm of the action.
+        :param kind: A value from :class:`.LogKind` indicating
+                     the kind of the action that was performed.
+        :param module: A human-friendly string describing the module
+                       related to the action.
+        :param summary: A one-line summary describing the logged action.
+        :param user: The user who performed the action.
+        :param type_: The type of the log entry. This is used for custom
+                      rendering of the log message/data
+        :param data: JSON-serializable data specific to the log type.
+        :param meta: JSON-serializable data that won't be displayed.
+        :return: The newly created `CategoryLogEntry`
path: indico/modules/users/models/users.py
user: login: ThiefMaster
body: ```suggestion
        :return: The newly created `UserLogEntry`
```
created_at: 2025-03-20T17:28:39Z
author_association: MEMBER

PR comments:
user: login: tomako
created_at: 2025-03-14T17:50:47Z
body: @ThiefMaster What do you think about logging ACL changes on both object and user level?
That would mean some changes [here](https://github.com/indico/indico/blob/master/indico/core/db/sqlalchemy/protection.py#L588) and also adding other signal handler(s) for objects whose don't have own logs.
author_association: CONTRIBUTOR
user: login: ThiefMaster
created_at: 2025-03-20T17:05:12Z
body: It sounds useful, but something I'd rather have in a separate PR.
author_association: MEMBER