diff --git a/app/admin_model.py b/app/admin_model.py index 8a1d8e58..16d6b01e 100644 --- a/app/admin_model.py +++ b/app/admin_model.py @@ -1,4 +1,7 @@ import arrow +import sqlalchemy + +from app import models from flask import redirect, url_for, request, flash from flask_admin import expose, AdminIndexView from flask_admin.actions import action @@ -6,7 +9,15 @@ from flask_admin.contrib import sqla from flask_login import current_user from app.db import Session -from app.models import User, ManualSubscription, Fido, Subscription, AppleSubscription +from app.models import ( + User, + ManualSubscription, + Fido, + Subscription, + AppleSubscription, + AdminAuditLog, + AuditLogActionEnum, +) class SLModelView(sqla.ModelView): @@ -25,6 +36,42 @@ class SLModelView(sqla.ModelView): # redirect to login page if user doesn't have access return redirect(url_for("auth.login", next=request.url)) + def on_model_change(self, form, model, is_created): + changes = {} + for attr in sqlalchemy.inspect(model).attrs: + if attr.history.has_changes() and attr.key not in ( + "created_at", + "updated_at", + ): + value = attr.value + # If it's a model reference, get the source id + if issubclass(type(value), models.Base): + value = value.id + # otherwise, if its a generic object stringify it + if issubclass(type(value), object): + value = str(value) + changes[attr.key] = value + auditAction = ( + AuditLogActionEnum.create_object + if is_created + else AuditLogActionEnum.update_object + ) + AdminAuditLog.create( + admin_user_id=current_user.id, + model=model.__class__.__name__, + model_id=model.id, + action=auditAction.value, + data=changes, + ) + + def on_model_delete(self, model): + AdminAuditLog.create( + admin_user_id=current_user.id, + model=model.__class__.__name__, + model_id=model.id, + action=AuditLogActionEnum.delete_object.value, + ) + class SLAdminIndexView(AdminIndexView): @expose("/") @@ -113,6 +160,9 @@ class UserAdmin(SLModelView): user.trial_end = arrow.now().shift(weeks=1) flash(f"Extend trial for {user} to {user.trial_end}", "success") + AdminAuditLog.extend_trial( + current_user.id, user.id, user.trial_end, "1 week" + ) Session.commit() @@ -123,14 +173,19 @@ class UserAdmin(SLModelView): ) def disable_otp_fido(self, ids): for user in User.filter(User.id.in_(ids)): + user_had_otp = user.enable_otp if user.enable_otp: user.enable_otp = False flash(f"Disable OTP for {user}", "info") + user_had_fido = user.fido_uuid is not None if user.fido_uuid: Fido.filter_by(uuid=user.fido_uuid).delete() user.fido_uuid = None flash(f"Disable FIDO for {user}", "info") + AdminAuditLog.disable_otp_fido( + current_user.id, user.id, user_had_otp, user_had_fido + ) Session.commit() @@ -145,6 +200,7 @@ class UserAdmin(SLModelView): # return # # for user in User.filter(User.id.in_(ids)): + # AdminAuditLog.logged_as_user(current_user.id, user.id) # login_user(user) # flash(f"Login as user {user}", "success") # return redirect("/") @@ -172,6 +228,7 @@ def manual_upgrade(way: str, ids: [int], is_giveaway: bool): ) continue + AdminAuditLog.create_manual_upgrade(current_user.id, way, user.id, is_giveaway) manual_sub: ManualSubscription = ManualSubscription.get_by(user_id=user.id) if manual_sub: # renew existing subscription @@ -179,7 +236,6 @@ def manual_upgrade(way: str, ids: [int], is_giveaway: bool): manual_sub.end_at = manual_sub.end_at.shift(years=1) else: manual_sub.end_at = arrow.now().shift(years=1, days=1) - Session.commit() flash(f"Subscription extended to {manual_sub.end_at.humanize()}", "success") continue @@ -188,10 +244,10 @@ def manual_upgrade(way: str, ids: [int], is_giveaway: bool): end_at=arrow.now().shift(years=1, days=1), comment=way, is_giveaway=is_giveaway, - commit=True, ) flash(f"New {way} manual subscription for {user} is created", "success") + Session.commit() class EmailLogAdmin(SLModelView): @@ -235,6 +291,9 @@ class ManualSubscriptionAdmin(SLModelView): for ms in ManualSubscription.filter(ManualSubscription.id.in_(ids)): ms.end_at = ms.end_at.shift(years=1) flash(f"Extend subscription for 1 year for {ms.user}", "success") + AdminAuditLog.extend_subscription( + current_user.id, ms.user.id, ms.end_at, "1 year" + ) Session.commit() @@ -247,6 +306,9 @@ class ManualSubscriptionAdmin(SLModelView): for ms in ManualSubscription.filter(ManualSubscription.id.in_(ids)): ms.end_at = ms.end_at.shift(months=1) flash(f"Extend subscription for 1 month for {ms.user}", "success") + AdminAuditLog.extend_subscription( + current_user.id, ms.user.id, ms.end_at, "1 month" + ) Session.commit() @@ -280,3 +342,27 @@ class ReferralAdmin(SLModelView): # can_edit = True # can_create = True # can_delete = True + + +def _admin_action_formatter(view, context, model, name): + action_name = AuditLogActionEnum.get_name(model.action) + return "{} ({})".format(action_name, model.action) + + +def _admin_created_at_formatter(view, context, model, name): + return model.created_at.format() + + +class AdminAuditLogAdmin(SLModelView): + column_searchable_list = ["admin.id", "admin.email", "model_id", "created_at"] + column_filters = ["admin.id", "admin.email", "model_id", "created_at"] + column_exclude_list = ["id"] + column_hide_backrefs = False + can_edit = False + can_create = False + can_delete = False + + column_formatters = { + "action": _admin_action_formatter, + "created_at": _admin_created_at_formatter, + } diff --git a/app/models.py b/app/models.py index 0b721825..2a7cf9ca 100644 --- a/app/models.py +++ b/app/models.py @@ -226,6 +226,17 @@ class BlockBehaviourEnum(EnumE): return_5xx = 1 +class AuditLogActionEnum(EnumE): + create_object = 0 + update_object = 1 + delete_object = 2 + manual_upgrade = 3 + extend_trial = 4 + disable_2fa = 5 + logged_as_user = 6 + extend_subscription = 7 + + class Hibp(Base, ModelMixin): __tablename__ = "hibp" name = sa.Column(sa.String(), nullable=False, unique=True, index=True) @@ -2878,4 +2889,95 @@ class PhoneMessage(Base, ModelMixin): number = orm.relationship(PhoneNumber) +class AdminAuditLog(Base): + __tablename__ = "admin_audit_log" + + id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) + created_at = sa.Column(ArrowType, default=arrow.utcnow, nullable=False) + admin_user_id = sa.Column(sa.ForeignKey("users.id"), nullable=False) + action = sa.Column(sa.Integer, nullable=False) + model = sa.Column(sa.Text, nullable=False) + model_id = sa.Column(sa.Integer, nullable=True) + data = sa.Column(sa.JSON, nullable=True) + + admin = orm.relationship(User, foreign_keys=[admin_user_id]) + + @classmethod + def create(cls, **kw): + r = cls(**kw) + Session.add(r) + + return r + + @classmethod + def create_manual_upgrade( + cls, admin_user_id: int, upgrade_type: str, user_id: int, giveaway: bool + ): + cls.create( + admin_user_id=admin_user_id, + action=AuditLogActionEnum.manual_upgrade.value, + model="User", + model_id=user_id, + data={ + "upgrade_type": upgrade_type, + "giveaway": giveaway, + }, + ) + + @classmethod + def extend_trial( + cls, admin_user_id: int, user_id: int, trial_end: arrow.Arrow, extend_time: str + ): + cls.create( + admin_user_id=admin_user_id, + action=AuditLogActionEnum.extend_trial.value, + model="User", + model_id=user_id, + data={ + "trial_end": trial_end.format(arrow.FORMAT_RFC3339), + "extend_time": extend_time, + }, + ) + + @classmethod + def disable_otp_fido( + cls, admin_user_id: int, user_id: int, had_otp: bool, had_fido: bool + ): + cls.create( + admin_user_id=admin_user_id, + action=AuditLogActionEnum.disable_2fa.value, + model="User", + model_id=user_id, + data={"had_otp": had_otp, "had_fido": had_fido}, + ) + + @classmethod + def logged_as_user(cls, admin_user_id: int, user_id: int): + cls.create( + admin_user_id=admin_user_id, + action=AuditLogActionEnum.logged_as_user.value, + model="User", + model_id=user_id, + ) + + @classmethod + def extend_subscription( + cls, + admin_user_id: int, + user_id: int, + subscription_end: arrow.Arrow, + extend_time: str, + ): + cls.create( + admin_user_id=admin_user_id, + action=AuditLogActionEnum.extend_subscription.value, + model="User", + model_id=user_id, + data={ + "subscription_end": subscription_end.format(arrow.FORMAT_RFC3339), + "extend_time": extend_time, + }, + ) + + # endregion diff --git a/migrations/versions/2022_031015_b500363567e3_create_admin_audit_log.py b/migrations/versions/2022_031015_b500363567e3_create_admin_audit_log.py new file mode 100644 index 00000000..88859a9b --- /dev/null +++ b/migrations/versions/2022_031015_b500363567e3_create_admin_audit_log.py @@ -0,0 +1,37 @@ +"""Create admin audit log + +Revision ID: b500363567e3 +Revises: 9282e982bc05 +Create Date: 2022-03-10 15:26:54.538717 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "b500363567e3" +down_revision = "4729b7096d12" +branch_labels = None +depends_on = None + + +def upgrade(): + admin_table = op.create_table( + "admin_audit_log", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("created_at", sqlalchemy_utils.types.arrow.ArrowType(), nullable=False), + sa.Column("admin_user_id", sa.Integer, nullable=False), + sa.Column("action", sa.Integer, nullable=False), + sa.Column("model", sa.String(length=256), nullable=False), + sa.Column("model_id", sa.Integer, nullable=True), + sa.Column("data", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Index("admin_audit_log_admin_user_id_idx", 'admin_user_id'), + sa.ForeignKeyConstraint(['admin_user_id'], ['users.id'], ondelete='cascade'), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade(): + op.drop_table("admin_audit_log") diff --git a/server.py b/server.py index 8d6c9426..d05308c1 100644 --- a/server.py +++ b/server.py @@ -36,6 +36,7 @@ from app.admin_model import ( ManualSubscriptionAdmin, CouponAdmin, CustomDomainAdmin, + AdminAuditLogAdmin, ) from app.api.base import api_bp from app.auth.base import auth_bp @@ -88,6 +89,7 @@ from app.models import ( Contact, ManualSubscription, Coupon, + AdminAuditLog, ) from app.monitor.base import monitor_bp from app.oauth.base import oauth_bp @@ -688,6 +690,7 @@ def init_admin(app): admin.add_view(CouponAdmin(Coupon, Session)) admin.add_view(ManualSubscriptionAdmin(ManualSubscription, Session)) admin.add_view(CustomDomainAdmin(CustomDomain, Session)) + admin.add_view(AdminAuditLogAdmin(AdminAuditLog, Session)) def register_custom_commands(app):