diff --git a/app/admin_model.py b/app/admin_model.py index 1906019f..192ef9c9 100644 --- a/app/admin_model.py +++ b/app/admin_model.py @@ -115,7 +115,7 @@ class SLAdminIndexView(AdminIndexView): if not current_user.is_authenticated or not current_user.is_admin: return redirect(url_for("auth.login", next=request.url)) - return redirect("/admin/user") + return redirect("/admin/email_search") class UserAdmin(SLModelView): @@ -743,13 +743,17 @@ class EmailSearchResult: mailbox: List[Mailbox] = [] mailbox_count: int = 0 deleted_alias: Optional[DeletedAlias] = None - deleted_custom_alias: Optional[DomainDeletedAlias] = None + deleted_alias_audit_log: Optional[List[AliasAuditLog]] = None + domain_deleted_alias: Optional[DomainDeletedAlias] = None + domain_deleted_alias_audit_log: Optional[List[AliasAuditLog]] = None user: Optional[User] = None user_audit_log: Optional[List[UserAuditLog]] = None + query: str @staticmethod def from_email(email: str) -> EmailSearchResult: output = EmailSearchResult() + output.query = email alias = Alias.get_by(email=email) if alias: output.alias = alias @@ -768,6 +772,15 @@ class EmailSearchResult: .all() ) output.no_match = False + + user_audit_log = ( + UserAuditLog.filter_by(user_email=email) + .order_by(UserAuditLog.created_at.desc()) + .all() + ) + if user_audit_log: + output.user_audit_log = user_audit_log + output.no_match = False mailboxes = ( Mailbox.filter_by(email=email).order_by(Mailbox.id.desc()).limit(10).all() ) @@ -778,10 +791,20 @@ class EmailSearchResult: deleted_alias = DeletedAlias.get_by(email=email) if deleted_alias: output.deleted_alias = deleted_alias + output.deleted_alias_audit_log = ( + AliasAuditLog.filter_by(alias_email=deleted_alias.email) + .order_by(AliasAuditLog.created_at.desc()) + .all() + ) output.no_match = False domain_deleted_alias = DomainDeletedAlias.get_by(email=email) if domain_deleted_alias: output.domain_deleted_alias = domain_deleted_alias + output.domain_deleted_alias_audit_log = ( + AliasAuditLog.filter_by(alias_email=domain_deleted_alias.email) + .order_by(AliasAuditLog.created_at.desc()) + .all() + ) output.no_match = False return output diff --git a/app/api/views/auth.py b/app/api/views/auth.py index c1cacefd..ed8d0457 100644 --- a/app/api/views/auth.py +++ b/app/api/views/auth.py @@ -23,6 +23,7 @@ from app.events.auth_event import LoginEvent, RegisterEvent from app.extensions import limiter from app.log import LOG from app.models import User, ApiKey, SocialAuth, AccountActivation +from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction from app.utils import sanitize_email, canonicalize_email @@ -187,6 +188,11 @@ def auth_activate(): LOG.d("activate user %s", user) user.activated = True + emit_user_audit_log( + user=user, + action=UserAuditLogAction.ActivateUser, + message=f"User has been activated: {user.email}", + ) AccountActivation.delete(account_activation.id) Session.commit() diff --git a/app/auth/views/activate.py b/app/auth/views/activate.py index debec916..885319a2 100644 --- a/app/auth/views/activate.py +++ b/app/auth/views/activate.py @@ -7,6 +7,7 @@ from app.db import Session from app.extensions import limiter from app.log import LOG from app.models import ActivationCode +from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction from app.utils import sanitize_next_url @@ -47,6 +48,11 @@ def activate(): user = activation_code.user user.activated = True + emit_user_audit_log( + user=user, + action=UserAuditLogAction.ActivateUser, + message=f"User has been activated: {user.email}", + ) login_user(user) # activation code is to be used only once diff --git a/app/auth/views/reset_password.py b/app/auth/views/reset_password.py index e331cd9f..23e813e7 100644 --- a/app/auth/views/reset_password.py +++ b/app/auth/views/reset_password.py @@ -9,6 +9,7 @@ from app.auth.views.login_utils import after_login from app.db import Session from app.extensions import limiter from app.models import ResetPasswordCode +from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction class ResetPasswordForm(FlaskForm): @@ -59,6 +60,11 @@ def reset_password(): # this can be served to activate user too user.activated = True + emit_user_audit_log( + user=user, + action=UserAuditLogAction.ResetPassword, + message="User has reset their password", + ) # remove all reset password codes ResetPasswordCode.filter_by(user_id=user.id).delete() diff --git a/app/models.py b/app/models.py index 536f9904..83140c75 100644 --- a/app/models.py +++ b/app/models.py @@ -616,6 +616,15 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle): if "alternative_id" not in kwargs: user.alternative_id = str(uuid.uuid4()) + from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction + + trail = ". Created from partner" if from_partner else "" + emit_user_audit_log( + user=user, + action=UserAuditLogAction.CreateUser, + message=f"Created user {email}{trail}", + ) + # If the user is created from partner, do not notify # nor give a trial if from_partner: diff --git a/app/user_audit_log_utils.py b/app/user_audit_log_utils.py index 985d83fe..963f88eb 100644 --- a/app/user_audit_log_utils.py +++ b/app/user_audit_log_utils.py @@ -4,6 +4,10 @@ from app.models import User, UserAuditLog class UserAuditLogAction(Enum): + CreateUser = "create_user" + ActivateUser = "activate_user" + ResetPassword = "reset_password" + Upgrade = "upgrade" SubscriptionExtended = "subscription_extended" SubscriptionCancelled = "subscription_cancelled" diff --git a/server.py b/server.py index de69b9d0..6018b7b3 100644 --- a/server.py +++ b/server.py @@ -442,10 +442,10 @@ def init_admin(app): admin = Admin(name="SimpleLogin", template_mode="bootstrap4") admin.init_app(app, index_view=SLAdminIndexView()) + admin.add_view(EmailSearchAdmin(name="Email Search", endpoint="email_search")) admin.add_view(UserAdmin(User, Session)) admin.add_view(AliasAdmin(Alias, Session)) admin.add_view(MailboxAdmin(Mailbox, Session)) - admin.add_view(EmailSearchAdmin(name="Email Search", endpoint="email_search")) admin.add_view(CouponAdmin(Coupon, Session)) admin.add_view(ManualSubscriptionAdmin(ManualSubscription, Session)) admin.add_view(CustomDomainAdmin(CustomDomain, Session)) diff --git a/templates/admin/email_search.html b/templates/admin/email_search.html index da538ca5..b462796b 100644 --- a/templates/admin/email_search.html +++ b/templates/admin/email_search.html @@ -239,6 +239,11 @@ {{ show_user(data.user) }} {{ list_mailboxes("Mailboxes for user", helper.mailbox_count(data.user) , helper.mailbox_list(data.user) ) }} {{ list_alias(helper.alias_count(data.user) ,helper.alias_list(data.user)) }} + + {% endif %} + {% if data.user_audit_log %} +
+

