Repository: FlexMeasures/flexmeasures
Pull request #1042 (2024-04-24 20:33:11)

Audit log
## Description

Added AuditLog data model
Started adding new records to it on user creation / deletion / activating etc.
New "Audit Log" buttons on account and user page that lead to audit log page

## Look & Feel

New endpoints:
 - GET /accounts/auditlog/<account_id>
 - GET /users/auditlog/<user_id>

## How to test

Run `flexmeasures db upgrade`
Create new user / delete existing one / activate or deactivate some user
Check there are records on audit log page for this user

## Further Improvements

Fix bug with user not being able to view himself unless he is an admin.

## Related Items

-

---

- [x] I agree to contribute to the project under Apache 2 License. 
- [x] To the best of my knowledge, the proposed patch is not based on code under GPL or other license that is incompatible with FlexMeasures



Patch:
diff --git a/documentation/changelog.rst b/documentation/changelog.rst
index 2e63d643..765163f5 100644
--- a/documentation/changelog.rst
+++ b/documentation/changelog.rst
@@ -11,6 +11,7 @@ New features
 
 * Add `asset/<id>/status` page to view asset statuses [see `PR #41 <https://github.com/FlexMeasures/flexmeasures/pull/941/>`_ and `PR #1035 <https://github.com/FlexMeasures/flexmeasures/pull/1035/>`_]
 * Support `start_date` and `end_date` query parameters for the asset page [see `PR #1030 <https://github.com/FlexMeasures/flexmeasures/pull/1030/>`_]
+* Add `account/<id>/auditlog` and `user/<id>/auditlog` to view user and account related actions [see `PR #1042 <https://github.com/FlexMeasures/flexmeasures/pull/1042>`_]
 
 Bugfixes
 -----------
diff --git a/flexmeasures/api/v3_0/accounts.py b/flexmeasures/api/v3_0/accounts.py
index f8cd5388..341ecf66 100644
--- a/flexmeasures/api/v3_0/accounts.py
+++ b/flexmeasures/api/v3_0/accounts.py
@@ -5,8 +5,9 @@ from flask_json import as_json
 
 from flexmeasures.auth.policy import user_has_admin_access
 from flexmeasures.auth.decorators import permission_required_for_context
+from flexmeasures.data.models.audit_log import AuditLog
 from flexmeasures.data.models.user import Account
-from flexmeasures.data.services.accounts import get_accounts
+from flexmeasures.data.services.accounts import get_accounts, get_audit_log_records
 from flexmeasures.api.common.schemas.users import AccountIdField
 from flexmeasures.data.schemas.account import AccountSchema
 
@@ -107,3 +108,41 @@ class AccountAPI(FlaskView):
         """
 
         return account_schema.dump(account), 200
+
+    @route("/<id>/auditlog", methods=["GET"])
+    @use_kwargs({"account": AccountIdField(data_key="id")}, location="path")
+    @permission_required_for_context(
+        "read",
+        ctx_arg_name="account",
+        pass_ctx_to_loader=True,
+        ctx_loader=AuditLog.account_table_acl,
+    )
+    @as_json
+    def auditlog(self, id: int, account: Account):
+        """API endpoint to get history of account actions.
+        **Example response**
+
+        .. sourcecode:: json
+            [
+                {
+                    'event': 'User test user deleted',
+                    'event_datetime': '2021-01-01T00:00:00',
+                    'active_user_id': 1,
+                }
+            ]
+
+        :reqheader Authorization: The authentication token
+        :reqheader Content-Type: application/json
+        :resheader Content-Type: application/json
+        :status 200: PROCESSED
+        :status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
+        :status 401: UNAUTHORIZED
+        :status 403: INVALID_SENDER
+        :status 422: UNPROCESSABLE_ENTITY
+        """
+        audit_logs = get_audit_log_records(account)
+        audit_logs = [
+            {k: getattr(log, k) for k in ("event", "event_datetime", "active_user_id")}
+            for log in audit_logs
+        ]
+        return audit_logs, 200
diff --git a/flexmeasures/api/v3_0/users.py b/flexmeasures/api/v3_0/users.py
index 7c753f70..378bf5c3 100644
--- a/flexmeasures/api/v3_0/users.py
+++ b/flexmeasures/api/v3_0/users.py
@@ -7,6 +7,7 @@ from flask_security.recoverable import send_reset_password_instructions
 from flask_json import as_json
 from werkzeug.exceptions import Forbidden
 
+from flexmeasures.data.models.audit_log import AuditLog
 from flexmeasures.data.models.user import User as UserModel, Account
 from flexmeasures.api.common.schemas.users import AccountIdField, UserIdField
 from flexmeasures.data.schemas.users import UserSchema
@@ -14,9 +15,11 @@ from flexmeasures.data.services.users import (
     get_users,
     set_random_password,
     remove_cookie_and_token_access,
+    get_audit_log_records,
 )
 from flexmeasures.auth.decorators import permission_required_for_context
 from flexmeasures.data import db
+from flexmeasures.utils.time_utils import server_now
 
 """
 API endpoints to manage users.
@@ -193,6 +196,22 @@ class UserAPI(FlaskView):
             setattr(user, k, v)
             if k == "active" and v is False:
                 remove_cookie_and_token_access(user)
+            if k == "active":
+                active_user_id, active_user_name = None, None
+                if hasattr(current_user, "id"):
+                    active_user_id, active_user_name = (
+                        current_user.id,
+                        current_user.username,
+                    )
+                user_audit_log = AuditLog(
+                    event_datetime=server_now(),
+                    event=f"Active status set to '{v}' for user {user.username}",
+                    active_user_id=active_user_id,
+                    active_user_name=active_user_name,
+                    affected_user_id=user.id,
+                    affected_account_id=user.account_id,
+                )
+                db.session.add(user_audit_log)
         db.session.add(user)
         try:
             db.session.commit()
@@ -234,3 +253,44 @@ class UserAPI(FlaskView):
 
         # commit only if sending instructions worked, as well
         db.session.commit()
+
+    @route("/<id>/auditlog")
+    @use_kwargs({"user": UserIdField(data_key="id")}, location="path")
+    @permission_required_for_context(
+        "read",
+        ctx_arg_name="user",
+        pass_ctx_to_loader=True,
+        ctx_loader=AuditLog.user_table_acl,
+    )
+    @as_json
+    def auditlog(self, id: int, user: UserModel):
+        """API endpoint to get history of user actions.
+        **Example response**
+
+        .. sourcecode:: json
+            [
+                {
+                    'event': 'User test user deleted',
+                    'event_datetime': '2021-01-01T00:00:00',
+                    'active_user_name': 'Test user',
+                }
+            ]
+
+        :reqheader Authorization: The authentication token
+        :reqheader Content-Type: application/json
+        :resheader Content-Type: application/json
+        :status 200: PROCESSED
+        :status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
+        :status 401: UNAUTHORIZED
+        :status 403: INVALID_SENDER
+        :status 422: UNPROCESSABLE_ENTITY
+        """
+        audit_logs = get_audit_log_records(user)
+        audit_logs = [
+            {
+                k: getattr(log, k)
+                for k in ("event", "event_datetime", "active_user_name")
+            }
+            for log in audit_logs
+        ]
+        return audit_logs, 200
diff --git a/flexmeasures/auth/policy.py b/flexmeasures/auth/policy.py
index 892df1e8..ab782801 100644
--- a/flexmeasures/auth/policy.py
+++ b/flexmeasures/auth/policy.py
@@ -68,8 +68,11 @@ class AuthModelMixin(object):
         # Row-level authorization
 
         This ACL approach to authorization is usually called "row-level authorization" ― it always requires an instance, from which to get the ACL.
-        Unlike pyramid, we have not implemented table-level authorization, where a class also can provide an ACL.
-        This works because we make use of the hierarchy in our model.
+        Unlike pyramid, we don't have a general solution for table-level auth (as we haven't needed a general implementation so far), but there is a nice custom approach to it.
+        A class method on the model can be added which returns an AuthModelMixin. That would have an __acl__() function with your rules, which the auth policy will then go on and use. The permission_required_for_context decorator can make sure this AuthModelMixin object is used by the policy via ctx_loader. It can even pass in the context if that is helpful for your logic.
+        See the AuditLog model class for an example, where we required authorization logic which governs if a subset of a table (e.g. all audit logs that relate to an account) are availabe to the current user."
+
+        Row level access policy works because we make use of the hierarchy in our model.
         The highest level (e.g. an account) is created by site-admins and usually not in the API, but CLI. For everything else, we can ask the ACL
         on an instance, if we can handle it like we intend to. For creation of instances (where there is no instance to ask), it makes sense to use the instance one level up to look up the correct permission ("create-children"). E.g. to create belief data for a sensor, we can check the "create-children" - permission on the sensor.
 
