Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Audit Log - Add Filter Option for Event Type #312

Merged
merged 16 commits into from
Feb 19, 2025
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@

import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.UUID;

@Path("/auditlog")
Expand All @@ -57,11 +58,12 @@ public class AuditLogResource {
@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)
@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)
@Parameter(name = "pageSize", description = "the maximum number of entries to return. Must be between 1 and 100.", in = ParameterIn.QUERY)
@Parameter(name = "type", description = "the list of type of events to return. Empty list is all events.", in = ParameterIn.QUERY)
@APIResponse(responseCode = "200", description = "Body contains list of events in the specified time interval")
@APIResponse(responseCode = "400", description = "startDate or endDate not specified, startDate > endDate, order specified and not in ['asc','desc'] or pageSize not in [1 .. 100]")
@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 a valid")
@APIResponse(responseCode = "402", description = "Community license used or license expired")
@APIResponse(responseCode = "403", description = "requesting user does not have admin role")
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) {
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) {
if (!license.isSet() || license.isExpired()) {
throw new PaymentRequiredException("Community license used or license expired");
}
Expand All @@ -74,11 +76,20 @@ public List<AuditEventDto> getAllEvents(@QueryParam("startDate") Instant startDa
throw new BadRequestException("order must be either 'asc' or 'desc'");
} else if (pageSize < 1 || pageSize > 100) {
throw new BadRequestException("pageSize must be between 1 and 100");
} else if (type == null) {
throw new BadRequestException("type must be specified");
} else if (!type.isEmpty()) {
var validTypes = Set.of(DeviceRegisteredEvent.TYPE, DeviceRemovedEvent.TYPE, UserAccountResetEvent.TYPE, UserKeysChangeEvent.TYPE, UserSetupCodeChangeEvent.TYPE,
SettingWotUpdateEvent.TYPE, SignedWotIdEvent.TYPE, VaultCreatedEvent.TYPE, VaultUpdatedEvent.TYPE, VaultAccessGrantedEvent.TYPE,
VaultKeyRetrievedEvent.TYPE, VaultMemberAddedEvent.TYPE, VaultMemberRemovedEvent.TYPE, VaultMemberUpdatedEvent.TYPE, VaultOwnershipClaimedEvent.TYPE);
if (!validTypes.containsAll(type)) {
throw new BadRequestException("Invalid event type provided");
}
} else if (paginationId == null) {
throw new BadRequestException("paginationId must be specified");
}

return auditEventRepo.findAllInPeriod(startDate, endDate, paginationId, order.equals("asc"), pageSize).map(AuditEventDto::fromEntity).toList();
return auditEventRepo.findAllInPeriod(startDate, endDate, type, paginationId, order.equals("asc"), pageSize).map(AuditEventDto::fromEntity).toList();
}

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import jakarta.persistence.Table;

import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.stream.Stream;

Expand All @@ -31,6 +32,7 @@
WHERE ae.timestamp >= :startDate
AND ae.timestamp < :endDate
AND ae.id < :paginationId
AND (:allTypes = true OR ae.type IN :types)
ORDER BY ae.id DESC
""")
@NamedQuery(name = "AuditEvent.listAllInPeriodAfterId",
Expand All @@ -40,6 +42,7 @@
WHERE ae.timestamp >= :startDate
AND ae.timestamp < :endDate
AND ae.id > :paginationId
AND (:allTypes = true OR ae.type IN :types)
ORDER BY ae.id ASC
""")
@SequenceGenerator(name = "audit_event_id_seq", sequenceName = "audit_event_id_seq", allocationSize = 1)
Expand Down Expand Up @@ -106,8 +109,14 @@ public int hashCode() {
@ApplicationScoped
public static class Repository implements PanacheRepository<AuditEvent> {

public Stream<AuditEvent> findAllInPeriod(Instant startDate, Instant endDate, long paginationId, boolean ascending, int pageSize) {
var parameters = Parameters.with("startDate", startDate).and("endDate", endDate).and("paginationId", paginationId);
public Stream<AuditEvent> findAllInPeriod(Instant startDate, Instant endDate, List<String> type, long paginationId, boolean ascending, int pageSize) {
var allTypes = type.isEmpty();

var parameters = Parameters.with("startDate", startDate)
.and("endDate", endDate)
.and("paginationId", paginationId)
.and("types", type)
.and("allTypes", allTypes);

final PanacheQuery<AuditEvent> query;
if (ascending) {
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/common/auditlog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,9 @@ export class AuditLogEntityCache {
/* Service */

class AuditLogService {
public async getAllEvents(startDate: Date, endDate: Date, paginationId: number, order: string, pageSize: number): Promise<AuditEventDto[]> {
return axiosAuth.get<AuditEventDto[]>(`/auditlog?startDate=${startDate.toISOString()}&endDate=${endDate.toISOString()}&paginationId=${paginationId}&order=${order}&pageSize=${pageSize}`)
public async getAllEvents(startDate: Date, endDate: Date, type: string[], paginationId: number, order: string, pageSize: number): Promise<AuditEventDto[]> {
const typeQuery = (type.length > 0 ? `&type=${type.join('&type=')}` : '');
return axiosAuth.get<AuditEventDto[]>(`/auditlog?startDate=${startDate.toISOString()}&endDate=${endDate.toISOString()}&paginationId=${paginationId}${typeQuery}&order=${order}&pageSize=${pageSize}`)
.then(response => response.data.map(dto => {
dto.timestamp = new Date(dto.timestamp);
return dto;
Expand Down
102 changes: 96 additions & 6 deletions frontend/src/components/AuditLog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,14 @@
</Listbox>

<PopoverGroup class="flex items-baseline space-x-8">
<Popover as="div" class="relative inline-block text-left">
<Popover v-slot="{ close }" as="div" class="relative inline-block text-left">
<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">
<span>{{ t('auditLog.filter') }}</span>
<ChevronDownIcon class="-mr-1 ml-2 h-5 w-5" aria-hidden="true" />
</PopoverButton>

<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">
<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">
<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">
<form class="space-y-4">
<div class="sm:grid sm:grid-cols-2 sm:items-center sm:gap-2">
<label for="filter-start-date" class="block text-sm font-medium text-gray-700">
Expand All @@ -62,11 +62,66 @@
</label>
<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" />
</div>
<div class="sm:grid sm:grid-cols-2 sm:items-center sm:gap-2">
<label class="block text-sm font-medium text-gray-700 flex items-center">
{{ t('auditLog.type') }}
<button
type="button"
class="ml-2 p-1 flex items-center justify-center focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="selectedEventTypes.length === 0"
:title="selectedEventTypes.length > 0 ? t('auditLog.filter.clerEventFilter') : ''"
@click="selectedEventTypes = []"
>
<TrashIcon class="h-5 w-5 text-gray-500 hover:text-gray-700 disabled:text-gray-300" aria-hidden="true" />
</button>
</label>
</div>
<Listbox v-model="selectedEventTypes" as="div" multiple>
<div class="relative w-88">
<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">
<div class="flex flex-wrap gap-2">
<template v-if="selectedEventTypes.length > 0">
<button
v-for="type in selectedEventTypes"
:key="type"
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"
@click.stop="removeEventType(type)"
>
<span class="mr-1">{{ eventTypeOptions[type] }}</span>
<span class="text-green-800 font-bold">&times;</span>
</button>
</template>
<template v-else>
<span class="text-gray-500">{{ t('auditLog.filter.selectEventFilter') }}</span>
</template>
</div>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</ListboxButton>
<transition leave-active-class="transition ease-in duration-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
<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 ring-opacity-5 focus:outline-none text-sm">
<ListboxOption
v-for="(label, key) in eventTypeOptions"
:key="key"
v-slot="{ active, selected }"
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"
:value="key"
>
<span :class="[selected ? 'font-semibold' : 'font-normal', 'block truncate']">{{ label }}</span>
<span v-if="selected" :class="[selected ? 'text-primary' : active ? 'text-white' : 'text-primary', 'absolute inset-y-0 right-0 flex items-center pr-4']">
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
<div class="flex flex-col sm:flex-row gap-2 pt-4 border-t border-gray-200">
<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()">
{{ t('common.reset') }}
</button>
<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()">
<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(); }">
{{ t('common.apply') }}
</button>
</div>
Expand Down Expand Up @@ -166,7 +221,7 @@
<script setup lang="ts">
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Popover, PopoverButton, PopoverGroup, PopoverPanel } from '@headlessui/vue';
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
import { CheckIcon, ChevronUpDownIcon, WrenchIcon } from '@heroicons/vue/24/solid';
import { CheckIcon, ChevronUpDownIcon, WrenchIcon, TrashIcon } from '@heroicons/vue/24/solid';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import auditlog, { AuditEventDto } from '../common/auditlog';
Expand Down Expand Up @@ -205,7 +260,11 @@ const startDateFilter = ref(startDate.value.toISOString().split('T')[0]);
const endDate = ref(endOfDate(new Date()));
const endDateFilter = ref(endDate.value.toISOString().split('T')[0]);

const filterIsReset = computed(() => startDateFilter.value == startDate.value.toISOString().split('T')[0] && endDateFilter.value == endDate.value.toISOString().split('T')[0]);
const filterIsReset = computed(() =>
startDateFilter.value == startDate.value.toISOString().split('T')[0] &&
endDateFilter.value == endDate.value.toISOString().split('T')[0] &&
selectedEventTypes.value.length == 0
);
const startDateFilterIsValid = computed(() => validateDateFilterValue(startDateFilter.value) != null);
const endDateFilterIsValid = computed(() => {
const endDate = validateDateFilterValue(endDateFilter.value);
Expand Down Expand Up @@ -235,6 +294,28 @@ const orderOptions = {
};
watch(selectedOrder, refreshData);

const eventTypeOptions = Object.fromEntries(
Object.entries({
DEVICE_REGISTER: t('auditLog.details.device.register'),
DEVICE_REMOVE: t('auditLog.details.device.remove'),
SETTING_WOT_UPDATE: t('auditLog.details.setting.wot.update'),
SIGN_WOT_ID: t('auditLog.details.wot.signedIdentity'),
USER_ACCOUNT_RESET: t('auditLog.details.user.account.reset'),
USER_KEYS_CHANGE: t('auditLog.details.user.keys.change'),
USER_SETUP_CODE_CHANGE: t('auditLog.details.user.setupCode.change'),
VAULT_ACCESS_GRANT: t('auditLog.details.vaultAccess.grant'),
VAULT_CREATE: t('auditLog.details.vault.create'),
VAULT_KEY_RETRIEVE: t('auditLog.details.vaultKey.retrieve'),
VAULT_MEMBER_ADD: t('auditLog.details.vaultMember.add'),
VAULT_MEMBER_REMOVE: t('auditLog.details.vaultMember.remove'),
VAULT_MEMBER_UPDATE: t('auditLog.details.vaultMember.update'),
VAULT_OWNERSHIP_CLAIM: t('auditLog.details.vaultOwnership.claim'),
VAULT_UPDATE: t('auditLog.details.vault.update')
}).sort(([,valueA], [,valueB]) => valueA.localeCompare(valueB))
);
const allEventTypes = Object.keys(eventTypeOptions);
const selectedEventTypes = ref<string[]>([]);

const currentPage = ref(0);
const pageSize = ref(20);
const paginationBegin = computed(() => auditEvents.value ? currentPage.value * pageSize.value + Math.min(1, auditEvents.value.length) : 0);
Expand All @@ -244,11 +325,15 @@ let lastIdOfPreviousPage = [Number.MAX_SAFE_INTEGER];

onMounted(fetchData);

watch(selectedEventTypes, (newSelection, oldSelection) => {
selectedEventTypes.value.sort((a, b) => eventTypeOptions[a].localeCompare(eventTypeOptions[b]));
});

async function fetchData(page: number = 0) {
onFetchError.value = null;
try {
// Fetch one more event than the page size to determine if there is a next page
const events = await auditlog.service.getAllEvents(startDate.value, endDate.value, lastIdOfPreviousPage[page], selectedOrder.value, pageSize.value + 1);
const events = await auditlog.service.getAllEvents(startDate.value, endDate.value, selectedEventTypes.value, lastIdOfPreviousPage[page], selectedOrder.value, pageSize.value + 1);
// If the lastIdOfPreviousPage for the first page has not been set yet, set it to an id "before"/"after" the first event
if (page == 0 && lastIdOfPreviousPage[0] == 0 && events.length > 0) {
lastIdOfPreviousPage[0] = events[0].id + orderOptions[selectedOrder.value].sign;
Expand Down Expand Up @@ -291,9 +376,14 @@ async function applyFilter() {
}
}

function removeEventType(type: string): void {
selectedEventTypes.value = selectedEventTypes.value.filter(t => t !== type);
}

function resetFilter() {
startDateFilter.value = startDate.value.toISOString().split('T')[0];
endDateFilter.value = endDate.value.toISOString().split('T')[0];
selectedEventTypes.value = [];
}

function beginOfDate(date: Date): Date {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"auditLog.timestamp": "Zeitstempel",
"auditLog.type": "Ereignis",
"auditLog.details": "Details",
"auditLog.details.all": "Alle",
"auditLog.details.device.register": "Gerät registrieren",
"auditLog.details.device.remove": "Gerät entfernen",
"auditLog.details.setting.wot.update": "WoT-Einstellung aktualisieren",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@
"auditLog.filter": "Filter",
"auditLog.filter.startDate": "Start Date",
"auditLog.filter.endDate": "End Date",
"auditLog.filter.selectEventFilter": "Select event filter",
"auditLog.filter.clerEventFilter": "Clear event filter",
"auditLog.timestamp": "Timestamp",
"auditLog.type": "Event",
"auditLog.details": "Details",
Expand Down