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

feat: support writing audit events to stdout for log #3113

Merged
merged 15 commits into from
Jun 6, 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
1 change: 1 addition & 0 deletions config/flipt.schema.cue
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ import "strings"
log?: {
enabled?: bool | *false
file?: string | *""
encoding?: *"" | "json" | "console"
}
webhook?: {
enabled?: bool | *false
Expand Down
4 changes: 4 additions & 0 deletions config/flipt.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1219,6 +1219,10 @@
"file": {
"type": "string",
"default": ""
},
"encoding": {
"type": "string",
"enum": ["json", "console", ""]
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the "" is required so that by default it inherits the same encoding as log.encoding, although i guess we could make an explicit inherit option?

}
},
"title": "Log File"
Expand Down
20 changes: 16 additions & 4 deletions internal/cmd/grpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import (
"go.flipt.io/flipt/internal/server/analytics/clickhouse"
"go.flipt.io/flipt/internal/server/audit"
"go.flipt.io/flipt/internal/server/audit/cloud"
"go.flipt.io/flipt/internal/server/audit/logfile"
"go.flipt.io/flipt/internal/server/audit/log"
"go.flipt.io/flipt/internal/server/audit/template"
"go.flipt.io/flipt/internal/server/audit/webhook"
authnmiddlewaregrpc "go.flipt.io/flipt/internal/server/authn/middleware/grpc"
Expand Down Expand Up @@ -392,10 +392,22 @@ func NewGRPCServer(
// audit sinks configuration
sinks := make([]audit.Sink, 0)

if cfg.Audit.Sinks.LogFile.Enabled {
logFileSink, err := logfile.NewSink(logger, cfg.Audit.Sinks.LogFile.File)
if cfg.Audit.Sinks.Log.Enabled {
opts := []log.Option{}
if cfg.Audit.Sinks.Log.File != "" {
opts = append(opts, log.WithPath(cfg.Audit.Sinks.Log.File))
}

if cfg.Audit.Sinks.Log.Encoding != "" {
opts = append(opts, log.WithEncoding(cfg.Audit.Sinks.Log.Encoding))
} else {
// inherit the global log encoding if not specified
opts = append(opts, log.WithEncoding(cfg.Log.Encoding))
}

logFileSink, err := log.NewSink(opts...)
if err != nil {
return nil, fmt.Errorf("opening file at path: %s", cfg.Audit.Sinks.LogFile.File)
return nil, fmt.Errorf("creating audit log sink: %w", err)
}

sinks = append(sinks, logFileSink)
Expand Down
20 changes: 8 additions & 12 deletions internal/config/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type AuditConfig struct {

// Enabled returns true if any nested sink is enabled
func (c AuditConfig) Enabled() bool {
return c.Sinks.LogFile.Enabled || c.Sinks.Webhook.Enabled || c.Sinks.Cloud.Enabled
return c.Sinks.Log.Enabled || c.Sinks.Webhook.Enabled || c.Sinks.Cloud.Enabled
}

func (c AuditConfig) IsZero() bool {
Expand All @@ -34,7 +34,6 @@ func (c *AuditConfig) setDefaults(v *viper.Viper) error {
"sinks": map[string]any{
"log": map[string]any{
"enabled": "false",
"file": "",
},
"webhook": map[string]any{
"enabled": "false",
Expand All @@ -54,10 +53,6 @@ func (c *AuditConfig) setDefaults(v *viper.Viper) error {
}

func (c *AuditConfig) validate() error {
if c.Sinks.LogFile.Enabled && c.Sinks.LogFile.File == "" {
return errors.New("file not specified")
}

if c.Sinks.Webhook.Enabled {
if c.Sinks.Webhook.URL == "" && len(c.Sinks.Webhook.Templates) == 0 {
return errors.New("url or template(s) not provided")
Expand All @@ -82,7 +77,7 @@ func (c *AuditConfig) validate() error {
// SinksConfig contains configuration held in structures for the different sinks
// that we will send audits to.
type SinksConfig struct {
LogFile LogFileSinkConfig `json:"log,omitempty" mapstructure:"log" yaml:"log,omitempty"`
Log LogSinkConfig `json:"log,omitempty" mapstructure:"log" yaml:"log,omitempty"`
Webhook WebhookSinkConfig `json:"webhook,omitempty" mapstructure:"webhook" yaml:"webhook,omitempty"`
Cloud CloudSinkConfig `json:"cloud,omitempty" mapstructure:"cloud" yaml:"cloud,omitempty"`
}
Expand All @@ -101,11 +96,12 @@ type WebhookSinkConfig struct {
Templates []WebhookTemplate `json:"templates,omitempty" mapstructure:"templates" yaml:"templates,omitempty"`
}

// LogFileSinkConfig contains fields that hold configuration for sending audits
// to a log file.
type LogFileSinkConfig struct {
Enabled bool `json:"enabled,omitempty" mapstructure:"enabled" yaml:"enabled,omitempty"`
File string `json:"file,omitempty" mapstructure:"file" yaml:"file,omitempty"`
// LogSinkConfig contains fields that hold configuration for sending audits
// to a log.
type LogSinkConfig struct {
Enabled bool `json:"enabled,omitempty" mapstructure:"enabled" yaml:"enabled,omitempty"`
File string `json:"file,omitempty" mapstructure:"file" yaml:"file,omitempty"`
Encoding LogEncoding `json:"encoding,omitempty" mapstructure:"encoding" yaml:"encoding,omitempty"`
}

// BufferConfig holds configuration for the buffering of sending the audit
Expand Down
3 changes: 1 addition & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ var (
var DecodeHooks = []mapstructure.DecodeHookFunc{
mapstructure.StringToTimeDurationHookFunc(),
stringToSliceHookFunc(),
stringToEnumHookFunc(stringToLogEncoding),
stringToEnumHookFunc(stringToCacheBackend),
stringToEnumHookFunc(stringToTracingExporter),
stringToEnumHookFunc(stringToScheme),
Expand Down Expand Up @@ -619,7 +618,7 @@ func Default() *Config {

Audit: AuditConfig{
Sinks: SinksConfig{
LogFile: LogFileSinkConfig{
Log: LogSinkConfig{
Enabled: false,
File: "",
},
Expand Down
12 changes: 8 additions & 4 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,7 @@ func TestLoad(t *testing.T) {

cfg.Audit = AuditConfig{
Sinks: SinksConfig{
LogFile: LogFileSinkConfig{
Log: LogSinkConfig{
Enabled: true,
File: "/path/to/logs.txt",
},
Expand Down Expand Up @@ -942,9 +942,13 @@ func TestLoad(t *testing.T) {
wantErr: errors.New("flush period below 2 minutes or greater than 5 minutes"),
},
{
name: "file not specified",
path: "./testdata/audit/invalid_enable_without_file.yml",
wantErr: errors.New("file not specified"),
name: "file not specified",
path: "./testdata/audit/enable_without_file.yml",
expected: func() *Config {
cfg := Default()
cfg.Audit.Sinks.Log.Enabled = true
return cfg
},
},
{
name: "url or template not specified",
Expand Down
22 changes: 4 additions & 18 deletions internal/config/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,30 +41,16 @@ func (c *LogConfig) setDefaults(v *viper.Viper) error {
return nil
}

var (
logEncodingToString = [...]string{
LogEncodingConsole: "console",
LogEncodingJSON: "json",
}

stringToLogEncoding = map[string]LogEncoding{
"console": LogEncodingConsole,
"json": LogEncodingJSON,
}
)

// LogEncoding is either console or JSON.
// TODO: can we use a string instead?
type LogEncoding uint8
type LogEncoding string

const (
_ LogEncoding = iota
LogEncodingConsole
LogEncodingJSON
LogEncodingConsole = LogEncoding("console")
LogEncodingJSON = LogEncoding("json")
)

func (e LogEncoding) String() string {
return logEncodingToString[e]
return string(e)
}

func (e LogEncoding) MarshalJSON() ([]byte, error) {
Expand Down
3 changes: 2 additions & 1 deletion internal/server/audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"io"

"github.com/hashicorp/go-multierror"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
Expand All @@ -14,7 +15,7 @@ import (
// that Flipt will support.
type Sink interface {
SendAudits(context.Context, []Event) error
Close() error
io.Closer
fmt.Stringer
}

Expand Down
141 changes: 141 additions & 0 deletions internal/server/audit/log/log.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package log

import (
"context"
"fmt"
"os"
"path/filepath"

"go.flipt.io/flipt/internal/config"
"go.flipt.io/flipt/internal/server/audit"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)

const (
sinkType = "log"
auditKey = "AUDIT"
)

// Sink is the structure in charge of sending audit events to a specified file location.
type Sink struct {
logger *zap.Logger
}

type logOptions struct {
path string
encoding config.LogEncoding
}

type Option func(*logOptions)

func WithPath(path string) Option {
return func(o *logOptions) {
o.path = path
}
}

func WithEncoding(encoding config.LogEncoding) Option {
return func(o *logOptions) {
o.encoding = encoding
}
}

// NewSink is the constructor for a Sink.
func NewSink(opts ...Option) (audit.Sink, error) {
oo := &logOptions{}

for _, o := range opts {
o(oo)
}

return newSink(*oo)
}

var (
consoleEncoderConfig = zapcore.EncoderConfig{
TimeKey: "T",
LevelKey: zapcore.OmitKey,
NameKey: "N",
CallerKey: zapcore.OmitKey,
FunctionKey: zapcore.OmitKey,
MessageKey: "M",
StacktraceKey: zapcore.OmitKey,
LineEnding: zapcore.DefaultLineEnding,
EncodeLevel: zapcore.CapitalColorLevelEncoder,
EncodeTime: zapcore.RFC3339TimeEncoder,
EncodeDuration: zapcore.StringDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
}

fileEncoderConfig = zapcore.EncoderConfig{
TimeKey: zapcore.OmitKey,
LevelKey: zapcore.OmitKey,
NameKey: zapcore.OmitKey,
CallerKey: zapcore.OmitKey,
FunctionKey: zapcore.OmitKey,
MessageKey: zapcore.OmitKey,
StacktraceKey: zapcore.OmitKey,
LineEnding: zapcore.DefaultLineEnding,
EncodeLevel: zapcore.CapitalColorLevelEncoder,
EncodeTime: zapcore.RFC3339TimeEncoder,
EncodeDuration: zapcore.StringDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
}
)

// newSink is the constructor for a Sink visible for testing.
func newSink(opts logOptions) (audit.Sink, error) {
cfg := zap.Config{
Level: zap.NewAtomicLevelAt(zapcore.InfoLevel),
Development: false,
Encoding: "console",
EncoderConfig: consoleEncoderConfig,
OutputPaths: []string{"stdout"},
ErrorOutputPaths: []string{"stderr"},
}

if opts.encoding != "" {
cfg.Encoding = string(opts.encoding)
}

if opts.path != "" {
// check path dir exists, if not create it
dir := filepath.Dir(opts.path)
if _, err := os.Stat(dir); err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("checking log file path: %w", err)
}

if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("creating log file path: %w", err)
}
}

cfg.OutputPaths = []string{opts.path}
markphelps marked this conversation as resolved.
Show resolved Hide resolved
cfg.EncoderConfig = fileEncoderConfig
}

return &Sink{
logger: zap.Must(cfg.Build()),
}, nil
}

func (l *Sink) SendAudits(_ context.Context, events []audit.Event) error {
for _, e := range events {
l.logger.Info(auditKey, zap.Any("event", e))
}

return nil
}

func (l *Sink) Close() error {
// see: https://github.com/uber-go/zap/issues/991
//nolint:errcheck
_ = l.logger.Sync()
return nil
}

func (l *Sink) String() string {
return sinkType
}
Loading
Loading