Skip to content

Commit a36ef63

Browse files
authoredFeb 19, 2025··
Merge pull request #312 from cryptomator/feature/audit-log-event-filter
Feature: Audit Log - Add Filter Option for Event Type
2 parents afc7a17 + 80aca9f commit a36ef63

File tree

6 files changed

+129
-14
lines changed

6 files changed

+129
-14
lines changed
 

‎CHANGELOG.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- WoT: Admins can adjust WoT parameters (#297)
1616
- Permission to create new vaults can now be controlled via the `create-vaults` role in Keycloak (#206)
1717
- Preserver user locale setting (#313)
18-
- Add Italian, Korean, Dutch and Portuguese translation
18+
- Italian, Korean, Dutch and Portuguese translation
19+
- Audit log filter by event type
1920

2021
### Changed
2122

‎backend/src/main/java/org/cryptomator/hub/api/AuditLogResource.java

+14-3
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838

3939
import java.time.Instant;
4040
import java.util.List;
41+
import java.util.Set;
4142
import java.util.UUID;
4243

4344
@Path("/auditlog")
@@ -57,11 +58,12 @@ public class AuditLogResource {
5758
@Parameter(name = "paginationId", description = "The smallest (asc ordering) or highest (desc ordering) audit entry id, not included in results. Used for pagination. ", in = ParameterIn.QUERY)
5859
@Parameter(name = "order", description = "The order of the queried table. Determines if most recent (desc) or oldest entries (asc) are considered first. Allowed Values are 'desc' (default) or 'asc'. Used for pagination.", in = ParameterIn.QUERY)
5960
@Parameter(name = "pageSize", description = "the maximum number of entries to return. Must be between 1 and 100.", in = ParameterIn.QUERY)
61+
@Parameter(name = "type", description = "the list of type of events to return. Empty list is all events.", in = ParameterIn.QUERY)
6062
@APIResponse(responseCode = "200", description = "Body contains list of events in the specified time interval")
61-
@APIResponse(responseCode = "400", description = "startDate or endDate not specified, startDate > endDate, order specified and not in ['asc','desc'] or pageSize not in [1 .. 100]")
63+
@APIResponse(responseCode = "400", description = "startDate or endDate not specified, startDate > endDate, order specified and not in ['asc','desc'], pageSize not in [1 .. 100] or type is not valid")
6264
@APIResponse(responseCode = "402", description = "Community license used or license expired")
6365
@APIResponse(responseCode = "403", description = "requesting user does not have admin role")
64-
public List<AuditEventDto> getAllEvents(@QueryParam("startDate") Instant startDate, @QueryParam("endDate") Instant endDate, @QueryParam("paginationId") Long paginationId, @QueryParam("order") @DefaultValue("desc") String order, @QueryParam("pageSize") @DefaultValue("20") int pageSize) {
66+
public List<AuditEventDto> getAllEvents(@QueryParam("startDate") Instant startDate, @QueryParam("endDate") Instant endDate, @QueryParam("type") List<String> type, @QueryParam("paginationId") Long paginationId, @QueryParam("order") @DefaultValue("desc") String order, @QueryParam("pageSize") @DefaultValue("20") int pageSize) {
6567
if (!license.isSet() || license.isExpired()) {
6668
throw new PaymentRequiredException("Community license used or license expired");
6769
}
@@ -74,11 +76,20 @@ public List<AuditEventDto> getAllEvents(@QueryParam("startDate") Instant startDa
7476
throw new BadRequestException("order must be either 'asc' or 'desc'");
7577
} else if (pageSize < 1 || pageSize > 100) {
7678
throw new BadRequestException("pageSize must be between 1 and 100");
79+
} else if (type == null) {
80+
throw new BadRequestException("type must be specified");
81+
} else if (!type.isEmpty()) {
82+
var validTypes = Set.of(DeviceRegisteredEvent.TYPE, DeviceRemovedEvent.TYPE, UserAccountResetEvent.TYPE, UserKeysChangeEvent.TYPE, UserSetupCodeChangeEvent.TYPE,
83+
SettingWotUpdateEvent.TYPE, SignedWotIdEvent.TYPE, VaultCreatedEvent.TYPE, VaultUpdatedEvent.TYPE, VaultAccessGrantedEvent.TYPE,
84+
VaultKeyRetrievedEvent.TYPE, VaultMemberAddedEvent.TYPE, VaultMemberRemovedEvent.TYPE, VaultMemberUpdatedEvent.TYPE, VaultOwnershipClaimedEvent.TYPE);
85+
if (!validTypes.containsAll(type)) {
86+
throw new BadRequestException("Invalid event type provided");
87+
}
7788
} else if (paginationId == null) {
7889
throw new BadRequestException("paginationId must be specified");
7990
}
8091

81-
return auditEventRepo.findAllInPeriod(startDate, endDate, paginationId, order.equals("asc"), pageSize).map(AuditEventDto::fromEntity).toList();
92+
return auditEventRepo.findAllInPeriod(startDate, endDate, type, paginationId, order.equals("asc"), pageSize).map(AuditEventDto::fromEntity).toList();
8293
}
8394

8495
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type")

‎backend/src/main/java/org/cryptomator/hub/entities/events/AuditEvent.java

+11-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import jakarta.persistence.Table;
1818

1919
import java.time.Instant;
20+
import java.util.List;
2021
import java.util.Objects;
2122
import java.util.stream.Stream;
2223

@@ -31,6 +32,7 @@
3132
WHERE ae.timestamp >= :startDate
3233
AND ae.timestamp < :endDate
3334
AND ae.id < :paginationId
35+
AND (:allTypes = true OR ae.type IN :types)
3436
ORDER BY ae.id DESC
3537
""")
3638
@NamedQuery(name = "AuditEvent.listAllInPeriodAfterId",
@@ -40,6 +42,7 @@
4042
WHERE ae.timestamp >= :startDate
4143
AND ae.timestamp < :endDate
4244
AND ae.id > :paginationId
45+
AND (:allTypes = true OR ae.type IN :types)
4346
ORDER BY ae.id ASC
4447
""")
4548
@SequenceGenerator(name = "audit_event_id_seq", sequenceName = "audit_event_id_seq", allocationSize = 1)
@@ -106,8 +109,14 @@ public int hashCode() {
106109
@ApplicationScoped
107110
public static class Repository implements PanacheRepository<AuditEvent> {
108111

109-
public Stream<AuditEvent> findAllInPeriod(Instant startDate, Instant endDate, long paginationId, boolean ascending, int pageSize) {
110-
var parameters = Parameters.with("startDate", startDate).and("endDate", endDate).and("paginationId", paginationId);
112+
public Stream<AuditEvent> findAllInPeriod(Instant startDate, Instant endDate, List<String> type, long paginationId, boolean ascending, int pageSize) {
113+
var allTypes = type.isEmpty();
114+
115+
var parameters = Parameters.with("startDate", startDate)
116+
.and("endDate", endDate)
117+
.and("paginationId", paginationId)
118+
.and("types", type)
119+
.and("allTypes", allTypes);
111120

112121
final PanacheQuery<AuditEvent> query;
113122
if (ascending) {

‎frontend/src/common/auditlog.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,9 @@ export class AuditLogEntityCache {
178178
/* Service */
179179

180180
class AuditLogService {
181-
public async getAllEvents(startDate: Date, endDate: Date, paginationId: number, order: string, pageSize: number): Promise<AuditEventDto[]> {
182-
return axiosAuth.get<AuditEventDto[]>(`/auditlog?startDate=${startDate.toISOString()}&endDate=${endDate.toISOString()}&paginationId=${paginationId}&order=${order}&pageSize=${pageSize}`)
181+
public async getAllEvents(startDate: Date, endDate: Date, type: string[], paginationId: number, order: string, pageSize: number): Promise<AuditEventDto[]> {
182+
const typeQuery = type.length > 0 ? `&type=${type.join('&type=')}` : '';
183+
return axiosAuth.get<AuditEventDto[]>(`/auditlog?startDate=${startDate.toISOString()}&endDate=${endDate.toISOString()}&paginationId=${paginationId}${typeQuery}&order=${order}&pageSize=${pageSize}`)
183184
.then(response => response.data.map(dto => {
184185
dto.timestamp = new Date(dto.timestamp);
185186
return dto;

‎frontend/src/components/AuditLog.vue

+97-6
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,14 @@
4141
</Listbox>
4242

4343
<PopoverGroup class="flex items-baseline space-x-8">
44-
<Popover as="div" class="relative inline-block text-left">
44+
<Popover v-slot="{ close }" as="div" class="relative inline-block text-left">
4545
<PopoverButton class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-xs text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-primary">
4646
<span>{{ t('auditLog.filter') }}</span>
4747
<ChevronDownIcon class="-mr-1 ml-2 h-5 w-5" aria-hidden="true" />
4848
</PopoverButton>
4949

5050
<transition enter-active-class="transition ease-out duration-100" enter-from-class="transform opacity-0 scale-95" enter-to-class="transform opacity-100 scale-100" leave-active-class="transition ease-in duration-75" leave-from-class="transform opacity-100 scale-100" leave-to-class="transform opacity-0 scale-95">
51-
<PopoverPanel class="absolute right-0 z-10 mt-2 origin-top-right rounded-md bg-white p-4 shadow-2xl ring-1 ring-black/5 focus:outline-hidden w-80">
51+
<PopoverPanel class="absolute right-0 z-10 mt-2 origin-top-right rounded-md bg-white p-4 shadow-2xl ring-1 ring-black/5 focus:outline-hidden w-96">
5252
<form class="space-y-4">
5353
<div class="sm:grid sm:grid-cols-2 sm:items-center sm:gap-2">
5454
<label for="filter-start-date" class="block text-sm font-medium text-gray-700">
@@ -62,11 +62,67 @@
6262
</label>
6363
<input id="filter-end-date" v-model="endDateFilter" type="text" class="shadow-xs focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 rounded-md" :class="{ 'border-red-300 text-red-900 focus:ring-red-500 focus:border-red-500': !endDateFilterIsValid }" placeholder="yyyy-MM-dd" />
6464
</div>
65+
<div class="sm:grid sm:grid-cols-2 sm:items-center sm:gap-2">
66+
<label class="block text-sm font-medium text-gray-700 flex items-center">
67+
{{ t('auditLog.type') }}
68+
<button
69+
type="button"
70+
class="ml-2 p-1 flex items-center justify-center focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-30 disabled:cursor-not-allowed"
71+
:disabled="selectedEventTypes.length === 0"
72+
:title="selectedEventTypes.length > 0 ? t('auditLog.filter.clerEventFilter') : ''"
73+
@click="selectedEventTypes = []"
74+
>
75+
<TrashIcon class="h-4 w-4 text-gray-500 hover:text-gray-700 disabled:text-gray-300" aria-hidden="true" />
76+
</button>
77+
</label>
78+
</div>
79+
<Listbox v-model="selectedEventTypes" as="div" multiple>
80+
<div class="relative w-88">
81+
<ListboxButton class="relative w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-10 text-left shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary text-sm">
82+
<div class="flex flex-wrap gap-2">
83+
<template v-if="selectedEventTypes.length > 0">
84+
<button
85+
v-for="type in selectedEventTypes"
86+
:key="type"
87+
class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20"
88+
:aria-label="t('auditLog.filter.removeEventType', { type: eventTypeOptions[type] })"
89+
@click.stop="removeEventType(type)"
90+
>
91+
<span class="mr-1">{{ eventTypeOptions[type] }}</span>
92+
<span class="text-green-800 font-bold" aria-hidden="true">&times;</span>
93+
</button>
94+
</template>
95+
<template v-else>
96+
<span class="text-gray-500">{{ t('auditLog.filter.selectEventFilter') }}</span>
97+
</template>
98+
</div>
99+
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
100+
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
101+
</span>
102+
</ListboxButton>
103+
<transition leave-active-class="transition ease-in duration-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
104+
<ListboxOptions class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5 focus:outline-none text-sm">
105+
<ListboxOption
106+
v-for="(label, key) in eventTypeOptions"
107+
:key="key"
108+
v-slot="{ selected }"
109+
class="relative cursor-default select-none py-2 pl-3 pr-9 ui-not-active:text-gray-900 ui-active:text-white ui-active:bg-primary"
110+
:value="key"
111+
>
112+
<span :class="[selected ? 'font-semibold' : 'font-normal', 'block truncate']">{{ label }}</span>
113+
<span v-if="selected" :class="['absolute inset-y-0 right-0 flex items-center pr-4', selected ? 'text-primary' : 'text-gray-400']">
114+
<CheckIcon class="h-5 w-5" aria-hidden="true" />
115+
</span>
116+
</ListboxOption>
117+
</ListboxOptions>
118+
</transition>
119+
</div>
120+
</Listbox>
65121
<div class="flex flex-col sm:flex-row gap-2 pt-4 border-t border-gray-200">
66122
<button type="button" class="w-full border border-gray-300 rounded-md bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:opacity-50 disabled:hover:bg-white disabled:cursor-not-allowed" :disabled="filterIsReset" @click="resetFilter()">
67123
{{ t('common.reset') }}
68124
</button>
69-
<button type="button" class="w-full rounded-md bg-primary px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-d1 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:opacity-50 disabled:hover:bg-primary disabled:cursor-not-allowed" :disabled="!filterIsValid" @click="applyFilter()">
125+
<button type="button" class="w-full rounded-md bg-primary px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-d1 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:opacity-50 disabled:hover:bg-primary disabled:cursor-not-allowed" :disabled="!filterIsValid" @click="async () => { close(); await applyFilter(); }">
70126
{{ t('common.apply') }}
71127
</button>
72128
</div>
@@ -166,7 +222,7 @@
166222
<script setup lang="ts">
167223
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Popover, PopoverButton, PopoverGroup, PopoverPanel } from '@headlessui/vue';
168224
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
169-
import { CheckIcon, ChevronUpDownIcon, WrenchIcon } from '@heroicons/vue/24/solid';
225+
import { CheckIcon, ChevronUpDownIcon, TrashIcon, WrenchIcon } from '@heroicons/vue/24/solid';
170226
import { computed, onMounted, ref, watch } from 'vue';
171227
import { useI18n } from 'vue-i18n';
172228
import auditlog, { AuditEventDto } from '../common/auditlog';
@@ -205,7 +261,11 @@ const startDateFilter = ref(startDate.value.toISOString().split('T')[0]);
205261
const endDate = ref(endOfDate(new Date()));
206262
const endDateFilter = ref(endDate.value.toISOString().split('T')[0]);
207263
208-
const filterIsReset = computed(() => startDateFilter.value == startDate.value.toISOString().split('T')[0] && endDateFilter.value == endDate.value.toISOString().split('T')[0]);
264+
const filterIsReset = computed(() =>
265+
startDateFilter.value == startDate.value.toISOString().split('T')[0] &&
266+
endDateFilter.value == endDate.value.toISOString().split('T')[0] &&
267+
selectedEventTypes.value.length == 0
268+
);
209269
const startDateFilterIsValid = computed(() => validateDateFilterValue(startDateFilter.value) != null);
210270
const endDateFilterIsValid = computed(() => {
211271
const endDate = validateDateFilterValue(endDateFilter.value);
@@ -235,6 +295,28 @@ const orderOptions = {
235295
};
236296
watch(selectedOrder, refreshData);
237297
298+
const eventTypeOptions = Object.fromEntries(
299+
Object.entries({
300+
DEVICE_REGISTER: t('auditLog.details.device.register'),
301+
DEVICE_REMOVE: t('auditLog.details.device.remove'),
302+
SETTING_WOT_UPDATE: t('auditLog.details.setting.wot.update'),
303+
SIGN_WOT_ID: t('auditLog.details.wot.signedIdentity'),
304+
USER_ACCOUNT_RESET: t('auditLog.details.user.account.reset'),
305+
USER_KEYS_CHANGE: t('auditLog.details.user.keys.change'),
306+
USER_SETUP_CODE_CHANGE: t('auditLog.details.user.setupCode.change'),
307+
VAULT_ACCESS_GRANT: t('auditLog.details.vaultAccess.grant'),
308+
VAULT_CREATE: t('auditLog.details.vault.create'),
309+
VAULT_KEY_RETRIEVE: t('auditLog.details.vaultKey.retrieve'),
310+
VAULT_MEMBER_ADD: t('auditLog.details.vaultMember.add'),
311+
VAULT_MEMBER_REMOVE: t('auditLog.details.vaultMember.remove'),
312+
VAULT_MEMBER_UPDATE: t('auditLog.details.vaultMember.update'),
313+
VAULT_OWNERSHIP_CLAIM: t('auditLog.details.vaultOwnership.claim'),
314+
VAULT_UPDATE: t('auditLog.details.vault.update')
315+
}).sort(([,valueA], [,valueB]) => valueA.localeCompare(valueB))
316+
);
317+
const allEventTypes = Object.keys(eventTypeOptions);
318+
const selectedEventTypes = ref<string[]>([]);
319+
238320
const currentPage = ref(0);
239321
const pageSize = ref(20);
240322
const paginationBegin = computed(() => auditEvents.value ? currentPage.value * pageSize.value + Math.min(1, auditEvents.value.length) : 0);
@@ -244,11 +326,15 @@ let lastIdOfPreviousPage = [Number.MAX_SAFE_INTEGER];
244326
245327
onMounted(fetchData);
246328
329+
watch(selectedEventTypes, (newSelection, oldSelection) => {
330+
selectedEventTypes.value.sort((a, b) => eventTypeOptions[a].localeCompare(eventTypeOptions[b]));
331+
});
332+
247333
async function fetchData(page: number = 0) {
248334
onFetchError.value = null;
249335
try {
250336
// Fetch one more event than the page size to determine if there is a next page
251-
const events = await auditlog.service.getAllEvents(startDate.value, endDate.value, lastIdOfPreviousPage[page], selectedOrder.value, pageSize.value + 1);
337+
const events = await auditlog.service.getAllEvents(startDate.value, endDate.value, selectedEventTypes.value, lastIdOfPreviousPage[page], selectedOrder.value, pageSize.value + 1);
252338
// If the lastIdOfPreviousPage for the first page has not been set yet, set it to an id "before"/"after" the first event
253339
if (page == 0 && lastIdOfPreviousPage[0] == 0 && events.length > 0) {
254340
lastIdOfPreviousPage[0] = events[0].id + orderOptions[selectedOrder.value].sign;
@@ -291,9 +377,14 @@ async function applyFilter() {
291377
}
292378
}
293379
380+
function removeEventType(type: string): void {
381+
selectedEventTypes.value = selectedEventTypes.value.filter(t => t !== type);
382+
}
383+
294384
function resetFilter() {
295385
startDateFilter.value = startDate.value.toISOString().split('T')[0];
296386
endDateFilter.value = endDate.value.toISOString().split('T')[0];
387+
selectedEventTypes.value = [];
297388
}
298389
299390
function beginOfDate(date: Date): Date {

‎frontend/src/i18n/en-US.json

+2
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@
7878
"auditLog.filter": "Filter",
7979
"auditLog.filter.startDate": "Start Date",
8080
"auditLog.filter.endDate": "End Date",
81+
"auditLog.filter.selectEventFilter": "Select event filter",
82+
"auditLog.filter.clerEventFilter": "Clear event filter",
8183
"auditLog.timestamp": "Timestamp",
8284
"auditLog.type": "Event",
8385
"auditLog.details": "Details",

0 commit comments

Comments
 (0)
Please sign in to comment.