Automatic audit logging using SQLA events
This commit is contained in:
14
atst/app.py
14
atst/app.py
@@ -18,6 +18,7 @@ from atst.routes.dev import bp as dev_routes
|
||||
from atst.routes.errors import make_error_pages
|
||||
from atst.domain.authnid.crl import CRLCache
|
||||
from atst.domain.auth import apply_authentication
|
||||
from atst.domain.authz import Authorization
|
||||
from atst.eda_client import MockEDAClient
|
||||
from atst.uploader import Uploader
|
||||
|
||||
@@ -69,13 +70,12 @@ def make_flask_callbacks(app):
|
||||
g.dev = os.getenv("FLASK_ENV", "dev") == "dev"
|
||||
g.matchesPath = lambda href: re.match("^" + href, request.path)
|
||||
g.modal = request.args.get("modal", None)
|
||||
g.current_user = {
|
||||
"id": "cce17030-4109-4719-b958-ed109dbb87c8",
|
||||
"first_name": "Amanda",
|
||||
"last_name": "Adamson",
|
||||
"atat_role": "default",
|
||||
"atat_permissions": [],
|
||||
}
|
||||
g.Authorization = Authorization
|
||||
|
||||
@app.after_request
|
||||
def _cleanup(response):
|
||||
g.pop("current_user", None)
|
||||
return response
|
||||
|
||||
|
||||
def map_config(config):
|
||||
|
53
atst/domain/audit_log.py
Normal file
53
atst/domain/audit_log.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from atst.database import db
|
||||
from atst.domain.common import Query
|
||||
from atst.domain.authz import Authorization, Permissions
|
||||
from atst.models.audit_event import AuditEvent
|
||||
|
||||
|
||||
class AuditEventQuery(Query):
|
||||
model = AuditEvent
|
||||
|
||||
@classmethod
|
||||
def get_all(cls):
|
||||
return db.session.query(cls.model).order_by(cls.model.time_created.desc()).all()
|
||||
|
||||
|
||||
class AuditLog(object):
|
||||
@classmethod
|
||||
def log_event(cls, user, resource, action):
|
||||
return cls._log(user=user, resource=resource, action=action)
|
||||
|
||||
@classmethod
|
||||
def log_workspace_event(cls, user, workspace, resource, action):
|
||||
return cls._log(
|
||||
user=user, workspace_id=workspace.id, resource=resource, action=action
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def log_system_event(cls, resource, action):
|
||||
return cls._log(resource=resource, action=action)
|
||||
|
||||
@classmethod
|
||||
def get_all_events(cls, user):
|
||||
Authorization.check_atat_permission(
|
||||
user, Permissions.VIEW_AUDIT_LOG, "view audit log"
|
||||
)
|
||||
return AuditEventQuery.get_all()
|
||||
|
||||
@classmethod
|
||||
def _resource_name(cls, resource):
|
||||
return type(resource).__name__.lower()
|
||||
|
||||
@classmethod
|
||||
def _log(cls, user=None, workspace_id=None, resource=None, action=None):
|
||||
resource_id = resource.id if resource else None
|
||||
resource_name = cls._resource_name(resource) if resource else None
|
||||
|
||||
audit_event = AuditEventQuery.create(
|
||||
user=user,
|
||||
workspace_id=workspace_id,
|
||||
resource_id=resource_id,
|
||||
resource_name=resource_name,
|
||||
action=action,
|
||||
)
|
||||
return AuditEventQuery.add_and_commit(audit_event)
|
@@ -34,6 +34,10 @@ def get_current_user():
|
||||
else:
|
||||
return False
|
||||
|
||||
def logout():
|
||||
if session.get("user_id"):
|
||||
del (session["user_id"])
|
||||
|
||||
|
||||
def _unprotected_route(request):
|
||||
if request.endpoint in UNPROTECTED_ROUTES:
|
||||
|
@@ -43,3 +43,12 @@ class Authorization(object):
|
||||
def check_workspace_permission(cls, user, workspace, permission, message):
|
||||
if not Authorization.has_workspace_permission(user, workspace, permission):
|
||||
raise UnauthorizedError(user, message)
|
||||
|
||||
@classmethod
|
||||
def check_atat_permission(cls, user, permission, message):
|
||||
if not Authorization.has_atat_permission(user, permission):
|
||||
raise UnauthorizedError(user, message)
|
||||
|
||||
@classmethod
|
||||
def can_view_audit_log(cls, user):
|
||||
return Authorization.has_atat_permission(user, Permissions.VIEW_AUDIT_LOG)
|
||||
|
@@ -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
|
||||
|
44
atst/models/audit_event.py
Normal file
44
atst/models/audit_event.py
Normal 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
|
||||
)
|
@@ -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()
|
||||
|
2
atst/models/mixins/__init__.py
Normal file
2
atst/models/mixins/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .timestamps import TimestampsMixin
|
||||
from .auditable import AuditableMixin
|
66
atst/models/mixins/auditable.py
Normal file
66
atst/models/mixins/auditable.py
Normal 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")
|
@@ -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 = (
|
||||
|
@@ -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()
|
||||
|
@@ -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()
|
||||
|
@@ -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()
|
||||
|
@@ -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()
|
||||
|
@@ -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()
|
||||
|
@@ -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
|
||||
|
@@ -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()
|
||||
|
@@ -5,6 +5,8 @@ import pendulum
|
||||
from atst.domain.requests import Requests
|
||||
from atst.domain.users import Users
|
||||
from atst.domain.authnid import AuthenticationContext
|
||||
from atst.domain.audit_log import AuditLog
|
||||
from atst.domain.auth import logout as _logout
|
||||
|
||||
|
||||
bp = Blueprint("atst", __name__)
|
||||
@@ -79,7 +81,11 @@ def login_redirect():
|
||||
|
||||
@bp.route("/logout")
|
||||
def logout():
|
||||
if session.get("user_id"):
|
||||
del (session["user_id"])
|
||||
|
||||
_logout()
|
||||
return redirect(url_for(".home"))
|
||||
|
||||
|
||||
@bp.route("/activity-history")
|
||||
def activity_history():
|
||||
audit_events = AuditLog.get_all_events(g.current_user)
|
||||
return render_template("audit_log.html", audit_events=audit_events)
|
||||
|
Reference in New Issue
Block a user