Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: add rotating log for audit data #473

Merged
merged 1 commit into from
Jul 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions internal/backend/runtime/omni/audit/audit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) 2024 Sidero Labs, Inc.
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.

// Package audit provides a state wrapper that logs audit events.
package audit

import (
"github.com/siderolabs/omni/internal/pkg/auth/role"
)

// Data contains the audit data.
type Data struct {
UserAgent string `json:"user_agent,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
UserID string `json:"user_id,omitempty"`
Identity string `json:"identity,omitempty"`
Role role.Role `json:"role,omitempty"`
Email string `json:"email,omitempty"`
}
12 changes: 12 additions & 0 deletions internal/backend/runtime/omni/audit/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright (c) 2024 Sidero Labs, Inc.
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.

package audit

import "time"

func (l *LogFile) DumpAt(data any, at time.Time) error {
return l.dumpAt(data, at)
}
106 changes: 106 additions & 0 deletions internal/backend/runtime/omni/audit/log_file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright (c) 2024 Sidero Labs, Inc.
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.

package audit

import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"sync"
"time"

"github.com/siderolabs/gen/pair/ordered"

"github.com/siderolabs/omni/internal/pkg/pool"
)

// LogFile is a rotating log file.
//
//nolint:govet
type LogFile struct {
dir string

mu sync.Mutex
f *os.File
lastWrite time.Time

pool pool.Pool[bytes.Buffer]
}

// NewLogFile creates a new rotating log file.
func NewLogFile(dir string) *LogFile {
return &LogFile{
dir: dir,
pool: pool.Pool[bytes.Buffer]{
New: func() *bytes.Buffer {
return &bytes.Buffer{}
},
},
}
}

// Dump writes data to the log file, creating new one on demand.
func (l *LogFile) Dump(data any) error {
return l.dumpAt(data, time.Time{})
}

func (l *LogFile) dumpAt(data any, at time.Time) error {
b := l.pool.Get()
defer func() { b.Reset(); l.pool.Put(b) }()

err := json.NewEncoder(b).Encode(data)
if err != nil {
return err
}

l.mu.Lock()
defer l.mu.Unlock()

if at.IsZero() {
at = time.Now()
}

f, err := l.openFile(at)
if err != nil {
return err
}

_, err = b.WriteTo(f)
if err != nil {
return err
}

l.lastWrite = at

return nil
}

// openFile opens a file for the given date. It returns the file is date for at matches
// the last write date. Otherwise, it opens a new file.
func (l *LogFile) openFile(at time.Time) (*os.File, error) {
if l.f != nil && ordered.MakeTriple(at.Date()).Compare(ordered.MakeTriple(l.lastWrite.Date())) <= 0 {
DmitriyMV marked this conversation as resolved.
Show resolved Hide resolved
return l.f, nil
}

if l.f != nil {
// Ignore the error, we can't do anything about it anyway
l.f.Close() //nolint:errcheck

l.f = nil
}

logPath := filepath.Join(l.dir, at.Format("2006-01-02")) + ".jsonlog"

f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
DmitriyMV marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return nil, err
}

l.f = f

return f, nil
}
163 changes: 163 additions & 0 deletions internal/backend/runtime/omni/audit/log_file_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// Copyright (c) 2024 Sidero Labs, Inc.
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.

package audit_test

import (
"embed"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"

"github.com/siderolabs/gen/xtesting/must"
"github.com/stretchr/testify/require"

"github.com/siderolabs/omni/internal/backend/runtime/omni/audit"
)

//go:embed testdata/currentday
var currentDay embed.FS

func TestLogFile_CurrentDay(t *testing.T) {
dir := must.Value(os.MkdirTemp("", "log_file_test"))(t)

t.Cleanup(func() { os.RemoveAll(dir) }) //nolint:errcheck

entries := []entry{
{shift: time.Second, data: audit.Data{UserAgent: "Mozilla/5.0", IPAddress: "10.10.0.1", Email: "[email protected]"}},
{shift: time.Minute, data: audit.Data{UserAgent: "Mozilla/5.0", IPAddress: "10.10.0.2", Email: "[email protected]"}},
{shift: 30 * time.Minute, data: audit.Data{UserAgent: "Mozilla/5.0", IPAddress: "10.10.0.3", Email: "[email protected]"}},
}

start := time.Date(2012, 1, 1, 23, 0, 0, 0, time.Local)
now := start
file := audit.NewLogFile(dir)

for _, e := range entries {
now = now.Add(e.shift)

require.NoError(t, file.DumpAt(e.data, now))
}

checkFiles(t, basicLoader(dir), fsSub(t, currentDay, "currentday"))
}

//go:embed testdata/nextday
var nextDay embed.FS

func TestLogFile_CurrentAndNewDay(t *testing.T) {
dir := must.Value(os.MkdirTemp("", "log_file_test"))(t)

t.Cleanup(func() { os.RemoveAll(dir) }) //nolint:errcheck

entries := []entry{
{shift: 0, data: audit.Data{UserAgent: "Mozilla/5.0", IPAddress: "10.10.0.1", Email: "[email protected]"}},
{shift: 55 * time.Minute, data: audit.Data{UserAgent: "Mozilla/5.0", IPAddress: "10.10.0.2", Email: "[email protected]"}},
{shift: 5 * time.Minute, data: audit.Data{UserAgent: "Mozilla/5.0", IPAddress: "10.10.0.3", Email: "[email protected]"}},
}

start := time.Date(2012, 1, 1, 23, 0, 0, 0, time.Local)
now := start
file := audit.NewLogFile(dir)

for _, e := range entries {
now = now.Add(e.shift)

require.NoError(t, file.DumpAt(e.data, now))
}

checkFiles(t, basicLoader(dir), fsSub(t, nextDay, "nextday"))
}

//go:embed testdata/concurrent
var concurrent embed.FS

func TestLogFile_CurrentDayConcurrent(t *testing.T) {
dir := must.Value(os.MkdirTemp("", "log_file_test"))(t)

t.Cleanup(func() { os.RemoveAll(dir) }) //nolint:errcheck

entries := make([]entry, 0, 250)

for i := range 250 {
address := fmt.Sprintf("10.10.0.%d", i+1)
email := fmt.Sprintf("random_email_%[email protected]", i+1)

entries = append(entries, entry{shift: time.Second, data: audit.Data{UserAgent: "Mozilla/5.0", IPAddress: address, Email: email}})
}

start := time.Date(2012, 1, 1, 23, 0, 0, 0, time.Local)
now := start
file := audit.NewLogFile(dir)

t.Run("concurrent", func(t *testing.T) {
for _, e := range entries {
now = now.Add(e.shift)
nowCopy := now

t.Run("", func(t *testing.T) {
t.Parallel()

require.NoError(t, file.DumpAt(e.data, nowCopy))
})
}
})

checkFiles(t, sortedLoader(basicLoader(dir)), fsSub(t, concurrent, "concurrent"))
}

//nolint:govet
type entry struct {
shift time.Duration
data audit.Data
}

type subFS interface {
fs.ReadFileFS
fs.ReadDirFS
}

func checkFiles(t *testing.T, loader fileLoader, expectedFS subFS) {
expectedFiles := must.Value(expectedFS.ReadDir("."))(t)

for _, expectedFile := range expectedFiles {
if expectedFile.IsDir() {
t.Fatal("unexpected directory", expectedFile.Name())
}

expectedData := string(must.Value(expectedFS.ReadFile(expectedFile.Name()))(t))
actualData := loader(t, expectedFile.Name())

require.Equal(t, expectedData, actualData, "file %s", expectedFile.Name())
}
}

func fsSub(t *testing.T, subFs subFS, folder string) subFS {
return must.Value(fs.Sub(subFs, filepath.Join("testdata", folder)))(t).(subFS) //nolint:forcetypeassert
}

type fileLoader func(t *testing.T, filename string) string

func basicLoader(dir string) func(t *testing.T, filename string) string {
return func(t *testing.T, filename string) string {
return string(must.Value(os.ReadFile(filepath.Join(dir, filename)))(t))
}
}

func sortedLoader(loader fileLoader) fileLoader {
return func(t *testing.T, filename string) string {
data := strings.TrimRight(loader(t, filename), "\n")
slc := strings.Split(data, "\n")

slices.Sort(slc)

return strings.Join(slc, "\n") + "\n"
}
}
Loading
Loading