diff --git a/flexmeasures/data/migrations/versions/81cbbf42357b_added_audit_log_table.py b/flexmeasures/data/migrations/versions/81cbbf42357b_added_audit_log_table.py
new file mode 100644
index 00000000..0eff6190
--- /dev/null
+++ b/flexmeasures/data/migrations/versions/81cbbf42357b_added_audit_log_table.py
@@ -0,0 +1,55 @@
+"""Added audit_log table
+
+Revision ID: 81cbbf42357b
+Revises: 6938f16617ab
+Create Date: 2024-04-22 12:40:20.483528
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = "81cbbf42357b"
+down_revision = "6938f16617ab"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table(
+        "audit_log",
+        sa.Column("id", sa.Integer(), nullable=False),
+        sa.Column("event_datetime", sa.DateTime(), nullable=True),
+        sa.Column("event", sa.String(length=255), nullable=True),
+        sa.Column("active_user_id", sa.Integer(), nullable=True),
+        sa.Column("active_user_name", sa.String(length=255), nullable=True),
+        sa.Column("affected_user_id", sa.Integer(), nullable=True),
+        sa.Column("affected_account_id", sa.Integer(), nullable=True),
+        sa.ForeignKeyConstraint(
+            ["active_user_id"],
+            ["fm_user.id"],
+            name=op.f("audit_log_active_user_id_fm_user_fkey"),
+            ondelete="SET NULL",
+        ),
+        sa.ForeignKeyConstraint(
+            ["affected_account_id"],
+            ["account.id"],
+            name=op.f("audit_log_affected_account_id_account_fkey"),
+            ondelete="SET NULL",
+        ),
+        sa.ForeignKeyConstraint(
+            ["affected_user_id"],
+            ["fm_user.id"],
+            name=op.f("audit_log_affected_user_id_fm_user_fkey"),
+            ondelete="SET NULL",
+        ),
+        sa.PrimaryKeyConstraint("id", name=op.f("audit_log_pkey")),
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_table("audit_log")
+    # ### end Alembic commands ###
diff --git a/flexmeasures/data/models/audit_log.py b/flexmeasures/data/models/audit_log.py
new file mode 100644
index 00000000..e0b6addd
--- /dev/null
+++ b/flexmeasures/data/models/audit_log.py
@@ -0,0 +1,88 @@
+from __future__ import annotations
+
+from sqlalchemy import DateTime, Column, Integer, String, ForeignKey
+
+from flexmeasures.data import db
+from flexmeasures.data.models.user import User, Account
+from flexmeasures.auth.policy import AuthModelMixin
+
+
+class AuditLog(db.Model, AuthModelMixin):
+    """
+    Model for storing actions that happen to user and tenant accounts
+    E.g user creation, password reset etc.
+    """
+
+    __tablename__ = "audit_log"
+    id = Column(Integer, primary_key=True)
+    event_datetime = Column(DateTime())
+    event = Column(String(255))
+    active_user_name = Column(String(255))
+    active_user_id = Column(
+        "active_user_id", Integer(), ForeignKey("fm_user.id", ondelete="SET NULL")
+    )
+    affected_user_id = Column(
+        "affected_user_id", Integer(), ForeignKey("fm_user.id", ondelete="SET NULL")
+    )
+    affected_account_id = Column(
+        "affected_account_id", Integer(), ForeignKey("account.id", ondelete="SET NULL")
+    )
+
+    @classmethod
+    def user_table_acl(cls, user: User):
+        """
+        Table-level access rules for user-affecting audit logs. Use directly in check_access or in @permission_required_for_context with pass_ctx_to_loader, ctx_loader=AuditLog.user_acl.
+        Permissions:
+            User can see his own audit logs.
+            Account-admin users can see audit logs for all users of their account.
+            Admins / admin-readers can see audit logs for all users.
+            Consultant users can see the audit log of all users in the client accounts.
+        """
+
+        class AuditLogAccess(AuthModelMixin):
+            def __init__(self, user: User):
+                if user:
+                    self.user_id = user.id
+                    self.account_id = user.account_id
+                    self.consultancy_account_id = user.account.consultancy_account_id
+
+            def __acl__(self):
+                if not self.user_id:
+                    return {}
+                return {
+                    "read": [
+                        f"user:{self.user_id}",
+                        (f"account:{self.account_id}", "role:account-admin"),
+                        (f"account:{self.consultancy_account_id}", "role:consultant"),
+                    ],
+                }
+
+        return AuditLogAccess(user)
+
+    @classmethod
+    def account_table_acl(cls, account: Account):
+        """
+        Table-level access rules for account-affecting audit logs. Use directly in check_access or in @permission_required_for_context with pass_ctx_to_loader, ctx_loader=AuditLog.user_acl.
+        Permissions:
+            Account-admin users can see audit logs for their account.
+            Admins / admin-readers can see audit logs for all accounts.
+            Consultant users can see the audit log of all client accounts.
+        """
+
+        class AuditLogAccess(AuthModelMixin):
+            def __init__(self, account: Account):
+                if account:
+                    self.account_id = account.id
+                    self.consultancy_account_id = account.consultancy_account_id
+
+            def __acl__(self):
+                if not self.account_id:
+                    return {}
+                return {
+                    "read": [
+                        (f"account:{self.account_id}", "role:account-admin"),
+                        (f"account:{self.consultancy_account_id}", "role:consultant"),
+                    ],
+                }
+
+        return AuditLogAccess(account)
diff --git a/flexmeasures/data/services/accounts.py b/flexmeasures/data/services/accounts.py
index 2c5cee86..75a5c489 100644
--- a/flexmeasures/data/services/accounts.py
+++ b/flexmeasures/data/services/accounts.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 from sqlalchemy import select, func
 
 from flexmeasures.data import db
+from flexmeasures.data.models.audit_log import AuditLog
 from flexmeasures.data.models.user import Account, AccountRole
 from flexmeasures.data.models.generic_assets import GenericAsset
 
@@ -42,3 +43,13 @@ def get_account_roles(account_id: int) -> list[AccountRole]:
     if account is None:
         return []
     return account.account_roles
+
+
+def get_audit_log_records(account: Account) -> list[AuditLog]:
+    """
+    Get history of account actions
+    """
+    audit_log_records = (
+        db.session.query(AuditLog).filter_by(affected_account_id=account.id).all()
+    )
+    return audit_log_records
diff --git a/flexmeasures/data/services/users.py b/flexmeasures/data/services/users.py
index c718387a..d483eba1 100644
--- a/flexmeasures/data/services/users.py
+++ b/flexmeasures/data/services/users.py
@@ -18,7 +18,9 @@ from sqlalchemy import select, delete
 
 from flexmeasures.data import db
 from flexmeasures.data.models.data_sources import DataSource
+from flexmeasures.data.models.audit_log import AuditLog
 from flexmeasures.data.models.user import User, Role, Account
+from flexmeasures.utils.time_utils import server_now
 
 
 class InvalidFlexMeasuresUser(Exception):
@@ -150,10 +152,22 @@ def create_user(  # noqa: C901
     account = db.session.execute(
         select(Account).filter_by(name=account_name)
     ).scalar_one_or_none()
+    active_user_id, active_user_name = None, None
+    if hasattr(current_user, "id"):
+        active_user_id, active_user_name = current_user.id, current_user.username
     if account is None:
         print(f"Creating account {account_name} ...")
         account = Account(name=account_name)
         db.session.add(account)
+        db.session.flush()
+        account_audit_log = AuditLog(
+            event_datetime=server_now(),
+            event=f"Account {account_name} created",
+            active_user_id=active_user_id,
+            active_user_name=active_user_name,
+            affected_account_id=account.id,
+        )
+        db.session.add(account_audit_log)
 
     user_datastore = SQLAlchemySessionUserDatastore(db.session, User, Role)
     kwargs.update(password=hash_password(password), email=email, username=username)
@@ -179,6 +193,17 @@ def create_user(  # noqa: C901
 
     # create data source
     db.session.add(DataSource(user=user))
+    db.session.flush()
+
+    user_audit_log = AuditLog(
+        event_datetime=server_now(),
+        event=f"User {user.username} created",
+        active_user_id=active_user_id,
+        active_user_name=active_user_name,
+        affected_user_id=user.id,
+        affected_account_id=account.id,
+    )
+    db.session.add(user_audit_log)
 
     return user
 
@@ -194,6 +219,18 @@ def set_random_password(user: User):
     )
     update_password(user, new_random_password)
 
+    active_user_id, active_user_name = None, None
+    if hasattr(current_user, "id"):
+        active_user_id, active_user_name = current_user.id, current_user.username
+    user_audit_log = AuditLog(
+        event_datetime=server_now(),
+        event=f"Password reset for user {user.username}",
+        active_user_id=active_user_id,
+        active_user_name=active_user_name,
+        affected_user_id=user.id,
+    )
+    db.session.add(user_audit_log)
+
 
 def remove_cookie_and_token_access(user: User):
     """
