diff --git a/atst/domain/audit_log.py b/atst/domain/audit_log.py index 6dc42a50..00bbba17 100644 --- a/atst/domain/audit_log.py +++ b/atst/domain/audit_log.py @@ -1,3 +1,5 @@ +from sqlalchemy import or_ + from atst.database import db from atst.domain.common import Query from atst.domain.authz import Authorization, Permissions @@ -12,11 +14,25 @@ class AuditEventQuery(Query): query = db.session.query(cls.model).order_by(cls.model.time_created.desc()) return cls.paginate(query, pagination_opts) + @classmethod + def get_ws_events(cls, workspace_id, pagination_opts): + query = ( + db.session.query(cls.model) + .filter( + or_( + cls.model.workspace_id == workspace_id, + cls.model.resource_id == workspace_id, + ) + ) + .order_by(cls.model.time_created.desc()) + ) + return cls.paginate(query, pagination_opts) + class AuditLog(object): @classmethod - def log_system_event(cls, resource, action): - return cls._log(resource=resource, action=action) + def log_system_event(cls, resource, action, workspace=None): + return cls._log(resource=resource, action=action, workspace=workspace) @classmethod def get_all_events(cls, user, pagination_opts=None): @@ -25,6 +41,16 @@ class AuditLog(object): ) return AuditEventQuery.get_all(pagination_opts) + @classmethod + def get_workspace_events(cls, user, workspace, pagination_opts=None): + Authorization.check_workspace_permission( + user, + workspace, + Permissions.VIEW_WORKSPACE_AUDIT_LOG, + "view workspace audit log", + ) + return AuditEventQuery.get_ws_events(workspace.id, pagination_opts) + @classmethod def get_by_resource(cls, resource_id): return ( @@ -39,9 +65,10 @@ class AuditLog(object): return type(resource).__name__.lower() @classmethod - def _log(cls, user=None, workspace_id=None, resource=None, action=None): + def _log(cls, user=None, workspace=None, resource=None, action=None): resource_id = resource.id if resource else None resource_type = cls._resource_type(resource) if resource else None + workspace_id = workspace.id if workspace else None audit_event = AuditEventQuery.create( user=user, diff --git a/atst/domain/common/__init__.py b/atst/domain/common/__init__.py index f829496f..684d4ce6 100644 --- a/atst/domain/common/__init__.py +++ b/atst/domain/common/__init__.py @@ -1 +1,2 @@ from .query import Query +from .query import Paginator diff --git a/atst/domain/common/query.py b/atst/domain/common/query.py index 07db46ef..0846387c 100644 --- a/atst/domain/common/query.py +++ b/atst/domain/common/query.py @@ -17,6 +17,13 @@ class Paginator(object): def __init__(self, query_set): self.query_set = query_set + @classmethod + def get_pagination_opts(cls, request, default_page=1, default_per_page=100): + return { + "page": int(request.args.get("page", default_page)), + "per_page": int(request.args.get("perPage", default_per_page)), + } + @classmethod def paginate(cls, query, pagination_opts=None): if pagination_opts is not None: diff --git a/atst/domain/roles.py b/atst/domain/roles.py index 91137cbc..5f7e1ebb 100644 --- a/atst/domain/roles.py +++ b/atst/domain/roles.py @@ -44,6 +44,7 @@ ATAT_ROLES = [ Permissions.ADD_TAG_TO_WORKSPACE, Permissions.REMOVE_TAG_FROM_WORKSPACE, Permissions.VIEW_AUDIT_LOG, + Permissions.VIEW_WORKSPACE_AUDIT_LOG, ], }, { @@ -84,6 +85,7 @@ WORKSPACE_ROLES = [ Permissions.DEACTIVATE_ENVIRONMENT_IN_APPLICATION, Permissions.VIEW_ENVIRONMENT_IN_APPLICATION, Permissions.RENAME_ENVIRONMENT_IN_APPLICATION, + Permissions.VIEW_WORKSPACE_AUDIT_LOG, ], }, { @@ -111,6 +113,7 @@ WORKSPACE_ROLES = [ Permissions.DEACTIVATE_ENVIRONMENT_IN_APPLICATION, Permissions.VIEW_ENVIRONMENT_IN_APPLICATION, Permissions.RENAME_ENVIRONMENT_IN_APPLICATION, + Permissions.VIEW_WORKSPACE_AUDIT_LOG, ], }, { diff --git a/atst/models/permissions.py b/atst/models/permissions.py index f7adc406..231d65a2 100644 --- a/atst/models/permissions.py +++ b/atst/models/permissions.py @@ -1,5 +1,6 @@ class Permissions(object): VIEW_AUDIT_LOG = "view_audit_log" + VIEW_WORKSPACE_AUDIT_LOG = "view_workspace_audit_log" REQUEST_JEDI_WORKSPACE = "request_jedi_workspace" VIEW_ORIGINAL_JEDI_REQEUST = "view_original_jedi_request" REVIEW_AND_APPROVE_JEDI_WORKSPACE_REQUEST = ( diff --git a/atst/routes/__init__.py b/atst/routes/__init__.py index 7581eed3..07550af7 100644 --- a/atst/routes/__init__.py +++ b/atst/routes/__init__.py @@ -12,6 +12,7 @@ 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 +from atst.domain.common import Paginator from atst.utils.flash import formatted_flash as flash @@ -126,16 +127,9 @@ def logout(): return redirect(url_for(".root")) -def get_pagination_opts(request, default_page=1, default_per_page=100): - return { - "page": int(request.args.get("page", default_page)), - "per_page": int(request.args.get("perPage", default_per_page)), - } - - @bp.route("/activity-history") def activity_history(): - pagination_opts = get_pagination_opts(request) + pagination_opts = Paginator.get_pagination_opts(request) audit_events = AuditLog.get_all_events(g.current_user, pagination_opts) return render_template("audit_log/audit_log.html", audit_events=audit_events) diff --git a/atst/routes/workspaces/index.py b/atst/routes/workspaces/index.py index 2bdcd6d2..063f66f5 100644 --- a/atst/routes/workspaces/index.py +++ b/atst/routes/workspaces/index.py @@ -5,8 +5,10 @@ from flask import render_template, request as http_request, g, redirect, url_for from . import workspaces_bp from atst.domain.reports import Reports from atst.domain.workspaces import Workspaces -from atst.forms.workspace import WorkspaceForm +from atst.domain.audit_log import AuditLog from atst.domain.authz import Authorization +from atst.domain.common import Paginator +from atst.forms.workspace import WorkspaceForm from atst.models.permissions import Permissions @@ -80,3 +82,19 @@ def workspace_reports(workspace_id): expiration_date=expiration_date, remaining_days=remaining_days, ) + + +@workspaces_bp.route("/workspaces//activity") +def workspace_activity(workspace_id): + workspace = Workspaces.get(g.current_user, workspace_id) + pagination_opts = Paginator.get_pagination_opts(http_request) + audit_events = AuditLog.get_workspace_events( + g.current_user, workspace, pagination_opts + ) + + return render_template( + "workspaces/activity/index.html", + workspace_name=workspace.name, + workspace_id=workspace_id, + audit_events=audit_events, + ) diff --git a/script/seed_roles.py b/script/seed_roles.py old mode 100644 new mode 100755 index d1d7298b..cfe0337f --- a/script/seed_roles.py +++ b/script/seed_roles.py @@ -1,3 +1,4 @@ +#! .venv/bin/python # Add root project dir to the python path import os import sys diff --git a/templates/audit_log/audit_log.html b/templates/audit_log/audit_log.html index 73a7443c..6c2cf5c4 100644 --- a/templates/audit_log/audit_log.html +++ b/templates/audit_log/audit_log.html @@ -2,24 +2,8 @@ {% from "components/pagination.html" import Pagination %} {% block content %} -
-
-
-

{{ "audit_log.header_title" | translate }}

-
- -
    - {% for event in audit_events %} -
  • - {% autoescape false %} - {{ event | renderAuditEvent }} - {% endautoescape %} -
  • - {% endfor %} -
-
- - {{ Pagination(audit_events, 'atst.activity_history') }} + {% include "fragments/audit_events_log.html" %} + {{ Pagination(audit_events, 'atst.activity_history')}}
{% endblock %} diff --git a/templates/components/pagination.html b/templates/components/pagination.html index 5b1c84a9..d7674d66 100644 --- a/templates/components/pagination.html +++ b/templates/components/pagination.html @@ -1,4 +1,4 @@ -{% macro Page(pagination, route, i, label=None, disabled=False) -%} +{% macro Page(pagination, route, i, label=None, disabled=False, workspace_id=None) -%} {% set label = label or i %} {% set button_class = "page usa-button " %} @@ -11,38 +11,43 @@ {% set button_class = button_class + "usa-button-secondary" %} {% endif %} - {{ label }} + {{ label }} {%- endmacro %} -{% macro Pagination(pagination, route) -%} +{% macro Pagination(pagination, route, workspace_id=None) -%} {%- endmacro %} diff --git a/templates/fragments/audit_events_log.html b/templates/fragments/audit_events_log.html new file mode 100644 index 00000000..4c9de82a --- /dev/null +++ b/templates/fragments/audit_events_log.html @@ -0,0 +1,17 @@ +{% from "components/pagination.html" import Pagination %} + +
+
+

{{ "audit_log.header_title" | translate }}

+
+ + +
diff --git a/templates/navigation/workspace_navigation.html b/templates/navigation/workspace_navigation.html index 3a36bff5..9ef4dd86 100644 --- a/templates/navigation/workspace_navigation.html +++ b/templates/navigation/workspace_navigation.html @@ -56,5 +56,13 @@ ) }} {% endif %} + {% if user_can(permissions.VIEW_WORKSPACE_AUDIT_LOG) %} + {{ SidenavItem( + ("navigation.workspace_navigation.activity_log" | translate), + href=url_for("workspaces.workspace_activity", workspace_id=workspace.id), + active=request.url_rule.rule.startswith('/workspaces//activity') + ) }} + {% endif %} + diff --git a/templates/workspaces/activity/index.html b/templates/workspaces/activity/index.html new file mode 100644 index 00000000..9c072af1 --- /dev/null +++ b/templates/workspaces/activity/index.html @@ -0,0 +1,9 @@ +{% extends "workspaces/base.html" %} +{% from "components/pagination.html" import Pagination %} + +{% block workspace_content %} +
+ {% include "fragments/audit_events_log.html" %} + {{ Pagination(audit_events, 'workspaces.workspace_activity', workspace_id=workspace_id) }} +
+{% endblock %} diff --git a/tests/domain/test_audit_log.py b/tests/domain/test_audit_log.py index 3e000dde..a8a5e1c2 100644 --- a/tests/domain/test_audit_log.py +++ b/tests/domain/test_audit_log.py @@ -2,7 +2,14 @@ import pytest from atst.domain.audit_log import AuditLog from atst.domain.exceptions import UnauthorizedError -from tests.factories import UserFactory +from atst.domain.roles import Roles +from atst.models.workspace_role import Status as WorkspaceRoleStatus +from tests.factories import ( + UserFactory, + WorkspaceFactory, + WorkspaceRoleFactory, + ProjectFactory, +) @pytest.fixture(scope="function") @@ -21,7 +28,8 @@ def test_non_admin_cannot_view_audit_log(developer): def test_ccpo_can_view_audit_log(ccpo): - AuditLog.get_all_events(ccpo) + events = AuditLog.get_all_events(ccpo) + assert len(events) > 0 def test_paginate_audit_log(ccpo): @@ -31,3 +39,72 @@ def test_paginate_audit_log(ccpo): events = AuditLog.get_all_events(ccpo, pagination_opts={"per_page": 25, "page": 2}) assert len(events) == 25 + + +def test_ccpo_can_view_ws_audit_log(ccpo): + workspace = WorkspaceFactory.create() + events = AuditLog.get_workspace_events(ccpo, workspace) + assert len(events) > 0 + + +def test_ws_admin_can_view_ws_audit_log(): + workspace = WorkspaceFactory.create() + admin = UserFactory.create() + WorkspaceRoleFactory.create( + workspace=workspace, + user=admin, + role=Roles.get("admin"), + status=WorkspaceRoleStatus.ACTIVE, + ) + events = AuditLog.get_workspace_events(admin, workspace) + assert len(events) > 0 + + +def test_ws_owner_can_view_ws_audit_log(): + workspace = WorkspaceFactory.create() + events = AuditLog.get_workspace_events(workspace.owner, workspace) + assert len(events) > 0 + + +def test_other_users_cannot_view_ws_audit_log(): + with pytest.raises(UnauthorizedError): + workspace = WorkspaceFactory.create() + dev = UserFactory.create() + WorkspaceRoleFactory.create( + workspace=workspace, + user=dev, + role=Roles.get("developer"), + status=WorkspaceRoleStatus.ACTIVE, + ) + AuditLog.get_workspace_events(dev, workspace) + + +def test_paginate_ws_audit_log(): + workspace = WorkspaceFactory.create() + project = ProjectFactory.create(workspace=workspace) + for _ in range(100): + AuditLog.log_system_event( + resource=project, action="create", workspace=workspace + ) + + events = AuditLog.get_workspace_events( + workspace.owner, workspace, pagination_opts={"per_page": 25, "page": 2} + ) + assert len(events) == 25 + + +def test_ws_audit_log_only_includes_current_ws_events(): + owner = UserFactory.create() + workspace = WorkspaceFactory.create(owner=owner) + other_workspace = WorkspaceFactory.create(owner=owner) + # Add some audit events + project_1 = ProjectFactory.create(workspace=workspace) + project_2 = ProjectFactory.create(workspace=other_workspace) + + events = AuditLog.get_workspace_events(workspace.owner, workspace) + for event in events: + assert event.workspace_id == workspace.id or event.resource_id == workspace.id + assert ( + not event.workspace_id == other_workspace.id + or event.resource_id == other_workspace.id + ) diff --git a/tests/routes/workspaces/test_projects.py b/tests/routes/workspaces/test_projects.py index af697c3c..a442e9fb 100644 --- a/tests/routes/workspaces/test_projects.py +++ b/tests/routes/workspaces/test_projects.py @@ -8,8 +8,10 @@ from tests.factories import ( EnvironmentFactory, ProjectFactory, ) + from atst.domain.projects import Projects from atst.domain.workspaces import Workspaces +from atst.domain.roles import Roles from atst.models.workspace_role import Status as WorkspaceRoleStatus @@ -36,6 +38,55 @@ def test_user_without_permission_has_no_budget_report_link(client, user_session) ) +def test_user_with_permission_has_activity_log_link(client, user_session): + workspace = WorkspaceFactory.create() + ccpo = UserFactory.from_atat_role("ccpo") + admin = UserFactory.create() + WorkspaceRoleFactory.create( + workspace=workspace, + user=admin, + role=Roles.get("admin"), + status=WorkspaceRoleStatus.ACTIVE, + ) + + user_session(workspace.owner) + response = client.get("/workspaces/{}/projects".format(workspace.id)) + assert ( + 'href="/workspaces/{}/activity"'.format(workspace.id).encode() in response.data + ) + + # logs out previous user before creating a new session + user_session(admin) + response = client.get("/workspaces/{}/projects".format(workspace.id)) + assert ( + 'href="/workspaces/{}/activity"'.format(workspace.id).encode() in response.data + ) + + user_session(ccpo) + response = client.get("/workspaces/{}/projects".format(workspace.id)) + assert ( + 'href="/workspaces/{}/activity"'.format(workspace.id).encode() in response.data + ) + + +def test_user_without_permission_has_no_activity_log_link(client, user_session): + workspace = WorkspaceFactory.create() + developer = UserFactory.create() + WorkspaceRoleFactory.create( + workspace=workspace, + user=developer, + role=Roles.get("developer"), + status=WorkspaceRoleStatus.ACTIVE, + ) + + user_session(developer) + response = client.get("/workspaces/{}/projects".format(workspace.id)) + assert ( + 'href="/workspaces/{}/activity"'.format(workspace.id).encode() + not in response.data + ) + + def test_user_with_permission_has_add_project_link(client, user_session): workspace = WorkspaceFactory.create() user_session(workspace.owner) diff --git a/translations.yaml b/translations.yaml index d4268246..61859980 100644 --- a/translations.yaml +++ b/translations.yaml @@ -202,6 +202,7 @@ navigation: add_new_member_label: Add New Member add_new_project_label: Add New Project budget_report: Budget Report + activity_log: Activity Log members: Members projects: Projects task_orders: Task Orders