feat: add simple audit ui

This commit is contained in:
Christoph Haas 2025-03-29 16:42:31 +01:00
parent a49cfa6343
commit 6cbccf6d43
23 changed files with 2098 additions and 1557 deletions

View File

@ -52,10 +52,6 @@ The configuration portal supports using a database (SQLite, MySQL, MsSQL or Post
For the complete documentation visit [wgportal.org](https://wgportal.org). For the complete documentation visit [wgportal.org](https://wgportal.org).
## V2 TODOs
* Audit UI
## What is out of scope ## What is out of scope
* Automatic generation or application of any `iptables` or `nftables` rules. * Automatic generation or application of any `iptables` or `nftables` rules.

View File

@ -71,6 +71,8 @@ func main() {
queueSize := 100 queueSize := 100
eventBus := evbus.New(queueSize) eventBus := evbus.New(queueSize)
auditManager := audit.NewManager(database)
auditRecorder, err := audit.NewAuditRecorder(cfg, eventBus, database) auditRecorder, err := audit.NewAuditRecorder(cfg, eventBus, database)
internal.AssertNoError(err) internal.AssertNoError(err)
auditRecorder.StartBackgroundJobs(ctx) auditRecorder.StartBackgroundJobs(ctx)
@ -115,6 +117,7 @@ func main() {
apiV0BackendPeers := backendV0.NewPeerService(cfg, wireGuardManager, cfgFileManager, mailManager) apiV0BackendPeers := backendV0.NewPeerService(cfg, wireGuardManager, cfgFileManager, mailManager)
apiV0EndpointAuth := handlersV0.NewAuthEndpoint(cfg, apiV0Auth, apiV0Session, validatorManager, authenticator) apiV0EndpointAuth := handlersV0.NewAuthEndpoint(cfg, apiV0Auth, apiV0Session, validatorManager, authenticator)
apiV0EndpointAudit := handlersV0.NewAuditEndpoint(cfg, apiV0Auth, auditManager)
apiV0EndpointUsers := handlersV0.NewUserEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendUsers) apiV0EndpointUsers := handlersV0.NewUserEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendUsers)
apiV0EndpointInterfaces := handlersV0.NewInterfaceEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendInterfaces) apiV0EndpointInterfaces := handlersV0.NewInterfaceEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendInterfaces)
apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers) apiV0EndpointPeers := handlersV0.NewPeerEndpoint(cfg, apiV0Auth, validatorManager, apiV0BackendPeers)
@ -123,6 +126,7 @@ func main() {
apiFrontend := handlersV0.NewRestApi(apiV0Session, apiFrontend := handlersV0.NewRestApi(apiV0Session,
apiV0EndpointAuth, apiV0EndpointAuth,
apiV0EndpointAudit,
apiV0EndpointUsers, apiV0EndpointUsers,
apiV0EndpointInterfaces, apiV0EndpointInterfaces,
apiV0EndpointPeers, apiV0EndpointPeers,

File diff suppressed because it is too large Load Diff

View File

@ -91,6 +91,7 @@ const currentYear = ref(new Date().getFullYear())
<div class="dropdown-menu"> <div class="dropdown-menu">
<RouterLink :to="{ name: 'profile' }" class="dropdown-item"><i class="fas fa-user"></i> {{ $t('menu.profile') }}</RouterLink> <RouterLink :to="{ name: 'profile' }" class="dropdown-item"><i class="fas fa-user"></i> {{ $t('menu.profile') }}</RouterLink>
<RouterLink :to="{ name: 'settings' }" class="dropdown-item" v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')"><i class="fas fa-gears"></i> {{ $t('menu.settings') }}</RouterLink> <RouterLink :to="{ name: 'settings' }" class="dropdown-item" v-if="auth.IsAdmin || !settings.Setting('ApiAdminOnly')"><i class="fas fa-gears"></i> {{ $t('menu.settings') }}</RouterLink>
<RouterLink :to="{ name: 'audit' }" class="dropdown-item" v-if="auth.IsAdmin"><i class="fas fa-file-shield"></i> {{ $t('menu.audit') }}</RouterLink>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" @click.prevent="auth.Logout"><i class="fas fa-sign-out-alt"></i> {{ $t('menu.logout') }}</a> <a class="dropdown-item" href="#" @click.prevent="auth.Logout"><i class="fas fa-sign-out-alt"></i> {{ $t('menu.logout') }}</a>
</div> </div>

View File

@ -38,6 +38,7 @@
"lang": "Toggle Language", "lang": "Toggle Language",
"profile": "My Profile", "profile": "My Profile",
"settings": "Settings", "settings": "Settings",
"audit": "Audit Log",
"login": "Login", "login": "Login",
"logout": "Logout" "logout": "Logout"
}, },
@ -188,6 +189,23 @@
"api-link": "API Documentation" "api-link": "API Documentation"
} }
}, },
"audit": {
"headline": "Audit Log",
"abstract": "Here you can find the audit log of all actions performed in the WireGuard Portal.",
"no-entries": {
"headline": "No log entries available",
"abstract": "Currently, there are no audit logs recorded."
},
"entries-headline": "Log Entries",
"table-heading": {
"id": "#",
"time": "Time",
"user": "User",
"severity": "Severity",
"origin": "Origin",
"message": "Message"
}
},
"modals": { "modals": {
"user-view": { "user-view": {
"headline": "User Account:", "headline": "User Account:",

View File

@ -56,6 +56,14 @@ const router = createRouter({
// this generates a separate chunk (About.[hash].js) for this route // this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited. // which is lazy-loaded when the route is visited.
component: () => import('../views/SettingsView.vue') component: () => import('../views/SettingsView.vue')
},
{
path: '/audit',
name: 'audit',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AuditView.vue')
} }
], ],
linkActiveClass: "active", linkActiveClass: "active",

View File

@ -0,0 +1,87 @@
import { defineStore } from 'pinia'
import {apiWrapper} from "@/helpers/fetch-wrapper";
import {notify} from "@kyvg/vue3-notification";
import { base64_url_encode } from '@/helpers/encoding';
const baseUrl = `/audit`
export const auditStore = defineStore('audit', {
state: () => ({
entries: [],
filter: "",
pageSize: 10,
pageOffset: 0,
pages: [],
fetching: false,
}),
getters: {
Count: (state) => state.entries.length,
FilteredCount: (state) => state.Filtered.length,
All: (state) => state.entries,
Filtered: (state) => {
if (!state.filter) {
return state.entries
}
return state.entries.filter((e) => {
return e.Timestamp.includes(state.filter) ||
e.Message.includes(state.filter) ||
e.Severity.includes(state.filter) ||
e.Origin.includes(state.filter)
})
},
FilteredAndPaged: (state) => {
return state.Filtered.slice(state.pageOffset, state.pageOffset + state.pageSize)
},
isFetching: (state) => state.fetching,
hasNextPage: (state) => state.pageOffset < (state.FilteredCount - state.pageSize),
hasPrevPage: (state) => state.pageOffset > 0,
currentPage: (state) => (state.pageOffset / state.pageSize)+1,
},
actions: {
afterPageSizeChange() {
// reset pageOffset to avoid problems with new page sizes
this.pageOffset = 0
this.calculatePages()
},
calculatePages() {
let pageCounter = 1;
this.pages = []
for (let i = 0; i < this.FilteredCount; i+=this.pageSize) {
this.pages.push(pageCounter++)
}
},
gotoPage(page) {
this.pageOffset = (page-1) * this.pageSize
this.calculatePages()
},
nextPage() {
this.pageOffset += this.pageSize
this.calculatePages()
},
previousPage() {
this.pageOffset -= this.pageSize
this.calculatePages()
},
setEntries(entries) {
this.entries = entries
this.calculatePages()
this.fetching = false
},
async LoadEntries() {
this.fetching = true
return apiWrapper.get(`${baseUrl}/entries`)
.then(this.setEntries)
.catch(error => {
this.setEntries([])
console.log("Failed to load audit entries: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to load audit entries!",
})
})
},
}
})

View File

@ -0,0 +1,96 @@
<script setup>
import { onMounted } from "vue";
import {auditStore} from "@/stores/audit";
const audit = auditStore()
onMounted(async () => {
await audit.LoadEntries()
})
</script>
<template>
<div class="page-header">
<h1>{{ $t('audit.headline') }}</h1>
</div>
<p class="lead">{{ $t('audit.abstract') }}</p>
<!-- Entry list -->
<div class="mt-4 row">
<div class="col-12 col-lg-6">
<h3>{{ $t('audit.entries-headline') }}</h3>
</div>
<div class="col-12 col-lg-6 text-lg-end">
<div class="form-group d-inline">
<div class="input-group mb-3">
<input v-model="audit.filter" class="form-control" :placeholder="$t('general.search.placeholder')" type="text" @keyup="audit.afterPageSizeChange">
<button class="input-group-text btn btn-primary" :title="$t('general.search.button')"><i class="fa-solid fa-search"></i></button>
</div>
</div>
</div>
</div>
<div class="mt-2 table-responsive">
<div v-if="audit.Count===0">
<h4>{{ $t('audit.no-entries.headline') }}</h4>
<p>{{ $t('audit.no-entries.abstract') }}</p>
</div>
<table v-if="audit.Count!==0" id="auditTable" class="table table-sm">
<thead>
<tr>
<th scope="col">{{ $t('audit.table-heading.id') }}</th>
<th class="text-center" scope="col">{{ $t('audit.table-heading.time') }}</th>
<th class="text-center" scope="col">{{ $t('audit.table-heading.severity') }}</th>
<th scope="col">{{ $t('audit.table-heading.user') }}</th>
<th scope="col">{{ $t('audit.table-heading.origin') }}</th>
<th scope="col">{{ $t('audit.table-heading.message') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in audit.FilteredAndPaged" :key="entry.Id">
<td>{{entry.Id}}</td>
<td>{{entry.Timestamp}}</td>
<td class="text-center"><span class="badge rounded-pill" :class="[ entry.Severity === 'low' ? 'bg-light' : entry.Severity === 'medium' ? 'bg-warning' : 'bg-danger']">{{entry.Severity}}</span></td>
<td>{{entry.ContextUser}}</td>
<td>{{entry.Origin}}</td>
<td>{{entry.Message}}</td>
</tr>
</tbody>
</table>
</div>
<hr>
<div class="mt-3">
<div class="row">
<div class="col-6">
<ul class="pagination pagination-sm">
<li :class="{disabled:audit.pageOffset===0}" class="page-item">
<a class="page-link" @click="audit.previousPage">&laquo;</a>
</li>
<li v-for="page in audit.pages" :key="page" :class="{active:audit.currentPage===page}" class="page-item">
<a class="page-link" @click="audit.gotoPage(page)">{{page}}</a>
</li>
<li :class="{disabled:!audit.hasNextPage}" class="page-item">
<a class="page-link" @click="audit.nextPage">&raquo;</a>
</li>
</ul>
</div>
<div class="col-6">
<div class="form-group row">
<label class="col-sm-6 col-form-label text-end" for="paginationSelector">{{ $t('general.pagination.size') }}:</label>
<div class="col-sm-6">
<select v-model.number="audit.pageSize" class="form-select" @click="audit.afterPageSizeChange()">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="999999999">{{ $t('general.pagination.all') }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,13 +1,8 @@
<script setup> <script setup>
import PeerViewModal from "../components/PeerViewModal.vue"; import { onMounted } from "vue";
import { onMounted, ref } from "vue";
import { profileStore } from "@/stores/profile"; import { profileStore } from "@/stores/profile";
import PeerEditModal from "@/components/PeerEditModal.vue";
import { settingsStore } from "@/stores/settings"; import { settingsStore } from "@/stores/settings";
import { humanFileSize } from "@/helpers/utils"; import { authStore } from "../stores/auth";
import {RouterLink} from "vue-router";
import {authStore} from "../stores/auth";
const profile = profileStore() const profile = profileStore()
const settings = settingsStore() const settings = settingsStore()

View File

@ -3,16 +3,12 @@ import {userStore} from "@/stores/users";
import {ref,onMounted} from "vue"; import {ref,onMounted} from "vue";
import UserEditModal from "../components/UserEditModal.vue"; import UserEditModal from "../components/UserEditModal.vue";
import UserViewModal from "../components/UserViewModal.vue"; import UserViewModal from "../components/UserViewModal.vue";
import {notify} from "@kyvg/vue3-notification";
import {settingsStore} from "@/stores/settings";
const settings = settingsStore()
const users = userStore() const users = userStore()
const editUserId = ref("") const editUserId = ref("")
const viewedUserId = ref("") const viewedUserId = ref("")
const selectAll = ref(false) const selectAll = ref(false)
function toggleSelectAll() { function toggleSelectAll() {

View File

@ -1049,4 +1049,16 @@ func (r *SqlRepo) SaveAuditEntry(ctx context.Context, entry *domain.AuditEntry)
return nil return nil
} }
// GetAllAuditEntries retrieves all audit entries from the database.
// The entries are ordered by timestamp, with the newest entries first.
func (r *SqlRepo) GetAllAuditEntries(ctx context.Context) ([]domain.AuditEntry, error) {
var entries []domain.AuditEntry
err := r.db.WithContext(ctx).Order("created_at desc").Find(&entries).Error
if err != nil {
return nil, err
}
return entries, nil
}
// endregion audit // endregion audit

View File

@ -11,6 +11,29 @@
}, },
"basePath": "/api/v0", "basePath": "/api/v0",
"paths": { "paths": {
"/audit/entries": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Audit"
],
"summary": "Get all available audit entries. Ordered by timestamp.",
"operationId": "audit_handleEntriesGet",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/model.AuditEntry"
}
}
}
}
}
},
"/auth/login": { "/auth/login": {
"post": { "post": {
"produces": [ "produces": [
@ -171,6 +194,9 @@
"schema": { "schema": {
"type": "string" "type": "string"
} }
},
"500": {
"description": "Internal Server Error"
} }
} }
} }
@ -1494,6 +1520,30 @@
} }
}, },
"definitions": { "definitions": {
"model.AuditEntry": {
"type": "object",
"properties": {
"Message": {
"type": "string"
},
"ctx_user": {
"type": "string"
},
"id": {
"type": "integer"
},
"origin": {
"description": "origin: for example user auth, stats, ...",
"type": "string"
},
"severity": {
"type": "string"
},
"timestamp": {
"type": "string"
}
}
},
"model.ConfigOption-array_string": { "model.ConfigOption-array_string": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -1,5 +1,21 @@
basePath: /api/v0 basePath: /api/v0
definitions: definitions:
model.AuditEntry:
properties:
Message:
type: string
ctx_user:
type: string
id:
type: integer
origin:
description: 'origin: for example user auth, stats, ...'
type: string
severity:
type: string
timestamp:
type: string
type: object
model.ConfigOption-array_string: model.ConfigOption-array_string:
properties: properties:
Overridable: Overridable:
@ -419,6 +435,21 @@ info:
title: WireGuard Portal SPA-UI API title: WireGuard Portal SPA-UI API
version: "0.0" version: "0.0"
paths: paths:
/audit/entries:
get:
operationId: audit_handleEntriesGet
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/model.AuditEntry'
type: array
summary: Get all available audit entries. Ordered by timestamp.
tags:
- Audit
/auth/{provider}/callback: /auth/{provider}/callback:
get: get:
operationId: auth_handleOauthCallbackGet operationId: auth_handleOauthCallbackGet
@ -523,6 +554,8 @@ paths:
description: The JavaScript contents description: The JavaScript contents
schema: schema:
type: string type: string
"500":
description: Internal Server Error
summary: Get the dynamic frontend configuration javascript. summary: Get the dynamic frontend configuration javascript.
tags: tags:
- Configuration - Configuration

View File

@ -0,0 +1,69 @@
package handlers
import (
"context"
"net/http"
"github.com/go-pkgz/routegroup"
"github.com/h44z/wg-portal/internal/app/api/core/respond"
"github.com/h44z/wg-portal/internal/app/api/v0/model"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
type AuditService interface {
// GetAll returns all audit entries ordered by timestamp. Newest first.
GetAll(ctx context.Context) ([]domain.AuditEntry, error)
}
type AuditEndpoint struct {
cfg *config.Config
authenticator Authenticator
auditService AuditService
}
func NewAuditEndpoint(
cfg *config.Config,
authenticator Authenticator,
auditService AuditService,
) AuditEndpoint {
return AuditEndpoint{
cfg: cfg,
authenticator: authenticator,
auditService: auditService,
}
}
func (e AuditEndpoint) GetName() string {
return "AuditEndpoint"
}
func (e AuditEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup := g.Mount("/audit")
apiGroup.Use(e.authenticator.LoggedIn(ScopeAdmin))
apiGroup.HandleFunc("GET /entries", e.handleEntriesGet())
}
// handleExternalLoginProvidersGet returns a gorm Handler function.
//
// @ID audit_handleEntriesGet
// @Tags Audit
// @Summary Get all available audit entries. Ordered by timestamp.
// @Produce json
// @Success 200 {object} []model.AuditEntry
// @Router /audit/entries [get]
func (e AuditEndpoint) handleEntriesGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
providers, err := e.auditService.GetAll(r.Context())
if err != nil {
respond.JSON(w, http.StatusInternalServerError, model.Error{
Code: http.StatusInternalServerError, Message: err.Error(),
})
return
}
respond.JSON(w, http.StatusOK, model.NewAuditEntries(providers))
}
}

View File

@ -0,0 +1,36 @@
package model
import (
"github.com/h44z/wg-portal/internal/domain"
)
type AuditEntry struct {
Id uint64 `json:"Id"`
Timestamp string `json:"Timestamp"`
ContextUser string `json:"ContextUser"`
Severity string `json:"Severity"`
Origin string `json:"Origin"` // origin: for example user auth, stats, ...
Message string `message:"Message"`
}
// NewAuditEntry creates a REST API AuditEntry from a domain AuditEntry.
func NewAuditEntry(src domain.AuditEntry) AuditEntry {
return AuditEntry{
Id: src.UniqueId,
Timestamp: src.CreatedAt.Format("2006-01-02 15:04:05"),
ContextUser: src.ContextUser,
Severity: string(src.Severity),
Origin: src.Origin,
Message: src.Message,
}
}
// NewAuditEntries creates a slice of REST API AuditEntry from a slice of domain AuditEntry.
func NewAuditEntries(src []domain.AuditEntry) []AuditEntry {
dst := make([]AuditEntry, 0, len(src))
for _, entry := range src {
dst = append(dst, NewAuditEntry(entry))
}
return dst
}

View File

@ -0,0 +1,37 @@
package audit
import (
"context"
"fmt"
"github.com/h44z/wg-portal/internal/domain"
)
type ManagerDatabaseRepo interface {
// GetAllAuditEntries retrieves all audit entries from the database.
// The entries are ordered by timestamp, with the newest entries first.
GetAllAuditEntries(ctx context.Context) ([]domain.AuditEntry, error)
}
type Manager struct {
db ManagerDatabaseRepo
}
func NewManager(db ManagerDatabaseRepo) *Manager {
return &Manager{db: db}
}
func (m *Manager) GetAll(ctx context.Context) ([]domain.AuditEntry, error) {
currentUser := domain.GetUserInfo(ctx)
if !currentUser.IsAdmin {
return nil, domain.ErrNoPermission
}
entries, err := m.db.GetAllAuditEntries(ctx)
if err != nil {
return nil, fmt.Errorf("failed to query audit entries: %w", err)
}
return entries, nil
}

View File

@ -0,0 +1,18 @@
package audit
import "github.com/h44z/wg-portal/internal/domain"
type AuthEvent struct {
Username string
Error string
}
type InterfaceEvent struct {
Interface domain.Interface
Action string
}
type PeerEvent struct {
Peer domain.Peer
Action string
}

View File

@ -78,22 +78,98 @@ func (r *Recorder) connectToMessageBus() error {
return nil // noting to do return nil // noting to do
} }
if err := r.bus.Subscribe(app.TopicAuthLogin, r.authLoginEvent); err != nil { if err := r.bus.Subscribe(app.TopicAuditLoginSuccess, r.handleAuthEvent); err != nil {
return fmt.Errorf("failed to subscribe to %s: %w", app.TopicAuthLogin, err) return fmt.Errorf("failed to subscribe to %s: %w", app.TopicAuditLoginSuccess, err)
}
if err := r.bus.Subscribe(app.TopicAuditLoginFailed, r.handleAuthEvent); err != nil {
return fmt.Errorf("failed to subscribe to %s: %w", app.TopicAuditLoginFailed, err)
}
if err := r.bus.Subscribe(app.TopicAuditInterfaceChanged, r.handleInterfaceEvent); err != nil {
return fmt.Errorf("failed to subscribe to %s: %w", app.TopicAuditInterfaceChanged, err)
}
if err := r.bus.Subscribe(app.TopicAuditPeerChanged, r.handleInterfaceEvent); err != nil {
return fmt.Errorf("failed to subscribe to %s: %w", app.TopicAuditPeerChanged, err)
} }
return nil return nil
} }
func (r *Recorder) authLoginEvent(userIdentifier domain.UserIdentifier) { func (r *Recorder) handleAuthEvent(event domain.AuditEventWrapper[AuthEvent]) {
err := r.db.SaveAuditEntry(context.Background(), &domain.AuditEntry{ err := r.db.SaveAuditEntry(context.Background(), r.authEventToAuditEntry(event))
CreatedAt: time.Time{},
Severity: domain.AuditSeverityLevelLow,
Origin: "authLoginEvent",
Message: fmt.Sprintf("user %s logged in", userIdentifier),
})
if err != nil { if err != nil {
slog.Error("failed to create audit entry for handleAuthLoginEvent", "error", err) slog.Error("failed to create audit entry for auth event", "error", err)
return return
} }
} }
func (r *Recorder) handleInterfaceEvent(event domain.AuditEventWrapper[InterfaceEvent]) {
err := r.db.SaveAuditEntry(context.Background(), r.interfaceEventToAuditEntry(event))
if err != nil {
slog.Error("failed to create audit entry for interface event", "error", err)
return
}
}
func (r *Recorder) handlePeerEvent(event domain.AuditEventWrapper[PeerEvent]) {
err := r.db.SaveAuditEntry(context.Background(), r.peerEventToAuditEntry(event))
if err != nil {
slog.Error("failed to create audit entry for peer event", "error", err)
return
}
}
func (r *Recorder) authEventToAuditEntry(event domain.AuditEventWrapper[AuthEvent]) *domain.AuditEntry {
contextUser := domain.GetUserInfo(event.Ctx)
e := domain.AuditEntry{
CreatedAt: time.Now(),
Severity: domain.AuditSeverityLevelLow,
ContextUser: contextUser.UserId(),
Origin: fmt.Sprintf("auth: %s", event.Source),
Message: fmt.Sprintf("%s logged in", event.Event.Username),
}
if event.Event.Error != "" {
e.Severity = domain.AuditSeverityLevelHigh
e.Message = fmt.Sprintf("%s failed to login: %s", event.Event.Username, event.Event.Error)
}
return &e
}
func (r *Recorder) interfaceEventToAuditEntry(event domain.AuditEventWrapper[InterfaceEvent]) *domain.AuditEntry {
contextUser := domain.GetUserInfo(event.Ctx)
e := domain.AuditEntry{
CreatedAt: time.Now(),
Severity: domain.AuditSeverityLevelLow,
ContextUser: contextUser.UserId(),
Origin: fmt.Sprintf("interface: %s", event.Event.Action),
}
switch event.Event.Action {
case "save":
e.Message = fmt.Sprintf("%s updated", event.Event.Interface.Identifier)
default:
e.Message = fmt.Sprintf("%s: unknown action", event.Event.Interface.Identifier)
}
return &e
}
func (r *Recorder) peerEventToAuditEntry(event domain.AuditEventWrapper[PeerEvent]) *domain.AuditEntry {
contextUser := domain.GetUserInfo(event.Ctx)
e := domain.AuditEntry{
CreatedAt: time.Now(),
Severity: domain.AuditSeverityLevelLow,
ContextUser: contextUser.UserId(),
Origin: fmt.Sprintf("peer: %s", event.Event.Action),
}
switch event.Event.Action {
case "save":
e.Message = fmt.Sprintf("%s updated", event.Event.Peer.Identifier)
default:
e.Message = fmt.Sprintf("%s: unknown action", event.Event.Peer.Identifier)
}
return &e
}