@@ -217,7 +254,31 @@ def delete_user(user: User):
     """
     if hasattr(current_user, "id") and user.id == current_user.id:
         raise Exception("You cannot delete yourself.")
+
     user_datastore = SQLAlchemySessionUserDatastore(db.session, User, Role)
     user_datastore.delete_user(user)
     db.session.execute(delete(User).filter_by(id=user.id))
     current_app.logger.info("Deleted %s." % user)
+
+    active_user_id, active_user_name = None, None
+    if hasattr(current_user, "id"):
+        active_user_id, active_user_name = current_user.id, current_user.username
+    user_audit_log = AuditLog(
+        event_datetime=server_now(),
+        event=f"User {user.username} deleted",
+        active_user_id=active_user_id,
+        active_user_name=active_user_name,
+        affected_user_id=None,  # add the audit log record even if the user is gone
+        affected_account_id=user.account_id,
+    )
+    db.session.add(user_audit_log)
+
+
+def get_audit_log_records(user: User):
+    """
+    Get history of user actions
+    """
+    audit_log_records = (
+        db.session.query(AuditLog).filter_by(affected_user_id=user.id).all()
+    )
+    return audit_log_records
diff --git a/flexmeasures/ui/crud/accounts.py b/flexmeasures/ui/crud/accounts.py
index 461ca54b..98259ac9 100644
--- a/flexmeasures/ui/crud/accounts.py
+++ b/flexmeasures/ui/crud/accounts.py
@@ -65,3 +65,17 @@ class AccountCrudUI(FlaskView):
             users=users,
             include_inactive=include_inactive,
         )
+
+    @login_required
+    def auditlog(self, account_id: str):
+        """/accounts/auditlog/<account_id>"""
+        account = get_account(account_id)
+        audit_log_response = InternalApi().get(
+            url_for("AccountAPI:auditlog", id=account_id)
+        )
+        audit_logs_response = audit_log_response.json()
+        return render_flexmeasures_template(
+            "crud/account_audit_log.html",
+            audit_logs=audit_logs_response,
+            account=account,
+        )
diff --git a/flexmeasures/ui/crud/users.py b/flexmeasures/ui/crud/users.py
index 643bde9a..acc3c720 100644
--- a/flexmeasures/ui/crud/users.py
+++ b/flexmeasures/ui/crud/users.py
@@ -161,3 +161,17 @@ class UserCrudUI(FlaskView):
             " and password reset instructions have been sent to the user."
             " Cookies and the API access token have also been invalidated.",
         )
+
+    @login_required
+    def auditlog(self, id: str):
+        """/users/auditlog/<id>
+        View all user actions.
+        """
+        user: User = get_user(id)
+        audit_log_response = InternalApi().get(url_for("UserAPI:auditlog", id=id))
+        audit_logs_response = audit_log_response.json()
+        return render_flexmeasures_template(
+            "crud/user_audit_log.html",
+            user=user,
+            audit_logs=audit_logs_response,
+        )
diff --git a/flexmeasures/ui/templates/admin/logged_in_user.html b/flexmeasures/ui/templates/admin/logged_in_user.html
index 024b181d..3c519ba8 100644
--- a/flexmeasures/ui/templates/admin/logged_in_user.html
+++ b/flexmeasures/ui/templates/admin/logged_in_user.html
@@ -103,6 +103,14 @@
             </div>
         </div>
         <div class="col-sm-2"></div>
+        <form action="/accounts/auditlog/{{ logged_in_user.account.id }}" method="get">
+            <button class="btn btn-sm btn-responsive btn-info delete-button" type="submit"
+             title="View history of account actions.">Account audit log</button>
+        </form>
+        <form action="/users/auditlog/{{ logged_in_user.id }}" method="get">
+            <button class="btn btn-sm btn-responsive btn-info delete-button" type="submit"
+             title="View history of user actions.">User audit log</button>
+        </form>
     </div>
 </div>
 
diff --git a/flexmeasures/ui/templates/crud/account.html b/flexmeasures/ui/templates/crud/account.html
index 7bbb56a4..676fb4bb 100644
--- a/flexmeasures/ui/templates/crud/account.html
+++ b/flexmeasures/ui/templates/crud/account.html
@@ -3,6 +3,12 @@
 {% block title %} Account overview {% endblock %} 
 {% block divs %}
 
+<div>
+    <form action="/accounts/auditlog/{{ account.id }}" method="get">
+            <button class="btn btn-sm btn-responsive btn-info delete-button" type="submit"
+             title="View history of user actions.">Audit log</button>
+    </form>
+</div>
 <div class="container-fluid">
     <div class="row">
         <div class="col-sm-2"></div>
diff --git a/flexmeasures/ui/templates/crud/account_audit_log.html b/flexmeasures/ui/templates/crud/account_audit_log.html
new file mode 100644
index 00000000..a4ae73c7
--- /dev/null
+++ b/flexmeasures/ui/templates/crud/account_audit_log.html
@@ -0,0 +1,47 @@
+{% extends "base.html" %}
+
+{% set active_page = "accounts" %}
+
+{% block title %} Account <a href="/accounts/{{ account.id }}">{{ account.name }}</a> actions history {% endblock %}
+
+{% block divs %}
+
+<div class="container-fluid">
+    <div class="row">
+        <div class="sensors-asset card">
+            <h3>History of actions for account <a href="/accounts/{{ account.id }}">{{ account.name }}</a></h3>
+            <table id="account_audit_log" class="table table-striped table-responsive paginate nav-on-click" title="View data">
+                <thead>
+                    <tr>
+                        <th>Event Name</th>
+                        <th>Event Datetime</th>
+                        <th>Active user id</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    {% for audit_log in audit_logs: %}
+                    <tr>
+                        <td>
+                            {{ audit_log.event }}
+                        </td>
+                        <td>
+                            {{ audit_log.event_datetime }}
+                        </td>
+                        <td>
+                            {{ audit_log.active_user_id }}
+                        </td>
+                    </tr>
+                    {% endfor %}
+                </tbody>
+            </table>
+        </div>
+    </div>
+</div>
+
+<script>
+    $(document).ready(function() {
+        $('#account_audit_log').DataTable({"order": [[ 1, "desc" ]]});
+    });
+</script>
+
+{% endblock %}
\ No newline at end of file
diff --git a/flexmeasures/ui/templates/crud/user.html b/flexmeasures/ui/templates/crud/user.html
index c173d856..e18ab935 100644
--- a/flexmeasures/ui/templates/crud/user.html
+++ b/flexmeasures/ui/templates/crud/user.html
@@ -103,6 +103,12 @@
                 </table>
             </div>
         </div>
+        <div>
+            <form action="/users/auditlog/{{ user.id }}" method="get">
+                    <button class="btn btn-sm btn-responsive btn-info delete-button" type="submit"
+                     title="View history of user actions.">Audit log</button>
+            </form>
+        </div>
         <div class="col-sm-2"></div>
     </div>
 </div>
diff --git a/flexmeasures/ui/templates/crud/user_audit_log.html b/flexmeasures/ui/templates/crud/user_audit_log.html
new file mode 100644
index 00000000..1f23fe99
--- /dev/null
+++ b/flexmeasures/ui/templates/crud/user_audit_log.html
@@ -0,0 +1,47 @@
+{% extends "base.html" %}
+
+{% set active_page = "users" %}
+
+{% block title %} User {{ user.username }} {% endblock %}
+
+{% block divs %}
+
+<div class="container-fluid">
+    <div class="row">
+        <div class="sensors-asset card">
+            <h3>History of actions for user <a href="/users/{{ user.id }}">{{ user.username }}</a></h3>
+            <table id="user_audit_log" class="table table-striped table-responsive paginate nav-on-click" title="View data">
+                <thead>
+                    <tr>
+                        <th>Event Name</th>
+                        <th>Event Datetime</th>
+                        <th>Acting user</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    {% for audit_log in audit_logs: %}
+                    <tr>
+                        <td>
+                            {{ audit_log.event }}
+                        </td>
+                        <td title="{{  audit_log.event_datetime }}">
+                            {{ audit_log.event_datetime | naturalized_datetime }}
+                        </td>
+                        <td>
+                            {{ audit_log.active_user_name }}
+                        </td>
+                    </tr>
+                    {% endfor %}
+                </tbody>
+            </table>
+        </div>
+    </div>
+</div>
+
+<script>
+    $(document).ready(function() {
+        $('#user_audit_log').DataTable({"order": [[ 1, "desc" ]]});
+    });
+</script>
+
+{% endblock %}
\ No newline at end of file
diff --git a/flexmeasures/utils/time_utils.py b/flexmeasures/utils/time_utils.py
index ac958812..ad350cf1 100644
--- a/flexmeasures/utils/time_utils.py
+++ b/flexmeasures/utils/time_utils.py
@@ -91,11 +91,16 @@ def localized_datetime_str(dt: datetime, dt_format: str = "%Y-%m-%d %I:%M %p") -
     return local_dt.strftime(dt_format)
 
 
-def naturalized_datetime_str(dt: datetime | None, now: datetime | None = None) -> str:
+def naturalized_datetime_str(
+    dt: datetime | str | None, now: datetime | None = None
+) -> str:
     """
     Naturalise a datetime object (into a human-friendly string).
     The dt parameter (as well as the now parameter if you use it)
     can be either naive or tz-aware. We assume UTC in the naive case.
