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.routes.errors import make_error_pages
from atst.domain.authnid.crl import CRLCache from atst.domain.authnid.crl import CRLCache
from atst.domain.auth import apply_authentication from atst.domain.auth import apply_authentication
from atst.domain.authz import Authorization
from atst.eda_client import MockEDAClient from atst.eda_client import MockEDAClient
from atst.uploader import Uploader from atst.uploader import Uploader
@ -69,13 +70,12 @@ def make_flask_callbacks(app):
g.dev = os.getenv("FLASK_ENV", "dev") == "dev" g.dev = os.getenv("FLASK_ENV", "dev") == "dev"
g.matchesPath = lambda href: re.match("^" + href, request.path) g.matchesPath = lambda href: re.match("^" + href, request.path)
g.modal = request.args.get("modal", None) g.modal = request.args.get("modal", None)
g.current_user = { g.Authorization = Authorization
"id": "cce17030-4109-4719-b958-ed109dbb87c8",
"first_name": "Amanda", @app.after_request
"last_name": "Adamson", def _cleanup(response):
"atat_role": "default", g.pop("current_user", None)
"atat_permissions": [], return response
}
def map_config(config): 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: else:
return False return False
def logout():
if session.get("user_id"):
del (session["user_id"])
def _unprotected_route(request): def _unprotected_route(request):
if request.endpoint in UNPROTECTED_ROUTES: if request.endpoint in UNPROTECTED_ROUTES:

View File

@ -43,3 +43,12 @@ class Authorization(object):
def check_workspace_permission(cls, user, workspace, permission, message): def check_workspace_permission(cls, user, workspace, permission, message):
if not Authorization.has_workspace_permission(user, workspace, permission): if not Authorization.has_workspace_permission(user, workspace, permission):
raise UnauthorizedError(user, message) 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_revision import RequestRevision
from .request_review import RequestReview from .request_review import RequestReview
from .request_internal_comment import RequestInternalComment 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 import Base
from atst.models.types import Id 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" __tablename__ = "environments"
id = Id() 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): class Permissions(object):
VIEW_AUDIT_LOG = "view_audit_log"
REQUEST_JEDI_WORKSPACE = "request_jedi_workspace" REQUEST_JEDI_WORKSPACE = "request_jedi_workspace"
VIEW_ORIGINAL_JEDI_REQEUST = "view_original_jedi_request" VIEW_ORIGINAL_JEDI_REQEUST = "view_original_jedi_request"
REVIEW_AND_APPROVE_JEDI_WORKSPACE_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 import Base
from atst.models.types import Id 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" __tablename__ = "projects"
id = Id() id = Id()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,8 @@ import pendulum
from atst.domain.requests import Requests from atst.domain.requests import Requests
from atst.domain.users import Users from atst.domain.users import Users
from atst.domain.authnid import AuthenticationContext 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__) bp = Blueprint("atst", __name__)
@ -79,7 +81,11 @@ def login_redirect():
@bp.route("/logout") @bp.route("/logout")
def logout(): def logout():
if session.get("user_id"): _logout()
del (session["user_id"])
return redirect(url_for(".home")) 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 %} {% if g.current_user.has_workspaces %}
{{ SidenavItem("Workspaces", href="/workspaces", icon="cloud", active=g.matchesPath('/workspaces')) }} {{ SidenavItem("Workspaces", href="/workspaces", icon="cloud", active=g.matchesPath('/workspaces')) }}
{% endif %} {% 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> </ul>
</div> </div>

View File

@ -9,6 +9,7 @@ from tempfile import TemporaryDirectory
from atst.app import make_app, make_config from atst.app import make_app, make_config
from atst.database import db as _db from atst.database import db as _db
from atst.domain.auth import logout
import tests.factories as factories import tests.factories as factories
from tests.mocks import PDF_FILENAME from tests.mocks import PDF_FILENAME
@ -108,7 +109,7 @@ def user_session(monkeypatch, session):
def set_user_session(user=None): def set_user_session(user=None):
monkeypatch.setattr( monkeypatch.setattr(
"atst.domain.auth.get_current_user", "atst.domain.auth.get_current_user",
lambda *args: user or factories.UserFactory.build(), lambda *args: user or factories.UserFactory.create(),
) )
return set_user_session 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.projects import Projects
from atst.domain.workspaces import Workspaces
from tests.factories import RequestFactory from tests.factories import RequestFactory
from atst.domain.workspaces import Workspaces
def test_create_project_with_multiple_environments(): def test_create_project_with_multiple_environments():
workspace = Workspaces.create(RequestFactory.create()) request = RequestFactory.create()
workspace = Workspaces.create(request)
project = Projects.create( project = Projects.create(
workspace.owner, workspace, "My Test Project", "Test", ["dev", "prod"] workspace.owner, workspace, "My Test Project", "Test", ["dev", "prod"]
) )

View File

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