View File

@ -17,6 +17,7 @@ import (
"golang.org/x/oauth2" "golang.org/x/oauth2"
"github.com/h44z/wg-portal/internal/app" "github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/app/audit"
"github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
) )
@ -245,10 +246,24 @@ func (a *Authenticator) PlainLogin(ctx context.Context, username, password strin
user, err := a.passwordAuthentication(ctx, domain.UserIdentifier(username), password) user, err := a.passwordAuthentication(ctx, domain.UserIdentifier(username), password)
if err != nil { if err != nil {
a.bus.Publish(app.TopicAuditLoginFailed, domain.AuditEventWrapper[audit.AuthEvent]{
Ctx: ctx,
Source: "plain",
Event: audit.AuthEvent{
Username: username, Error: err.Error(),
},
})
return nil, fmt.Errorf("login failed: %w", err) return nil, fmt.Errorf("login failed: %w", err)
} }
a.bus.Publish(app.TopicAuthLogin, user.Identifier) a.bus.Publish(app.TopicAuthLogin, user.Identifier)
a.bus.Publish(app.TopicAuditLoginSuccess, domain.AuditEventWrapper[audit.AuthEvent]{
Ctx: ctx,
Source: "plain",
Event: audit.AuthEvent{
Username: string(user.Identifier),
},
})
return user, nil return user, nil
} }
@ -405,14 +420,37 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
user, err := a.processUserInfo(ctx, userInfo, domain.UserSourceOauth, oauthProvider.GetName(), user, err := a.processUserInfo(ctx, userInfo, domain.UserSourceOauth, oauthProvider.GetName(),
oauthProvider.RegistrationEnabled()) oauthProvider.RegistrationEnabled())
if err != nil { if err != nil {
a.bus.Publish(app.TopicAuditLoginFailed, domain.AuditEventWrapper[audit.AuthEvent]{
Ctx: ctx,
Source: "oauth " + providerId,
Event: audit.AuthEvent{
Username: string(userInfo.Identifier),
Error: err.Error(),
},
})
return nil, fmt.Errorf("unable to process user information: %w", err) return nil, fmt.Errorf("unable to process user information: %w", err)
} }
if user.IsLocked() || user.IsDisabled() { if user.IsLocked() || user.IsDisabled() {
a.bus.Publish(app.TopicAuditLoginFailed, domain.AuditEventWrapper[audit.AuthEvent]{
Ctx: ctx,
Source: "oauth " + providerId,
Event: audit.AuthEvent{
Username: string(user.Identifier),
Error: "user is locked",
},
})
return nil, errors.New("user is locked") return nil, errors.New("user is locked")
} }
a.bus.Publish(app.TopicAuthLogin, user.Identifier) a.bus.Publish(app.TopicAuthLogin, user.Identifier)
a.bus.Publish(app.TopicAuditLoginSuccess, domain.AuditEventWrapper[audit.AuthEvent]{
Ctx: ctx,
Source: "oauth " + providerId,
Event: audit.AuthEvent{
Username: string(user.Identifier),
},
})
return user, nil return user, nil
} }