+    If dt parameter is a string it is expected to look like 'Sun, 28 Apr 2024 08:55:58 GMT'.
+    String format is supported for case when we make json from internal api response
+    e.g ui/crud/users auditlog router
 
     We use the `humanize` library to generate a human-friendly string.
     If dt is not longer ago than 24 hours, we use humanize.naturaltime (e.g. "3 hours ago"),
@@ -106,6 +111,8 @@ def naturalized_datetime_str(dt: datetime | None, now: datetime | None = None) -
     """
     if dt is None:
         return "never"
+    if isinstance(dt, str):
+        dt = datetime.strptime(dt, "%a, %d %b %Y %H:%M:%S %Z")
     if now is None:
         now = datetime.utcnow()
     naive_utc_now = naive_utc_from(now)


Test patch:
diff --git a/flexmeasures/api/v3_0/tests/test_accounts_api.py b/flexmeasures/api/v3_0/tests/test_accounts_api.py
index da25d60f..33079aa3 100644
--- a/flexmeasures/api/v3_0/tests/test_accounts_api.py
+++ b/flexmeasures/api/v3_0/tests/test_accounts_api.py
@@ -75,3 +75,68 @@ def test_get_one_account(client, setup_api_test_data, requesting_user, status_co
         assert get_account_response.json["account_roles"] == [
             {"id": 1, "name": "Prosumer"}
         ]
+
+
+@pytest.mark.parametrize(
+    "requesting_user, status_code",
+    [
+        (None, 401),  # no auth is not allowed
+        (
+            "test_prosumer_user@seita.nl",
+            403,
+        ),  # non account admin cant view account audit log
+        (
+            "test_prosumer_user_2@seita.nl",
+            200,
+        ),  # account-admin can view his account audit log
+        (
+            "test_dummy_account_admin@seita.nl",
+            403,
+        ),  # account-admin cannot view other account audit logs
+        ("test_admin_user@seita.nl", 200),  # admin can view another account audit log
+        (
+            "test_admin_reader_user@seita.nl",
+            200,
+        ),  # admin reader can view another account audit log
+    ],
+    indirect=["requesting_user"],
+)
+def test_get_one_account_audit_log(
+    client, setup_api_test_data, requesting_user, status_code
+):
+    """Get one account"""
+    test_user_account_id = find_user_by_email("test_prosumer_user@seita.nl").account.id
+    get_account_response = client.get(
+        url_for("AccountAPI:auditlog", id=test_user_account_id),
+    )
+    print("Server responded with:\n%s" % get_account_response.data)
+    assert get_account_response.status_code == status_code
+    if status_code == 200:
+        assert get_account_response.json[0] is not None
+
+
+@pytest.mark.parametrize(
+    "requesting_user, status_code",
+    [
+        # Consultant users can see the audit log of all users in the client accounts.
+        ("test_consultant@seita.nl", 200),
+        # Has no consultant role.
+        ("test_consultancy_user_without_consultant_access@seita.nl", 403),
+    ],
+    indirect=["requesting_user"],
+)
+def test_get_one_user_audit_log_consultant(
+    client, setup_api_test_data, requesting_user, status_code
+):
+    """Check correctness of consultant account audit log access rules"""
+    test_user_account_id = find_user_by_email(
+        "test_consultant_client@seita.nl"
+    ).account.id
+
+    get_account_response = client.get(
+        url_for("AccountAPI:auditlog", id=test_user_account_id),
+    )
+    print("Server responded with:\n%s" % get_account_response.data)
+    assert get_account_response.status_code == status_code
+    if status_code == 200:
+        assert get_account_response.json[0] is not None
diff --git a/flexmeasures/api/v3_0/tests/test_api_v3_0_users.py b/flexmeasures/api/v3_0/tests/test_api_v3_0_users.py
index 4e35b1d0..789eb75c 100644
--- a/flexmeasures/api/v3_0/tests/test_api_v3_0_users.py
+++ b/flexmeasures/api/v3_0/tests/test_api_v3_0_users.py
@@ -1,7 +1,9 @@
 from flask import url_for
 from flask_login import current_user, logout_user
+from sqlalchemy import select
 import pytest
 
+from flexmeasures.data.models.audit_log import AuditLog
 from flexmeasures.data.services.users import find_user_by_email
 from flexmeasures.api.tests.utils import UserContext
 
@@ -46,9 +48,9 @@ def test_get_users_inactive(
     assert get_users_response.status_code == 200
     assert isinstance(get_users_response.json, list)
     if include_inactive is False:
-        assert len(get_users_response.json) == 2
+        assert len(get_users_response.json) == 3
     else:
-        assert len(get_users_response.json) == 4
+        assert len(get_users_response.json) == 5
 
 
 @pytest.mark.parametrize(
@@ -72,6 +74,67 @@ def test_get_one_user(client, setup_api_test_data, requesting_user, status_code)
         assert get_user_response.json["username"] == "Test Prosumer User 2"
 
 
+@pytest.mark.parametrize(
+    "requesting_user, status_code",
+    [
+        (None, 401),  # no auth is not allowed
+        ("test_prosumer_user@seita.nl", 200),  # gets themselves
+        (
+            "test_prosumer_user_2@seita.nl",
+            200,
+        ),  # account-admin can view his account users
+        (
+            "test_dummy_account_admin@seita.nl",
+            403,
+        ),  # account-admin cannot view other account users
+        (
+            "test_prosumer_user_3@seita.nl",
+            403,
+        ),  # plain user cant view his account users
+        ("test_dummy_user_3@seita.nl", 403),  # plain user cant view other account users
+        ("test_admin_user@seita.nl", 200),  # admin can do this from another account
+        (
+            "test_admin_reader_user@seita.nl",
+            200,
+        ),  # admin reader can do this from another account
+    ],
+    indirect=["requesting_user"],
+)
+def test_get_one_user_audit_log(
+    client, setup_api_test_data, requesting_user, status_code
+):
+    requesting_user_id = find_user_by_email("test_prosumer_user@seita.nl").id
+
+    get_user_response = client.get(url_for("UserAPI:auditlog", id=requesting_user_id))
+    print("Server responded with:\n%s" % get_user_response.data)
+    assert get_user_response.status_code == status_code
+    if status_code == 200:
+        assert get_user_response.json[0] is not None
+
+
+@pytest.mark.parametrize(
+    "requesting_user, status_code",
+    [
+        # Consultant users can see the audit log of all users in the client accounts.
+        ("test_consultant@seita.nl", 200),
+        # Has no consultant role.
+        ("test_consultancy_user_without_consultant_access@seita.nl", 403),
+    ],
+    indirect=["requesting_user"],
+)
+def test_get_one_user_audit_log_consultant(
+    client, setup_api_test_data, requesting_user, status_code
+):
+    """Check correctness of consultant account audit log access rules"""
+    requesting_user_id = find_user_by_email("test_consultant_client@seita.nl").id
+
+    get_user_response = client.get(url_for("UserAPI:auditlog", id=requesting_user_id))
+    print("Server responded with:\n%s" % get_user_response.data)
+    assert get_user_response.status_code == status_code
+    if status_code == 200:
+        assert get_user_response.json[0] is not None
+
+
 @pytest.mark.parametrize(
     "requesting_user, requested_user, status_code",
     [
@@ -95,7 +158,7 @@ def test_get_one_user(client, setup_api_test_data, requesting_user, status_code)
     indirect=["requesting_user"],
 )
 def test_edit_user(
-    requesting_user, requested_user, status_code, client, setup_api_test_data
+    db, requesting_user, requested_user, status_code, client, setup_api_test_data
 ):
     with UserContext(requested_user) as u:
         requested_user_id = u.id
@@ -111,6 +174,15 @@ def test_edit_user(
         assert user.active is False
         assert user.id == requested_user_id
 
+        assert db.session.execute(
+            select(AuditLog).filter_by(
+                affected_user_id=user.id,
+                event=f"Active status set to 'False' for user {user.username}",
+                active_user_id=requesting_user.id,
+                affected_account_id=user.account_id,
+            )
+        ).scalar_one_or_none()
+
 
 @pytest.mark.parametrize(
     "unexpected_fields",
diff --git a/flexmeasures/api/v3_0/tests/test_api_v3_0_users_fresh_db.py b/flexmeasures/api/v3_0/tests/test_api_v3_0_users_fresh_db.py
index 03f6bd43..b5a759b5 100644
--- a/flexmeasures/api/v3_0/tests/test_api_v3_0_users_fresh_db.py
+++ b/flexmeasures/api/v3_0/tests/test_api_v3_0_users_fresh_db.py
@@ -1,8 +1,10 @@
 import pytest
 from flask import url_for, request
+from sqlalchemy import select
 
 from flexmeasures.api.tests.utils import UserContext
 from flexmeasures.data.services.users import find_user_by_email
+from flexmeasures.data.models.audit_log import AuditLog
 
 
 @pytest.mark.parametrize(
@@ -18,7 +20,7 @@ from flexmeasures.data.services.users import find_user_by_email
     indirect=["requesting_user"],
 )
 def test_user_reset_password(
-    app, client, setup_inactive_user, requesting_user, status_code
+    db, app, client, setup_inactive_user, requesting_user, status_code
 ):
     """
     Reset the password of User 2.
