Merge pull request #446 from dod-ccpo/add-ws-role-changes-to-audit-log

Add ws role changes to audit log
This commit is contained in:
montana-mil 2018-11-26 11:13:38 -05:00 committed by GitHub
commit 4db7102626
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 181 additions and 17 deletions

View File

@ -0,0 +1,30 @@
"""Add columns to AuditEvent
Revision ID: 4f46aecb337f
Revises: 4c0b8263d800
Create Date: 2018-11-12 16:03:55.281648
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '4f46aecb337f'
down_revision = '4c0b8263d800'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('audit_events', sa.Column('changed_state', postgresql.JSON(astext_type=sa.Text()), nullable=True))
op.add_column('audit_events', sa.Column('event_details', postgresql.JSON(astext_type=sa.Text()), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('audit_events', 'event_details')
op.drop_column('audit_events', 'changed_state')
# ### end Alembic commands ###

View File

@ -14,16 +14,6 @@ class AuditEventQuery(Query):
class AuditLog(object): 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 @classmethod
def log_system_event(cls, resource, action): def log_system_event(cls, resource, action):
return cls._log(resource=resource, action=action) return cls._log(resource=resource, action=action)

View File

@ -101,9 +101,8 @@ class WorkspaceRoles(object):
return new_workspace_role return new_workspace_role
@classmethod @classmethod
def update_role(cls, member, workspace_id, role_name): def update_role(cls, workspace_role, role_name):
new_role = Roles.get(role_name) new_role = Roles.get(role_name)
workspace_role = WorkspaceRoles._get_workspace_role(member.user, workspace_id)
workspace_role.role = new_role workspace_role.role = new_role
db.session.add(workspace_role) db.session.add(workspace_role)

View File

@ -119,7 +119,7 @@ class Workspaces(object):
"edit workspace member", "edit workspace member",
) )
return WorkspaceRoles.update_role(member, workspace.id, role_name) return WorkspaceRoles.update_role(member, role_name)
@classmethod @classmethod
def _create_workspace_role( def _create_workspace_role(

View File

@ -1,5 +1,5 @@
from sqlalchemy import String, Column, ForeignKey, inspect from sqlalchemy import String, Column, ForeignKey, inspect
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from atst.models import Base, types from atst.models import Base, types
@ -20,6 +20,9 @@ class AuditEvent(Base, TimestampsMixin):
request_id = Column(UUID(as_uuid=True), ForeignKey("requests.id"), index=True) request_id = Column(UUID(as_uuid=True), ForeignKey("requests.id"), index=True)
request = relationship("Request", backref="audit_events") request = relationship("Request", backref="audit_events")
changed_state = Column(JSONB())
event_details = Column(JSONB())
resource_type = Column(String(), nullable=False) resource_type = Column(String(), nullable=False)
resource_id = Column(UUID(as_uuid=True), index=True, nullable=False) resource_id = Column(UUID(as_uuid=True), index=True, nullable=False)
display_name = Column(String()) display_name = Column(String())

View File

@ -1,4 +1,4 @@
from sqlalchemy import event from sqlalchemy import event, inspect
from flask import g from flask import g
from atst.models.audit_event import AuditEvent from atst.models.audit_event import AuditEvent
@ -17,6 +17,8 @@ class AuditableMixin(object):
request_id = resource.auditable_request_id() request_id = resource.auditable_request_id()
resource_type = resource.auditable_resource_type() resource_type = resource.auditable_resource_type()
display_name = resource.auditable_displayname() display_name = resource.auditable_displayname()
changed_state = resource.auditable_changed_state()
event_details = resource.auditable_event_details()
audit_event = AuditEvent( audit_event = AuditEvent(
user_id=user_id, user_id=user_id,
@ -26,6 +28,8 @@ class AuditableMixin(object):
resource_id=resource.id, resource_id=resource.id,
display_name=display_name, display_name=display_name,
action=action, action=action,
changed_state=changed_state,
event_details=event_details,
) )
audit_event.save(connection) audit_event.save(connection)
@ -50,6 +54,32 @@ class AuditableMixin(object):
def audit_update(mapper, connection, target): def audit_update(mapper, connection, target):
target.create_audit_event(connection, target, ACTION_UPDATE) target.create_audit_event(connection, target, ACTION_UPDATE)
def get_changes(self):
"""
This function borrows largely from a gist:
https://gist.github.com/ngse/c20058116b8044c65d3fbceda3fdf423#file-audit_mixin-py-L106-L120
It returns a dictionary of the form {item: [from_value, to_value]},
where 'item' is the attribute on the target that has been updated,
'from_value' is the value of the attribute before it was updated,
and 'to_value' is the current value of the attribute.
There may be more than one item in the dictionary, but that is not expected.
"""
previous_state = {}
attrs = inspect(self).mapper.column_attrs
for attr in attrs:
history = getattr(inspect(self).attrs, attr.key).history
if history.has_changes():
previous_state[attr.key] = [history.deleted.pop(), history.added.pop()]
return previous_state
def auditable_changed_state(self):
return getattr_path(self, "history")
def auditable_event_details(self):
return getattr_path(self, "event_details")
def auditable_resource_type(self): def auditable_resource_type(self):
return camel_to_snake(type(self).__name__) return camel_to_snake(type(self).__name__)

View File

@ -10,6 +10,7 @@ from atst.database import db
from atst.models.environment_role import EnvironmentRole from atst.models.environment_role import EnvironmentRole
from atst.models.project import Project from atst.models.project import Project
from atst.models.environment import Environment from atst.models.environment import Environment
from atst.models.role import Role
class Status(Enum): class Status(Enum):
@ -34,13 +35,35 @@ class WorkspaceRole(Base, mixins.TimestampsMixin, mixins.AuditableMixin):
UUID(as_uuid=True), ForeignKey("users.id"), index=True, nullable=False UUID(as_uuid=True), ForeignKey("users.id"), index=True, nullable=False
) )
status = Column(SQLAEnum(Status, native_enum=False, default=Status.PENDING)) status = Column(SQLAEnum(Status, native_enum=False), default=Status.PENDING)
def __repr__(self): def __repr__(self):
return "<WorkspaceRole(role='{}', workspace='{}', user_id='{}', id='{}')>".format( return "<WorkspaceRole(role='{}', workspace='{}', user_id='{}', id='{}')>".format(
self.role.name, self.workspace.name, self.user_id, self.id self.role.name, self.workspace.name, self.user_id, self.id
) )
@property
def history(self):
previous_state = self.get_changes()
change_set = {}
if "role_id" in previous_state:
from_role_id = previous_state["role_id"][0]
from_role = db.session.query(Role).filter(Role.id == from_role_id).one()
to_role = self.role_name
change_set["role"] = [from_role.name, to_role]
if "status" in previous_state:
from_status = previous_state["status"][0].value
to_status = self.status.value
change_set["status"] = [from_status, to_status]
return change_set
@property
def event_details(self):
return {
"updated_user_name": self.user_name,
"updated_user_id": str(self.user_id),
}
@property @property
def latest_invitation(self): def latest_invitation(self):
if self.invitations: if self.invitations:

View File

@ -28,6 +28,16 @@
<br> <br>
{% if event.event_details %}
for User <code>{{ event.event_details.updated_user_id }}</code> ({{ event.event_details.updated_user_name }})
<br>
{% endif %}
{% if event.changed_state %}
from {{ event.changed_state.role[0] }} to {{ event.changed_state.role[1] }}
<br>
{% endif %}
{% if event.workspace %} {% if event.workspace %}
in Workspace <code>{{ event.workspace_id }}</code> ({{ event.workspace.name }}) in Workspace <code>{{ event.workspace_id }}</code> ({{ event.workspace.name }})
{% elif event.request %} {% elif event.request %}

View File

@ -20,7 +20,7 @@ def test_non_admin_cannot_view_audit_log(developer):
AuditLog.get_all_events(developer) AuditLog.get_all_events(developer)
def test_ccpo_can_iview_audit_log(ccpo): def test_ccpo_can_view_audit_log(ccpo):
AuditLog.get_all_events(ccpo) AuditLog.get_all_events(ccpo)

View File

@ -326,6 +326,7 @@ class WorkspaceRoleFactory(Base):
workspace = factory.SubFactory(WorkspaceFactory) workspace = factory.SubFactory(WorkspaceFactory)
role = factory.SubFactory(RoleFactory) role = factory.SubFactory(RoleFactory)
user = factory.SubFactory(UserFactory) user = factory.SubFactory(UserFactory)
status = WorkspaceRoleStatus.PENDING
class EnvironmentRoleFactory(Base): class EnvironmentRoleFactory(Base):

View File

@ -5,7 +5,9 @@ from atst.domain.workspaces import Workspaces
from atst.domain.projects import Projects from atst.domain.projects import Projects
from atst.domain.workspace_roles import WorkspaceRoles from atst.domain.workspace_roles import WorkspaceRoles
from atst.models.workspace_role import Status from atst.models.workspace_role import Status
from atst.models.role import Role
from atst.models.invitation import Status as InvitationStatus from atst.models.invitation import Status as InvitationStatus
from atst.models.audit_event import AuditEvent
from tests.factories import ( from tests.factories import (
RequestFactory, RequestFactory,
UserFactory, UserFactory,
@ -14,6 +16,82 @@ from tests.factories import (
) )
def test_has_no_history(session):
owner = UserFactory.create()
user = UserFactory.create()
workspace = Workspaces.create(RequestFactory.create(creator=owner))
workspace_role = WorkspaceRoles.add(user, workspace.id, "developer")
create_event = (
session.query(AuditEvent)
.filter(
AuditEvent.resource_id == workspace_role.id, AuditEvent.action == "create"
)
.one()
)
assert not create_event.changed_state
def test_has_role_history(session):
owner = UserFactory.create()
user = UserFactory.create()
workspace = Workspaces.create(RequestFactory.create(creator=owner))
role = session.query(Role).filter(Role.name == "developer").one()
# in order to get the history, we don't want the WorkspaceRoleFactory
# to commit after create()
WorkspaceRoleFactory._meta.sqlalchemy_session_persistence = "flush"
workspace_role = WorkspaceRoleFactory.create(
workspace=workspace, user=user, role=role
)
WorkspaceRoles.update_role(workspace_role, "admin")
changed_events = (
session.query(AuditEvent)
.filter(
AuditEvent.resource_id == workspace_role.id, AuditEvent.action == "update"
)
.all()
)
# changed_state["role"] returns a list [previous role, current role]
assert changed_events[0].changed_state["role"][0] == "developer"
assert changed_events[0].changed_state["role"][1] == "admin"
def test_has_status_history(session):
owner = UserFactory.create()
user = UserFactory.create()
workspace = Workspaces.create(RequestFactory.create(creator=owner))
# in order to get the history, we don't want the WorkspaceRoleFactory
# to commit after create()
WorkspaceRoleFactory._meta.sqlalchemy_session_persistence = "flush"
workspace_role = WorkspaceRoleFactory.create(workspace=workspace, user=user)
WorkspaceRoles.enable(workspace_role)
changed_events = (
session.query(AuditEvent)
.filter(
AuditEvent.resource_id == workspace_role.id, AuditEvent.action == "update"
)
.all()
)
# changed_state["status"] returns a list [previous status, current status]
assert changed_events[0].changed_state["status"][0] == "pending"
assert changed_events[0].changed_state["status"][1] == "active"
def test_event_details():
owner = UserFactory.create()
user = UserFactory.create()
workspace = Workspaces.create(RequestFactory.create(creator=owner))
workspace_role = WorkspaceRoles.add(user, workspace.id, "developer")
assert workspace_role.event_details["updated_user_name"] == user.displayname
assert workspace_role.event_details["updated_user_id"] == str(user.id)
def test_has_no_environment_roles(): def test_has_no_environment_roles():
owner = UserFactory.create() owner = UserFactory.create()
developer_data = { developer_data = {