diff --git a/client/acl.go b/client/acl.go index abccdc01f..43d994bc4 100644 --- a/client/acl.go +++ b/client/acl.go @@ -74,6 +74,11 @@ func (c *Client) ResolveToken(secretID string) (*acl.ACL, error) { return a, err } +func (c *Client) ResolveSecretToken(secretID string) (*structs.ACLToken, error) { + _, t, err := c.resolveTokenAndACL(secretID) + return t, err +} + func (c *Client) resolveTokenAndACL(secretID string) (*acl.ACL, *structs.ACLToken, error) { // Fast-path if ACLs are disabled if !c.config.ACLEnabled { diff --git a/client/acl_test.go b/client/acl_test.go index eef97b7e5..a0192642e 100644 --- a/client/acl_test.go +++ b/client/acl_test.go @@ -165,3 +165,29 @@ func TestClient_ACL_ResolveToken(t *testing.T) { assert.Equal(t, structs.ErrTokenNotFound, err) assert.Nil(t, out4) } + +func TestClient_ACL_ResolveSecretToken(t *testing.T) { + t.Parallel() + + s1, _, _, cleanupS1 := testACLServer(t, nil) + defer cleanupS1() + testutil.WaitForLeader(t, s1.RPC) + + c1, cleanup := TestClient(t, func(c *config.Config) { + c.RPCHandler = s1 + c.ACLEnabled = true + }) + defer cleanup() + + token := mock.ACLToken() + + err := s1.State().UpsertACLTokens(110, []*structs.ACLToken{token}) + assert.Nil(t, err) + + respToken, err := c1.ResolveSecretToken(token.SecretID) + assert.Nil(t, err) + if assert.NotNil(t, respToken) { + assert.NotEmpty(t, respToken.AccessorID) + } + +} diff --git a/command/agent/agent.go b/command/agent/agent.go index 04eff1cc6..db67ce710 100644 --- a/command/agent/agent.go +++ b/command/agent/agent.go @@ -22,6 +22,7 @@ import ( clientconfig "github.com/hashicorp/nomad/client/config" "github.com/hashicorp/nomad/client/state" "github.com/hashicorp/nomad/command/agent/consul" + "github.com/hashicorp/nomad/command/agent/event" "github.com/hashicorp/nomad/helper/pluginutils/loader" "github.com/hashicorp/nomad/helper/uuid" "github.com/hashicorp/nomad/nomad" @@ -53,6 +54,7 @@ type Agent struct { configLock sync.Mutex logger log.InterceptLogger + eventer event.Eventer httpLogger log.Logger logOutput io.Writer @@ -118,6 +120,9 @@ func NewAgent(config *Config, logger log.InterceptLogger, logOutput io.Writer, i if err := a.setupClient(); err != nil { return nil, err } + if err := a.setupEnterpriseAgent(logger); err != nil { + return nil, err + } if a.client == nil && a.server == nil { return nil, fmt.Errorf("must have at least client or server mode enabled") } @@ -998,6 +1003,13 @@ func (a *Agent) Reload(newConfig *Config) error { a.logger.SetLevel(log.LevelFromString(newConfig.LogLevel)) } + // Update eventer config + if newConfig.Audit != nil { + if err := a.entReloadEventer(a.config.Audit); err != nil { + return err + } + } + fullUpdateTLSConfig := func() { // Completely reload the agent's TLS configuration (moving from non-TLS to // TLS, or vice versa) diff --git a/command/agent/agent_oss.go b/command/agent/agent_oss.go new file mode 100644 index 000000000..cadaf428e --- /dev/null +++ b/command/agent/agent_oss.go @@ -0,0 +1,41 @@ +// +build !ent + +package agent + +import ( + "context" + + hclog "github.com/hashicorp/go-hclog" + "github.com/hashicorp/nomad/command/agent/event" + "github.com/hashicorp/nomad/nomad/structs/config" +) + +type noOpEventer struct{} + +// Ensure noOpEventer is an Eventer +var _ event.Eventer = &noOpEventer{} + +func (e *noOpEventer) Event(ctx context.Context, eventType string, payload interface{}) error { + return nil +} + +func (e *noOpEventer) Enabled() bool { + return false +} + +func (e *noOpEventer) Reopen() error { + return nil +} + +func (e *noOpEventer) SetEnabled(enabled bool) {} + +func (a *Agent) setupEnterpriseAgent(log hclog.Logger) error { + // configure eventer + a.eventer = &noOpEventer{} + + return nil +} + +func (a *Agent) entReloadEventer(cfg *config.AuditConfig) error { + return nil +} diff --git a/command/agent/command.go b/command/agent/command.go index e018a1e1c..a7ab4424b 100644 --- a/command/agent/command.go +++ b/command/agent/command.go @@ -72,6 +72,7 @@ func (c *Command) readConfig() *Config { }, Vault: &config.VaultConfig{}, ACL: &ACLConfig{}, + Audit: &config.AuditConfig{}, } flags := flag.NewFlagSet("agent", flag.ContinueOnError) diff --git a/command/agent/config.go b/command/agent/config.go index f350aa708..3e1d2b17e 100644 --- a/command/agent/config.go +++ b/command/agent/config.go @@ -169,6 +169,9 @@ type Config struct { // Limits contains the configuration for timeouts. Limits config.Limits `hcl:"limits"` + // Audit contains the configuration for audit logging. + Audit *config.AuditConfig `hcl:"audit"` + // ExtraKeysHCL is used by hcl to surface unexpected keys ExtraKeysHCL []string `hcl:",unusedKeys" json:"-"` } @@ -865,6 +868,7 @@ func DefaultConfig() *Config { Sentinel: &config.SentinelConfig{}, Version: version.GetVersion(), Autopilot: config.DefaultAutopilotConfig(), + Audit: &config.AuditConfig{}, DisableUpdateCheck: helper.BoolToPtr(false), Limits: config.DefaultLimits(), } @@ -996,6 +1000,14 @@ func (c *Config) Merge(b *Config) *Config { result.ACL = result.ACL.Merge(b.ACL) } + // Apply the Audit config + if result.Audit == nil && b.Audit != nil { + audit := *b.Audit + result.Audit = &audit + } else if b.ACL != nil { + result.Audit = result.Audit.Merge(b.Audit) + } + // Apply the ports config if result.Ports == nil && b.Ports != nil { ports := *b.Ports diff --git a/command/agent/config_parse.go b/command/agent/config_parse.go index 34e192b74..cde091bb2 100644 --- a/command/agent/config_parse.go +++ b/command/agent/config_parse.go @@ -35,6 +35,7 @@ func ParseConfigFile(path string) (*Config, error) { c := &Config{ Client: &ClientConfig{ServerJoin: &ServerJoin{}}, ACL: &ACLConfig{}, + Audit: &config.AuditConfig{}, Server: &ServerConfig{ServerJoin: &ServerJoin{}}, Consul: &config.ConsulConfig{}, Autopilot: &config.AutopilotConfig{}, @@ -48,7 +49,7 @@ func ParseConfigFile(path string) (*Config, error) { } // convert strings to time.Durations - err = durations([]td{ + tds := []td{ {"gc_interval", &c.Client.GCInterval, &c.Client.GCIntervalHCL}, {"acl.token_ttl", &c.ACL.TokenTTL, &c.ACL.TokenTTLHCL}, {"acl.policy_ttl", &c.ACL.PolicyTTL, &c.ACL.PolicyTTLHCL}, @@ -61,7 +62,17 @@ func ParseConfigFile(path string) (*Config, error) { {"autopilot.server_stabilization_time", &c.Autopilot.ServerStabilizationTime, &c.Autopilot.ServerStabilizationTimeHCL}, {"autopilot.last_contact_threshold", &c.Autopilot.LastContactThreshold, &c.Autopilot.LastContactThresholdHCL}, {"telemetry.collection_interval", &c.Telemetry.collectionInterval, &c.Telemetry.CollectionInterval}, - }) + } + + // Add enterprise audit sinks for time.Duration parsing + for i, sink := range c.Audit.Sinks { + tds = append(tds, td{ + fmt.Sprintf("audit.sink.%d", i), &sink.RotateDuration, &sink.RotateDurationHCL, + }) + } + + // convert strings to time.Durations + err = durations(tds) if err != nil { return nil, err } @@ -144,6 +155,17 @@ func extraKeys(c *Config) error { removeEqualFold(&c.Client.ExtraKeysHCL, "host_volume") } + // Remove AuditConfig extra keys + for _, f := range c.Audit.Filters { + removeEqualFold(&c.Audit.ExtraKeysHCL, f.Name) + removeEqualFold(&c.Audit.ExtraKeysHCL, "filter") + } + + for _, s := range c.Audit.Sinks { + removeEqualFold(&c.Audit.ExtraKeysHCL, s.Name) + removeEqualFold(&c.Audit.ExtraKeysHCL, "sink") + } + for _, k := range []string{"enabled_schedulers", "start_join", "retry_join", "server_join"} { removeEqualFold(&c.ExtraKeysHCL, k) removeEqualFold(&c.ExtraKeysHCL, "server") diff --git a/command/agent/config_parse_test.go b/command/agent/config_parse_test.go index eb9691ef1..796c30e0f 100644 --- a/command/agent/config_parse_test.go +++ b/command/agent/config_parse_test.go @@ -134,6 +134,31 @@ var basicConfig = &Config{ PolicyTTLHCL: "60s", ReplicationToken: "foobar", }, + Audit: &config.AuditConfig{ + Enabled: helper.BoolToPtr(true), + Sinks: []*config.AuditSink{ + { + DeliveryGuarantee: "enforced", + Name: "file", + Type: "file", + Format: "json", + Path: "/opt/nomad/audit.log", + RotateDuration: 24 * time.Hour, + RotateDurationHCL: "24h", + RotateBytes: 100, + RotateMaxFiles: 10, + }, + }, + Filters: []*config.AuditFilter{ + { + Name: "default", + Type: "HTTPEvent", + Endpoints: []string{"/ui/", "/v1/agent/health"}, + Stages: []string{"*"}, + Operations: []string{"*"}, + }, + }, + }, Telemetry: &Telemetry{ StatsiteAddr: "127.0.0.1:1234", StatsdAddr: "127.0.0.1:2345", @@ -389,6 +414,7 @@ func TestConfig_ParseMerge(t *testing.T) { Autopilot: config.DefaultAutopilotConfig(), Client: &ClientConfig{}, Server: &ServerConfig{}, + Audit: &config.AuditConfig{}, } merged := oldDefault.Merge(actual) require.Equal(t, basicConfig.Client, merged.Client) @@ -480,6 +506,9 @@ func (c *Config) addDefaults() { if c.ACL == nil { c.ACL = &ACLConfig{} } + if c.Audit == nil { + c.Audit = &config.AuditConfig{} + } if c.Consul == nil { c.Consul = config.DefaultConsulConfig() } @@ -575,6 +604,31 @@ var sample0 = &Config{ ACL: &ACLConfig{ Enabled: true, }, + Audit: &config.AuditConfig{ + Enabled: helper.BoolToPtr(true), + Sinks: []*config.AuditSink{ + { + DeliveryGuarantee: "enforced", + Name: "file", + Type: "file", + Format: "json", + Path: "/opt/nomad/audit.log", + RotateDuration: 24 * time.Hour, + RotateDurationHCL: "24h", + RotateBytes: 100, + RotateMaxFiles: 10, + }, + }, + Filters: []*config.AuditFilter{ + { + Name: "default", + Type: "HTTPEvent", + Endpoints: []string{"/ui/", "/v1/agent/health"}, + Stages: []string{"*"}, + Operations: []string{"*"}, + }, + }, + }, Telemetry: &Telemetry{ PrometheusMetrics: true, DisableHostname: true, @@ -638,6 +692,31 @@ var sample1 = &Config{ ACL: &ACLConfig{ Enabled: true, }, + Audit: &config.AuditConfig{ + Enabled: helper.BoolToPtr(true), + Sinks: []*config.AuditSink{ + { + Name: "file", + Type: "file", + DeliveryGuarantee: "enforced", + Format: "json", + Path: "/opt/nomad/audit.log", + RotateDuration: 24 * time.Hour, + RotateDurationHCL: "24h", + RotateBytes: 100, + RotateMaxFiles: 10, + }, + }, + Filters: []*config.AuditFilter{ + { + Name: "default", + Type: "HTTPEvent", + Endpoints: []string{"/ui/", "/v1/agent/health"}, + Stages: []string{"*"}, + Operations: []string{"*"}, + }, + }, + }, Telemetry: &Telemetry{ PrometheusMetrics: true, DisableHostname: true, diff --git a/command/agent/config_test.go b/command/agent/config_test.go index c09c6c402..ce4fb96a0 100644 --- a/command/agent/config_test.go +++ b/command/agent/config_test.go @@ -34,6 +34,7 @@ func TestConfig_Merge(t *testing.T) { Client: &ClientConfig{}, Server: &ServerConfig{}, ACL: &ACLConfig{}, + Audit: &config.AuditConfig{}, Ports: &Ports{}, Addresses: &Addresses{}, AdvertiseAddrs: &AdvertiseAddrs{}, @@ -83,6 +84,22 @@ func TestConfig_Merge(t *testing.T) { CirconusBrokerSelectTag: "dc:dc1", PrefixFilter: []string{"filter1", "filter2"}, }, + Audit: &config.AuditConfig{ + Enabled: helper.BoolToPtr(true), + Sinks: []*config.AuditSink{ + { + DeliveryGuarantee: "enforced", + Name: "file", + Type: "file", + Format: "json", + Path: "/opt/nomad/audit.log", + RotateDuration: 24 * time.Hour, + RotateDurationHCL: "24h", + RotateBytes: 100, + RotateMaxFiles: 10, + }, + }, + }, Client: &ClientConfig{ Enabled: false, StateDir: "/tmp/state1", @@ -213,6 +230,22 @@ func TestConfig_Merge(t *testing.T) { DisableUpdateCheck: helper.BoolToPtr(true), DisableAnonymousSignature: true, BindAddr: "127.0.0.2", + Audit: &config.AuditConfig{ + Enabled: helper.BoolToPtr(true), + Sinks: []*config.AuditSink{ + { + DeliveryGuarantee: "enforced", + Name: "file", + Type: "file", + Format: "json", + Path: "/opt/nomad/audit.log", + RotateDuration: 24 * time.Hour, + RotateDurationHCL: "24h", + RotateBytes: 100, + RotateMaxFiles: 10, + }, + }, + }, Telemetry: &Telemetry{ StatsiteAddr: "127.0.0.2:8125", StatsdAddr: "127.0.0.2:8125", diff --git a/command/agent/event/event.go b/command/agent/event/event.go new file mode 100644 index 000000000..973bd6369 --- /dev/null +++ b/command/agent/event/event.go @@ -0,0 +1,19 @@ +package event + +import ( + "context" +) + +// Eventer describes the interface that must be implemented by an eventer. +type Eventer interface { + // Emit and event + Event(ctx context.Context, eventType string, payload interface{}) error + // Specifies if the eventer is enabled or not + Enabled() bool + + // Reopen signals to eventer to reopen any files they have open. + Reopen() error + + // SetEnabled sets the eventer to enabled or disabled. + SetEnabled(enabled bool) +} diff --git a/command/agent/http.go b/command/agent/http.go index 910bb74ef..5821978d3 100644 --- a/command/agent/http.go +++ b/command/agent/http.go @@ -31,6 +31,13 @@ const ( // ErrEntOnly is the error returned if accessing an enterprise only // endpoint ErrEntOnly = "Nomad Enterprise only endpoint" + + // ContextKeyReqID is a unique ID for a given request + ContextKeyReqID = "requestID" + + // MissingRequestID is a placeholder if we cannot retrieve a request + // UUID from context + MissingRequestID = "" ) var ( @@ -49,6 +56,9 @@ var ( }) ) +type handlerFn func(resp http.ResponseWriter, req *http.Request) (interface{}, error) +type handlerByteFn func(resp http.ResponseWriter, req *http.Request) ([]byte, error) + // HTTPServer is used to wrap an Agent and expose it over an HTTP interface type HTTPServer struct { agent *Agent @@ -380,6 +390,32 @@ func handleRootFallthrough() http.Handler { }) } +func errCodeFromHandler(err error) (int, string) { + if err == nil { + return 0, "" + } + + code := 500 + errMsg := err.Error() + if http, ok := err.(HTTPCodedError); ok { + code = http.Code() + } else if ecode, emsg, ok := structs.CodeFromRPCCodedErr(err); ok { + code = ecode + errMsg = emsg + } else { + // RPC errors get wrapped, so manually unwrap by only looking at their suffix + if strings.HasSuffix(errMsg, structs.ErrPermissionDenied.Error()) { + errMsg = structs.ErrPermissionDenied.Error() + code = 403 + } else if strings.HasSuffix(errMsg, structs.ErrTokenNotFound.Error()) { + errMsg = structs.ErrTokenNotFound.Error() + code = 403 + } + } + + return code, errMsg +} + // wrap is used to wrap functions to make them more convenient func (s *HTTPServer) wrap(handler func(resp http.ResponseWriter, req *http.Request) (interface{}, error)) func(resp http.ResponseWriter, req *http.Request) { f := func(resp http.ResponseWriter, req *http.Request) { @@ -390,7 +426,7 @@ func (s *HTTPServer) wrap(handler func(resp http.ResponseWriter, req *http.Reque defer func() { s.logger.Debug("request complete", "method", req.Method, "path", reqURL, "duration", time.Now().Sub(start)) }() - obj, err := handler(resp, req) + obj, err := s.auditHandler(handler)(resp, req) // Check for an error HAS_ERR: @@ -462,28 +498,11 @@ func (s *HTTPServer) wrapNonJSON(handler func(resp http.ResponseWriter, req *htt defer func() { s.logger.Debug("request complete", "method", req.Method, "path", reqURL, "duration", time.Now().Sub(start)) }() - obj, err := handler(resp, req) + obj, err := s.auditByteHandler(handler)(resp, req) // Check for an error if err != nil { - code := 500 - errMsg := err.Error() - if http, ok := err.(HTTPCodedError); ok { - code = http.Code() - } else if ecode, emsg, ok := structs.CodeFromRPCCodedErr(err); ok { - code = ecode - errMsg = emsg - } else { - // RPC errors get wrapped, so manually unwrap by only looking at their suffix - if strings.HasSuffix(errMsg, structs.ErrPermissionDenied.Error()) { - errMsg = structs.ErrPermissionDenied.Error() - code = 403 - } else if strings.HasSuffix(errMsg, structs.ErrTokenNotFound.Error()) { - errMsg = structs.ErrTokenNotFound.Error() - code = 403 - } - } - + code, errMsg := errCodeFromHandler(err) resp.WriteHeader(code) resp.Write([]byte(errMsg)) s.logger.Error("request failed", "method", req.Method, "path", reqURL, "error", err, "code", code) diff --git a/command/agent/http_oss.go b/command/agent/http_oss.go index 2c9fa0e82..71d73e8fa 100644 --- a/command/agent/http_oss.go +++ b/command/agent/http_oss.go @@ -22,3 +22,15 @@ func (s *HTTPServer) registerEnterpriseHandlers() { func (s *HTTPServer) entOnly(resp http.ResponseWriter, req *http.Request) (interface{}, error) { return nil, CodedError(501, ErrEntOnly) } + +func (s HTTPServer) auditHandler(h handlerFn) handlerFn { + return h +} + +func (s *HTTPServer) auditByteHandler(h handlerByteFn) handlerByteFn { + return h +} + +func (s *HTTPServer) auditHTTPHandler(h http.Handler) http.Handler { + return h +} diff --git a/command/agent/testdata/basic.hcl b/command/agent/testdata/basic.hcl index a3d3bcb9e..235dd21cc 100644 --- a/command/agent/testdata/basic.hcl +++ b/command/agent/testdata/basic.hcl @@ -142,6 +142,27 @@ acl { replication_token = "foobar" } +audit { + enabled = true + + sink "file" { + type = "file" + delivery_guarantee = "enforced" + format = "json" + path = "/opt/nomad/audit.log" + rotate_bytes = 100 + rotate_duration = "24h" + rotate_max_files = 10 + } + + filter "default" { + type = "HTTPEvent" + endpoints = ["/ui/", "/v1/agent/health"] + stages = ["*"] + operations = ["*"] + } +} + telemetry { statsite_address = "127.0.0.1:1234" statsd_address = "127.0.0.1:2345" diff --git a/command/agent/testdata/basic.json b/command/agent/testdata/basic.json index 7048d681e..f2aefd302 100644 --- a/command/agent/testdata/basic.json +++ b/command/agent/testdata/basic.json @@ -7,6 +7,34 @@ "token_ttl": "60s" } ], + "audit": { + "enabled": true, + "sink": [ + { + "file": { + "type": "file", + "format": "json", + "delivery_guarantee": "enforced", + "path": "/opt/nomad/audit.log", + "rotate_bytes": 100, + "rotate_duration": "24h", + "rotate_max_files": 10 + } + } + ], + "filter": [ + { + "default": [ + { + "endpoints": ["/ui/", "/v1/agent/health"], + "operations": ["*"], + "stages": ["*"], + "type": "HTTPEvent" + } + ] + } + ] + }, "addresses": [ { "http": "127.0.0.1", diff --git a/command/agent/testdata/sample0.json b/command/agent/testdata/sample0.json index c9f9a8bd9..6bff9ef4e 100644 --- a/command/agent/testdata/sample0.json +++ b/command/agent/testdata/sample0.json @@ -5,6 +5,34 @@ "acl": { "enabled": true }, + "audit": { + "enabled": true, + "sink": [ + { + "file": { + "type": "file", + "format": "json", + "delivery_guarantee": "enforced", + "path": "/opt/nomad/audit.log", + "rotate_bytes": 100, + "rotate_duration": "24h", + "rotate_max_files": 10 + } + } + ], + "filter": [ + { + "default": [ + { + "endpoints": ["/ui/", "/v1/agent/health"], + "operations": ["*"], + "stages": ["*"], + "type": "HTTPEvent" + } + ] + } + ] + }, "advertise": { "http": "host.example.com", "rpc": "host.example.com", diff --git a/command/agent/testdata/sample1/sample2.hcl b/command/agent/testdata/sample1/sample2.hcl index 5c07391bf..eb71dce51 100644 --- a/command/agent/testdata/sample1/sample2.hcl +++ b/command/agent/testdata/sample1/sample2.hcl @@ -17,3 +17,24 @@ vault = { enabled = true } + +audit { + enabled = true + + sink "file" { + type = "file" + format = "json" + delivery_guarantee = "enforced" + path = "/opt/nomad/audit.log" + rotate_bytes = 100 + rotate_duration = "24h" + rotate_max_files = 10 + } + + filter "default" { + type = "HTTPEvent" + endpoints = ["/ui/", "/v1/agent/health"] + stages = ["*"] + operations = ["*"] + } +} diff --git a/jobspec/test-fixtures/basic.hcl b/jobspec/test-fixtures/basic.hcl index b5068a1c4..1f9d3e73d 100644 --- a/jobspec/test-fixtures/basic.hcl +++ b/jobspec/test-fixtures/basic.hcl @@ -293,7 +293,7 @@ job "binstore-storagelocker" { driver = "docker" lifecycle { - hook = "prestart" + hook = "prestart" sidecar = true } diff --git a/nomad/acl.go b/nomad/acl.go index 902ef933c..90ecb417b 100644 --- a/nomad/acl.go +++ b/nomad/acl.go @@ -84,3 +84,37 @@ func resolveTokenFromSnapshotCache(snap *state.StateSnapshot, cache *lru.TwoQueu } return aclObj, nil } + +// ResolveSecretToken is used to translate an ACL Token Secret ID into +// an ACLToken object, nil if ACLs are disabled, or an error. +func (s *Server) ResolveSecretToken(secretID string) (*structs.ACLToken, error) { + // TODO(Drew) Look into using ACLObject cache or create a separate cache + + // Fast-path if ACLs are disabled + if !s.config.ACLEnabled { + return nil, nil + } + defer metrics.MeasureSince([]string{"nomad", "acl", "resolveSecretToken"}, time.Now()) + + snap, err := s.fsm.State().Snapshot() + if err != nil { + return nil, err + } + + // Lookup the ACL Token + var token *structs.ACLToken + // Handle anonymous requests + if secretID == "" { + token = structs.AnonymousACLToken + } else { + token, err = snap.ACLTokenBySecretID(nil, secretID) + if err != nil { + return nil, err + } + if token == nil { + return nil, structs.ErrTokenNotFound + } + } + + return token, nil +} diff --git a/nomad/acl_test.go b/nomad/acl_test.go index 41ba4be6f..3a2400af4 100644 --- a/nomad/acl_test.go +++ b/nomad/acl_test.go @@ -107,3 +107,27 @@ func TestResolveACLToken_LeaderToken(t *testing.T) { assert.True(token.IsManagement()) } } + +func TestResolveSecretToken(t *testing.T) { + t.Parallel() + + s1, _, cleanupS1 := TestACLServer(t, nil) + defer cleanupS1() + testutil.WaitForLeader(t, s1.RPC) + + state := s1.State() + leaderToken := s1.getLeaderAcl() + assert.NotEmpty(t, leaderToken) + + token := mock.ACLToken() + + err := state.UpsertACLTokens(110, []*structs.ACLToken{token}) + assert.Nil(t, err) + + respToken, err := s1.ResolveSecretToken(token.SecretID) + assert.Nil(t, err) + if assert.NotNil(t, respToken) { + assert.NotEmpty(t, respToken.AccessorID) + } + +} diff --git a/nomad/structs/config/audit.go b/nomad/structs/config/audit.go new file mode 100644 index 000000000..8ca6851c7 --- /dev/null +++ b/nomad/structs/config/audit.go @@ -0,0 +1,213 @@ +package config + +import ( + "time" + + "github.com/hashicorp/nomad/helper" +) + +// AuditConfig is the configuration specific to Audit Logging +type AuditConfig struct { + // Enabled controls the Audit Logging mode + Enabled *bool `hcl:"enabled"` + + // Sinks configure output sinks for audit logs + Sinks []*AuditSink `hcl:"sink"` + + // Filters configure audit event filters to filter out certain eevents + // from being written to a sink. + Filters []*AuditFilter `hcl:"filter"` + + // ExtraKeysHCL is used by hcl to surface unexpected keys + ExtraKeysHCL []string `hcl:",unusedKeys" json:"-"` +} + +type AuditSink struct { + // Name is a unique name given to the filter + Name string `hcl:",key"` + + // DeliveryGuarantee is the level at which delivery of logs must + // be met in order to successfully make requests + DeliveryGuarantee string `hcl:"delivery_guarantee"` + + // Type is the sink type to configure. (file) + Type string `hcl:"type"` + + // Format is the sink output format. (json) + Format string `hcl:"format"` + + // FileName is the name that the audit log should follow. + // If rotation is enabled the pattern will be name-timestamp.log + Path string `hcl:"path"` + + // RotateDuration is the time period that logs should be rotated in + RotateDuration time.Duration + RotateDurationHCL string `hcl:"rotate_duration" json:"-"` + + // RotateBytes is the max number of bytes that should be written to a file + RotateBytes int `hcl:"rotate_bytes"` + + // RotateMaxFiles is the max number of log files to keep + RotateMaxFiles int `hcl:"rotate_max_files"` +} + +// AuditFilter is the configuration for a Audit Log Filter +type AuditFilter struct { + // Name is a unique name given to the filter + Name string `hcl:",key"` + + // Type of auditing event to filter, such as HTTPEvent + Type string `hcl:"type"` + + // Endpoints is the list of endpoints to include in the filter + Endpoints []string `hcl:"endpoints"` + + // State is the auditing request lifecycle stage to filter + Stages []string `hcl:"stages"` + + // Operations is the type of operation to filter, such as GET, DELETE + Operations []string `hcl:"operations"` +} + +// Copy returns a new copy of an AuditConfig +func (a *AuditConfig) Copy() *AuditConfig { + if a == nil { + return nil + } + + nc := new(AuditConfig) + *nc = *a + + // Copy bool pointers + if a.Enabled != nil { + nc.Enabled = helper.BoolToPtr(*a.Enabled) + } + + // Copy Sinks and Filters + nc.Sinks = copySliceAuditSink(nc.Sinks) + nc.Filters = copySliceAuditFilter(nc.Filters) + + return nc +} + +// Merge is used to merge two Audit Configs together. Settings from the input take precedence. +func (a *AuditConfig) Merge(b *AuditConfig) *AuditConfig { + result := a.Copy() + + if b.Enabled != nil { + result.Enabled = helper.BoolToPtr(*b.Enabled) + } + + // Merge Sinks + if len(a.Sinks) == 0 && len(b.Sinks) != 0 { + result.Sinks = copySliceAuditSink(b.Sinks) + } else if len(b.Sinks) != 0 { + result.Sinks = auditSinkSliceMerge(a.Sinks, b.Sinks) + } + + // Merge Filters + if len(a.Filters) == 0 && len(b.Filters) != 0 { + result.Filters = copySliceAuditFilter(b.Filters) + } else if len(b.Filters) != 0 { + result.Filters = auditFilterSliceMerge(a.Filters, b.Filters) + } + + return result +} + +func (a *AuditSink) Copy() *AuditSink { + if a == nil { + return nil + } + + nc := new(AuditSink) + *nc = *a + + return nc +} + +func (a *AuditFilter) Copy() *AuditFilter { + if a == nil { + return nil + } + + nc := new(AuditFilter) + *nc = *a + + // Copy slices + nc.Endpoints = helper.CopySliceString(nc.Endpoints) + nc.Stages = helper.CopySliceString(nc.Stages) + nc.Operations = helper.CopySliceString(nc.Operations) + + return nc +} + +func copySliceAuditFilter(a []*AuditFilter) []*AuditFilter { + l := len(a) + if l == 0 { + return nil + } + + ns := make([]*AuditFilter, l) + for idx, cfg := range a { + ns[idx] = cfg.Copy() + } + + return ns +} + +func auditFilterSliceMerge(a, b []*AuditFilter) []*AuditFilter { + n := make([]*AuditFilter, len(a)) + seenKeys := make(map[string]int, len(a)) + + for i, config := range a { + n[i] = config.Copy() + seenKeys[config.Name] = i + } + + for _, config := range b { + if fIndex, ok := seenKeys[config.Name]; ok { + n[fIndex] = config.Copy() + continue + } + + n = append(n, config.Copy()) + } + + return n +} + +func copySliceAuditSink(a []*AuditSink) []*AuditSink { + l := len(a) + if l == 0 { + return nil + } + + ns := make([]*AuditSink, l) + for idx, cfg := range a { + ns[idx] = cfg.Copy() + } + + return ns +} + +func auditSinkSliceMerge(a, b []*AuditSink) []*AuditSink { + n := make([]*AuditSink, len(a)) + seenKeys := make(map[string]int, len(a)) + + for i, config := range a { + n[i] = config.Copy() + seenKeys[config.Name] = i + } + + for _, config := range b { + if fIndex, ok := seenKeys[config.Name]; ok { + n[fIndex] = config.Copy() + continue + } + + n = append(n, config.Copy()) + } + + return n +} diff --git a/nomad/structs/config/audit_test.go b/nomad/structs/config/audit_test.go new file mode 100644 index 000000000..3cc135e5e --- /dev/null +++ b/nomad/structs/config/audit_test.go @@ -0,0 +1,106 @@ +package config + +import ( + "testing" + "time" + + "github.com/hashicorp/nomad/helper" + "github.com/stretchr/testify/require" +) + +func TestAuditConfig_Merge(t *testing.T) { + c1 := &AuditConfig{ + Enabled: helper.BoolToPtr(true), + Sinks: []*AuditSink{ + { + DeliveryGuarantee: "enforced", + Name: "file", + Type: "file", + Format: "json", + Path: "/opt/nomad/audit.log", + RotateDuration: 24 * time.Hour, + RotateDurationHCL: "24h", + RotateBytes: 100, + RotateMaxFiles: 10, + }, + }, + Filters: []*AuditFilter{ + { + Name: "one", + Type: "HTTPEvent", + Endpoints: []string{"/ui/", "/v1/agent/health"}, + Stages: []string{"*"}, + Operations: []string{"*"}, + }, + }, + } + + c2 := &AuditConfig{ + Sinks: []*AuditSink{ + { + DeliveryGuarantee: "best-effort", + Name: "file", + Type: "file", + Format: "json", + Path: "/opt/nomad/audit.log", + RotateDuration: 48 * time.Hour, + RotateDurationHCL: "48h", + RotateBytes: 20, + RotateMaxFiles: 2, + }, + }, + Filters: []*AuditFilter{ + { + Name: "one", + Type: "HTTPEvent", + Endpoints: []string{"/ui/", "/v1/agent/health"}, + Stages: []string{"OperationReceived"}, + Operations: []string{"GET"}, + }, + { + Name: "two", + Type: "HTTPEvent", + Endpoints: []string{"*"}, + Stages: []string{"OperationReceived"}, + Operations: []string{"OPTIONS"}, + }, + }, + } + + e := &AuditConfig{ + Enabled: helper.BoolToPtr(true), + Sinks: []*AuditSink{ + { + DeliveryGuarantee: "best-effort", + Name: "file", + Type: "file", + Format: "json", + Path: "/opt/nomad/audit.log", + RotateDuration: 48 * time.Hour, + RotateDurationHCL: "48h", + RotateBytes: 20, + RotateMaxFiles: 2, + }, + }, + Filters: []*AuditFilter{ + { + Name: "one", + Type: "HTTPEvent", + Endpoints: []string{"/ui/", "/v1/agent/health"}, + Stages: []string{"OperationReceived"}, + Operations: []string{"GET"}, + }, + { + Name: "two", + Type: "HTTPEvent", + Endpoints: []string{"*"}, + Stages: []string{"OperationReceived"}, + Operations: []string{"OPTIONS"}, + }, + }, + } + + result := c1.Merge(c2) + + require.Equal(t, e, result) +}