Audit log entries for user {{ data.query }}

{{ list_user_audit_log(data.user_audit_log) }}
{% endif %} @@ -260,6 +265,7 @@

Found DeletedAlias {{ data.deleted_alias.email }}

{{ show_deleted_alias(data.deleted_alias) }} + {{ list_alias_audit_log(data.deleted_alias_audit_log) }}
{% endif %} {% if data.domain_deleted_alias %} @@ -267,6 +273,7 @@

Found DomainDeletedAlias {{ data.domain_deleted_alias.email }}

{{ show_domain_deleted_alias(data.domain_deleted_alias) }} + {{ list_alias_audit_log(data.domain_deleted_alias_audit_log) }}
{% endif %} {% endblock %} diff --git a/tests/test_account_linking.py b/tests/test_account_linking.py index 7ae6272c..0c644169 100644 --- a/tests/test_account_linking.py +++ b/tests/test_account_linking.py @@ -94,10 +94,12 @@ def test_login_case_from_partner(): ) assert res.user.activated is True - audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(user_id=res.user.id).all() + audit_logs: List[UserAuditLog] = UserAuditLog.filter_by( + user_id=res.user.id, + action=UserAuditLogAction.LinkAccount.value, + ).all() assert len(audit_logs) == 1 assert audit_logs[0].user_id == res.user.id - assert audit_logs[0].action == UserAuditLogAction.LinkAccount.value def test_login_case_from_partner_with_uppercase_email(): @@ -133,7 +135,10 @@ def test_login_case_from_web(): assert 0 == (res.user.flags & User.FLAG_CREATED_FROM_PARTNER) assert res.user.activated is True - audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(user_id=res.user.id).all() + audit_logs: List[UserAuditLog] = UserAuditLog.filter_by( + user_id=res.user.id, + action=UserAuditLogAction.LinkAccount.value, + ).all() assert len(audit_logs) == 1 assert audit_logs[0].user_id == res.user.id assert audit_logs[0].action == UserAuditLogAction.LinkAccount.value @@ -218,7 +223,10 @@ def test_link_account_with_proton_account_same_address(flask_client): ) assert partner_user.partner_id == get_proton_partner().id assert partner_user.external_user_id == partner_user_id - audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(user_id=res.user.id).all() + audit_logs: List[UserAuditLog] = UserAuditLog.filter_by( + user_id=res.user.id, + action=UserAuditLogAction.LinkAccount.value, + ).all() assert len(audit_logs) == 1 assert audit_logs[0].user_id == res.user.id assert audit_logs[0].action == UserAuditLogAction.LinkAccount.value @@ -246,7 +254,10 @@ def test_link_account_with_proton_account_different_address(flask_client): assert partner_user.partner_id == get_proton_partner().id assert partner_user.external_user_id == partner_user_id - audit_logs: List[UserAuditLog] = UserAuditLog.filter_by(user_id=res.user.id).all() + audit_logs: List[UserAuditLog] = UserAuditLog.filter_by( + user_id=res.user.id, + action=UserAuditLogAction.LinkAccount.value, + ).all() assert len(audit_logs) == 1 assert audit_logs[0].user_id == res.user.id assert audit_logs[0].action == UserAuditLogAction.LinkAccount.value @@ -304,19 +315,19 @@ def test_link_account_with_proton_account_same_address_but_linked_to_other_user( # Ensure audit logs for sl_user_1 show the link action sl_user_1_audit_logs: List[UserAuditLog] = UserAuditLog.filter_by( - user_id=sl_user_1.id + user_id=sl_user_1.id, + action=UserAuditLogAction.LinkAccount.value, ).all() assert len(sl_user_1_audit_logs) == 1 assert sl_user_1_audit_logs[0].user_id == sl_user_1.id - assert sl_user_1_audit_logs[0].action == UserAuditLogAction.LinkAccount.value # Ensure audit logs for sl_user_2 show the unlink action sl_user_2_audit_logs: List[UserAuditLog] = UserAuditLog.filter_by( - user_id=sl_user_2.id + user_id=sl_user_2.id, + action=UserAuditLogAction.UnlinkAccount.value, ).all() assert len(sl_user_2_audit_logs) == 1 assert sl_user_2_audit_logs[0].user_id == sl_user_2.id - assert sl_user_2_audit_logs[0].action == UserAuditLogAction.UnlinkAccount.value def test_link_account_with_proton_account_different_address_and_linked_to_other_user( @@ -356,19 +367,19 @@ def test_link_account_with_proton_account_different_address_and_linked_to_other_ # Ensure audit logs for sl_user_1 show the link action sl_user_1_audit_logs: List[UserAuditLog] = UserAuditLog.filter_by( - user_id=sl_user_1.id + user_id=sl_user_1.id, + action=UserAuditLogAction.LinkAccount.value, ).all() assert len(sl_user_1_audit_logs) == 1 assert sl_user_1_audit_logs[0].user_id == sl_user_1.id - assert sl_user_1_audit_logs[0].action == UserAuditLogAction.LinkAccount.value # Ensure audit logs for sl_user_2 show the unlink action sl_user_2_audit_logs: List[UserAuditLog] = UserAuditLog.filter_by( - user_id=sl_user_2.id + user_id=sl_user_2.id, + action=UserAuditLogAction.UnlinkAccount.value, ).all() assert len(sl_user_2_audit_logs) == 1 assert sl_user_2_audit_logs[0].user_id == sl_user_2.id - assert sl_user_2_audit_logs[0].action == UserAuditLogAction.UnlinkAccount.value def test_cannot_create_instance_of_base_strategy(): diff --git a/tests/test_mailbox_utils.py b/tests/test_mailbox_utils.py index 0db184f3..51aabb53 100644 --- a/tests/test_mailbox_utils.py +++ b/tests/test_mailbox_utils.py @@ -351,7 +351,9 @@ def test_perform_mailbox_email_change_valid_id_not_new_email(): res = mailbox_utils.perform_mailbox_email_change(mb.id) assert res.error == MailboxEmailChangeError.InvalidId assert res.message_category == "error" - audit_log_entries = UserAuditLog.filter_by(user_id=user.id).count() + audit_log_entries = UserAuditLog.filter_by( + user_id=user.id, action=UserAuditLogAction.UpdateMailbox.value + ).count() assert audit_log_entries == 0 @@ -374,7 +376,9 @@ def test_perform_mailbox_email_change_valid_id_email_already_used(): res = mailbox_utils.perform_mailbox_email_change(mb_to_change.id) assert res.error == MailboxEmailChangeError.EmailAlreadyUsed assert res.message_category == "error" - audit_log_entries = UserAuditLog.filter_by(user_id=user.id).count() + audit_log_entries = UserAuditLog.filter_by( + user_id=user.id, action=UserAuditLogAction.UpdateMailbox.value + ).count() assert audit_log_entries == 0 @@ -398,6 +402,7 @@ def test_perform_mailbox_email_change_success(): assert db_mailbox.email == new_email assert db_mailbox.new_email is None - audit_log_entries = UserAuditLog.filter_by(user_id=user.id).all() - assert len(audit_log_entries) == 1 - assert audit_log_entries[0].action == UserAuditLogAction.UpdateMailbox.value + audit_log_entries = UserAuditLog.filter_by( + user_id=user.id, action=UserAuditLogAction.UpdateMailbox.value + ).count() + assert audit_log_entries == 1 diff --git a/tests/test_user_audit_log_utils.py b/tests/test_user_audit_log_utils.py index a3c9b374..5300cf54 100644 --- a/tests/test_user_audit_log_utils.py +++ b/tests/test_user_audit_log_utils.py @@ -27,7 +27,9 @@ def test_emit_alias_audit_log_for_random_data(): commit=True, ) - logs_for_user: List[UserAuditLog] = UserAuditLog.filter_by(user_id=user.id).all() + logs_for_user: List[UserAuditLog] = UserAuditLog.filter_by( + user_id=user.id, action=action.value + ).all() assert len(logs_for_user) == 1 assert logs_for_user[0].user_id == user.id assert logs_for_user[0].user_email == user.email @@ -41,7 +43,10 @@ def test_emit_audit_log_on_mailbox_creation(): user=user, email=random_email(), verified=True ) - logs_for_user: List[UserAuditLog] = UserAuditLog.filter_by(user_id=user.id).all() + logs_for_user: List[UserAuditLog] = UserAuditLog.filter_by( + user_id=user.id, + action=UserAuditLogAction.CreateMailbox.value, + ).all() assert len(logs_for_user) == 1 assert logs_for_user[0].user_id == user.id assert logs_for_user[0].user_email == user.email