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

@@ -11,6 +11,29 @@
},
"basePath": "/api/v0",
"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": {
"post": {
"produces": [
@@ -171,6 +194,9 @@
"schema": {
"type": "string"
}
},
"500": {
"description": "Internal Server Error"
}
}
}
@@ -1494,6 +1520,30 @@
}
},
"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": {
"type": "object",
"properties": {

View File

@@ -1,5 +1,21 @@
basePath: /api/v0
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:
properties:
Overridable:
@@ -419,6 +435,21 @@ info:
title: WireGuard Portal SPA-UI API
version: "0.0"
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:
get:
operationId: auth_handleOauthCallbackGet
@@ -523,6 +554,8 @@ paths:
description: The JavaScript contents
schema:
type: string
"500":
description: Internal Server Error
summary: Get the dynamic frontend configuration javascript.
tags:
- 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
}
if err := r.bus.Subscribe(app.TopicAuthLogin, r.authLoginEvent); err != nil {
return fmt.Errorf("failed to subscribe to %s: %w", app.TopicAuthLogin, err)
if err := r.bus.Subscribe(app.TopicAuditLoginSuccess, r.handleAuthEvent); err != nil {
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
}
func (r *Recorder) authLoginEvent(userIdentifier domain.UserIdentifier) {
err := r.db.SaveAuditEntry(context.Background(), &domain.AuditEntry{
CreatedAt: time.Time{},
Severity: domain.AuditSeverityLevelLow,
Origin: "authLoginEvent",
Message: fmt.Sprintf("user %s logged in", userIdentifier),
})
func (r *Recorder) handleAuthEvent(event domain.AuditEventWrapper[AuthEvent]) {
err := r.db.SaveAuditEntry(context.Background(), r.authEventToAuditEntry(event))
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
}
}
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"
"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/domain"
)
@@ -245,10 +246,24 @@ func (a *Authenticator) PlainLogin(ctx context.Context, username, password strin
user, err := a.passwordAuthentication(ctx, domain.UserIdentifier(username), password)
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)
}
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
}
@@ -405,14 +420,37 @@ func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce,
user, err := a.processUserInfo(ctx, userInfo, domain.UserSourceOauth, oauthProvider.GetName(),
oauthProvider.RegistrationEnabled())
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)
}
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")
}
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
}

View File

@@ -13,3 +13,9 @@ const TopicRouteRemove = "route:remove"
const TopicInterfaceUpdated = "interface:updated"
const TopicPeerInterfaceUpdated = "peer:interface: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"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/app/audit"
"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.TopicAuditInterfaceChanged, domain.AuditEventWrapper[audit.InterfaceEvent]{
Ctx: ctx,
Event: audit.InterfaceEvent{
Interface: *iface,
Action: "save",
},
})
return iface, nil
}

View File

@@ -8,6 +8,7 @@ import (
"time"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/app/audit"
"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)
}
// publish event
m.bus.Publish(app.TopicAuditPeerChanged, domain.AuditEventWrapper[audit.PeerEvent]{
Ctx: ctx,
Event: audit.PeerEvent{
Action: "save",
Peer: *peer,
},
})
interfaces[peer.InterfaceIdentifier] = struct{}{}
}