From 939daaf1abed4941557f07d099cb8f00ee54c747 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:03:49 +0200 Subject: [PATCH] feat(audit): add audit logging package Entry schema with constructor-enforced actor/target types, a generic RegisterEventForAudit helper that maps opted-in events to entries on the existing watermill bus (license-gated per event since licenses are runtime-mutable), and a JSONL writer with size-based rotation, age-based cleanup of rotated files and batched fsync. --- pkg/audit/entry.go | 127 +++++++++++++++++++++++++++ pkg/audit/listener.go | 88 +++++++++++++++++++ pkg/audit/writer.go | 199 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 414 insertions(+) create mode 100644 pkg/audit/entry.go create mode 100644 pkg/audit/listener.go create mode 100644 pkg/audit/writer.go 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) + } + } +}