mirror of https://github.com/h44z/wg-portal.git
				
				
				
			feat: add simple audit ui
This commit is contained in:
		
							parent
							
								
									a49cfa6343
								
							
						
					
					
						commit
						6cbccf6d43
					
				|  | @ -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. | ||||||
|  |  | ||||||
|  | @ -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
											
										
									
								
							|  | @ -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> | ||||||
|  |  | ||||||
|  | @ -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:", | ||||||
|  |  | ||||||
|  | @ -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", | ||||||
|  |  | ||||||
|  | @ -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!", | ||||||
|  |           }) | ||||||
|  |         }) | ||||||
|  |     }, | ||||||
|  |   } | ||||||
|  | }) | ||||||
|  | @ -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">«</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">»</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> | ||||||
|  | @ -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() | ||||||
|  |  | ||||||
|  | @ -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() { | ||||||
|  |  | ||||||
|  | @ -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
 | ||||||
|  |  | ||||||
|  | @ -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": { | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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)) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | @ -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 | ||||||
|  | } | ||||||
|  | @ -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 | ||||||
|  | } | ||||||
|  | @ -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 | ||||||
|  | } | ||||||
|  | @ -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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -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" | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -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{}{} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  | } | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue