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

@ -0,0 +1,39 @@
"""add view_audit_log permission
Revision ID: 7958cca588a1
Revises: 875841fac207
Create Date: 2018-09-14 10:20:20.016575
"""
from alembic import op
from sqlalchemy.orm.session import Session
from atst.models.role import Role
from atst.models.permissions import Permissions
# revision identifiers, used by Alembic.
revision = '7958cca588a1'
down_revision = '875841fac207'
branch_labels = None
depends_on = None
def upgrade():
session = Session(bind=op.get_bind())
admin_roles = session.query(Role).filter(Role.name.in_(["ccpo", "security_auditor"])).all()
for role in admin_roles:
role.add_permission(Permissions.VIEW_AUDIT_LOG)
session.add(role)
session.commit()
def downgrade():
session = Session(bind=op.get_bind())
admin_roles = session.query(Role).filter(Role.name.in_(["ccpo", "security_auditor"])).all()
for role in admin_roles:
role.remove_permission(Permissions.VIEW_AUDIT_LOG)
session.add(role)
session.commit()

View File

@ -0,0 +1,44 @@
"""add audit_events table
Revision ID: 875841fac207
Revises: 2572be7fb7fc
Create Date: 2018-09-13 15:34:18.815205
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '875841fac207'
down_revision = '359caaf8c5f1'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('audit_events',
sa.Column('time_created', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('time_updated', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('uuid_generate_v4()'), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('resource_name', sa.String(), nullable=False),
sa.Column('resource_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('action', sa.String(), nullable=False),
sa.Column('workspace_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id']),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_audit_events_resource_id'), 'audit_events', ['resource_id'], unique=False)
op.create_index(op.f('ix_audit_events_user_id'), 'audit_events', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_audit_events_user_id'), table_name='audit_events')
op.drop_index(op.f('ix_audit_events_resource_id'), table_name='audit_events')
op.drop_table('audit_events')
# ### end Alembic commands ###

View File

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

View File

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

View File

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

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()

View File

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

22
templates/audit_log.html Normal file
View File

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block content %}
<div class="panel">
<section class="block-list">
<header class="block-list__header">
<h1 class="block-list__title">Acitivity History</h1>
</header>
<ul>
{% for event in audit_events %}
<li class="block-list__item">{{ event }}</li>
{% endfor %}
</ul>
</section>
</div>
{% endblock %}

View File

@ -25,5 +25,9 @@
{% if g.current_user.has_workspaces %}
{{ SidenavItem("Workspaces", href="/workspaces", icon="cloud", active=g.matchesPath('/workspaces')) }}
{% endif %}
{% if g.Authorization.can_view_audit_log(g.current_user) %}
{{ SidenavItem("Activity History", url_for('atst.activity_history'), icon="document", active=g.matchesPath('/activity-history')) }}
{% endif %}
</ul>
</div>

View File

@ -9,6 +9,7 @@ from tempfile import TemporaryDirectory
from atst.app import make_app, make_config
from atst.database import db as _db
from atst.domain.auth import logout
import tests.factories as factories
from tests.mocks import PDF_FILENAME
@ -108,7 +109,7 @@ def user_session(monkeypatch, session):
def set_user_session(user=None):
monkeypatch.setattr(
"atst.domain.auth.get_current_user",
lambda *args: user or factories.UserFactory.build(),
lambda *args: user or factories.UserFactory.create(),
)
return set_user_session

View File

@ -0,0 +1,20 @@
import pytest
from atst.domain.audit_log import AuditLog
from atst.domain.exceptions import UnauthorizedError
from tests.factories import UserFactory
@pytest.fixture(scope="function")
def ccpo():
return UserFactory.from_atat_role("ccpo")
@pytest.fixture(scope="function")
def developer():
return UserFactory.from_atat_role("default")
def test_non_admin_cannot_view_audit_log(developer):
with pytest.raises(UnauthorizedError):
AuditLog.get_all_events(developer)

View File

@ -1,10 +1,11 @@
from atst.domain.projects import Projects
from atst.domain.workspaces import Workspaces
from tests.factories import RequestFactory
from atst.domain.workspaces import Workspaces
def test_create_project_with_multiple_environments():
workspace = Workspaces.create(RequestFactory.create())
request = RequestFactory.create()
workspace = Workspaces.create(request)
project = Projects.create(
workspace.owner, workspace, "My Test Project", "Test", ["dev", "prod"]
)

View File

@ -133,7 +133,7 @@ class TestPENumberInForm:
request = RequestFactory.create(creator=user)
monkeypatch.setattr("atst.domain.requests.Requests.get", lambda *args: request)
monkeypatch.setattr("atst.forms.financial.validate_pe_id", lambda *args: True)
user_session()
user_session(user)
data = {**self.required_data, **extended_financial_verification_data}
data["task_order_number"] = "1234567"