Merge pull request #835 from acasajus/new/admin-audit-trail

New/admin audit trail
This commit is contained in:
Son Nguyen Kim 2022-03-14 16:51:38 +01:00 committed by GitHub
commit 6d52daee21
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 231 additions and 3 deletions

View File

@ -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,
}

View File

@ -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

View File

@ -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")

View File

@ -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):