@@ -38,6 +40,14 @@ def test_user_reset_password(
         if status_code != 200:
             return
 
+        assert db.session.execute(
+            select(AuditLog).filter_by(
+                affected_user_id=user2.id,
+                event=f"Password reset for user {user2.username}",
+                active_user_id=requesting_user.id,
+            )
+        ).scalar_one_or_none()
+
         user2 = find_user_by_email("test_prosumer_user_2@seita.nl")
         assert len(outbox) == 2
         assert "has been reset" in outbox[0].subject
diff --git a/flexmeasures/cli/tests/test_data_delete.py b/flexmeasures/cli/tests/test_data_delete.py
new file mode 100644
index 00000000..53c930f5
--- /dev/null
+++ b/flexmeasures/cli/tests/test_data_delete.py
@@ -0,0 +1,49 @@
+from sqlalchemy import select, func
+
+from flexmeasures.cli.tests.utils import to_flags
+from flexmeasures.data.models.audit_log import AuditLog
+from flexmeasures.data.models.user import Account, User
+from flexmeasures.data.services.users import find_user_by_email
+
+
+def test_delete_account(
+    fresh_db, setup_roles_users_fresh_db, setup_assets_fresh_db, app
+):
+    """Check account is deleted + old audit log entries get affected_account_id set to None."""
+    from flexmeasures.cli.data_delete import delete_account
+
+    prosumer: User = find_user_by_email("test_prosumer_user@seita.nl")
+    prosumer_account_id = prosumer.account_id
+
+    num_accounts = fresh_db.session.scalar(select(func.count()).select_from(Account))
+
+    # Add creation audit log record
+    user_creation_audit_log = AuditLog(
+        event="User Test Prosumer User created test",
+        affected_user_id=prosumer.id,
+        affected_account_id=prosumer_account_id,
+    )
+    fresh_db.session.add(user_creation_audit_log)
+
+    # Delete an account
+    cli_input = {
+        "id": prosumer_account_id,
+    }
+    runner = app.test_cli_runner()
+    result = runner.invoke(delete_account, to_flags(cli_input), input="y\n")
+    assert (
+        result.exit_code == 0
+        and "Account Test Prosumer Account has been deleted" in result.output
+    ), result.exception
+
+    assert (
+        fresh_db.session.scalar(select(func.count()).select_from(Account))
+        == num_accounts - 1
+    )
+
+    user_creation_audit_log = (
+        fresh_db.session.query(AuditLog)
+        .filter_by(event="User Test Prosumer User created test")
+        .one_or_none()
+    )
+    assert user_creation_audit_log.affected_account_id is None
diff --git a/flexmeasures/conftest.py b/flexmeasures/conftest.py
index d82b475c..d3bee458 100644
--- a/flexmeasures/conftest.py
+++ b/flexmeasures/conftest.py
@@ -23,7 +23,7 @@ from werkzeug.exceptions import (
 )
 
 from flexmeasures.app import create as create_app
-from flexmeasures.auth.policy import ADMIN_ROLE
+from flexmeasures.auth.policy import ADMIN_ROLE, ADMIN_READER_ROLE
 from flexmeasures.data.services.users import create_user
 from flexmeasures.data.models.generic_assets import GenericAssetType, GenericAsset
 from flexmeasures.data.models.data_sources import DataSource
@@ -196,7 +196,7 @@ def setup_roles_users_fresh_db(fresh_db, setup_accounts_fresh_db) -> dict[str, U
 def create_roles_users(db, test_accounts) -> dict[str, User]:
     """Create a minimal set of roles and users"""
     new_users: list[User] = []
-    # Two Prosumer users
+    # 3 Prosumer users: 2 plain ones, 1 account admin
     new_users.append(
         create_user(
             username="Test Prosumer User",
@@ -216,6 +216,14 @@ def create_roles_users(db, test_accounts) -> dict[str, User]:
             user_roles=dict(name="account-admin", description="Admin for this account"),
         )
     )
+    new_users.append(
+        create_user(
+            username="Test Another Plain Prosumer User",
+            email="test_prosumer_user_3@seita.nl",
+            account_name=test_accounts["Prosumer"].name,
+            password="testtest",
+        )
+    )
     # A user on an account without any special rights
     new_users.append(
         create_user(
@@ -225,6 +233,16 @@ def create_roles_users(db, test_accounts) -> dict[str, User]:
             password="testtest",
         )
     )
+    # Account admin on dummy account
+    new_users.append(
+        create_user(
+            username="Test Dummy Account Admin",
+            email="test_dummy_account_admin@seita.nl",
+            account_name=test_accounts["Dummy"].name,
+            password="testtest",
+            user_roles=dict(name="account-admin", description="Admin for this account"),
+        )
+    )
     # A supplier user
     new_users.append(
         create_user(
@@ -248,6 +266,20 @@ def create_roles_users(db, test_accounts) -> dict[str, User]:
             ),
         )
     )
+    # One platform admin reader
+    new_users.append(
+        create_user(
+            username="Test Admin Reader User",
+            email="test_admin_reader_user@seita.nl",
+            account_name=test_accounts[
+                "Dummy"
+            ].name,  # the account does not give rights
+            password="testtest",
+            user_roles=dict(
+                name=ADMIN_READER_ROLE, description="A user who can do everything."
+            ),
+        )
+    )
     new_users.append(
         create_user(
             username="Test Consultant User",
@@ -265,6 +297,15 @@ def create_roles_users(db, test_accounts) -> dict[str, User]:
             password="testtest",
         )
     )
+    # Consultancy client account user
+    new_users.append(
+        create_user(
+            username="Test Consultancy Client User",
+            email="test_consultant_client@seita.nl",
+            account_name=test_accounts["ConsultancyClient"].name,
+            password="testtest",
+        )
+    )
     return {user.username: user.id for user in new_users}
 
 
diff --git a/flexmeasures/data/tests/test_user_services.py b/flexmeasures/data/tests/test_user_services.py
index 6af76419..c4257656 100644
--- a/flexmeasures/data/tests/test_user_services.py
+++ b/flexmeasures/data/tests/test_user_services.py
@@ -1,6 +1,7 @@
 import pytest
 from sqlalchemy import select, func
 
+from flexmeasures.data.models.audit_log import AuditLog
 from flexmeasures.data.models.user import User, Role
 from flexmeasures.data.services.users import (
     create_user,
@@ -18,10 +19,11 @@ def test_create_user(
 ):
     """Create a user"""
     num_users = fresh_db.session.scalar(select(func.count()).select_from(User))
+    prosumer_account = setup_accounts_fresh_db["Prosumer"]
     user = create_user(
         email="new_user@seita.nl",
         password="testtest",
-        account_name=setup_accounts_fresh_db["Prosumer"].name,
+        account_name=prosumer_account.name,
         user_roles=["SomeRole"],
     )
     assert (
@@ -42,6 +44,44 @@ def test_create_user(
         select(DataSource).filter_by(name=user.username)
     ).scalar_one_or_none()
 
+    user_audit_log = (
+        fresh_db.session.query(AuditLog)
+        .filter_by(affected_user_id=user.id)
+        .one_or_none()
+    )
+    assert user_audit_log.event == "User new_user created"
+    assert user_audit_log.affected_account_id == prosumer_account.id
+    assert user_audit_log.active_user_id is None
+
+
+def test_create_user_no_account(
+    fresh_db, setup_accounts_fresh_db, setup_roles_users_fresh_db, app
+):
+    """Create a user where the account still needs creation, test for both audit log entries"""
+    user = create_user(
+        email="new_user@seita.nl",
+        password="testtest",
+        account_name="new_account",
+        user_roles=["SomeRole"],
+    )
+
+    user_audit_log = (
+        fresh_db.session.query(AuditLog)
+        .filter_by(event="User new_user created")
+        .one_or_none()
+    )
+    assert user_audit_log.affected_user_id == user.id
+    assert user_audit_log.affected_account_id == user.account_id
+
+    account_audit_log = (
+        fresh_db.session.query(AuditLog)
+        .filter_by(event="Account new_account created")
+        .one_or_none()
+    )
+    assert account_audit_log.affected_user_id is None
+    assert account_audit_log.affected_account_id == user.account_id
+    assert account_audit_log.active_user_id is None
+
 
 def test_create_invalid_user(
     fresh_db, setup_accounts_fresh_db, setup_roles_users_fresh_db, app
@@ -91,12 +131,15 @@ def test_create_invalid_user(
 
 
 def test_delete_user(fresh_db, setup_roles_users_fresh_db, setup_assets_fresh_db, app):
-    """Check that deleting a user does not lead to deleting their organisation's (asset/sensor/beliefs) data."""
+    """Check that deleting a user does not lead to deleting their organisation's (asset/sensor/beliefs) data.
+    Also check that an audit log entry is created + old audit log entries get affected_user_id set to None.
+    """
     prosumer: User = find_user_by_email("test_prosumer_user@seita.nl")
+    prosumer_account_id = prosumer.account_id
     num_users_before = fresh_db.session.scalar(select(func.count(User.id)))
 
     # Find assets belonging to the user's organisation
-    asset_query = select(GenericAsset).filter_by(account_id=prosumer.account_id)
+    asset_query = select(GenericAsset).filter_by(account_id=prosumer_account_id)
     assets_before = fresh_db.session.scalars(asset_query).all()
     assert (
         len(assets_before) > 0
@@ -116,6 +159,14 @@ def test_delete_user(fresh_db, setup_roles_users_fresh_db, setup_assets_fresh_db
         num_beliefs_before > 0
     ), "Some beliefs should have been set up, otherwise we'd not be testing whether they're kept."
 
+    # Add creation audit log record
+    user_creation_audit_log = AuditLog(
+        event="User Test Prosumer User created test",
+        affected_user_id=prosumer.id,
+        affected_account_id=prosumer_account_id,
+    )
+    fresh_db.session.add(user_creation_audit_log)
+
     # Delete the user
     delete_user(prosumer)
     assert find_user_by_email("test_prosumer_user@seita.nl") is None
@@ -127,3 +178,15 @@ def test_delete_user(fresh_db, setup_roles_users_fresh_db, setup_assets_fresh_db
 
     num_beliefs_after = fresh_db.session.scalar(beliefs_query)
     assert num_beliefs_after == num_beliefs_before
+
+    user_deletion_audit_log = (
+        fresh_db.session.query(AuditLog)
+        .filter_by(event="User Test Prosumer User deleted")
+        .one_or_none()
+    )
+    assert user_deletion_audit_log.affected_user_id is None
+    assert user_deletion_audit_log.affected_account_id == prosumer_account_id
+    assert user_deletion_audit_log.active_user_id is None
+
+    fresh_db.session.refresh(user_creation_audit_log)
+    assert user_creation_audit_log.affected_user_id is None