diff --git a/pkg/audit/entry.go b/pkg/audit/entry.go
new file mode 100644
index 000000000..e2ed91876
--- /dev/null
+++ b/pkg/audit/entry.go
@@ -0,0 +1,127 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package audit
+
+import "time"
+
+// Entry is one audit log record. It only references actors and targets by
+// opaque ID — no names, emails or content — so GDPR erasure is satisfied by
+// deleting the referenced row.
+type Entry struct {
+ EventID string `json:"event_id"` // UUIDv7
+ Timestamp time.Time `json:"timestamp"`
+ Actor Actor `json:"actor"`
+ Source Source `json:"source"`
+ Action string `json:"action"`
+ Target Target `json:"target"`
+ Outcome string `json:"outcome"`
+ Reason string `json:"reason,omitempty"`
+ RequestID string `json:"request_id,omitempty"`
+ Metadata map[string]any `json:"metadata,omitempty"`
+}
+
+type actorType string
+type targetType string
+
+// Actor is the principal which performed the audited action.
+type Actor struct {
+ Type actorType `json:"type"`
+ ID int64 `json:"id,omitempty"`
+}
+
+// Source describes where the action originated from.
+type Source struct {
+ Type string `json:"type"`
+ IP string `json:"ip,omitempty"`
+ UserAgent string `json:"user_agent,omitempty"`
+}
+
+// Target is the resource the audited action was performed on.
+type Target struct {
+ Type targetType `json:"type"`
+ ID int64 `json:"id,omitempty"`
+}
+
+// Outcome values for an Entry.
+const (
+ OutcomeSuccess = "success"
+ OutcomeFailure = "failure"
+)
+
+// Source types for an Entry.
+const (
+ SourceHTTP = "http"
+ SourceSystem = "system"
+)
+
+// The action catalog. Every audited action is listed here.
+const (
+ ActionLoginSucceeded = "auth.login.succeeded"
+ ActionLoginFailed = "auth.login.failed"
+ ActionLogout = "auth.logout"
+ ActionAPITokenIssued = "auth.api_token.issued"
+ ActionAPITokenRevoked = "auth.api_token.revoked"
+ ActionAPITokenUsed = "auth.api_token.used"
+
+ ActionUserCreated = "user.created"
+
+ ActionTaskCreated = "task.created"
+ ActionTaskUpdated = "task.updated"
+ ActionTaskDeleted = "task.deleted"
+ ActionTaskAssigneeAdded = "task.assignee.added"
+ ActionTaskAssigneeRemoved = "task.assignee.removed"
+ ActionTaskCommentCreated = "task.comment.created"
+ ActionTaskCommentUpdated = "task.comment.updated"
+ ActionTaskCommentDeleted = "task.comment.deleted"
+ ActionTaskAttachmentCreated = "task.attachment.created"
+ ActionTaskAttachmentDeleted = "task.attachment.deleted"
+ ActionTaskRelationCreated = "task.relation.created"
+ ActionTaskRelationDeleted = "task.relation.deleted"
+
+ ActionProjectCreated = "project.created"
+ ActionProjectUpdated = "project.updated"
+ ActionProjectDeleted = "project.deleted"
+ ActionProjectSharedWithUser = "project.shared.user"
+ ActionProjectSharedWithTeam = "project.shared.team"
+
+ ActionTeamCreated = "team.created"
+ ActionTeamDeleted = "team.deleted"
+ ActionTeamMemberAdded = "team.member.added"
+ ActionTeamMemberRemoved = "team.member.removed"
+)
+
+// The type strings are unexported; these constructors are the only way to
+// build an Actor or Target, so a mismatched type/ID pair can't be expressed.
+
+func UserActor(id int64) Actor { return Actor{Type: "user", ID: id} }
+func LinkShareActor(id int64) Actor { return Actor{Type: "link_share", ID: id} }
+func SystemActor() Actor { return Actor{Type: "system"} }
+
+// ActorFromDoerID maps a doer ID to an actor. Link shares are disguised as
+// users with negative IDs throughout the event payloads.
+func ActorFromDoerID(id int64) Actor {
+ if id < 0 {
+ return LinkShareActor(-id)
+ }
+ return UserActor(id)
+}
+
+func TaskTarget(id int64) Target { return Target{Type: "task", ID: id} }
+func ProjectTarget(id int64) Target { return Target{Type: "project", ID: id} }
+func UserTarget(id int64) Target { return Target{Type: "user", ID: id} }
+func TeamTarget(id int64) Target { return Target{Type: "team", ID: id} }
+func APITokenTarget(id int64) Target { return Target{Type: "api_token", ID: id} }
diff --git a/pkg/audit/listener.go b/pkg/audit/listener.go
new file mode 100644
index 000000000..c0454512a
--- /dev/null
+++ b/pkg/audit/listener.go
@@ -0,0 +1,88 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package audit
+
+import (
+ "encoding/json"
+
+ "code.vikunja.io/api/pkg/events"
+ "code.vikunja.io/api/pkg/license"
+
+ "github.com/ThreeDotsLabs/watermill/message"
+)
+
+type auditListener struct {
+ handle func(msg *message.Message) error
+}
+
+func (l *auditListener) Handle(msg *message.Message) error {
+ return l.handle(msg)
+}
+
+func (l *auditListener) Name() string {
+ return "audit"
+}
+
+// RegisterEventForAudit opts an event into audit logging. The event→Entry
+// mapping is passed at registration, so opting in and defining the mapping
+// are one unit and can't drift apart. Returning a nil Entry skips the event.
+func RegisterEventForAudit[T any, PT interface {
+ *T
+ events.Event
+}](toEntry func(PT) *Entry) {
+ name := PT(new(T)).Name()
+ RegisterEventNameForAudit(name, func(payload []byte) (*Entry, error) {
+ e := PT(new(T)) // fresh instance per message — handlers run concurrently
+ if err := json.Unmarshal(payload, e); err != nil {
+ return nil, err
+ }
+ return toEntry(e), nil
+ })
+}
+
+// RegisterEventNameForAudit is the untyped variant for events which cannot be
+// unmarshaled into their Go struct directly (e.g. interface-typed Doer
+// fields); the mapping decodes the raw payload itself.
+func RegisterEventNameForAudit(name string, toEntry func(payload []byte) (*Entry, error)) {
+ events.RegisterListener(name, &auditListener{handle: func(msg *message.Message) error {
+ if !license.IsFeatureEnabled(license.FeatureAuditLogs) {
+ return nil // license is runtime-mutable — checked per event, not at registration
+ }
+ entry, err := toEntry(msg.Payload)
+ if err != nil {
+ return err
+ }
+ if entry == nil {
+ return nil
+ }
+ enrichFromMetadata(entry, msg.Metadata)
+ return WriteAuditEvent(entry)
+ }})
+}
+
+func enrichFromMetadata(entry *Entry, meta message.Metadata) {
+ entry.Source.IP = meta.Get(events.MetadataKeyIP)
+ entry.Source.UserAgent = meta.Get(events.MetadataKeyUserAgent)
+ entry.RequestID = meta.Get(events.MetadataKeyRequestID)
+ if entry.Source.Type == "" {
+ if entry.Source.IP != "" || entry.Source.UserAgent != "" {
+ entry.Source.Type = SourceHTTP
+ } else {
+ entry.Source.Type = SourceSystem
+ }
+ }
+}
diff --git a/pkg/audit/writer.go b/pkg/audit/writer.go
new file mode 100644
index 000000000..548c380fe
--- /dev/null
+++ b/pkg/audit/writer.go
@@ -0,0 +1,199 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package audit
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/log"
+
+ "github.com/google/uuid"
+)
+
+var (
+ mu sync.Mutex
+ initialized bool
+ logFile *os.File
+ logfilePath string
+ currentSize int64
+ maxSizeBytes int64
+ maxAge time.Duration
+ lastSync time.Time
+)
+
+// Init opens the audit log file.
+// Safe to call again to re-read the config (used by tests).
+func Init() error {
+ mu.Lock()
+ defer mu.Unlock()
+
+ closeLocked()
+
+ logfilePath = config.AuditLogfile.GetString()
+ if logfilePath == "" {
+ logfilePath = filepath.Join(config.LogPath.GetString(), "audit.log")
+ }
+ maxSizeBytes = config.AuditRotationMaxSizeMB.GetInt64() * 1024 * 1024
+ maxAge = time.Duration(config.AuditRotationMaxAge.GetInt64()) * 24 * time.Hour
+
+ if err := os.MkdirAll(filepath.Dir(logfilePath), 0750); err != nil {
+ return fmt.Errorf("could not create audit log directory: %w", err)
+ }
+ if err := openLogFileLocked(); err != nil {
+ return err
+ }
+
+ initialized = true
+ return nil
+}
+
+// Close closes the audit log file. Used by tests.
+func Close() {
+ mu.Lock()
+ defer mu.Unlock()
+ closeLocked()
+}
+
+func closeLocked() {
+ if logFile != nil {
+ _ = logFile.Sync()
+ _ = logFile.Close()
+ logFile = nil
+ }
+ initialized = false
+}
+
+func openLogFileLocked() error {
+ f, err := os.OpenFile(logfilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
+ if err != nil {
+ return fmt.Errorf("could not open audit log file %s: %w", logfilePath, err)
+ }
+ info, err := f.Stat()
+ if err != nil {
+ _ = f.Close()
+ return fmt.Errorf("could not stat audit log file %s: %w", logfilePath, err)
+ }
+ logFile = f
+ currentSize = info.Size()
+ return nil
+}
+
+// WriteAuditEvent writes one entry to the local audit log. A failed write is
+// returned so the event router retries it.
+func WriteAuditEvent(entry *Entry) error {
+ if entry.EventID == "" {
+ id, err := uuid.NewV7()
+ if err != nil {
+ return fmt.Errorf("could not generate audit event id: %w", err)
+ }
+ entry.EventID = id.String()
+ }
+ if entry.Timestamp.IsZero() {
+ entry.Timestamp = time.Now().UTC()
+ }
+ if entry.Outcome == "" {
+ entry.Outcome = OutcomeSuccess
+ }
+
+ line, err := json.Marshal(entry)
+ if err != nil {
+ return fmt.Errorf("could not marshal audit entry: %w", err)
+ }
+
+ mu.Lock()
+ if !initialized {
+ mu.Unlock()
+ return fmt.Errorf("audit log not initialized")
+ }
+
+ if err := rotateIfNeededLocked(int64(len(line)) + 1); err != nil {
+ mu.Unlock()
+ return err
+ }
+
+ written, err := logFile.Write(append(line, '\n'))
+ currentSize += int64(written)
+ if err == nil && time.Since(lastSync) > time.Second {
+ err = logFile.Sync()
+ lastSync = time.Now()
+ }
+ mu.Unlock()
+
+ if err != nil {
+ return fmt.Errorf("could not write audit entry: %w", err)
+ }
+
+ return nil
+}
+
+func rotateIfNeededLocked(addition int64) error {
+ if maxSizeBytes <= 0 || currentSize+addition <= maxSizeBytes {
+ return nil
+ }
+
+ _ = logFile.Sync()
+ _ = logFile.Close()
+ logFile = nil
+
+ rotatedPath := rotatedFileName(logfilePath, time.Now().UTC())
+ if err := os.Rename(logfilePath, rotatedPath); err != nil {
+ // Reopen the original so logging continues even if rotation failed.
+ _ = openLogFileLocked()
+ return fmt.Errorf("could not rotate audit log: %w", err)
+ }
+
+ cleanupRotatedFiles()
+
+ return openLogFileLocked()
+}
+
+func rotatedFileName(path string, now time.Time) string {
+ ext := filepath.Ext(path)
+ return strings.TrimSuffix(path, ext) + "-" + now.Format("20060102T150405.000") + ext
+}
+
+func cleanupRotatedFiles() {
+ if maxAge <= 0 {
+ return
+ }
+
+ ext := filepath.Ext(logfilePath)
+ pattern := strings.TrimSuffix(logfilePath, ext) + "-*" + ext
+ matches, err := filepath.Glob(pattern)
+ if err != nil {
+ log.Errorf("Could not list rotated audit log files: %s", err)
+ return
+ }
+
+ cutoff := time.Now().Add(-maxAge)
+ for _, match := range matches {
+ info, err := os.Stat(match)
+ if err != nil || info.ModTime().After(cutoff) {
+ continue
+ }
+ if err := os.Remove(match); err != nil {
+ log.Errorf("Could not remove old audit log file %s: %s", match, err)
+ }
+ }
+}