View File

@ -13,3 +13,9 @@ const TopicRouteRemove = "route:remove"
const TopicInterfaceUpdated = "interface:updated" const TopicInterfaceUpdated = "interface:updated"
const TopicPeerInterfaceUpdated = "peer:interface:updated" const TopicPeerInterfaceUpdated = "peer:interface:updated"
const TopicPeerIdentifierUpdated = "peer:identifier:updated" const TopicPeerIdentifierUpdated = "peer:identifier:updated"
const TopicAuditLoginSuccess = "audit:login:success"
const TopicAuditLoginFailed = "audit:login:failed"
const TopicAuditInterfaceChanged = "audit:interface:changed"
const TopicAuditPeerChanged = "audit:peer:changed"

View File

@ -10,6 +10,7 @@ import (
"time" "time"
"github.com/h44z/wg-portal/internal/app" "github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/app/audit"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
) )
@ -549,6 +550,13 @@ func (m Manager) saveInterface(ctx context.Context, iface *domain.Interface) (
} }
m.bus.Publish(app.TopicInterfaceUpdated, iface) m.bus.Publish(app.TopicInterfaceUpdated, iface)
m.bus.Publish(app.TopicAuditInterfaceChanged, domain.AuditEventWrapper[audit.InterfaceEvent]{
Ctx: ctx,
Event: audit.InterfaceEvent{
Interface: *iface,
Action: "save",
},
})
return iface, nil return iface, nil
} }

View File

@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/h44z/wg-portal/internal/app" "github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/app/audit"
"github.com/h44z/wg-portal/internal/domain" "github.com/h44z/wg-portal/internal/domain"
) )
@ -426,6 +427,15 @@ func (m Manager) savePeers(ctx context.Context, peers ...*domain.Peer) error {
return fmt.Errorf("save failure for peer %s: %w", peer.Identifier, err) return fmt.Errorf("save failure for peer %s: %w", peer.Identifier, err)
} }
// publish event
m.bus.Publish(app.TopicAuditPeerChanged, domain.AuditEventWrapper[audit.PeerEvent]{
Ctx: ctx,
Event: audit.PeerEvent{
Action: "save",
Peer: *peer,
},
})
interfaces[peer.InterfaceIdentifier] = struct{}{} interfaces[peer.InterfaceIdentifier] = struct{}{}
} }

View File

@ -1,18 +1,30 @@
package domain package domain
import "time" import (
"context"
"time"
)
type AuditSeverityLevel string type AuditSeverityLevel string
const AuditSeverityLevelLow AuditSeverityLevel = "low" const AuditSeverityLevelLow AuditSeverityLevel = "low"
const AuditSeverityLevelHigh AuditSeverityLevel = "high"
type AuditEntry struct { type AuditEntry struct {
UniqueId uint64 `gorm:"primaryKey;autoIncrement:true;column:id"` UniqueId uint64 `gorm:"primaryKey;autoIncrement:true;column:id"`
CreatedAt time.Time `gorm:"column:created_at;index:idx_au_created"` CreatedAt time.Time `gorm:"column:created_at;index:idx_au_created"`
ContextUser string `gorm:"column:context_user;index:idx_au_context_user"`
Severity AuditSeverityLevel `gorm:"column:severity;index:idx_au_severity"` Severity AuditSeverityLevel `gorm:"column:severity;index:idx_au_severity"`
Origin string `gorm:"column:origin"` // origin: for example user auth, stats, ... Origin string `gorm:"column:origin"` // origin: for example user auth, stats, ...
Message string `gorm:"column:message"` Message string `gorm:"column:message"`
} }
type AuditEventWrapper[T any] struct {
Ctx context.Context
Source string
Event T
}