diff --git a/app/alias_audit_log_utils.py b/app/alias_audit_log_utils.py new file mode 100644 index 000000000..f986389c7 --- /dev/null +++ b/app/alias_audit_log_utils.py @@ -0,0 +1,31 @@ +from enum import Enum +from typing import Optional + +from app.models import Alias, AliasAuditLog + + +class AliasAuditLogAction(Enum): + CreateAlias = "create" + ChangeAliasStatus = "change_status" + DeleteAlias = "delete" + UpdateAlias = "update" + InitiateTransferAlias = "initiate_transfer_alias" + AcceptTransferAlias = "accept_transfer_alias" + LoseOwnershipDueToTransferAlias = "lose_owner_due_to_transfer_alias" + + +def emit_alias_audit_log( + alias: Alias, + action: AliasAuditLogAction, + message: str, + user_id: Optional[int] = None, + commit: bool = False, +): + AliasAuditLog.create( + user_id=user_id or alias.user_id, + alias_id=alias.id, + alias_email=alias.email, + action=action.value, + message=message, + commit=commit, + ) diff --git a/app/alias_utils.py b/app/alias_utils.py index 47851f8ba..a639ac4b0 100644 --- a/app/alias_utils.py +++ b/app/alias_utils.py @@ -8,6 +8,7 @@ from sqlalchemy.exc import IntegrityError, DataError from flask import make_response +from app.alias_audit_log_utils import AliasAuditLogAction, emit_alias_audit_log from app.config import ( BOUNCE_PREFIX_FOR_REPLY_PHASE, BOUNCE_PREFIX, @@ -368,6 +369,10 @@ def delete_alias( alias_id = alias.id alias_email = alias.email + + emit_alias_audit_log( + alias, AliasAuditLogAction.DeleteAlias, "Alias deleted by user action" + ) Alias.filter(Alias.id == alias.id).delete() Session.commit() @@ -450,7 +455,7 @@ def alias_export_csv(user, csv_direct_export=False): return output -def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]): +def transfer_alias(alias: Alias, new_user: User, new_mailboxes: [Mailbox]): # cannot transfer alias which is used for receiving newsletter if User.get_by(newsletter_alias_id=alias.id): raise Exception("Cannot transfer alias that's used to receive newsletter") @@ -504,6 +509,12 @@ def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]): alias.disable_pgp = False alias.pinned = False + emit_alias_audit_log( + alias=alias, + action=AliasAuditLogAction.LoseOwnershipDueToTransferAlias, + message=f"Lost ownership of alias due to alias transfer confirmed. New owner is {new_user.id}", + user_id=old_user.id, + ) EventDispatcher.send_event( old_user, EventContent( @@ -513,6 +524,13 @@ def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]): ) ), ) + + emit_alias_audit_log( + alias=alias, + action=AliasAuditLogAction.AcceptTransferAlias, + message=f"Accepted alias transfer from user {old_user.id}", + user_id=new_user.id, + ) EventDispatcher.send_event( new_user, EventContent( @@ -540,6 +558,9 @@ def change_alias_status(alias: Alias, enabled: bool, commit: bool = False): created_at=int(alias.created_at.timestamp), ) EventDispatcher.send_event(alias.user, EventContent(alias_status_change=event)) + emit_alias_audit_log( + alias, AliasAuditLogAction.ChangeAliasStatus, f"Set alias status to {enabled}" + ) if commit: Session.commit() diff --git a/app/api/views/alias.py b/app/api/views/alias.py index fffe34340..ee6460831 100644 --- a/app/api/views/alias.py +++ b/app/api/views/alias.py @@ -4,6 +4,7 @@ from flask import request from app import alias_utils +from app.alias_audit_log_utils import emit_alias_audit_log, AliasAuditLogAction from app.api.base import api_bp, require_api_auth from app.api.serializer import ( AliasInfo, @@ -336,6 +337,9 @@ def update_alias(alias_id): changed = True if changed: + emit_alias_audit_log( + alias, AliasAuditLogAction.UpdateAlias, "Alias fields updated" + ) Session.commit() return jsonify(ok=True), 200 diff --git a/app/dashboard/views/alias_transfer.py b/app/dashboard/views/alias_transfer.py index 8b2e7676a..99670c35a 100644 --- a/app/dashboard/views/alias_transfer.py +++ b/app/dashboard/views/alias_transfer.py @@ -7,6 +7,7 @@ from flask_login import login_required, current_user from app import config +from app.alias_audit_log_utils import emit_alias_audit_log, AliasAuditLogAction from app.alias_utils import transfer_alias from app.dashboard.base import dashboard_bp from app.dashboard.views.enter_sudo import sudo_required @@ -57,6 +58,12 @@ def alias_transfer_send_route(alias_id): transfer_token = f"{alias.id}.{secrets.token_urlsafe(32)}" alias.transfer_token = hmac_alias_transfer_token(transfer_token) alias.transfer_token_expiration = arrow.utcnow().shift(hours=24) + + emit_alias_audit_log( + alias, + AliasAuditLogAction.InitiateTransferAlias, + "Initiated alias transfer", + ) Session.commit() alias_transfer_url = ( config.URL diff --git a/app/models.py b/app/models.py index 092c10608..6bd6be66a 100644 --- a/app/models.py +++ b/app/models.py @@ -1673,6 +1673,7 @@ def create(cls, **kw): Session.flush() # Internal import to avoid global import cycles + from app.alias_audit_log_utils import AliasAuditLogAction, emit_alias_audit_log from app.events.event_dispatcher import EventDispatcher from app.events.generated.event_pb2 import AliasCreated, EventContent @@ -1684,6 +1685,9 @@ def create(cls, **kw): created_at=int(new_alias.created_at.timestamp), ) EventDispatcher.send_event(user, EventContent(alias_created=event)) + emit_alias_audit_log( + new_alias, AliasAuditLogAction.CreateAlias, "New alias created" + ) return new_alias @@ -3801,3 +3805,21 @@ def get_dead_letter(cls, older_than: Arrow, max_retries: int) -> [SyncEvent]: .limit(100) .all() ) + + +class AliasAuditLog(Base, ModelMixin): + """This model holds an audit log for all the actions performed to an alias""" + + __tablename__ = "alias_audit_log" + + user_id = sa.Column(sa.Integer, nullable=False) + alias_id = sa.Column(sa.Integer, nullable=False) + alias_email = sa.Column(sa.String(255), nullable=False) + action = sa.Column(sa.String(255), nullable=False) + message = sa.Column(sa.Text, default=None, nullable=True) + + __table_args__ = ( + sa.Index("ix_alias_audit_log_user_id", "user_id"), + sa.Index("ix_alias_audit_log_alias_id", "alias_id"), + sa.Index("ix_alias_audit_log_alias_email", "alias_email"), + ) diff --git a/migrations/versions/2024_101113_91ed7f46dc81_alias_audit_log.py b/migrations/versions/2024_101113_91ed7f46dc81_alias_audit_log.py new file mode 100644 index 000000000..11b448f0a --- /dev/null +++ b/migrations/versions/2024_101113_91ed7f46dc81_alias_audit_log.py @@ -0,0 +1,45 @@ +"""alias_audit_log + +Revision ID: 91ed7f46dc81 +Revises: 62afa3a10010 +Create Date: 2024-10-11 13:22:11.594054 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '91ed7f46dc81' +down_revision = '62afa3a10010' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('alias_audit_log', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False), + sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('alias_id', sa.Integer(), nullable=False), + sa.Column('alias_email', sa.String(length=255), nullable=False), + sa.Column('action', sa.String(length=255), nullable=False), + sa.Column('message', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_alias_audit_log_alias_email', 'alias_audit_log', ['alias_email'], unique=False) + op.create_index('ix_alias_audit_log_alias_id', 'alias_audit_log', ['alias_id'], unique=False) + op.create_index('ix_alias_audit_log_user_id', 'alias_audit_log', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_alias_audit_log_user_id', table_name='alias_audit_log') + op.drop_index('ix_alias_audit_log_alias_id', table_name='alias_audit_log') + op.drop_index('ix_alias_audit_log_alias_email', table_name='alias_audit_log') + op.drop_table('alias_audit_log') + # ### end Alembic commands ### diff --git a/tests/test_alias_audit_log_utils.py b/tests/test_alias_audit_log_utils.py new file mode 100644 index 000000000..3e7a7760d --- /dev/null +++ b/tests/test_alias_audit_log_utils.py @@ -0,0 +1,98 @@ +import random + +from app.alias_audit_log_utils import emit_alias_audit_log, AliasAuditLogAction +from app.alias_utils import delete_alias, transfer_alias +from app.models import Alias, AliasAuditLog, AliasDeleteReason +from app.utils import random_string +from tests.utils import create_new_user, random_email + + +def test_emit_alias_audit_log_for_random_data(): + user = create_new_user() + alias = Alias.create( + user_id=user.id, + email=random_email(), + mailbox_id=user.default_mailbox_id, + ) + + random_user_id = random.randint(1000, 2000) + message = random_string() + action = AliasAuditLogAction.ChangeAliasStatus + emit_alias_audit_log( + alias=alias, + user_id=random_user_id, + action=action, + message=message, + commit=True, + ) + + logs_for_alias = AliasAuditLog.filter_by(alias_id=alias.id).all() + assert len(logs_for_alias) == 2 + + last_log = logs_for_alias[-1] + assert last_log.alias_id == alias.id + assert last_log.alias_email == alias.email + assert last_log.user_id == random_user_id + assert last_log.action == action.value + assert last_log.message == message + + +def test_emit_alias_audit_log_on_alias_creation(): + user = create_new_user() + alias = Alias.create( + user_id=user.id, + email=random_email(), + mailbox_id=user.default_mailbox_id, + ) + + log_for_alias = AliasAuditLog.filter_by(alias_id=alias.id).all() + assert len(log_for_alias) == 1 + assert log_for_alias[0].alias_id == alias.id + assert log_for_alias[0].alias_email == alias.email + assert log_for_alias[0].user_id == user.id + assert log_for_alias[0].action == AliasAuditLogAction.CreateAlias.value + + +def test_alias_audit_log_exists_after_alias_deletion(): + user = create_new_user() + alias = Alias.create( + user_id=user.id, + email=random_email(), + mailbox_id=user.default_mailbox_id, + ) + alias_id = alias.id + emit_alias_audit_log(alias, AliasAuditLogAction.UpdateAlias, "") + emit_alias_audit_log(alias, AliasAuditLogAction.UpdateAlias, "") + delete_alias(alias, user, AliasDeleteReason.ManualAction, commit=True) + + db_alias = Alias.get_by(id=alias_id) + assert db_alias is None + + logs_for_alias = AliasAuditLog.filter_by(alias_id=alias.id).all() + assert len(logs_for_alias) == 4 + assert logs_for_alias[0].action == AliasAuditLogAction.CreateAlias.value + assert logs_for_alias[1].action == AliasAuditLogAction.UpdateAlias.value + assert logs_for_alias[2].action == AliasAuditLogAction.UpdateAlias.value + assert logs_for_alias[3].action == AliasAuditLogAction.DeleteAlias.value + + +def test_alias_audit_log_for_transfer(): + original_user = create_new_user() + new_user = create_new_user() + alias = Alias.create( + user_id=original_user.id, + email=random_email(), + mailbox_id=original_user.default_mailbox_id, + ) + transfer_alias(alias, new_user, [new_user.default_mailbox]) + + logs_for_alias = AliasAuditLog.filter_by(alias_id=alias.id).all() + assert len(logs_for_alias) == 3 + assert logs_for_alias[0].action == AliasAuditLogAction.CreateAlias.value + assert ( + logs_for_alias[1].action + == AliasAuditLogAction.LoseOwnershipDueToTransferAlias.value + ) + assert logs_for_alias[1].user_id == original_user.id + assert logs_for_alias[2].action == AliasAuditLogAction.AcceptTransferAlias.value + assert logs_for_alias[2].user_id == new_user.id