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.
This commit is contained in:
parent
bc2b780e99
commit
014d9c4dab
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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} }
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue