diff --git a/assets/js/lib/test-utils/index.jsx b/assets/js/lib/test-utils/index.jsx
index a427ea6bcc..1cb8f6ffc9 100644
--- a/assets/js/lib/test-utils/index.jsx
+++ b/assets/js/lib/test-utils/index.jsx
@@ -16,6 +16,9 @@ const middlewares = [];
const mockStore = configureStore(middlewares);
export const defaultInitialState = {
+ activityLog: {
+ users: [],
+ },
user: {
abilities: [{ name: 'all', resource: 'all' }],
},
diff --git a/assets/js/pages/ActivityLogPage/ActivityLogPage.jsx b/assets/js/pages/ActivityLogPage/ActivityLogPage.jsx
index 7ed8b592e5..a27440f045 100644
--- a/assets/js/pages/ActivityLogPage/ActivityLogPage.jsx
+++ b/assets/js/pages/ActivityLogPage/ActivityLogPage.jsx
@@ -6,7 +6,7 @@ import { map, pipe } from 'lodash/fp';
import { getActivityLog } from '@lib/api/activityLogs';
import { allowedActivities } from '@lib/model/activityLog';
-
+import { getActivityLogUsers } from '@state/selectors/activityLog';
import { getUserProfile } from '@state/selectors/user';
import PageHeader from '@common/PageHeader';
@@ -20,6 +20,7 @@ import {
} from './searchParams';
function ActivityLogPage() {
+ const users = useSelector(getActivityLogUsers);
const [searchParams, setSearchParams] = useSearchParams();
const [activityLog, setActivityLog] = useState([]);
const [isLoading, setLoading] = useState(true);
@@ -37,6 +38,12 @@ function ActivityLogPage() {
map(([key, value]) => [key, value.label])
)(abilities),
},
+ {
+ key: 'actor',
+ type: 'select',
+ title: 'User',
+ options: users,
+ },
{
key: 'to_date',
title: 'newer than',
diff --git a/assets/js/pages/ActivityLogPage/ActivityLogPage.test.jsx b/assets/js/pages/ActivityLogPage/ActivityLogPage.test.jsx
index 60b1c1a849..b2e703c001 100644
--- a/assets/js/pages/ActivityLogPage/ActivityLogPage.test.jsx
+++ b/assets/js/pages/ActivityLogPage/ActivityLogPage.test.jsx
@@ -1,12 +1,19 @@
import React, { act } from 'react';
import { screen } from '@testing-library/react';
import '@testing-library/jest-dom';
+import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter';
-import { renderWithRouter, withDefaultState } from '@lib/test-utils';
import { networkClient } from '@lib/network';
+import {
+ renderWithRouter,
+ withDefaultState,
+ withState,
+ defaultInitialState,
+} from '@lib/test-utils';
import { activityLogEntryFactory } from '@lib/test-utils/factories/activityLog';
+import { userFactory } from '@lib/test-utils/factories/users';
import ActivityLogPage from './ActivityLogPage';
@@ -15,8 +22,8 @@ const axiosMock = new MockAdapter(networkClient);
describe('ActivityLogPage', () => {
it('should render table without data', async () => {
axiosMock.onGet('/api/v1/activity_log').reply(200, { data: [] });
- const [StatefulActivityLogPage] = withDefaultState();
- await act(async () => renderWithRouter(StatefulActivityLogPage));
+ const [StatefulActivityLogPage, _] = withDefaultState();
+ await act(() => renderWithRouter(StatefulActivityLogPage));
expect(screen.getByText('No data available')).toBeVisible();
});
@@ -39,7 +46,9 @@ describe('ActivityLogPage', () => {
axiosMock
.onGet('/api/v1/activity_log')
.reply(responseStatus, responseBody);
- const [StatefulActivityLogPage] = withDefaultState();
+ const [StatefulActivityLogPage, _] = withDefaultState(
+
+ );
await act(() => renderWithRouter(StatefulActivityLogPage));
expect(screen.getByText('No data available')).toBeVisible();
@@ -50,13 +59,29 @@ describe('ActivityLogPage', () => {
axiosMock
.onGet('/api/v1/activity_log')
.reply(200, { data: activityLogEntryFactory.buildList(5) });
+ const [StatefulActivityLogPage, _] = withDefaultState();
+ const { container } = await act(() =>
+ renderWithRouter(StatefulActivityLogPage)
+ );
+ expect(container.querySelectorAll('tbody > tr')).toHaveLength(5);
+ });
- const [StatefulActivityLogPage] = withDefaultState();
-
+ it('should render tracked activity log and the users filter with non-default/non-empty state', async () => {
+ const users = userFactory.buildList(5).map((user) => user.username);
+ axiosMock
+ .onGet('/api/v1/activity_log')
+ .reply(200, { data: activityLogEntryFactory.buildList(5) });
+ const [StatefulActivityLogPage, _] = withState(, {
+ ...defaultInitialState,
+ activityLog: { users },
+ });
const { container } = await act(() =>
renderWithRouter(StatefulActivityLogPage)
);
- expect(container.querySelectorAll('tbody > tr')).toHaveLength(5);
+ await userEvent.click(screen.getByTestId('filter-User'));
+ expect(container.querySelectorAll('ul > li[role="option"]')).toHaveLength(
+ users.length
+ );
});
});
diff --git a/assets/js/state/activityLog.js b/assets/js/state/activityLog.js
new file mode 100644
index 0000000000..b9da15352a
--- /dev/null
+++ b/assets/js/state/activityLog.js
@@ -0,0 +1,21 @@
+import { createAction, createSlice } from '@reduxjs/toolkit';
+
+export const initialState = {
+ users: [],
+};
+
+export const activityLogSlice = createSlice({
+ name: 'activityLog',
+ initialState,
+ reducers: {
+ setUsers(state, { payload: { users } }) {
+ state.users = users;
+ },
+ },
+});
+
+export const ACTIVITY_LOG_USERS_PUSHED = 'ACTIVITY_LOG_USERS_PUSHED';
+export const activityLogUsersPushed = createAction(ACTIVITY_LOG_USERS_PUSHED);
+export const { setUsers } = activityLogSlice.actions;
+
+export default activityLogSlice.reducer;
diff --git a/assets/js/state/activityLog.test.js b/assets/js/state/activityLog.test.js
new file mode 100644
index 0000000000..84bf130973
--- /dev/null
+++ b/assets/js/state/activityLog.test.js
@@ -0,0 +1,27 @@
+import { userFactory } from '@lib/test-utils/factories/users';
+
+import activityLogReducer, { setUsers, initialState } from './activityLog';
+
+describe('activityLog reducer', () => {
+ it('should set the users for activity log when setUsers is dispatched', () => {
+ const { username: username1 } = userFactory.build();
+ const { username: username2 } = userFactory.build();
+ const users = [username1, username2];
+ const action = setUsers({ users });
+ expect(activityLogReducer(initialState, action)).toEqual({
+ users,
+ });
+ });
+
+ it('should set the users for activity log when setUsers is dispatched with non empty initial state', () => {
+ const { username: username1 } = userFactory.build();
+ const { username: username2 } = userFactory.build();
+ const { username: username3 } = userFactory.build();
+ const users = [username1, username2];
+ const nonEmptyInitialState = { users: [username3] };
+ const action = setUsers({ users });
+ expect(activityLogReducer(nonEmptyInitialState, action)).toEqual({
+ users,
+ });
+ });
+});
diff --git a/assets/js/state/index.js b/assets/js/state/index.js
index b2a16edca7..5da328ce03 100644
--- a/assets/js/state/index.js
+++ b/assets/js/state/index.js
@@ -14,6 +14,7 @@ import settingsReducer from './settings';
import userReducer from './user';
import softwareUpdatesReducer from './softwareUpdates';
import activityLogsSettingsReducer from './activityLogsSettings';
+import activityLogReducer from './activityLog';
import rootSaga from './sagas';
export const createStore = (router) => {
@@ -38,6 +39,7 @@ export const createStore = (router) => {
user: userReducer,
softwareUpdates: softwareUpdatesReducer,
activityLogsSettings: activityLogsSettingsReducer,
+ activityLog: activityLogReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(sagaMiddleware),
diff --git a/assets/js/state/sagas/activityLog.js b/assets/js/state/sagas/activityLog.js
new file mode 100644
index 0000000000..83cb159823
--- /dev/null
+++ b/assets/js/state/sagas/activityLog.js
@@ -0,0 +1,10 @@
+import { put, takeEvery } from 'redux-saga/effects';
+import { setUsers, ACTIVITY_LOG_USERS_PUSHED } from '@state/activityLog';
+
+export function* activityLogUsersUpdate({ payload: { users } }) {
+ yield put(setUsers({ users }));
+}
+
+export function* watchActivityLogActions() {
+ yield takeEvery(ACTIVITY_LOG_USERS_PUSHED, activityLogUsersUpdate);
+}
diff --git a/assets/js/state/sagas/activityLog.test.js b/assets/js/state/sagas/activityLog.test.js
new file mode 100644
index 0000000000..f1a0d77e98
--- /dev/null
+++ b/assets/js/state/sagas/activityLog.test.js
@@ -0,0 +1,16 @@
+import { recordSaga } from '@lib/test-utils';
+import { userFactory } from '@lib/test-utils/factories/users';
+import { setUsers } from '@state/activityLog';
+import { activityLogUsersUpdate } from './activityLog';
+
+describe('Activity Logs saga', () => {
+ it('should set users when activity log users are updated', async () => {
+ const users = userFactory.buildList(5).map((user) => user.username);
+
+ const dispatched = await recordSaga(activityLogUsersUpdate, {
+ payload: { users },
+ });
+
+ expect(dispatched).toEqual([setUsers({ users })]);
+ });
+});
diff --git a/assets/js/state/sagas/channels.js b/assets/js/state/sagas/channels.js
index 5ec791be63..c0e07511f3 100644
--- a/assets/js/state/sagas/channels.js
+++ b/assets/js/state/sagas/channels.js
@@ -55,6 +55,7 @@ import {
} from '@state/lastExecutions';
import { userUpdated, userLocked, userDeleted } from '@state/user';
+import { activityLogUsersPushed } from '@state/activityLog';
import { getUserProfile } from '@state/selectors/user';
@@ -236,6 +237,13 @@ const userEvents = [
},
];
+const activityLogEvents = [
+ {
+ name: 'al_users_pushed',
+ action: activityLogUsersPushed,
+ },
+];
+
const createEventChannel = (channel, events) =>
eventChannel((emitter) => {
events.forEach((event) => {
@@ -281,5 +289,11 @@ export function* watchSocketEvents(socket) {
fork(watchChannelEvents, socket, 'monitoring:databases', databaseEvents),
fork(watchChannelEvents, socket, 'monitoring:executions', executionEvents),
fork(watchChannelEvents, socket, `users:${userID}`, userEvents),
+ fork(
+ watchChannelEvents,
+ socket,
+ `activity_log:${userID}`,
+ activityLogEvents
+ ),
]);
}
diff --git a/assets/js/state/sagas/channels.test.js b/assets/js/state/sagas/channels.test.js
index b6bf070cdf..58f81867ff 100644
--- a/assets/js/state/sagas/channels.test.js
+++ b/assets/js/state/sagas/channels.test.js
@@ -6,6 +6,7 @@ import {
setExecutionStarted,
} from '@state/lastExecutions';
import { userUpdated } from '@state/user';
+import { activityLogUsersPushed } from '@state/activityLog';
import { watchSocketEvents } from './channels';
@@ -48,6 +49,7 @@ const channels = {
'monitoring:databases': new MockChannel(),
'monitoring:executions': new MockChannel(),
[`users:${USER_ID}`]: new MockChannel(),
+ [`activity_log:${USER_ID}`]: new MockChannel(),
};
const mockSocket = {
@@ -125,4 +127,18 @@ describe('Channels saga', () => {
expect(dispatched).toEqual([userUpdated({ email: 'new@email.com' })]);
});
+ it('should listen to specific activity log events', async () => {
+ const { saga, dispatched } = runWatchSocketEventsSaga(mockSocket);
+
+ channels[`activity_log:${USER_ID}`].emit('al_users_pushed', {
+ users: ['user1', 'user2', 'user3'],
+ });
+
+ closeSocket();
+ await saga;
+
+ expect(dispatched).toEqual([
+ activityLogUsersPushed({ users: ['user1', 'user2', 'user3'] }),
+ ]);
+ });
});
diff --git a/assets/js/state/sagas/index.js b/assets/js/state/sagas/index.js
index 1e3dae42d1..de70adf3c1 100644
--- a/assets/js/state/sagas/index.js
+++ b/assets/js/state/sagas/index.js
@@ -80,6 +80,7 @@ import { watchActivityLogsSettings } from '@state/sagas/activityLogsSettings';
import { watchSoftwareUpdates } from '@state/sagas/softwareUpdates';
import { watchSocketEvents } from '@state/sagas/channels';
+import { watchActivityLogActions } from '@state/sagas/activityLog';
import { checkApiKeyExpiration } from '@state/sagas/settings';
const RESET_STATE = 'RESET_STATE';
@@ -249,5 +250,6 @@ export default function* rootSaga() {
watchUserLoggedIn(),
watchActivityLogsSettings(),
watchSoftwareUpdates(),
+ watchActivityLogActions(),
]);
}
diff --git a/assets/js/state/selectors/activityLog.js b/assets/js/state/selectors/activityLog.js
new file mode 100644
index 0000000000..6c28a68be3
--- /dev/null
+++ b/assets/js/state/selectors/activityLog.js
@@ -0,0 +1,6 @@
+import { createSelector } from '@reduxjs/toolkit';
+
+export const getActivityLogUsers = createSelector(
+ [(state) => state.activityLog],
+ (activityLog) => activityLog.users
+);
diff --git a/assets/js/state/selectors/activityLog.test.js b/assets/js/state/selectors/activityLog.test.js
new file mode 100644
index 0000000000..e01d04c941
--- /dev/null
+++ b/assets/js/state/selectors/activityLog.test.js
@@ -0,0 +1,12 @@
+import { getActivityLogUsers } from './activityLog';
+
+describe('Activity Log users selector', () => {
+ it('should return a list of users from activity log state', () => {
+ const users = ['user1', 'user2', 'user3'];
+ const state = {
+ activityLog: { users },
+ };
+
+ expect(getActivityLogUsers(state)).toEqual(users);
+ });
+});
diff --git a/config/config.exs b/config/config.exs
index abbea1856f..a526aa6efe 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -221,6 +221,8 @@ config :flop, repo: Trento.Repo
config :trento,
admin_user: "admin"
+config :trento, :activity_log, refresh_interval: 60_000
+
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"
diff --git a/config/test.exs b/config/test.exs
index e780cd81e7..e8aaff9925 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -91,3 +91,5 @@ config :trento, Trento.Infrastructure.SoftwareUpdates.MockSuma,
# 448 matches to "test" fqdn
448
]
+
+config :trento, :activity_log, refresh_interval: 1
diff --git a/lib/trento/users.ex b/lib/trento/users.ex
index bb12353c4b..a641c14182 100644
--- a/lib/trento/users.ex
+++ b/lib/trento/users.ex
@@ -37,6 +37,16 @@ defmodule Trento.Users do
|> Repo.all()
end
+ @doc """
+ Returns all usernames tupled with the deleted_at timestamp, including those for users that are soft-deleted.
+ """
+ @spec list_all_usernames :: list({String.t(), DateTime.t()})
+ def list_all_usernames do
+ User
+ |> select([u], {u.username, u.deleted_at})
+ |> Repo.all()
+ end
+
def get_user(id) do
case User
|> where([u], is_nil(u.deleted_at) and u.id == ^id)
diff --git a/lib/trento_web/channels/activity_log_channel.ex b/lib/trento_web/channels/activity_log_channel.ex
new file mode 100644
index 0000000000..64fa177e28
--- /dev/null
+++ b/lib/trento_web/channels/activity_log_channel.ex
@@ -0,0 +1,57 @@
+defmodule TrentoWeb.ActivityLogChannel do
+ @moduledoc """
+ Activity Log channel, each user is subscribed to this channel,
+
+ """
+ require Logger
+ use TrentoWeb, :channel
+ alias Trento.Users
+ @refresh_interval Application.compile_env!(:trento, [:activity_log, :refresh_interval])
+
+ @impl true
+ def join(
+ "activity_log:" <> user_id,
+ _payload,
+ %{assigns: %{current_user_id: current_user_id}} = socket
+ ) do
+ if allowed?(user_id, current_user_id) do
+ send(self(), :after_join)
+ {:ok, socket}
+ else
+ Logger.error(
+ "Could not join activity_log channel, requested user id: #{user_id}, authenticated user id: #{current_user_id}"
+ )
+
+ {:error, :unauthorized}
+ end
+ end
+
+ def join("activity_log:" <> _user_id, _payload, _socket) do
+ {:error, :user_not_logged}
+ end
+
+ @impl true
+ def handle_info(:after_join, socket) do
+ all_usernames_ts = Users.list_all_usernames()
+ collapsed_usernames = collapse_usernames(all_usernames_ts)
+
+ users = ["system" | collapsed_usernames]
+ push(socket, "al_users_pushed", %{users: users})
+ Process.send_after(self(), :after_join, @refresh_interval)
+ {:noreply, socket}
+ end
+
+ defp allowed?(user_id, current_user_id), do: String.to_integer(user_id) == current_user_id
+
+ defp collapse_usernames(usernames_ts) do
+ usernames_ts
+ |> Enum.map(fn
+ {username, nil} ->
+ username
+
+ {username, deleted_at} ->
+ String.trim_trailing(username, "__" <> DateTime.to_string(deleted_at))
+ end)
+ |> Enum.uniq()
+ end
+end
diff --git a/lib/trento_web/channels/user_socket.ex b/lib/trento_web/channels/user_socket.ex
index 7e20cdb649..fbebeb6b43 100644
--- a/lib/trento_web/channels/user_socket.ex
+++ b/lib/trento_web/channels/user_socket.ex
@@ -13,6 +13,7 @@ defmodule TrentoWeb.UserSocket do
channel "monitoring:*", TrentoWeb.MonitoringChannel
channel "users:*", TrentoWeb.UserChannel
+ channel "activity_log:*", TrentoWeb.ActivityLogChannel
# Socket params are passed from the client and can
# be used to verify and authenticate a user. After
diff --git a/test/trento/users_test.exs b/test/trento/users_test.exs
index 7f1f69ab78..40494a0ef9 100644
--- a/test/trento/users_test.exs
+++ b/test/trento/users_test.exs
@@ -117,6 +117,25 @@ defmodule Trento.UsersTest do
assert length(users) == 1
end
+ test "list_all_usernames returns all usernames tupled with the deleted_at field, including those for deleted users" do
+ %{username: username1, deleted_at: deleted_at1} = insert(:user)
+
+ %{username: username2, deleted_at: deleted_at2} =
+ insert(:user, deleted_at: DateTime.utc_now())
+
+ sorter_fn = fn {username, _deleted_at} -> username end
+
+ inserted_sorted_usernames =
+ Enum.sort_by([{username1, deleted_at1}, {username2, deleted_at2}], sorter_fn)
+
+ sorted_usernames =
+ Enum.sort_by(Users.list_all_usernames(), sorter_fn)
+
+ assert inserted_sorted_usernames == sorted_usernames
+ assert length(sorted_usernames) == 2
+ assert length(inserted_sorted_usernames) == 2
+ end
+
test "get_user returns a user when the user exist" do
%{id: user_id} = insert(:user)
diff --git a/test/trento_web/channels/activity_log_channel_test.exs b/test/trento_web/channels/activity_log_channel_test.exs
new file mode 100644
index 0000000000..ad1398d6bd
--- /dev/null
+++ b/test/trento_web/channels/activity_log_channel_test.exs
@@ -0,0 +1,28 @@
+defmodule TrentoWeb.ActivityLogChannelTest do
+ use TrentoWeb.ChannelCase
+
+ test "Socket users can only join the activity log channel" do
+ assert {:ok, _, _socket} =
+ TrentoWeb.UserSocket
+ |> socket("user_id", %{current_user_id: 876})
+ |> join(TrentoWeb.ActivityLogChannel, "activity_log:876")
+
+ assert_push("al_users_pushed", %{users: _})
+ assert_push("al_users_pushed", %{users: _})
+ assert_push("al_users_pushed", %{users: _})
+ end
+
+ test "Unauthorized users cannot join the activity log channel" do
+ assert {:error, :unauthorized} =
+ TrentoWeb.UserSocket
+ |> socket("user_id", %{current_user_id: 788})
+ |> join(TrentoWeb.ActivityLogChannel, "activity_log:876")
+ end
+
+ test "Non logged users cannot join an activity log channel" do
+ assert {:error, :user_not_logged} =
+ TrentoWeb.UserSocket
+ |> socket("user_id", %{})
+ |> join(TrentoWeb.ActivityLogChannel, "activity_log:8989")
+ end
+end