Automatic audit logging using SQLA events

This commit is contained in:
richard-dds
2018-09-19 11:41:27 -04:00
parent b7a33de29d
commit ddc2e2fad7
27 changed files with 346 additions and 26 deletions

View File

@@ -17,3 +17,4 @@ from .attachment import Attachment
from .request_revision import RequestRevision
from .request_review import RequestReview
from .request_internal_comment import RequestInternalComment
from .audit_event import AuditEvent

View File

@@ -0,0 +1,44 @@
from sqlalchemy import String, Column, ForeignKey, inspect
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from atst.models import Base, types
from atst.models.mixins.timestamps import TimestampsMixin
class AuditEvent(Base, TimestampsMixin):
__tablename__ = "audit_events"
id = types.Id()
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
user = relationship("User", backref="audit_events")
workspace_id = Column(UUID(as_uuid=True), ForeignKey("workspaces.id"), index=True)
workspace = relationship("Workspace", backref="audit_events")
resource_name = Column(String(), nullable=False)
resource_id = Column(UUID(as_uuid=True), index=True, nullable=False)
action = Column(String(), nullable=False)
def __str__(self):
full_action = "{} on {} {}".format(
self.action, self.resource_name, self.resource_id
)
if self.user and self.workspace:
return "{} performed {} in workspace {}".format(
self.user.full_name, full_action, self.workspace_id
)
if self.user:
return "{} performed {}".format(self.user.full_name, full_action)
else:
return "ATAT System performed {}".format(full_action)
def save(self, connection):
attrs = inspect(self).dict
connection.execute(
self.__table__.insert(),
**attrs
)

View File

@@ -3,10 +3,10 @@ from sqlalchemy.orm import relationship
from atst.models import Base
from atst.models.types import Id
from atst.models.mixins import TimestampsMixin
from atst.models import mixins
class Environment(Base, TimestampsMixin):
class Environment(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
__tablename__ = "environments"
id = Id()

View File

@@ -0,0 +1,2 @@
from .timestamps import TimestampsMixin
from .auditable import AuditableMixin

View File

@@ -0,0 +1,66 @@
from sqlalchemy import event
from flask import g
import re
from atst.models.audit_event import AuditEvent
ACTION_CREATE = "create"
ACTION_UPDATE = "update"
ACTION_DELETE = "delete"
def getattr_path(obj, path, default=None):
_obj = obj
for item in path.split('.'):
_obj = getattr(_obj, item, default)
return _obj
def camel_to_snake(camel_cased):
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', camel_cased)
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
class AuditableMixin(object):
@staticmethod
def create_audit_event(connection, resource, action):
user_id = getattr_path(g, "current_user.id")
workspace_id = resource.auditable_workspace_id()
resource_name = resource.auditable_resource_name()
audit_event = AuditEvent(
user_id=user_id,
workspace_id=workspace_id,
resource_name=resource_name,
resource_id=resource.id,
action=action,
)
audit_event.save(connection)
@classmethod
def __declare_last__(cls):
event.listen(cls, 'after_insert', cls.audit_insert)
event.listen(cls, 'after_delete', cls.audit_delete)
event.listen(cls, 'after_update', cls.audit_update)
@staticmethod
def audit_insert(mapper, connection, target):
"""Listen for the `after_insert` event and create an AuditLog entry"""
target.create_audit_event(connection, target, ACTION_CREATE)
@staticmethod
def audit_delete(mapper, connection, target):
"""Listen for the `after_delete` event and create an AuditLog entry"""
target.create_audit_event(connection, target, ACTION_DELETE)
@staticmethod
def audit_update(mapper, connection, target):
target.create_audit_event(connection, target, ACTION_UPDATE)
def auditable_resource_name(self):
return camel_to_snake(type(self).__name__)
def auditable_workspace_id(self):
return getattr_path(self, "workspace.id")

View File

@@ -1,4 +1,5 @@
class Permissions(object):
VIEW_AUDIT_LOG = "view_audit_log"
REQUEST_JEDI_WORKSPACE = "request_jedi_workspace"
VIEW_ORIGINAL_JEDI_REQEUST = "view_original_jedi_request"
REVIEW_AND_APPROVE_JEDI_WORKSPACE_REQUEST = (

View File

@@ -3,10 +3,10 @@ from sqlalchemy.orm import relationship
from atst.models import Base
from atst.models.types import Id
from atst.models.mixins import TimestampsMixin
from atst.models import mixins
class Project(Base, TimestampsMixin):
class Project(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
__tablename__ = "projects"
id = Id()

View File

@@ -24,7 +24,7 @@ def update_dict_with_properties(instance, body, top_level_key, properties):
return body
class Request(Base, mixins.TimestampsMixin):
class Request(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
__tablename__ = "requests"
id = types.Id()

View File

@@ -4,7 +4,7 @@ from sqlalchemy.orm import relationship
from atst.models import Base, mixins, types
class RequestReview(Base, mixins.TimestampsMixin):
class RequestReview(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
__tablename__ = "request_reviews"
id = types.Id()

View File

@@ -12,11 +12,11 @@ from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import ARRAY
from atst.models import Base
from atst.models.mixins import TimestampsMixin
from atst.models import mixins
from atst.models.types import Id
class RequestRevision(Base, TimestampsMixin):
class RequestRevision(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
__tablename__ = "request_revisions"
id = Id()

View File

@@ -22,7 +22,7 @@ class RequestStatus(Enum):
DELETED = "Deleted"
class RequestStatusEvent(Base, mixins.TimestampsMixin):
class RequestStatusEvent(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
__tablename__ = "request_status_events"
id = Id()

View File

@@ -3,12 +3,12 @@ from sqlalchemy.orm import relationship
from atst.models import Base
from atst.models.types import Id
from atst.models.mixins import TimestampsMixin
from atst.models import mixins
from atst.models.workspace_user import WorkspaceUser
from atst.utils import first_or_none
class Workspace(Base, TimestampsMixin):
class Workspace(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
__tablename__ = "workspaces"
id = Id()
@@ -40,3 +40,6 @@ class Workspace(Base, TimestampsMixin):
@property
def members(self):
return [WorkspaceUser(role.user, role) for role in self.roles]
def auditable_workspace_id(self):
return self.id

View File

@@ -6,7 +6,7 @@ from atst.models import Base, mixins
from .types import Id
class WorkspaceRole(Base, mixins.TimestampsMixin):
class WorkspaceRole(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
__tablename__ = "workspace_roles"
id = Id()