Skip to content

Commit

Permalink
Audit log WIP
Browse files Browse the repository at this point in the history
Signed-off-by: Nikolai Rozanov <nickolay.rozanov@gmail.com>
  • Loading branch information
nrozanov committed Apr 24, 2024
1 parent 62a8cb0 commit 0350c75
Show file tree
Hide file tree
Showing 17 changed files with 548 additions and 10 deletions.
22 changes: 20 additions & 2 deletions flexmeasures/api/v3_0/accounts.py
Expand Up @@ -5,8 +5,8 @@

from flexmeasures.auth.policy import user_has_admin_access
from flexmeasures.auth.decorators import permission_required_for_context
from flexmeasures.data.models.user import Account
from flexmeasures.data.services.accounts import get_accounts
from flexmeasures.data.models.user import Account, AuditLog
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

Expand Down Expand Up @@ -107,3 +107,21 @@ def get(self, id: int, account: Account):
"""

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_acl,
)
@as_json
def auditlog(self, id: int, account: Account):
"""API endpoint to get a user audit log."""
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": audit_logs}, 200
38 changes: 38 additions & 0 deletions flexmeasures/api/v3_0/tests/test_accounts_api.py
Expand Up @@ -75,3 +75,41 @@ 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["audit_logs"] is not None
50 changes: 49 additions & 1 deletion 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.user import AuditLog
from flexmeasures.data.services.users import find_user_by_email
from flexmeasures.api.tests.utils import UserContext

Expand Down Expand Up @@ -72,6 +74,44 @@ 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["audit_logs"] is not None


@pytest.mark.parametrize(
"requesting_user, requested_user, status_code",
[
Expand All @@ -95,7 +135,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
Expand All @@ -111,6 +151,14 @@ 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"User {user.username} set active to False",
active_user_id=requesting_user.id,
)
).scalar_one_or_none()


@pytest.mark.parametrize(
"unexpected_fields",
Expand Down
12 changes: 11 additions & 1 deletion 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.user import AuditLog


@pytest.mark.parametrize(
Expand All @@ -18,7 +20,7 @@
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.
Expand All @@ -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"User {user2.username} reset password",
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
Expand Down
33 changes: 32 additions & 1 deletion flexmeasures/api/v3_0/users.py
Expand Up @@ -7,16 +7,18 @@
from flask_json import as_json
from werkzeug.exceptions import Forbidden

from flexmeasures.data.models.user import User as UserModel, Account
from flexmeasures.data.models.user import User as UserModel, Account, AuditLog
from flexmeasures.api.common.schemas.users import AccountIdField, UserIdField
from flexmeasures.data.schemas.users import UserSchema
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.
Expand Down Expand Up @@ -193,6 +195,17 @@ def patch(self, id: int, user: UserModel, **user_data):
setattr(user, k, v)
if k == "active" and v is False:
remove_cookie_and_token_access(user)
if k == "active":
active_user_id = (
current_user.id if hasattr(current_user, "id") else None
)
user_audit_log = AuditLog(
event_datetime=server_now(),
event=f"User {user.username} set active to {v}",
active_user_id=active_user_id,
affected_user_id=user.id,
)
db.session.add(user_audit_log)
db.session.add(user)
try:
db.session.commit()
Expand Down Expand Up @@ -234,3 +247,21 @@ def reset_user_password(self, id: int, user: UserModel):

# 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_acl,
)
@as_json
def auditlog(self, id: int, user: UserModel):
"""API endpoint to get a user audit log."""
audit_logs = get_audit_log_records(user)
audit_logs = [
{k: getattr(log, k) for k in ("event", "event_datetime", "active_user_id")}
for log in audit_logs
]
return {"audit_logs": audit_logs}, 200
36 changes: 34 additions & 2 deletions flexmeasures/conftest.py
Expand Up @@ -23,7 +23,7 @@
)

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
Expand Down Expand Up @@ -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",
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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",
Expand Down
@@ -0,0 +1,51 @@
"""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("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"),
),
sa.ForeignKeyConstraint(
["affected_account_id"],
["account.id"],
name=op.f("audit_log_affected_account_id_account_fkey"),
),
sa.ForeignKeyConstraint(
["affected_user_id"],
["fm_user.id"],
name=op.f("audit_log_affected_user_id_fm_user_fkey"),
),
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 ###

0 comments on commit 0350c75

Please sign in to comment.