VAULT-18284: Audit refactor packages (#21972)

* initial git mv to rename 'audit' packages

* remove 'Audit' prefix from structs inside audit package

* refactor of event/audit pacakges

* EventFormatter => EntryFormatter

* 'AuditFormat' => EntryFormat

* Use NewFormatterConfig func

---------

Co-authored-by: Marc Boudreau <marc.boudreau@hashicorp.com>
This commit is contained in:
Peter Wilson
2023-07-20 18:32:06 +01:00
committed by GitHub
parent a4f67a6b2b
commit 31074bc448
35 changed files with 3236 additions and 2798 deletions

View File

@@ -1,62 +0,0 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package audit
import (
"context"
"github.com/hashicorp/vault/sdk/helper/salt"
"github.com/hashicorp/vault/sdk/logical"
)
// Backend interface must be implemented for an audit
// mechanism to be made available. Audit backends can be enabled to
// sink information to different backends such as logs, file, databases,
// or other external services.
type Backend interface {
// LogRequest is used to synchronously log a request. This is done after the
// request is authorized but before the request is executed. The arguments
// MUST not be modified in anyway. They should be deep copied if this is
// a possibility.
LogRequest(context.Context, *logical.LogInput) error
// LogResponse is used to synchronously log a response. This is done after
// the request is processed but before the response is sent. The arguments
// MUST not be modified in anyway. They should be deep copied if this is
// a possibility.
LogResponse(context.Context, *logical.LogInput) error
// LogTestMessage is used to check an audit backend before adding it
// permanently. It should attempt to synchronously log the given test
// message, WITHOUT using the normal Salt (which would require a storage
// operation on creation, which is currently disallowed.)
LogTestMessage(context.Context, *logical.LogInput, map[string]string) error
// GetHash is used to return the given data with the backend's hash,
// so that a caller can determine if a value in the audit log matches
// an expected plaintext value
GetHash(context.Context, string) (string, error)
// Reload is called on SIGHUP for supporting backends.
Reload(context.Context) error
// Invalidate is called for path invalidation
Invalidate(context.Context)
}
// BackendConfig contains configuration parameters used in the factory func to
// instantiate audit backends
type BackendConfig struct {
// The view to store the salt
SaltView logical.Storage
// The salt config that should be used for any secret obfuscation
SaltConfig *salt.Config
// Config is the opaque user configuration provided when mounting
Config map[string]string
}
// Factory is the factory function to create an audit backend.
type Factory func(context.Context, *BackendConfig, bool) (Backend, error)

571
audit/entry_formatter.go Normal file
View File

@@ -0,0 +1,571 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package audit
import (
"context"
"crypto/tls"
"errors"
"fmt"
"strings"
"time"
"github.com/jefferai/jsonx"
"github.com/hashicorp/vault/helper/namespace"
"github.com/hashicorp/vault/sdk/logical"
"github.com/go-jose/go-jose/v3/jwt"
"github.com/hashicorp/vault/internal/observability/event"
"github.com/hashicorp/vault/sdk/helper/jsonutil"
"github.com/hashicorp/eventlogger"
)
var (
_ Formatter = (*EntryFormatter)(nil)
_ eventlogger.Node = (*EntryFormatter)(nil)
)
// NewEntryFormatter should be used to create an EntryFormatter.
// Accepted options: WithPrefix.
func NewEntryFormatter(config FormatterConfig, salter Salter, opt ...Option) (*EntryFormatter, error) {
const op = "audit.NewEntryFormatter"
if salter == nil {
return nil, fmt.Errorf("%s: cannot create a new audit formatter with nil salter: %w", op, event.ErrInvalidParameter)
}
// We need to ensure that the format isn't just some default empty string.
if err := config.RequiredFormat.validate(); err != nil {
return nil, fmt.Errorf("%s: format not valid: %w", op, err)
}
opts, err := getOpts(opt...)
if err != nil {
return nil, fmt.Errorf("%s: error applying options: %w", op, err)
}
return &EntryFormatter{
salter: salter,
config: config,
prefix: opts.withPrefix,
}, nil
}
// Reopen is a no-op for the formatter node.
func (_ *EntryFormatter) Reopen() error {
return nil
}
// Type describes the type of this node (formatter).
func (_ *EntryFormatter) Type() eventlogger.NodeType {
return eventlogger.NodeTypeFormatter
}
// Process will attempt to parse the incoming event data into a corresponding
// audit Request/Response which is serialized to JSON/JSONx and stored within the event.
func (f *EntryFormatter) Process(ctx context.Context, e *eventlogger.Event) (*eventlogger.Event, error) {
const op = "audit.(EntryFormatter).Process"
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if e == nil {
return nil, fmt.Errorf("%s: event is nil: %w", op, event.ErrInvalidParameter)
}
a, ok := e.Payload.(*auditEvent)
if !ok {
return nil, fmt.Errorf("%s: cannot parse event payload: %w", op, event.ErrInvalidParameter)
}
var result []byte
switch a.Subtype {
case RequestType:
entry, err := f.FormatRequest(ctx, a.Data)
if err != nil {
return nil, fmt.Errorf("%s: unable to parse request from audit event: %w", op, err)
}
result, err = jsonutil.EncodeJSON(entry)
if err != nil {
return nil, fmt.Errorf("%s: unable to format request: %w", op, err)
}
case ResponseType:
entry, err := f.FormatResponse(ctx, a.Data)
if err != nil {
return nil, fmt.Errorf("%s: unable to parse response from audit event: %w", op, err)
}
result, err = jsonutil.EncodeJSON(entry)
if err != nil {
return nil, fmt.Errorf("%s: unable to format response: %w", op, err)
}
default:
return nil, fmt.Errorf("%s: unknown audit event subtype: %q", op, a.Subtype)
}
if f.config.RequiredFormat == JSONxFormat {
var err error
result, err = jsonx.EncodeJSONBytes(result)
if err != nil {
return nil, fmt.Errorf("%s: unable to encode JSONx using JSON data: %w", op, err)
}
if result == nil {
return nil, fmt.Errorf("%s: encoded JSONx was nil: %w", op, err)
}
}
// This makes a bit of a mess of the 'format' since both JSON and XML (JSONx)
// don't support a prefix just sitting there.
// However, this would be a breaking change to how Vault currently works to
// include the prefix as part of the JSON object or XML document.
if f.prefix != "" {
result = append([]byte(f.prefix), result...)
}
// Store the final format.
e.FormattedAs(f.config.RequiredFormat.String(), result)
return e, nil
}
// FormatRequest attempts to format the specified logical.LogInput into a RequestEntry.
func (f *EntryFormatter) FormatRequest(ctx context.Context, in *logical.LogInput) (*RequestEntry, error) {
switch {
case in == nil || in.Request == nil:
return nil, errors.New("request to request-audit a nil request")
case f.salter == nil:
return nil, errors.New("salt func not configured")
}
s, err := f.salter.Salt(ctx)
if err != nil {
return nil, fmt.Errorf("error fetching salt: %w", err)
}
// Set these to the input values at first
auth := in.Auth
req := in.Request
var connState *tls.ConnectionState
if auth == nil {
auth = new(logical.Auth)
}
if in.Request.Connection != nil && in.Request.Connection.ConnState != nil {
connState = in.Request.Connection.ConnState
}
if !f.config.Raw {
auth, err = HashAuth(s, auth, f.config.HMACAccessor)
if err != nil {
return nil, err
}
req, err = HashRequest(s, req, f.config.HMACAccessor, in.NonHMACReqDataKeys)
if err != nil {
return nil, err
}
}
var errString string
if in.OuterErr != nil {
errString = in.OuterErr.Error()
}
ns, err := namespace.FromContext(ctx)
if err != nil {
return nil, err
}
reqType := in.Type
if reqType == "" {
reqType = "request"
}
reqEntry := &RequestEntry{
Type: reqType,
Error: errString,
ForwardedFrom: req.ForwardedFrom,
Auth: &Auth{
ClientToken: auth.ClientToken,
Accessor: auth.Accessor,
DisplayName: auth.DisplayName,
Policies: auth.Policies,
TokenPolicies: auth.TokenPolicies,
IdentityPolicies: auth.IdentityPolicies,
ExternalNamespacePolicies: auth.ExternalNamespacePolicies,
NoDefaultPolicy: auth.NoDefaultPolicy,
Metadata: auth.Metadata,
EntityID: auth.EntityID,
RemainingUses: req.ClientTokenRemainingUses,
TokenType: auth.TokenType.String(),
TokenTTL: int64(auth.TTL.Seconds()),
},
Request: &Request{
ID: req.ID,
ClientID: req.ClientID,
ClientToken: req.ClientToken,
ClientTokenAccessor: req.ClientTokenAccessor,
Operation: req.Operation,
MountPoint: req.MountPoint,
MountType: req.MountType,
MountAccessor: req.MountAccessor,
MountRunningVersion: req.MountRunningVersion(),
MountRunningSha256: req.MountRunningSha256(),
MountIsExternalPlugin: req.MountIsExternalPlugin(),
MountClass: req.MountClass(),
Namespace: &Namespace{
ID: ns.ID,
Path: ns.Path,
},
Path: req.Path,
Data: req.Data,
PolicyOverride: req.PolicyOverride,
RemoteAddr: getRemoteAddr(req),
RemotePort: getRemotePort(req),
ReplicationCluster: req.ReplicationCluster,
Headers: req.Headers,
ClientCertificateSerialNumber: getClientCertificateSerialNumber(connState),
},
}
if !auth.IssueTime.IsZero() {
reqEntry.Auth.TokenIssueTime = auth.IssueTime.Format(time.RFC3339)
}
if auth.PolicyResults != nil {
reqEntry.Auth.PolicyResults = &PolicyResults{
Allowed: auth.PolicyResults.Allowed,
}
for _, p := range auth.PolicyResults.GrantingPolicies {
reqEntry.Auth.PolicyResults.GrantingPolicies = append(reqEntry.Auth.PolicyResults.GrantingPolicies, PolicyInfo{
Name: p.Name,
NamespaceId: p.NamespaceId,
NamespacePath: p.NamespacePath,
Type: p.Type,
})
}
}
if req.WrapInfo != nil {
reqEntry.Request.WrapTTL = int(req.WrapInfo.TTL / time.Second)
}
if !f.config.OmitTime {
reqEntry.Time = time.Now().UTC().Format(time.RFC3339Nano)
}
return reqEntry, nil
}
// FormatResponse attempts to format the specified logical.LogInput into a ResponseEntry.
func (f *EntryFormatter) FormatResponse(ctx context.Context, in *logical.LogInput) (*ResponseEntry, error) {
switch {
case f == nil:
return nil, errors.New("formatter is nil")
case in == nil || in.Request == nil:
return nil, errors.New("request to response-audit a nil request")
case f.salter == nil:
return nil, errors.New("salt func not configured")
}
s, err := f.salter.Salt(ctx)
if err != nil {
return nil, fmt.Errorf("error fetching salt: %w", err)
}
// Set these to the input values at first
auth, req, resp := in.Auth, in.Request, in.Response
if auth == nil {
auth = new(logical.Auth)
}
if resp == nil {
resp = new(logical.Response)
}
var connState *tls.ConnectionState
if in.Request.Connection != nil && in.Request.Connection.ConnState != nil {
connState = in.Request.Connection.ConnState
}
elideListResponseData := f.config.ElideListResponses && req.Operation == logical.ListOperation
var respData map[string]interface{}
if f.config.Raw {
// In the non-raw case, elision of list response data occurs inside HashResponse, to avoid redundant deep
// copies and hashing of data only to elide it later. In the raw case, we need to do it here.
if elideListResponseData && resp.Data != nil {
// Copy the data map before making changes, but we only need to go one level deep in this case
respData = make(map[string]interface{}, len(resp.Data))
for k, v := range resp.Data {
respData[k] = v
}
doElideListResponseData(respData)
} else {
respData = resp.Data
}
} else {
auth, err = HashAuth(s, auth, f.config.HMACAccessor)
if err != nil {
return nil, err
}
req, err = HashRequest(s, req, f.config.HMACAccessor, in.NonHMACReqDataKeys)
if err != nil {
return nil, err
}
resp, err = HashResponse(s, resp, f.config.HMACAccessor, in.NonHMACRespDataKeys, elideListResponseData)
if err != nil {
return nil, err
}
respData = resp.Data
}
var errString string
if in.OuterErr != nil {
errString = in.OuterErr.Error()
}
ns, err := namespace.FromContext(ctx)
if err != nil {
return nil, err
}
var respAuth *Auth
if resp.Auth != nil {
respAuth = &Auth{
ClientToken: resp.Auth.ClientToken,
Accessor: resp.Auth.Accessor,
DisplayName: resp.Auth.DisplayName,
Policies: resp.Auth.Policies,
TokenPolicies: resp.Auth.TokenPolicies,
IdentityPolicies: resp.Auth.IdentityPolicies,
ExternalNamespacePolicies: resp.Auth.ExternalNamespacePolicies,
NoDefaultPolicy: resp.Auth.NoDefaultPolicy,
Metadata: resp.Auth.Metadata,
NumUses: resp.Auth.NumUses,
EntityID: resp.Auth.EntityID,
TokenType: resp.Auth.TokenType.String(),
TokenTTL: int64(resp.Auth.TTL.Seconds()),
}
if !resp.Auth.IssueTime.IsZero() {
respAuth.TokenIssueTime = resp.Auth.IssueTime.Format(time.RFC3339)
}
}
var respSecret *Secret
if resp.Secret != nil {
respSecret = &Secret{
LeaseID: resp.Secret.LeaseID,
}
}
var respWrapInfo *ResponseWrapInfo
if resp.WrapInfo != nil {
token := resp.WrapInfo.Token
if jwtToken := parseVaultTokenFromJWT(token); jwtToken != nil {
token = *jwtToken
}
respWrapInfo = &ResponseWrapInfo{
TTL: int(resp.WrapInfo.TTL / time.Second),
Token: token,
Accessor: resp.WrapInfo.Accessor,
CreationTime: resp.WrapInfo.CreationTime.UTC().Format(time.RFC3339Nano),
CreationPath: resp.WrapInfo.CreationPath,
WrappedAccessor: resp.WrapInfo.WrappedAccessor,
}
}
respType := in.Type
if respType == "" {
respType = "response"
}
respEntry := &ResponseEntry{
Type: respType,
Error: errString,
Forwarded: req.ForwardedFrom != "",
Auth: &Auth{
ClientToken: auth.ClientToken,
Accessor: auth.Accessor,
DisplayName: auth.DisplayName,
Policies: auth.Policies,
TokenPolicies: auth.TokenPolicies,
IdentityPolicies: auth.IdentityPolicies,
ExternalNamespacePolicies: auth.ExternalNamespacePolicies,
NoDefaultPolicy: auth.NoDefaultPolicy,
Metadata: auth.Metadata,
RemainingUses: req.ClientTokenRemainingUses,
EntityID: auth.EntityID,
EntityCreated: auth.EntityCreated,
TokenType: auth.TokenType.String(),
TokenTTL: int64(auth.TTL.Seconds()),
},
Request: &Request{
ID: req.ID,
ClientToken: req.ClientToken,
ClientTokenAccessor: req.ClientTokenAccessor,
ClientID: req.ClientID,
Operation: req.Operation,
MountPoint: req.MountPoint,
MountType: req.MountType,
MountAccessor: req.MountAccessor,
MountRunningVersion: req.MountRunningVersion(),
MountRunningSha256: req.MountRunningSha256(),
MountIsExternalPlugin: req.MountIsExternalPlugin(),
MountClass: req.MountClass(),
Namespace: &Namespace{
ID: ns.ID,
Path: ns.Path,
},
Path: req.Path,
Data: req.Data,
PolicyOverride: req.PolicyOverride,
RemoteAddr: getRemoteAddr(req),
RemotePort: getRemotePort(req),
ClientCertificateSerialNumber: getClientCertificateSerialNumber(connState),
ReplicationCluster: req.ReplicationCluster,
Headers: req.Headers,
},
Response: &Response{
MountPoint: req.MountPoint,
MountType: req.MountType,
MountAccessor: req.MountAccessor,
MountRunningVersion: req.MountRunningVersion(),
MountRunningSha256: req.MountRunningSha256(),
MountIsExternalPlugin: req.MountIsExternalPlugin(),
MountClass: req.MountClass(),
Auth: respAuth,
Secret: respSecret,
Data: respData,
Warnings: resp.Warnings,
Redirect: resp.Redirect,
WrapInfo: respWrapInfo,
Headers: resp.Headers,
},
}
if auth.PolicyResults != nil {
respEntry.Auth.PolicyResults = &PolicyResults{
Allowed: auth.PolicyResults.Allowed,
}
for _, p := range auth.PolicyResults.GrantingPolicies {
respEntry.Auth.PolicyResults.GrantingPolicies = append(respEntry.Auth.PolicyResults.GrantingPolicies, PolicyInfo{
Name: p.Name,
NamespaceId: p.NamespaceId,
NamespacePath: p.NamespacePath,
Type: p.Type,
})
}
}
if !auth.IssueTime.IsZero() {
respEntry.Auth.TokenIssueTime = auth.IssueTime.Format(time.RFC3339)
}
if req.WrapInfo != nil {
respEntry.Request.WrapTTL = int(req.WrapInfo.TTL / time.Second)
}
if !f.config.OmitTime {
respEntry.Time = time.Now().UTC().Format(time.RFC3339Nano)
}
return respEntry, nil
}
// NewFormatterConfig should be used to create a FormatterConfig.
// Accepted options: WithElision, WithHMACAccessor, WithOmitTime, WithRaw, WithFormat.
func NewFormatterConfig(opt ...Option) (FormatterConfig, error) {
const op = "audit.NewFormatterConfig"
opts, err := getOpts(opt...)
if err != nil {
return FormatterConfig{}, fmt.Errorf("%s: error applying options: %w", op, err)
}
return FormatterConfig{
ElideListResponses: opts.withElision,
HMACAccessor: opts.withHMACAccessor,
OmitTime: opts.withOmitTime,
Raw: opts.withRaw,
RequiredFormat: opts.withFormat,
}, nil
}
// getRemoteAddr safely gets the remote address avoiding a nil pointer
func getRemoteAddr(req *logical.Request) string {
if req != nil && req.Connection != nil {
return req.Connection.RemoteAddr
}
return ""
}
// getRemotePort safely gets the remote port avoiding a nil pointer
func getRemotePort(req *logical.Request) int {
if req != nil && req.Connection != nil {
return req.Connection.RemotePort
}
return 0
}
// getClientCertificateSerialNumber attempts the retrieve the serial number of
// the peer certificate from the specified tls.ConnectionState.
func getClientCertificateSerialNumber(connState *tls.ConnectionState) string {
if connState == nil || len(connState.VerifiedChains) == 0 || len(connState.VerifiedChains[0]) == 0 {
return ""
}
return connState.VerifiedChains[0][0].SerialNumber.String()
}
// parseVaultTokenFromJWT returns a string iff the token was a JWT, and we could
// extract the original token ID from inside
func parseVaultTokenFromJWT(token string) *string {
if strings.Count(token, ".") != 2 {
return nil
}
parsedJWT, err := jwt.ParseSigned(token)
if err != nil {
return nil
}
var claims jwt.Claims
if err = parsedJWT.UnsafeClaimsWithoutVerification(&claims); err != nil {
return nil
}
return &claims.ID
}
// doElideListResponseData performs the actual elision of list operation response data, once surrounding code has
// determined it should apply to a particular request. The data map that is passed in must be a copy that is safe to
// modify in place, but need not be a full recursive deep copy, as only top-level keys are changed.
//
// See the documentation of the controlling option in FormatterConfig for more information on the purpose.
func doElideListResponseData(data map[string]interface{}) {
for k, v := range data {
if k == "keys" {
if vSlice, ok := v.([]string); ok {
data[k] = len(vSlice)
}
} else if k == "key_info" {
if vMap, ok := v.(map[string]interface{}); ok {
data[k] = len(vMap)
}
}
}
}

View File

@@ -0,0 +1,401 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package audit
import (
"context"
"testing"
"time"
"github.com/hashicorp/vault/internal/observability/event"
"github.com/hashicorp/vault/helper/namespace"
"github.com/hashicorp/vault/sdk/logical"
"github.com/hashicorp/eventlogger"
"github.com/stretchr/testify/require"
)
// fakeEvent will return a new fake event containing audit data based on the
// specified subtype, format and logical.LogInput.
func fakeEvent(tb testing.TB, subtype subtype, format format, input *logical.LogInput) *eventlogger.Event {
tb.Helper()
date := time.Date(2023, time.July, 11, 15, 49, 10, 0o0, time.Local)
auditEvent, err := newEvent(subtype, format,
WithID("123"),
WithNow(date),
)
require.NoError(tb, err)
require.NotNil(tb, auditEvent)
require.Equal(tb, "123", auditEvent.ID)
require.Equal(tb, "v0.1", auditEvent.Version)
require.Equal(tb, format, auditEvent.RequiredFormat)
require.Equal(tb, subtype, auditEvent.Subtype)
require.Equal(tb, date, auditEvent.Timestamp)
auditEvent.Data = input
e := &eventlogger.Event{
Type: eventlogger.EventType(event.AuditType),
CreatedAt: auditEvent.Timestamp,
Formatted: make(map[string][]byte),
Payload: auditEvent,
}
return e
}
// TestNewEntryFormatter ensures we can create new EntryFormatter structs.
func TestNewEntryFormatter(t *testing.T) {
tests := map[string]struct {
UseStaticSalt bool
Options []Option // Only supports WithPrefix
IsErrorExpected bool
ExpectedErrorMessage string
ExpectedFormat format
ExpectedPrefix string
}{
"nil-salter": {
UseStaticSalt: false,
IsErrorExpected: true,
ExpectedErrorMessage: "audit.NewEntryFormatter: cannot create a new audit formatter with nil salter: invalid parameter",
},
"static-salter": {
UseStaticSalt: true,
IsErrorExpected: false,
Options: []Option{
WithFormat(JSONFormat.String()),
},
ExpectedFormat: JSONFormat,
},
"default": {
UseStaticSalt: true,
IsErrorExpected: false,
ExpectedFormat: JSONFormat,
},
"config-json": {
UseStaticSalt: true,
Options: []Option{
WithFormat(JSONFormat.String()),
},
IsErrorExpected: false,
ExpectedFormat: JSONFormat,
},
"config-jsonx": {
UseStaticSalt: true,
Options: []Option{
WithFormat(JSONxFormat.String()),
},
IsErrorExpected: false,
ExpectedFormat: JSONxFormat,
},
"config-json-prefix": {
UseStaticSalt: true,
Options: []Option{
WithPrefix("foo"),
WithFormat(JSONFormat.String()),
},
IsErrorExpected: false,
ExpectedFormat: JSONFormat,
ExpectedPrefix: "foo",
},
"config-jsonx-prefix": {
UseStaticSalt: true,
Options: []Option{
WithPrefix("foo"),
WithFormat(JSONxFormat.String()),
},
IsErrorExpected: false,
ExpectedFormat: JSONxFormat,
ExpectedPrefix: "foo",
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
var ss Salter
if tc.UseStaticSalt {
ss = newStaticSalt(t)
}
cfg, err := NewFormatterConfig(tc.Options...)
require.NoError(t, err)
f, err := NewEntryFormatter(cfg, ss, tc.Options...)
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
require.Nil(t, f)
default:
require.NoError(t, err)
require.NotNil(t, f)
require.Equal(t, tc.ExpectedFormat, f.config.RequiredFormat)
require.Equal(t, tc.ExpectedPrefix, f.prefix)
}
})
}
}
// TestEntryFormatter_Reopen ensures that we do not get an error when calling Reopen.
func TestEntryFormatter_Reopen(t *testing.T) {
ss := newStaticSalt(t)
cfg, err := NewFormatterConfig()
require.NoError(t, err)
f, err := NewEntryFormatter(cfg, ss)
require.NoError(t, err)
require.NotNil(t, f)
require.NoError(t, f.Reopen())
}
// TestEntryFormatter_Type ensures that the node is a 'formatter' type.
func TestEntryFormatter_Type(t *testing.T) {
ss := newStaticSalt(t)
cfg, err := NewFormatterConfig()
require.NoError(t, err)
f, err := NewEntryFormatter(cfg, ss)
require.NoError(t, err)
require.NotNil(t, f)
require.Equal(t, eventlogger.NodeTypeFormatter, f.Type())
}
// TestEntryFormatter_Process attempts to run the Process method to convert the
// logical.LogInput within an audit event to JSON and JSONx (RequestEntry or ResponseEntry).
func TestEntryFormatter_Process(t *testing.T) {
tests := map[string]struct {
IsErrorExpected bool
ExpectedErrorMessage string
Subtype subtype
RequiredFormat format
Data *logical.LogInput
RootNamespace bool
}{
"json-request-no-data": {
IsErrorExpected: true,
ExpectedErrorMessage: "audit.(EntryFormatter).Process: unable to parse request from audit event: request to request-audit a nil request",
Subtype: RequestType,
RequiredFormat: JSONFormat,
Data: nil,
},
"json-response-no-data": {
IsErrorExpected: true,
ExpectedErrorMessage: "audit.(EntryFormatter).Process: unable to parse response from audit event: request to response-audit a nil request",
Subtype: ResponseType,
RequiredFormat: JSONFormat,
Data: nil,
},
"json-request-basic-input": {
IsErrorExpected: true,
ExpectedErrorMessage: "audit.(EntryFormatter).Process: unable to parse request from audit event: request to request-audit a nil request",
Subtype: RequestType,
RequiredFormat: JSONFormat,
Data: &logical.LogInput{Type: "magic"},
},
"json-response-basic-input": {
IsErrorExpected: true,
ExpectedErrorMessage: "audit.(EntryFormatter).Process: unable to parse response from audit event: request to response-audit a nil request",
Subtype: ResponseType,
RequiredFormat: JSONFormat,
Data: &logical.LogInput{Type: "magic"},
},
"json-request-basic-input-and-request-no-ns": {
IsErrorExpected: true,
ExpectedErrorMessage: "audit.(EntryFormatter).Process: unable to parse request from audit event: no namespace",
Subtype: RequestType,
RequiredFormat: JSONFormat,
Data: &logical.LogInput{Request: &logical.Request{ID: "123"}},
},
"json-response-basic-input-and-request-no-ns": {
IsErrorExpected: true,
ExpectedErrorMessage: "audit.(EntryFormatter).Process: unable to parse response from audit event: no namespace",
Subtype: ResponseType,
RequiredFormat: JSONFormat,
Data: &logical.LogInput{Request: &logical.Request{ID: "123"}},
},
"json-request-basic-input-and-request-with-ns": {
IsErrorExpected: false,
Subtype: RequestType,
RequiredFormat: JSONFormat,
Data: &logical.LogInput{Request: &logical.Request{ID: "123"}},
RootNamespace: true,
},
"json-response-basic-input-and-request-with-ns": {
IsErrorExpected: false,
Subtype: ResponseType,
RequiredFormat: JSONFormat,
Data: &logical.LogInput{Request: &logical.Request{ID: "123"}},
RootNamespace: true,
},
"jsonx-request-no-data": {
IsErrorExpected: true,
ExpectedErrorMessage: "audit.(EntryFormatter).Process: unable to parse request from audit event: request to request-audit a nil request",
Subtype: RequestType,
RequiredFormat: JSONxFormat,
Data: nil,
},
"jsonx-response-no-data": {
IsErrorExpected: true,
ExpectedErrorMessage: "audit.(EntryFormatter).Process: unable to parse response from audit event: request to response-audit a nil request",
Subtype: ResponseType,
RequiredFormat: JSONxFormat,
Data: nil,
},
"jsonx-request-basic-input": {
IsErrorExpected: true,
ExpectedErrorMessage: "audit.(EntryFormatter).Process: unable to parse request from audit event: request to request-audit a nil request",
Subtype: RequestType,
RequiredFormat: JSONxFormat,
Data: &logical.LogInput{Type: "magic"},
},
"jsonx-response-basic-input": {
IsErrorExpected: true,
ExpectedErrorMessage: "audit.(EntryFormatter).Process: unable to parse response from audit event: request to response-audit a nil request",
Subtype: ResponseType,
RequiredFormat: JSONxFormat,
Data: &logical.LogInput{Type: "magic"},
},
"jsonx-request-basic-input-and-request-no-ns": {
IsErrorExpected: true,
ExpectedErrorMessage: "audit.(EntryFormatter).Process: unable to parse request from audit event: no namespace",
Subtype: RequestType,
RequiredFormat: JSONxFormat,
Data: &logical.LogInput{Request: &logical.Request{ID: "123"}},
},
"jsonx-response-basic-input-and-request-no-ns": {
IsErrorExpected: true,
ExpectedErrorMessage: "audit.(EntryFormatter).Process: unable to parse response from audit event: no namespace",
Subtype: ResponseType,
RequiredFormat: JSONxFormat,
Data: &logical.LogInput{Request: &logical.Request{ID: "123"}},
},
"jsonx-request-basic-input-and-request-with-ns": {
IsErrorExpected: false,
Subtype: RequestType,
RequiredFormat: JSONxFormat,
Data: &logical.LogInput{Request: &logical.Request{ID: "123"}},
RootNamespace: true,
},
"jsonx-response-basic-input-and-request-with-ns": {
IsErrorExpected: false,
Subtype: ResponseType,
RequiredFormat: JSONxFormat,
Data: &logical.LogInput{Request: &logical.Request{ID: "123"}},
RootNamespace: true,
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
e := fakeEvent(t, tc.Subtype, tc.RequiredFormat, tc.Data)
require.NotNil(t, e)
ss := newStaticSalt(t)
cfg, err := NewFormatterConfig(WithFormat(tc.RequiredFormat.String()))
require.NoError(t, err)
f, err := NewEntryFormatter(cfg, ss)
require.NoError(t, err)
require.NotNil(t, f)
var ctx context.Context
switch {
case tc.RootNamespace:
ctx = namespace.RootContext(context.Background())
default:
ctx = context.Background()
}
processed, err := f.Process(ctx, e)
b, found := e.Format(string(tc.RequiredFormat))
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
require.Nil(t, processed)
require.False(t, found)
require.Nil(t, b)
default:
require.NoError(t, err)
require.NotNil(t, processed)
require.True(t, found)
require.NotNil(t, b)
}
})
}
}
// BenchmarkAuditFileSink_Process benchmarks the EntryFormatter and then event.FileSink calling Process.
// This should replicate the original benchmark testing which used to perform both of these roles together.
func BenchmarkAuditFileSink_Process(b *testing.B) {
// Base input
in := &logical.LogInput{
Auth: &logical.Auth{
ClientToken: "foo",
Accessor: "bar",
EntityID: "foobarentity",
DisplayName: "testtoken",
NoDefaultPolicy: true,
Policies: []string{"root"},
TokenType: logical.TokenTypeService,
},
Request: &logical.Request{
Operation: logical.UpdateOperation,
Path: "/foo",
Connection: &logical.Connection{
RemoteAddr: "127.0.0.1",
},
WrapInfo: &logical.RequestWrapInfo{
TTL: 60 * time.Second,
},
Headers: map[string][]string{
"foo": {"bar"},
},
},
}
ctx := namespace.RootContext(nil)
// Create the formatter node.
cfg, err := NewFormatterConfig()
require.NoError(b, err)
ss := newStaticSalt(b)
formatter, err := NewEntryFormatter(cfg, ss)
require.NoError(b, err)
require.NotNil(b, formatter)
// Create the sink node.
sink, err := event.NewFileSink("/dev/null", JSONFormat.String())
require.NoError(b, err)
require.NotNil(b, sink)
// Generate the event
event := fakeEvent(b, RequestType, JSONFormat, in)
require.NotNil(b, event)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
event, err = formatter.Process(ctx, event)
if err != nil {
panic(err)
}
_, err := sink.Process(ctx, event)
if err != nil {
panic(err)
}
}
})
}

View File

@@ -0,0 +1,318 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package audit
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/hashicorp/vault/sdk/helper/salt"
"github.com/hashicorp/vault/sdk/logical"
)
var (
_ Formatter = (*EntryFormatterWriter)(nil)
_ Writer = (*EntryFormatterWriter)(nil)
)
// Salter is an interface that provides a way to obtain a Salt for hashing.
type Salter interface {
// Salt returns a non-nil salt or an error.
Salt(context.Context) (*salt.Salt, error)
}
// Formatter is an interface that is responsible for formatting a request/response into some format.
// It is recommended that you pass data through Hash prior to formatting it.
type Formatter interface {
// FormatRequest formats the logical.LogInput into an RequestEntry.
FormatRequest(context.Context, *logical.LogInput) (*RequestEntry, error)
// FormatResponse formats the logical.LogInput into an ResponseEntry.
FormatResponse(context.Context, *logical.LogInput) (*ResponseEntry, error)
}
// Writer is an interface that provides a way to write request and response audit entries.
// Formatters write their output to an io.Writer.
type Writer interface {
// WriteRequest writes the request entry to the writer or returns an error.
WriteRequest(io.Writer, *RequestEntry) error
// WriteResponse writes the response entry to the writer or returns an error.
WriteResponse(io.Writer, *ResponseEntry) error
}
// EntryFormatter should be used to format audit entries.
type EntryFormatter struct {
salter Salter
config FormatterConfig
prefix string
}
// EntryFormatterWriter should be used to format and write out audit entries.
type EntryFormatterWriter struct {
Formatter
Writer
config FormatterConfig
}
// FormatterConfig is used to provide basic configuration to a formatter.
// Use NewFormatterConfig to initialize the FormatterConfig struct.
type FormatterConfig struct {
Raw bool
HMACAccessor bool
// Vault lacks pagination in its APIs. As a result, certain list operations can return **very** large responses.
// The user's chosen audit sinks may experience difficulty consuming audit records that swell to tens of megabytes
// of JSON. The responses of list operations are typically not very interesting, as they are mostly lists of keys,
// or, even when they include a "key_info" field, are not returning confidential information. They become even less
// interesting once HMAC-ed by the audit system.
//
// Some example Vault "list" operations that are prone to becoming very large in an active Vault installation are:
// auth/token/accessors/
// identity/entity/id/
// identity/entity-alias/id/
// pki/certs/
//
// This option exists to provide such users with the option to have response data elided from audit logs, only when
// the operation type is "list". For added safety, the elision only applies to the "keys" and "key_info" fields
// within the response data - these are conventionally the only fields present in a list response - see
// logical.ListResponse, and logical.ListResponseWithInfo. However, other fields are technically possible if a
// plugin author writes unusual code, and these will be preserved in the audit log even with this option enabled.
// The elision replaces the values of the "keys" and "key_info" fields with an integer count of the number of
// entries. This allows even the elided audit logs to still be useful for answering questions like
// "Was any data returned?" or "How many records were listed?".
ElideListResponses bool
// This should only ever be used in a testing context
OmitTime bool
// The required/target format for the audit entry (supported: JSONFormat and JSONxFormat).
RequiredFormat format
}
// nonPersistentSalt is used for obtaining a salt that is
type nonPersistentSalt struct{}
type auditEvent struct {
ID string `json:"id"`
Version string `json:"version"`
Subtype subtype `json:"subtype"` // the subtype of the audit event.
Timestamp time.Time `json:"timestamp"`
Data *logical.LogInput `json:"data"`
RequiredFormat format `json:"format"`
}
// Salt returns a new salt with default configuration and no storage usage, and no error.
func (s *nonPersistentSalt) Salt(_ context.Context) (*salt.Salt, error) {
return salt.NewNonpersistentSalt(), nil
}
// NewEntryFormatterWriter should be used to create a new EntryFormatterWriter.
// Deprecated: Please move to using eventlogger.Event via EntryFormatter and a sink.
func NewEntryFormatterWriter(config FormatterConfig, formatter Formatter, writer Writer) (*EntryFormatterWriter, error) {
switch {
case formatter == nil:
return nil, errors.New("cannot create a new audit formatter writer with nil formatter")
case writer == nil:
return nil, errors.New("cannot create a new audit formatter writer with nil formatter")
}
fw := &EntryFormatterWriter{
Formatter: formatter,
Writer: writer,
config: config,
}
return fw, nil
}
// FormatAndWriteRequest attempts to format the specified logical.LogInput into an RequestEntry,
// and then write the request using the specified io.Writer.
// Deprecated: Please move to using eventlogger.Event via EntryFormatter and a sink.
func (f *EntryFormatterWriter) FormatAndWriteRequest(ctx context.Context, w io.Writer, in *logical.LogInput) error {
switch {
case in == nil || in.Request == nil:
return fmt.Errorf("request to request-audit a nil request")
case w == nil:
return fmt.Errorf("writer for audit request is nil")
case f.Formatter == nil:
return fmt.Errorf("no formatter specifed")
case f.Writer == nil:
return fmt.Errorf("no writer specified")
}
reqEntry, err := f.Formatter.FormatRequest(ctx, in)
if err != nil {
return err
}
return f.Writer.WriteRequest(w, reqEntry)
}
// FormatAndWriteResponse attempts to format the specified logical.LogInput into an ResponseEntry,
// and then write the response using the specified io.Writer.
// Deprecated: Please move to using eventlogger.Event via EntryFormatter and a sink.
func (f *EntryFormatterWriter) FormatAndWriteResponse(ctx context.Context, w io.Writer, in *logical.LogInput) error {
switch {
case in == nil || in.Request == nil:
return errors.New("request to response-audit a nil request")
case w == nil:
return errors.New("writer for audit request is nil")
case f.Formatter == nil:
return errors.New("no formatter specified")
case f.Writer == nil:
return errors.New("no writer specified")
}
respEntry, err := f.FormatResponse(ctx, in)
if err != nil {
return err
}
return f.Writer.WriteResponse(w, respEntry)
}
// RequestEntry is the structure of a request audit log entry in Audit.
type RequestEntry struct {
Time string `json:"time,omitempty"`
Type string `json:"type,omitempty"`
Auth *Auth `json:"auth,omitempty"`
Request *Request `json:"request,omitempty"`
Error string `json:"error,omitempty"`
ForwardedFrom string `json:"forwarded_from,omitempty"` // Populated in Enterprise when a request is forwarded
}
// ResponseEntry is the structure of a response audit log entry in Audit.
type ResponseEntry struct {
Time string `json:"time,omitempty"`
Type string `json:"type,omitempty"`
Auth *Auth `json:"auth,omitempty"`
Request *Request `json:"request,omitempty"`
Response *Response `json:"response,omitempty"`
Error string `json:"error,omitempty"`
Forwarded bool `json:"forwarded,omitempty"`
}
type Request struct {
ID string `json:"id,omitempty"`
ClientID string `json:"client_id,omitempty"`
ReplicationCluster string `json:"replication_cluster,omitempty"`
Operation logical.Operation `json:"operation,omitempty"`
MountPoint string `json:"mount_point,omitempty"`
MountType string `json:"mount_type,omitempty"`
MountAccessor string `json:"mount_accessor,omitempty"`
MountRunningVersion string `json:"mount_running_version,omitempty"`
MountRunningSha256 string `json:"mount_running_sha256,omitempty"`
MountClass string `json:"mount_class,omitempty"`
MountIsExternalPlugin bool `json:"mount_is_external_plugin,omitempty"`
ClientToken string `json:"client_token,omitempty"`
ClientTokenAccessor string `json:"client_token_accessor,omitempty"`
Namespace *Namespace `json:"namespace,omitempty"`
Path string `json:"path,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
PolicyOverride bool `json:"policy_override,omitempty"`
RemoteAddr string `json:"remote_address,omitempty"`
RemotePort int `json:"remote_port,omitempty"`
WrapTTL int `json:"wrap_ttl,omitempty"`
Headers map[string][]string `json:"headers,omitempty"`
ClientCertificateSerialNumber string `json:"client_certificate_serial_number,omitempty"`
}
type Response struct {
Auth *Auth `json:"auth,omitempty"`
MountPoint string `json:"mount_point,omitempty"`
MountType string `json:"mount_type,omitempty"`
MountAccessor string `json:"mount_accessor,omitempty"`
MountRunningVersion string `json:"mount_running_plugin_version,omitempty"`
MountRunningSha256 string `json:"mount_running_sha256,omitempty"`
MountClass string `json:"mount_class,omitempty"`
MountIsExternalPlugin bool `json:"mount_is_external_plugin,omitempty"`
Secret *Secret `json:"secret,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Redirect string `json:"redirect,omitempty"`
WrapInfo *ResponseWrapInfo `json:"wrap_info,omitempty"`
Headers map[string][]string `json:"headers,omitempty"`
}
type Auth struct {
ClientToken string `json:"client_token,omitempty"`
Accessor string `json:"accessor,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Policies []string `json:"policies,omitempty"`
TokenPolicies []string `json:"token_policies,omitempty"`
IdentityPolicies []string `json:"identity_policies,omitempty"`
ExternalNamespacePolicies map[string][]string `json:"external_namespace_policies,omitempty"`
NoDefaultPolicy bool `json:"no_default_policy,omitempty"`
PolicyResults *PolicyResults `json:"policy_results,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
NumUses int `json:"num_uses,omitempty"`
RemainingUses int `json:"remaining_uses,omitempty"`
EntityID string `json:"entity_id,omitempty"`
EntityCreated bool `json:"entity_created,omitempty"`
TokenType string `json:"token_type,omitempty"`
TokenTTL int64 `json:"token_ttl,omitempty"`
TokenIssueTime string `json:"token_issue_time,omitempty"`
}
type PolicyResults struct {
Allowed bool `json:"allowed"`
GrantingPolicies []PolicyInfo `json:"granting_policies,omitempty"`
}
type PolicyInfo struct {
Name string `json:"name,omitempty"`
NamespaceId string `json:"namespace_id,omitempty"`
NamespacePath string `json:"namespace_path,omitempty"`
Type string `json:"type"`
}
type Secret struct {
LeaseID string `json:"lease_id,omitempty"`
}
type ResponseWrapInfo struct {
TTL int `json:"ttl,omitempty"`
Token string `json:"token,omitempty"`
Accessor string `json:"accessor,omitempty"`
CreationTime string `json:"creation_time,omitempty"`
CreationPath string `json:"creation_path,omitempty"`
WrappedAccessor string `json:"wrapped_accessor,omitempty"`
}
type Namespace struct {
ID string `json:"id,omitempty"`
Path string `json:"path,omitempty"`
}
// NewTemporaryFormatter creates a formatter not backed by a persistent salt
func NewTemporaryFormatter(requiredFormat, prefix string) (*EntryFormatterWriter, error) {
cfg, err := NewFormatterConfig(WithFormat(requiredFormat))
if err != nil {
return nil, err
}
eventFormatter, err := NewEntryFormatter(cfg, &nonPersistentSalt{}, WithPrefix(prefix))
if err != nil {
return nil, err
}
var w Writer
switch {
case strings.EqualFold(requiredFormat, JSONxFormat.String()):
w = &JSONxWriter{Prefix: prefix}
default:
w = &JSONWriter{Prefix: prefix}
}
fw, err := NewEntryFormatterWriter(cfg, eventFormatter, w)
if err != nil {
return nil, err
}
return fw, nil
}

View File

@@ -17,9 +17,9 @@ import (
) )
// newStaticSalt returns a new staticSalt for use in testing. // newStaticSalt returns a new staticSalt for use in testing.
func newStaticSalt(t *testing.T) *staticSalt { func newStaticSalt(tb testing.TB) *staticSalt {
s, err := salt.NewSalt(context.Background(), nil, nil) s, err := salt.NewSalt(context.Background(), nil, nil)
require.NoError(t, err) require.NoError(tb, err)
return &staticSalt{salt: s} return &staticSalt{salt: s}
} }
@@ -37,16 +37,16 @@ func (s *staticSalt) Salt(_ context.Context) (*salt.Salt, error) {
type testingFormatWriter struct { type testingFormatWriter struct {
salt *salt.Salt salt *salt.Salt
lastRequest *AuditRequestEntry lastRequest *RequestEntry
lastResponse *AuditResponseEntry lastResponse *ResponseEntry
} }
func (fw *testingFormatWriter) WriteRequest(_ io.Writer, entry *AuditRequestEntry) error { func (fw *testingFormatWriter) WriteRequest(_ io.Writer, entry *RequestEntry) error {
fw.lastRequest = entry fw.lastRequest = entry
return nil return nil
} }
func (fw *testingFormatWriter) WriteResponse(_ io.Writer, entry *AuditResponseEntry) error { func (fw *testingFormatWriter) WriteResponse(_ io.Writer, entry *ResponseEntry) error {
fw.lastResponse = entry fw.lastResponse = entry
return nil return nil
} }
@@ -86,23 +86,26 @@ func (fw *testingFormatWriter) hashExpectedValueForComparison(input map[string]i
return copiedAsMap return copiedAsMap
} }
// TestNewAuditFormatter tests that creating a new AuditFormatter can be done safely. // TestNewEntryFormatterWriter tests that creating a new EntryFormatterWriter can be done safely.
func TestNewAuditFormatter(t *testing.T) { func TestNewEntryFormatterWriter(t *testing.T) {
tests := map[string]struct { tests := map[string]struct {
Salter Salter Salter Salter
UseStaticSalter bool UseStaticSalter bool
IsErrorExpected bool UseNilFormatter bool
ExpectedErrorMessag string UseNilWriter bool
IsErrorExpected bool
ExpectedErrorMessage string
}{ }{
"nil": { "nil": {
Salter: nil, Salter: nil,
IsErrorExpected: true, UseNilFormatter: true,
ExpectedErrorMessag: "cannot create a new audit formatter with nil salter", UseNilWriter: true,
IsErrorExpected: true,
ExpectedErrorMessage: "cannot create a new audit formatter with nil salter",
}, },
"static": { "static": {
UseStaticSalter: true, UseStaticSalter: true,
IsErrorExpected: false, IsErrorExpected: false,
ExpectedErrorMessag: "",
}, },
} }
@@ -119,22 +122,38 @@ func TestNewAuditFormatter(t *testing.T) {
s = tc.Salter s = tc.Salter
} }
f, err := NewAuditFormatter(s) cfg, err := NewFormatterConfig()
require.NoError(t, err)
var f Formatter
if !tc.UseNilFormatter {
tempFormatter, err := NewEntryFormatter(cfg, s)
require.NoError(t, err)
require.NotNil(t, tempFormatter)
f = tempFormatter
}
var w Writer
if !tc.UseNilWriter {
w = &JSONWriter{}
}
fw, err := NewEntryFormatterWriter(cfg, f, w)
switch { switch {
case tc.IsErrorExpected: case tc.IsErrorExpected:
require.Error(t, err) require.Error(t, err)
require.Nil(t, f) require.Nil(t, fw)
default: default:
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, f) require.NotNil(t, fw)
} }
}) })
} }
} }
// TestAuditFormatter_FormatRequest exercises AuditFormatter.FormatRequest with // TestEntryFormatter_FormatRequest exercises EntryFormatter.FormatRequest with
// varying inputs. // varying inputs.
func TestAuditFormatter_FormatRequest(t *testing.T) { func TestEntryFormatter_FormatRequest(t *testing.T) {
tests := map[string]struct { tests := map[string]struct {
Input *logical.LogInput Input *logical.LogInput
IsErrorExpected bool IsErrorExpected bool
@@ -169,8 +188,10 @@ func TestAuditFormatter_FormatRequest(t *testing.T) {
tc := tc tc := tc
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel() t.Parallel()
config := FormatterConfig{}
f, err := NewAuditFormatter(newStaticSalt(t)) cfg, err := NewFormatterConfig()
require.NoError(t, err)
f, err := NewEntryFormatter(cfg, newStaticSalt(t))
require.NoError(t, err) require.NoError(t, err)
var ctx context.Context var ctx context.Context
@@ -181,7 +202,7 @@ func TestAuditFormatter_FormatRequest(t *testing.T) {
ctx = context.Background() ctx = context.Background()
} }
entry, err := f.FormatRequest(ctx, config, tc.Input) entry, err := f.FormatRequest(ctx, tc.Input)
switch { switch {
case tc.IsErrorExpected: case tc.IsErrorExpected:
@@ -196,9 +217,9 @@ func TestAuditFormatter_FormatRequest(t *testing.T) {
} }
} }
// TestAuditFormatter_FormatResponse exercises AuditFormatter.FormatResponse with // TestEntryFormatter_FormatResponse exercises EntryFormatter.FormatResponse with
// varying inputs. // varying inputs.
func TestAuditFormatter_FormatResponse(t *testing.T) { func TestEntryFormatter_FormatResponse(t *testing.T) {
tests := map[string]struct { tests := map[string]struct {
Input *logical.LogInput Input *logical.LogInput
IsErrorExpected bool IsErrorExpected bool
@@ -233,8 +254,10 @@ func TestAuditFormatter_FormatResponse(t *testing.T) {
tc := tc tc := tc
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel() t.Parallel()
config := FormatterConfig{}
f, err := NewAuditFormatter(newStaticSalt(t)) cfg, err := NewFormatterConfig()
require.NoError(t, err)
f, err := NewEntryFormatter(cfg, newStaticSalt(t))
require.NoError(t, err) require.NoError(t, err)
var ctx context.Context var ctx context.Context
@@ -245,7 +268,7 @@ func TestAuditFormatter_FormatResponse(t *testing.T) {
ctx = context.Background() ctx = context.Background()
} }
entry, err := f.FormatResponse(ctx, config, tc.Input) entry, err := f.FormatResponse(ctx, tc.Input)
switch { switch {
case tc.IsErrorExpected: case tc.IsErrorExpected:
@@ -261,15 +284,6 @@ func TestAuditFormatter_FormatResponse(t *testing.T) {
} }
func TestElideListResponses(t *testing.T) { func TestElideListResponses(t *testing.T) {
tfw := testingFormatWriter{}
f, err := NewAuditFormatter(&tfw)
require.NoError(t, err)
formatter := AuditFormatterWriter{
Formatter: f,
Writer: &tfw,
}
ctx := namespace.RootContext(context.Background())
type test struct { type test struct {
name string name string
inputData map[string]interface{} inputData map[string]interface{}
@@ -340,13 +354,17 @@ func TestElideListResponses(t *testing.T) {
} }
oneInterestingTestCase := tests[2] oneInterestingTestCase := tests[2]
formatResponse := func( tfw := testingFormatWriter{}
t *testing.T, ctx := namespace.RootContext(context.Background())
config FormatterConfig,
operation logical.Operation, formatResponse := func(t *testing.T, config FormatterConfig, operation logical.Operation, inputData map[string]interface{},
inputData map[string]interface{},
) { ) {
err := formatter.FormatAndWriteResponse(ctx, io.Discard, config, &logical.LogInput{ f, err := NewEntryFormatter(config, &tfw)
require.NoError(t, err)
formatter, err := NewEntryFormatterWriter(config, f, &tfw)
require.NoError(t, err)
require.NotNil(t, formatter)
err = formatter.FormatAndWriteResponse(ctx, io.Discard, &logical.LogInput{
Request: &logical.Request{Operation: operation}, Request: &logical.Request{Operation: operation},
Response: &logical.Response{Data: inputData}, Response: &logical.Response{Data: inputData},
}) })
@@ -354,7 +372,8 @@ func TestElideListResponses(t *testing.T) {
} }
t.Run("Default case", func(t *testing.T) { t.Run("Default case", func(t *testing.T) {
config := FormatterConfig{ElideListResponses: true} config, err := NewFormatterConfig(WithElision(true))
require.NoError(t, err)
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
formatResponse(t, config, logical.ListOperation, tc.inputData) formatResponse(t, config, logical.ListOperation, tc.inputData)
@@ -364,21 +383,24 @@ func TestElideListResponses(t *testing.T) {
}) })
t.Run("When Operation is not list, eliding does not happen", func(t *testing.T) { t.Run("When Operation is not list, eliding does not happen", func(t *testing.T) {
config := FormatterConfig{ElideListResponses: true} config, err := NewFormatterConfig(WithElision(true))
require.NoError(t, err)
tc := oneInterestingTestCase tc := oneInterestingTestCase
formatResponse(t, config, logical.ReadOperation, tc.inputData) formatResponse(t, config, logical.ReadOperation, tc.inputData)
assert.Equal(t, tfw.hashExpectedValueForComparison(tc.inputData), tfw.lastResponse.Response.Data) assert.Equal(t, tfw.hashExpectedValueForComparison(tc.inputData), tfw.lastResponse.Response.Data)
}) })
t.Run("When ElideListResponses is false, eliding does not happen", func(t *testing.T) { t.Run("When ElideListResponses is false, eliding does not happen", func(t *testing.T) {
config := FormatterConfig{ElideListResponses: false} config, err := NewFormatterConfig(WithElision(false), WithFormat(JSONFormat.String()))
require.NoError(t, err)
tc := oneInterestingTestCase tc := oneInterestingTestCase
formatResponse(t, config, logical.ListOperation, tc.inputData) formatResponse(t, config, logical.ListOperation, tc.inputData)
assert.Equal(t, tfw.hashExpectedValueForComparison(tc.inputData), tfw.lastResponse.Response.Data) assert.Equal(t, tfw.hashExpectedValueForComparison(tc.inputData), tfw.lastResponse.Response.Data)
}) })
t.Run("When Raw is true, eliding still happens", func(t *testing.T) { t.Run("When Raw is true, eliding still happens", func(t *testing.T) {
config := FormatterConfig{ElideListResponses: true, Raw: true} config, err := NewFormatterConfig(WithElision(true), WithRaw(true), WithFormat(JSONFormat.String()))
require.NoError(t, err)
tc := oneInterestingTestCase tc := oneInterestingTestCase
formatResponse(t, config, logical.ListOperation, tc.inputData) formatResponse(t, config, logical.ListOperation, tc.inputData)
assert.Equal(t, tc.expectedData, tfw.lastResponse.Response.Data) assert.Equal(t, tc.expectedData, tfw.lastResponse.Response.Data)

182
audit/event.go Normal file
View File

@@ -0,0 +1,182 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package audit
import (
"context"
"fmt"
"github.com/hashicorp/vault/internal/observability/event"
"github.com/hashicorp/vault/sdk/helper/salt"
"github.com/hashicorp/vault/sdk/logical"
)
// Audit subtypes.
const (
RequestType subtype = "AuditRequest"
ResponseType subtype = "AuditResponse"
)
// Audit formats.
const (
JSONFormat format = "json"
JSONxFormat format = "jsonx"
)
// version defines the version of audit events.
const version = "v0.1"
// subtype defines the type of audit event.
type subtype string
// format defines types of format audit events support.
type format string
// Backend interface must be implemented for an audit
// mechanism to be made available. Audit backends can be enabled to
// sink information to different backends such as logs, file, databases,
// or other external services.
type Backend interface {
// LogRequest is used to synchronously log a request. This is done after the
// request is authorized but before the request is executed. The arguments
// MUST not be modified in anyway. They should be deep copied if this is
// a possibility.
LogRequest(context.Context, *logical.LogInput) error
// LogResponse is used to synchronously log a response. This is done after
// the request is processed but before the response is sent. The arguments
// MUST not be modified in anyway. They should be deep copied if this is
// a possibility.
LogResponse(context.Context, *logical.LogInput) error
// LogTestMessage is used to check an audit backend before adding it
// permanently. It should attempt to synchronously log the given test
// message, WITHOUT using the normal Salt (which would require a storage
// operation on creation, which is currently disallowed.)
LogTestMessage(context.Context, *logical.LogInput, map[string]string) error
// GetHash is used to return the given data with the backend's hash,
// so that a caller can determine if a value in the audit log matches
// an expected plaintext value
GetHash(context.Context, string) (string, error)
// Reload is called on SIGHUP for supporting backends.
Reload(context.Context) error
// Invalidate is called for path invalidation
Invalidate(context.Context)
}
// BackendConfig contains configuration parameters used in the factory func to
// instantiate audit backends
type BackendConfig struct {
// The view to store the salt
SaltView logical.Storage
// The salt config that should be used for any secret obfuscation
SaltConfig *salt.Config
// Config is the opaque user configuration provided when mounting
Config map[string]string
}
// Factory is the factory function to create an audit backend.
type Factory func(context.Context, *BackendConfig, bool) (Backend, error)
// newEvent should be used to create an audit event.
// subtype and format are needed for audit.
// It will generate an ID if no ID is supplied.
// Supported options: WithID, WithNow.
func newEvent(s subtype, f format, opt ...Option) (*auditEvent, error) {
const op = "audit.newEvent"
// Get the default options
opts, err := getOpts(opt...)
if err != nil {
return nil, fmt.Errorf("%s: error applying options: %w", op, err)
}
if opts.withID == "" {
var err error
opts.withID, err = event.NewID(string(event.AuditType))
if err != nil {
return nil, fmt.Errorf("%s: error creating ID for event: %w", op, err)
}
}
audit := &auditEvent{
ID: opts.withID,
Timestamp: opts.withNow,
Version: version,
Subtype: s,
RequiredFormat: f,
}
if err := audit.validate(); err != nil {
return nil, fmt.Errorf("%s: %w", op, err)
}
return audit, nil
}
// validate attempts to ensure the audit event in its present state is valid.
func (a *auditEvent) validate() error {
const op = "audit.(auditEvent).validate"
if a == nil {
return fmt.Errorf("%s: event is nil: %w", op, event.ErrInvalidParameter)
}
if a.ID == "" {
return fmt.Errorf("%s: missing ID: %w", op, event.ErrInvalidParameter)
}
if a.Version != version {
return fmt.Errorf("%s: event version unsupported: %w", op, event.ErrInvalidParameter)
}
if a.Timestamp.IsZero() {
return fmt.Errorf("%s: event timestamp cannot be the zero time instant: %w", op, event.ErrInvalidParameter)
}
err := a.Subtype.validate()
if err != nil {
return fmt.Errorf("%s: %w", op, err)
}
err = a.RequiredFormat.validate()
if err != nil {
return fmt.Errorf("%s: %w", op, err)
}
return nil
}
// validate ensures that subtype is one of the set of allowed event subtypes.
func (t subtype) validate() error {
const op = "audit.(subtype).validate"
switch t {
case RequestType, ResponseType:
return nil
default:
return fmt.Errorf("%s: '%s' is not a valid event subtype: %w", op, t, event.ErrInvalidParameter)
}
}
// validate ensures that format is one of the set of allowed event formats.
func (f format) validate() error {
const op = "audit.(format).validate"
switch f {
case JSONFormat, JSONxFormat:
return nil
default:
return fmt.Errorf("%s: '%s' is not a valid format: %w", op, f, event.ErrInvalidParameter)
}
}
// String returns the string version of a format.
func (f format) String() string {
return string(f)
}

View File

@@ -1,7 +1,7 @@
// Copyright (c) HashiCorp, Inc. // Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0 // SPDX-License-Identifier: MPL-2.0
package event package audit
import ( import (
"testing" "testing"
@@ -10,56 +10,68 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// TestAuditEvent_New exercises the newAudit func to create audit events. // TestAuditEvent_new exercises the newEvent func to create audit events.
func TestAuditEvent_New(t *testing.T) { func TestAuditEvent_new(t *testing.T) {
tests := map[string]struct { tests := map[string]struct {
Options []Option Options []Option
Subtype subtype
Format format
IsErrorExpected bool IsErrorExpected bool
ExpectedErrorMessage string ExpectedErrorMessage string
ExpectedID string ExpectedID string
ExpectedFormat auditFormat ExpectedFormat format
ExpectedSubtype auditSubtype ExpectedSubtype subtype
ExpectedTimestamp time.Time ExpectedTimestamp time.Time
IsNowExpected bool IsNowExpected bool
}{ }{
"nil": { "nil": {
Options: nil, Options: nil,
Subtype: subtype(""),
Format: format(""),
IsErrorExpected: true, IsErrorExpected: true,
ExpectedErrorMessage: "event.newAudit: event.(audit).validate: event.(auditSubtype).validate: '' is not a valid event subtype: invalid parameter", ExpectedErrorMessage: "audit.newEvent: audit.(auditEvent).validate: audit.(subtype).validate: '' is not a valid event subtype: invalid parameter",
}, },
"empty-option": { "empty-Option": {
Options: []Option{}, Options: []Option{},
Subtype: subtype(""),
Format: format(""),
IsErrorExpected: true, IsErrorExpected: true,
ExpectedErrorMessage: "event.newAudit: event.(audit).validate: event.(auditSubtype).validate: '' is not a valid event subtype: invalid parameter", ExpectedErrorMessage: "audit.newEvent: audit.(auditEvent).validate: audit.(subtype).validate: '' is not a valid event subtype: invalid parameter",
}, },
"bad-id": { "bad-id": {
Options: []Option{WithID("")}, Options: []Option{WithID("")},
Subtype: ResponseType,
Format: JSONFormat,
IsErrorExpected: true, IsErrorExpected: true,
ExpectedErrorMessage: "event.newAudit: error applying options: id cannot be empty", ExpectedErrorMessage: "audit.newEvent: error applying options: id cannot be empty",
}, },
"good": { "good": {
Options: []Option{ Options: []Option{
WithID("audit_123"), WithID("audit_123"),
WithFormat(string(AuditFormatJSON)), WithFormat(string(JSONFormat)),
WithSubtype(string(AuditResponse)), WithSubtype(string(ResponseType)),
WithNow(time.Date(2023, time.July, 4, 12, 3, 0, 0, time.Local)), WithNow(time.Date(2023, time.July, 4, 12, 3, 0, 0, time.Local)),
}, },
Subtype: RequestType,
Format: JSONxFormat,
IsErrorExpected: false, IsErrorExpected: false,
ExpectedID: "audit_123", ExpectedID: "audit_123",
ExpectedTimestamp: time.Date(2023, time.July, 4, 12, 3, 0, 0, time.Local), ExpectedTimestamp: time.Date(2023, time.July, 4, 12, 3, 0, 0, time.Local),
ExpectedSubtype: AuditResponse, ExpectedSubtype: RequestType,
ExpectedFormat: AuditFormatJSON, ExpectedFormat: JSONxFormat,
}, },
"good-no-time": { "good-no-time": {
Options: []Option{ Options: []Option{
WithID("audit_123"), WithID("audit_123"),
WithFormat(string(AuditFormatJSON)), WithFormat(string(JSONFormat)),
WithSubtype(string(AuditResponse)), WithSubtype(string(ResponseType)),
}, },
Subtype: RequestType,
Format: JSONxFormat,
IsErrorExpected: false, IsErrorExpected: false,
ExpectedID: "audit_123", ExpectedID: "audit_123",
ExpectedSubtype: AuditResponse, ExpectedSubtype: RequestType,
ExpectedFormat: AuditFormatJSON, ExpectedFormat: JSONxFormat,
IsNowExpected: true, IsNowExpected: true,
}, },
} }
@@ -70,7 +82,7 @@ func TestAuditEvent_New(t *testing.T) {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel() t.Parallel()
audit, err := newAudit(tc.Options...) audit, err := newEvent(tc.Subtype, tc.Format, tc.Options...)
switch { switch {
case tc.IsErrorExpected: case tc.IsErrorExpected:
require.Error(t, err) require.Error(t, err)
@@ -97,88 +109,88 @@ func TestAuditEvent_New(t *testing.T) {
// TestAuditEvent_Validate exercises the validation for an audit event. // TestAuditEvent_Validate exercises the validation for an audit event.
func TestAuditEvent_Validate(t *testing.T) { func TestAuditEvent_Validate(t *testing.T) {
tests := map[string]struct { tests := map[string]struct {
Value *audit Value *auditEvent
IsErrorExpected bool IsErrorExpected bool
ExpectedErrorMessage string ExpectedErrorMessage string
}{ }{
"nil": { "nil": {
Value: nil, Value: nil,
IsErrorExpected: true, IsErrorExpected: true,
ExpectedErrorMessage: "event.(audit).validate: audit is nil: invalid parameter", ExpectedErrorMessage: "audit.(auditEvent).validate: event is nil: invalid parameter",
}, },
"default": { "default": {
Value: &audit{}, Value: &auditEvent{},
IsErrorExpected: true, IsErrorExpected: true,
ExpectedErrorMessage: "event.(audit).validate: missing ID: invalid parameter", ExpectedErrorMessage: "audit.(auditEvent).validate: missing ID: invalid parameter",
}, },
"id-empty": { "id-empty": {
Value: &audit{ Value: &auditEvent{
ID: "", ID: "",
Version: auditVersion, Version: version,
Subtype: AuditRequest, Subtype: RequestType,
Timestamp: time.Now(), Timestamp: time.Now(),
Data: nil, Data: nil,
RequiredFormat: AuditFormatJSON, RequiredFormat: JSONFormat,
}, },
IsErrorExpected: true, IsErrorExpected: true,
ExpectedErrorMessage: "event.(audit).validate: missing ID: invalid parameter", ExpectedErrorMessage: "audit.(auditEvent).validate: missing ID: invalid parameter",
}, },
"version-fiddled": { "version-fiddled": {
Value: &audit{ Value: &auditEvent{
ID: "audit_123", ID: "audit_123",
Version: "magic-v2", Version: "magic-v2",
Subtype: AuditRequest, Subtype: RequestType,
Timestamp: time.Now(), Timestamp: time.Now(),
Data: nil, Data: nil,
RequiredFormat: AuditFormatJSON, RequiredFormat: JSONFormat,
}, },
IsErrorExpected: true, IsErrorExpected: true,
ExpectedErrorMessage: "event.(audit).validate: audit version unsupported: invalid parameter", ExpectedErrorMessage: "audit.(auditEvent).validate: event version unsupported: invalid parameter",
}, },
"subtype-fiddled": { "subtype-fiddled": {
Value: &audit{ Value: &auditEvent{
ID: "audit_123", ID: "audit_123",
Version: auditVersion, Version: version,
Subtype: auditSubtype("moon"), Subtype: subtype("moon"),
Timestamp: time.Now(), Timestamp: time.Now(),
Data: nil, Data: nil,
RequiredFormat: AuditFormatJSON, RequiredFormat: JSONFormat,
}, },
IsErrorExpected: true, IsErrorExpected: true,
ExpectedErrorMessage: "event.(audit).validate: event.(auditSubtype).validate: 'moon' is not a valid event subtype: invalid parameter", ExpectedErrorMessage: "audit.(auditEvent).validate: audit.(subtype).validate: 'moon' is not a valid event subtype: invalid parameter",
}, },
"format-fiddled": { "format-fiddled": {
Value: &audit{ Value: &auditEvent{
ID: "audit_123", ID: "audit_123",
Version: auditVersion, Version: version,
Subtype: AuditResponse, Subtype: ResponseType,
Timestamp: time.Now(), Timestamp: time.Now(),
Data: nil, Data: nil,
RequiredFormat: auditFormat("blah"), RequiredFormat: format("blah"),
}, },
IsErrorExpected: true, IsErrorExpected: true,
ExpectedErrorMessage: "event.(audit).validate: event.(auditFormat).validate: 'blah' is not a valid format: invalid parameter", ExpectedErrorMessage: "audit.(auditEvent).validate: audit.(format).validate: 'blah' is not a valid format: invalid parameter",
}, },
"default-time": { "default-time": {
Value: &audit{ Value: &auditEvent{
ID: "audit_123", ID: "audit_123",
Version: auditVersion, Version: version,
Subtype: AuditResponse, Subtype: ResponseType,
Timestamp: time.Time{}, Timestamp: time.Time{},
Data: nil, Data: nil,
RequiredFormat: AuditFormatJSON, RequiredFormat: JSONFormat,
}, },
IsErrorExpected: true, IsErrorExpected: true,
ExpectedErrorMessage: "event.(audit).validate: audit timestamp cannot be the zero time instant: invalid parameter", ExpectedErrorMessage: "audit.(auditEvent).validate: event timestamp cannot be the zero time instant: invalid parameter",
}, },
"valid": { "valid": {
Value: &audit{ Value: &auditEvent{
ID: "audit_123", ID: "audit_123",
Version: auditVersion, Version: version,
Subtype: AuditResponse, Subtype: ResponseType,
Timestamp: time.Now(), Timestamp: time.Now(),
Data: nil, Data: nil,
RequiredFormat: AuditFormatJSON, RequiredFormat: JSONFormat,
}, },
IsErrorExpected: false, IsErrorExpected: false,
}, },
@@ -212,12 +224,12 @@ func TestAuditEvent_Validate_Subtype(t *testing.T) {
"empty": { "empty": {
Value: "", Value: "",
IsErrorExpected: true, IsErrorExpected: true,
ExpectedErrorMessage: "event.(auditSubtype).validate: '' is not a valid event subtype: invalid parameter", ExpectedErrorMessage: "audit.(subtype).validate: '' is not a valid event subtype: invalid parameter",
}, },
"unsupported": { "unsupported": {
Value: "foo", Value: "foo",
IsErrorExpected: true, IsErrorExpected: true,
ExpectedErrorMessage: "event.(auditSubtype).validate: 'foo' is not a valid event subtype: invalid parameter", ExpectedErrorMessage: "audit.(subtype).validate: 'foo' is not a valid event subtype: invalid parameter",
}, },
"request": { "request": {
Value: "AuditRequest", Value: "AuditRequest",
@@ -235,7 +247,7 @@ func TestAuditEvent_Validate_Subtype(t *testing.T) {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel() t.Parallel()
err := auditSubtype(tc.Value).validate() err := subtype(tc.Value).validate()
switch { switch {
case tc.IsErrorExpected: case tc.IsErrorExpected:
require.Error(t, err) require.Error(t, err)
@@ -257,12 +269,12 @@ func TestAuditEvent_Validate_Format(t *testing.T) {
"empty": { "empty": {
Value: "", Value: "",
IsErrorExpected: true, IsErrorExpected: true,
ExpectedErrorMessage: "event.(auditFormat).validate: '' is not a valid format: invalid parameter", ExpectedErrorMessage: "audit.(format).validate: '' is not a valid format: invalid parameter",
}, },
"unsupported": { "unsupported": {
Value: "foo", Value: "foo",
IsErrorExpected: true, IsErrorExpected: true,
ExpectedErrorMessage: "event.(auditFormat).validate: 'foo' is not a valid format: invalid parameter", ExpectedErrorMessage: "audit.(format).validate: 'foo' is not a valid format: invalid parameter",
}, },
"json": { "json": {
Value: "json", Value: "json",
@@ -280,7 +292,7 @@ func TestAuditEvent_Validate_Format(t *testing.T) {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel() t.Parallel()
err := auditFormat(tc.Value).validate() err := format(tc.Value).validate()
switch { switch {
case tc.IsErrorExpected: case tc.IsErrorExpected:
require.Error(t, err) require.Error(t, err)

View File

@@ -1,716 +0,0 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package audit
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/go-jose/go-jose/v3/jwt"
"github.com/hashicorp/vault/helper/namespace"
"github.com/hashicorp/vault/sdk/helper/salt"
"github.com/hashicorp/vault/sdk/logical"
)
// Salter is an interface that provides a way to obtain a Salt for hashing.
type Salter interface {
// Salt returns a non-nil salt or an error.
Salt(context.Context) (*salt.Salt, error)
}
// Formatter is an interface that is responsible for formatting a request/response into some format.
// It is recommended that you pass data through Hash prior to formatting it.
type Formatter interface {
// FormatRequest formats the logical.LogInput into an AuditRequestEntry.
FormatRequest(context.Context, FormatterConfig, *logical.LogInput) (*AuditRequestEntry, error)
// FormatResponse formats the logical.LogInput into an AuditResponseEntry.
FormatResponse(context.Context, FormatterConfig, *logical.LogInput) (*AuditResponseEntry, error)
}
// Writer is an interface that provides a way to write request and response audit entries.
// Formatters write their output to an io.Writer.
type Writer interface {
// WriteRequest writes the request entry to the writer or returns an error.
WriteRequest(io.Writer, *AuditRequestEntry) error
// WriteResponse writes the response entry to the writer or returns an error.
WriteResponse(io.Writer, *AuditResponseEntry) error
}
var (
_ Formatter = (*AuditFormatter)(nil)
_ Formatter = (*AuditFormatterWriter)(nil)
_ Writer = (*AuditFormatterWriter)(nil)
)
// AuditFormatter should be used to format audit requests and responses.
type AuditFormatter struct {
salter Salter
}
// AuditFormatterWriter should be used to format and write out audit requests and responses.
type AuditFormatterWriter struct {
Formatter
Writer
}
type FormatterConfig struct {
Raw bool
HMACAccessor bool
// Vault lacks pagination in its APIs. As a result, certain list operations can return **very** large responses.
// The user's chosen audit sinks may experience difficulty consuming audit records that swell to tens of megabytes
// of JSON. The responses of list operations are typically not very interesting, as they are mostly lists of keys,
// or, even when they include a "key_info" field, are not returning confidential information. They become even less
// interesting once HMAC-ed by the audit system.
//
// Some example Vault "list" operations that are prone to becoming very large in an active Vault installation are:
// auth/token/accessors/
// identity/entity/id/
// identity/entity-alias/id/
// pki/certs/
//
// This option exists to provide such users with the option to have response data elided from audit logs, only when
// the operation type is "list". For added safety, the elision only applies to the "keys" and "key_info" fields
// within the response data - these are conventionally the only fields present in a list response - see
// logical.ListResponse, and logical.ListResponseWithInfo. However, other fields are technically possible if a
// plugin author writes unusual code, and these will be preserved in the audit log even with this option enabled.
// The elision replaces the values of the "keys" and "key_info" fields with an integer count of the number of
// entries. This allows even the elided audit logs to still be useful for answering questions like
// "Was any data returned?" or "How many records were listed?".
ElideListResponses bool
// This should only ever be used in a testing context
OmitTime bool
}
// nonPersistentSalt is used for obtaining a salt that is
type nonPersistentSalt struct{}
// Salt returns a new salt with default configuration and no storage usage, and no error.
func (s *nonPersistentSalt) Salt(_ context.Context) (*salt.Salt, error) {
return salt.NewNonpersistentSalt(), nil
}
// NewAuditFormatter should be used to create an AuditFormatter.
func NewAuditFormatter(salter Salter) (*AuditFormatter, error) {
if salter == nil {
return nil, errors.New("cannot create a new audit formatter with nil salter")
}
return &AuditFormatter{salter: salter}, nil
}
// NewAuditFormatterWriter should be used to create a new AuditFormatterWriter.
func NewAuditFormatterWriter(formatter Formatter, writer Writer) (*AuditFormatterWriter, error) {
switch {
case formatter == nil:
return nil, errors.New("cannot create a new audit formatter writer with nil formatter")
case writer == nil:
return nil, errors.New("cannot create a new audit formatter writer with nil formatter")
}
fw := &AuditFormatterWriter{
Formatter: formatter,
Writer: writer,
}
return fw, nil
}
// FormatRequest attempts to format the specified logical.LogInput into an AuditRequestEntry.
func (f *AuditFormatter) FormatRequest(ctx context.Context, config FormatterConfig, in *logical.LogInput) (*AuditRequestEntry, error) {
switch {
case in == nil || in.Request == nil:
return nil, errors.New("request to request-audit a nil request")
case f.salter == nil:
return nil, errors.New("salt func not configured")
}
s, err := f.salter.Salt(ctx)
if err != nil {
return nil, fmt.Errorf("error fetching salt: %w", err)
}
// Set these to the input values at first
auth := in.Auth
req := in.Request
var connState *tls.ConnectionState
if auth == nil {
auth = new(logical.Auth)
}
if in.Request.Connection != nil && in.Request.Connection.ConnState != nil {
connState = in.Request.Connection.ConnState
}
if !config.Raw {
auth, err = HashAuth(s, auth, config.HMACAccessor)
if err != nil {
return nil, err
}
req, err = HashRequest(s, req, config.HMACAccessor, in.NonHMACReqDataKeys)
if err != nil {
return nil, err
}
}
var errString string
if in.OuterErr != nil {
errString = in.OuterErr.Error()
}
ns, err := namespace.FromContext(ctx)
if err != nil {
return nil, err
}
reqType := in.Type
if reqType == "" {
reqType = "request"
}
reqEntry := &AuditRequestEntry{
Type: reqType,
Error: errString,
ForwardedFrom: req.ForwardedFrom,
Auth: &AuditAuth{
ClientToken: auth.ClientToken,
Accessor: auth.Accessor,
DisplayName: auth.DisplayName,
Policies: auth.Policies,
TokenPolicies: auth.TokenPolicies,
IdentityPolicies: auth.IdentityPolicies,
ExternalNamespacePolicies: auth.ExternalNamespacePolicies,
NoDefaultPolicy: auth.NoDefaultPolicy,
Metadata: auth.Metadata,
EntityID: auth.EntityID,
RemainingUses: req.ClientTokenRemainingUses,
TokenType: auth.TokenType.String(),
TokenTTL: int64(auth.TTL.Seconds()),
},
Request: &AuditRequest{
ID: req.ID,
ClientID: req.ClientID,
ClientToken: req.ClientToken,
ClientTokenAccessor: req.ClientTokenAccessor,
Operation: req.Operation,
MountPoint: req.MountPoint,
MountType: req.MountType,
MountAccessor: req.MountAccessor,
MountRunningVersion: req.MountRunningVersion(),
MountRunningSha256: req.MountRunningSha256(),
MountIsExternalPlugin: req.MountIsExternalPlugin(),
MountClass: req.MountClass(),
Namespace: &AuditNamespace{
ID: ns.ID,
Path: ns.Path,
},
Path: req.Path,
Data: req.Data,
PolicyOverride: req.PolicyOverride,
RemoteAddr: getRemoteAddr(req),
RemotePort: getRemotePort(req),
ReplicationCluster: req.ReplicationCluster,
Headers: req.Headers,
ClientCertificateSerialNumber: getClientCertificateSerialNumber(connState),
},
}
if !auth.IssueTime.IsZero() {
reqEntry.Auth.TokenIssueTime = auth.IssueTime.Format(time.RFC3339)
}
if auth.PolicyResults != nil {
reqEntry.Auth.PolicyResults = &AuditPolicyResults{
Allowed: auth.PolicyResults.Allowed,
}
for _, p := range auth.PolicyResults.GrantingPolicies {
reqEntry.Auth.PolicyResults.GrantingPolicies = append(reqEntry.Auth.PolicyResults.GrantingPolicies, PolicyInfo{
Name: p.Name,
NamespaceId: p.NamespaceId,
NamespacePath: p.NamespacePath,
Type: p.Type,
})
}
}
if req.WrapInfo != nil {
reqEntry.Request.WrapTTL = int(req.WrapInfo.TTL / time.Second)
}
if !config.OmitTime {
reqEntry.Time = time.Now().UTC().Format(time.RFC3339Nano)
}
return reqEntry, nil
}
// FormatResponse attempts to format the specified logical.LogInput into an AuditResponseEntry.
func (f *AuditFormatter) FormatResponse(ctx context.Context, config FormatterConfig, in *logical.LogInput) (*AuditResponseEntry, error) {
switch {
case in == nil || in.Request == nil:
return nil, errors.New("request to response-audit a nil request")
case f.salter == nil:
return nil, errors.New("salt func not configured")
}
s, err := f.salter.Salt(ctx)
if err != nil {
return nil, fmt.Errorf("error fetching salt: %w", err)
}
// Set these to the input values at first
auth, req, resp := in.Auth, in.Request, in.Response
if auth == nil {
auth = new(logical.Auth)
}
if resp == nil {
resp = new(logical.Response)
}
var connState *tls.ConnectionState
if in.Request.Connection != nil && in.Request.Connection.ConnState != nil {
connState = in.Request.Connection.ConnState
}
elideListResponseData := config.ElideListResponses && req.Operation == logical.ListOperation
var respData map[string]interface{}
if config.Raw {
// In the non-raw case, elision of list response data occurs inside HashResponse, to avoid redundant deep
// copies and hashing of data only to elide it later. In the raw case, we need to do it here.
if elideListResponseData && resp.Data != nil {
// Copy the data map before making changes, but we only need to go one level deep in this case
respData = make(map[string]interface{}, len(resp.Data))
for k, v := range resp.Data {
respData[k] = v
}
doElideListResponseData(respData)
} else {
respData = resp.Data
}
} else {
auth, err = HashAuth(s, auth, config.HMACAccessor)
if err != nil {
return nil, err
}
req, err = HashRequest(s, req, config.HMACAccessor, in.NonHMACReqDataKeys)
if err != nil {
return nil, err
}
resp, err = HashResponse(s, resp, config.HMACAccessor, in.NonHMACRespDataKeys, elideListResponseData)
if err != nil {
return nil, err
}
respData = resp.Data
}
var errString string
if in.OuterErr != nil {
errString = in.OuterErr.Error()
}
ns, err := namespace.FromContext(ctx)
if err != nil {
return nil, err
}
var respAuth *AuditAuth
if resp.Auth != nil {
respAuth = &AuditAuth{
ClientToken: resp.Auth.ClientToken,
Accessor: resp.Auth.Accessor,
DisplayName: resp.Auth.DisplayName,
Policies: resp.Auth.Policies,
TokenPolicies: resp.Auth.TokenPolicies,
IdentityPolicies: resp.Auth.IdentityPolicies,
ExternalNamespacePolicies: resp.Auth.ExternalNamespacePolicies,
NoDefaultPolicy: resp.Auth.NoDefaultPolicy,
Metadata: resp.Auth.Metadata,
NumUses: resp.Auth.NumUses,
EntityID: resp.Auth.EntityID,
TokenType: resp.Auth.TokenType.String(),
TokenTTL: int64(resp.Auth.TTL.Seconds()),
}
if !resp.Auth.IssueTime.IsZero() {
respAuth.TokenIssueTime = resp.Auth.IssueTime.Format(time.RFC3339)
}
}
var respSecret *AuditSecret
if resp.Secret != nil {
respSecret = &AuditSecret{
LeaseID: resp.Secret.LeaseID,
}
}
var respWrapInfo *AuditResponseWrapInfo
if resp.WrapInfo != nil {
token := resp.WrapInfo.Token
if jwtToken := parseVaultTokenFromJWT(token); jwtToken != nil {
token = *jwtToken
}
respWrapInfo = &AuditResponseWrapInfo{
TTL: int(resp.WrapInfo.TTL / time.Second),
Token: token,
Accessor: resp.WrapInfo.Accessor,
CreationTime: resp.WrapInfo.CreationTime.UTC().Format(time.RFC3339Nano),
CreationPath: resp.WrapInfo.CreationPath,
WrappedAccessor: resp.WrapInfo.WrappedAccessor,
}
}
respType := in.Type
if respType == "" {
respType = "response"
}
respEntry := &AuditResponseEntry{
Type: respType,
Error: errString,
Forwarded: req.ForwardedFrom != "",
Auth: &AuditAuth{
ClientToken: auth.ClientToken,
Accessor: auth.Accessor,
DisplayName: auth.DisplayName,
Policies: auth.Policies,
TokenPolicies: auth.TokenPolicies,
IdentityPolicies: auth.IdentityPolicies,
ExternalNamespacePolicies: auth.ExternalNamespacePolicies,
NoDefaultPolicy: auth.NoDefaultPolicy,
Metadata: auth.Metadata,
RemainingUses: req.ClientTokenRemainingUses,
EntityID: auth.EntityID,
EntityCreated: auth.EntityCreated,
TokenType: auth.TokenType.String(),
TokenTTL: int64(auth.TTL.Seconds()),
},
Request: &AuditRequest{
ID: req.ID,
ClientToken: req.ClientToken,
ClientTokenAccessor: req.ClientTokenAccessor,
ClientID: req.ClientID,
Operation: req.Operation,
MountPoint: req.MountPoint,
MountType: req.MountType,
MountAccessor: req.MountAccessor,
MountRunningVersion: req.MountRunningVersion(),
MountRunningSha256: req.MountRunningSha256(),
MountIsExternalPlugin: req.MountIsExternalPlugin(),
MountClass: req.MountClass(),
Namespace: &AuditNamespace{
ID: ns.ID,
Path: ns.Path,
},
Path: req.Path,
Data: req.Data,
PolicyOverride: req.PolicyOverride,
RemoteAddr: getRemoteAddr(req),
RemotePort: getRemotePort(req),
ClientCertificateSerialNumber: getClientCertificateSerialNumber(connState),
ReplicationCluster: req.ReplicationCluster,
Headers: req.Headers,
},
Response: &AuditResponse{
MountPoint: req.MountPoint,
MountType: req.MountType,
MountAccessor: req.MountAccessor,
MountRunningVersion: req.MountRunningVersion(),
MountRunningSha256: req.MountRunningSha256(),
MountIsExternalPlugin: req.MountIsExternalPlugin(),
MountClass: req.MountClass(),
Auth: respAuth,
Secret: respSecret,
Data: respData,
Warnings: resp.Warnings,
Redirect: resp.Redirect,
WrapInfo: respWrapInfo,
Headers: resp.Headers,
},
}
if auth.PolicyResults != nil {
respEntry.Auth.PolicyResults = &AuditPolicyResults{
Allowed: auth.PolicyResults.Allowed,
}
for _, p := range auth.PolicyResults.GrantingPolicies {
respEntry.Auth.PolicyResults.GrantingPolicies = append(respEntry.Auth.PolicyResults.GrantingPolicies, PolicyInfo{
Name: p.Name,
NamespaceId: p.NamespaceId,
NamespacePath: p.NamespacePath,
Type: p.Type,
})
}
}
if !auth.IssueTime.IsZero() {
respEntry.Auth.TokenIssueTime = auth.IssueTime.Format(time.RFC3339)
}
if req.WrapInfo != nil {
respEntry.Request.WrapTTL = int(req.WrapInfo.TTL / time.Second)
}
if !config.OmitTime {
respEntry.Time = time.Now().UTC().Format(time.RFC3339Nano)
}
return respEntry, nil
}
// FormatAndWriteRequest attempts to format the specified logical.LogInput into an AuditRequestEntry,
// and then write the request using the specified io.Writer.
func (f *AuditFormatterWriter) FormatAndWriteRequest(ctx context.Context, w io.Writer, config FormatterConfig, in *logical.LogInput) error {
switch {
case in == nil || in.Request == nil:
return fmt.Errorf("request to request-audit a nil request")
case w == nil:
return fmt.Errorf("writer for audit request is nil")
case f.Formatter == nil:
return fmt.Errorf("no formatter specifed")
case f.Writer == nil:
return fmt.Errorf("no writer specified")
}
reqEntry, err := f.Formatter.FormatRequest(ctx, config, in)
if err != nil {
return err
}
return f.Writer.WriteRequest(w, reqEntry)
}
// FormatAndWriteResponse attempts to format the specified logical.LogInput into an AuditResponseEntry,
// and then write the response using the specified io.Writer.
func (f *AuditFormatterWriter) FormatAndWriteResponse(ctx context.Context, w io.Writer, config FormatterConfig, in *logical.LogInput) error {
switch {
case in == nil || in.Request == nil:
return errors.New("request to response-audit a nil request")
case w == nil:
return errors.New("writer for audit request is nil")
case f.Formatter == nil:
return errors.New("no formatter specified")
case f.Writer == nil:
return errors.New("no writer specified")
}
respEntry, err := f.FormatResponse(ctx, config, in)
if err != nil {
return err
}
return f.Writer.WriteResponse(w, respEntry)
}
// AuditRequestEntry is the structure of a request audit log entry in Audit.
type AuditRequestEntry struct {
Time string `json:"time,omitempty"`
Type string `json:"type,omitempty"`
Auth *AuditAuth `json:"auth,omitempty"`
Request *AuditRequest `json:"request,omitempty"`
Error string `json:"error,omitempty"`
ForwardedFrom string `json:"forwarded_from,omitempty"` // Populated in Enterprise when a request is forwarded
}
// AuditResponseEntry is the structure of a response audit log entry in Audit.
type AuditResponseEntry struct {
Time string `json:"time,omitempty"`
Type string `json:"type,omitempty"`
Auth *AuditAuth `json:"auth,omitempty"`
Request *AuditRequest `json:"request,omitempty"`
Response *AuditResponse `json:"response,omitempty"`
Error string `json:"error,omitempty"`
Forwarded bool `json:"forwarded,omitempty"`
}
type AuditRequest struct {
ID string `json:"id,omitempty"`
ClientID string `json:"client_id,omitempty"`
ReplicationCluster string `json:"replication_cluster,omitempty"`
Operation logical.Operation `json:"operation,omitempty"`
MountPoint string `json:"mount_point,omitempty"`
MountType string `json:"mount_type,omitempty"`
MountAccessor string `json:"mount_accessor,omitempty"`
MountRunningVersion string `json:"mount_running_version,omitempty"`
MountRunningSha256 string `json:"mount_running_sha256,omitempty"`
MountClass string `json:"mount_class,omitempty"`
MountIsExternalPlugin bool `json:"mount_is_external_plugin,omitempty"`
ClientToken string `json:"client_token,omitempty"`
ClientTokenAccessor string `json:"client_token_accessor,omitempty"`
Namespace *AuditNamespace `json:"namespace,omitempty"`
Path string `json:"path,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
PolicyOverride bool `json:"policy_override,omitempty"`
RemoteAddr string `json:"remote_address,omitempty"`
RemotePort int `json:"remote_port,omitempty"`
WrapTTL int `json:"wrap_ttl,omitempty"`
Headers map[string][]string `json:"headers,omitempty"`
ClientCertificateSerialNumber string `json:"client_certificate_serial_number,omitempty"`
}
type AuditResponse struct {
Auth *AuditAuth `json:"auth,omitempty"`
MountPoint string `json:"mount_point,omitempty"`
MountType string `json:"mount_type,omitempty"`
MountAccessor string `json:"mount_accessor,omitempty"`
MountRunningVersion string `json:"mount_running_plugin_version,omitempty"`
MountRunningSha256 string `json:"mount_running_sha256,omitempty"`
MountClass string `json:"mount_class,omitempty"`
MountIsExternalPlugin bool `json:"mount_is_external_plugin,omitempty"`
Secret *AuditSecret `json:"secret,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Redirect string `json:"redirect,omitempty"`
WrapInfo *AuditResponseWrapInfo `json:"wrap_info,omitempty"`
Headers map[string][]string `json:"headers,omitempty"`
}
type AuditAuth struct {
ClientToken string `json:"client_token,omitempty"`
Accessor string `json:"accessor,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Policies []string `json:"policies,omitempty"`
TokenPolicies []string `json:"token_policies,omitempty"`
IdentityPolicies []string `json:"identity_policies,omitempty"`
ExternalNamespacePolicies map[string][]string `json:"external_namespace_policies,omitempty"`
NoDefaultPolicy bool `json:"no_default_policy,omitempty"`
PolicyResults *AuditPolicyResults `json:"policy_results,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
NumUses int `json:"num_uses,omitempty"`
RemainingUses int `json:"remaining_uses,omitempty"`
EntityID string `json:"entity_id,omitempty"`
EntityCreated bool `json:"entity_created,omitempty"`
TokenType string `json:"token_type,omitempty"`
TokenTTL int64 `json:"token_ttl,omitempty"`
TokenIssueTime string `json:"token_issue_time,omitempty"`
}
type AuditPolicyResults struct {
Allowed bool `json:"allowed"`
GrantingPolicies []PolicyInfo `json:"granting_policies,omitempty"`
}
type PolicyInfo struct {
Name string `json:"name,omitempty"`
NamespaceId string `json:"namespace_id,omitempty"`
NamespacePath string `json:"namespace_path,omitempty"`
Type string `json:"type"`
}
type AuditSecret struct {
LeaseID string `json:"lease_id,omitempty"`
}
type AuditResponseWrapInfo struct {
TTL int `json:"ttl,omitempty"`
Token string `json:"token,omitempty"`
Accessor string `json:"accessor,omitempty"`
CreationTime string `json:"creation_time,omitempty"`
CreationPath string `json:"creation_path,omitempty"`
WrappedAccessor string `json:"wrapped_accessor,omitempty"`
}
type AuditNamespace struct {
ID string `json:"id,omitempty"`
Path string `json:"path,omitempty"`
}
// getRemoteAddr safely gets the remote address avoiding a nil pointer
func getRemoteAddr(req *logical.Request) string {
if req != nil && req.Connection != nil {
return req.Connection.RemoteAddr
}
return ""
}
// getRemotePort safely gets the remote port avoiding a nil pointer
func getRemotePort(req *logical.Request) int {
if req != nil && req.Connection != nil {
return req.Connection.RemotePort
}
return 0
}
// getClientCertificateSerialNumber attempts the retrieve the serial number of
// the peer certificate from the specified tls.ConnectionState.
func getClientCertificateSerialNumber(connState *tls.ConnectionState) string {
if connState == nil || len(connState.VerifiedChains) == 0 || len(connState.VerifiedChains[0]) == 0 {
return ""
}
return connState.VerifiedChains[0][0].SerialNumber.String()
}
// parseVaultTokenFromJWT returns a string iff the token was a JWT and we could
// extract the original token ID from inside
func parseVaultTokenFromJWT(token string) *string {
if strings.Count(token, ".") != 2 {
return nil
}
parsedJWT, err := jwt.ParseSigned(token)
if err != nil {
return nil
}
var claims jwt.Claims
if err = parsedJWT.UnsafeClaimsWithoutVerification(&claims); err != nil {
return nil
}
return &claims.ID
}
// NewTemporaryFormatter creates a formatter not backed by a persistent salt
func NewTemporaryFormatter(format, prefix string) *AuditFormatterWriter {
// We can ignore the error from NewAuditFormatter since we are sure the salter isn't nil.
f, _ := NewAuditFormatter(&nonPersistentSalt{})
var w Writer
switch format {
case "jsonx":
w = &JSONxWriter{Prefix: prefix}
default:
w = &JSONWriter{Prefix: prefix}
}
// We can ignore the error from NewAuditFormatterWriter since we are sure both
// the formatter and writer are not nil.
fw, _ := NewAuditFormatterWriter(f, w)
return fw
}
// doElideListResponseData performs the actual elision of list operation response data, once surrounding code has
// determined it should apply to a particular request. The data map that is passed in must be a copy that is safe to
// modify in place, but need not be a full recursive deep copy, as only top-level keys are changed.
//
// See the documentation of the controlling option in FormatterConfig for more information on the purpose.
func doElideListResponseData(data map[string]interface{}) {
for k, v := range data {
if k == "keys" {
if vSlice, ok := v.([]string); ok {
data[k] = len(vSlice)
}
} else if k == "key_info" {
if vMap, ok := v.(map[string]interface{}); ok {
data[k] = len(vMap)
}
}
}
}

166
audit/options.go Normal file
View File

@@ -0,0 +1,166 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package audit
import (
"errors"
"strings"
"time"
)
// Option is how options are passed as arguments.
type Option func(*options) error
// options are used to represent configuration for a audit related nodes.
type options struct {
withID string
withNow time.Time
withSubtype subtype
withFormat format
withPrefix string
withRaw bool
withElision bool
withOmitTime bool
withHMACAccessor bool
}
// getDefaultOptions returns options with their default values.
func getDefaultOptions() options {
return options{
withNow: time.Now(),
withFormat: JSONFormat,
}
}
// getOpts applies each supplied Option and returns the fully configured options.
// Each Option is applied in the order it appears in the argument list, so it is
// possible to supply the same Option numerous times and the 'last write wins'.
func getOpts(opt ...Option) (options, error) {
opts := getDefaultOptions()
for _, o := range opt {
if o == nil {
continue
}
if err := o(&opts); err != nil {
return options{}, err
}
}
return opts, nil
}
// WithID provides an optional ID.
func WithID(id string) Option {
return func(o *options) error {
var err error
id := strings.TrimSpace(id)
switch {
case id == "":
err = errors.New("id cannot be empty")
default:
o.withID = id
}
return err
}
}
// WithNow provides an Option to represent 'now'.
func WithNow(now time.Time) Option {
return func(o *options) error {
var err error
switch {
case now.IsZero():
err = errors.New("cannot specify 'now' to be the zero time instant")
default:
o.withNow = now
}
return err
}
}
// WithSubtype provides an Option to represent the event subtype.
func WithSubtype(s string) Option {
return func(o *options) error {
s := strings.TrimSpace(s)
if s == "" {
return errors.New("subtype cannot be empty")
}
parsed := subtype(s)
err := parsed.validate()
if err != nil {
return err
}
o.withSubtype = parsed
return nil
}
}
// WithFormat provides an Option to represent event format.
func WithFormat(f string) Option {
return func(o *options) error {
f := strings.TrimSpace(f)
if f == "" {
// Return early, we won't attempt to apply this option if its empty.
return nil
}
parsed := format(f)
err := parsed.validate()
if err != nil {
return err
}
o.withFormat = parsed
return nil
}
}
// WithPrefix provides an Option to represent a prefix for a file sink.
func WithPrefix(prefix string) Option {
return func(o *options) error {
prefix = strings.TrimSpace(prefix)
if prefix != "" {
o.withPrefix = prefix
}
return nil
}
}
// WithRaw provides an Option to represent whether 'raw' is required.
func WithRaw(r bool) Option {
return func(o *options) error {
o.withRaw = r
return nil
}
}
// WithElision provides an Option to represent whether elision (...) is required.
func WithElision(e bool) Option {
return func(o *options) error {
o.withElision = e
return nil
}
}
// WithOmitTime provides an Option to represent whether to omit time.
func WithOmitTime(t bool) Option {
return func(o *options) error {
o.withOmitTime = t
return nil
}
}
// WithHMACAccessor provides an Option to represent whether an HMAC accessor is applicable.
func WithHMACAccessor(h bool) Option {
return func(o *options) error {
o.withHMACAccessor = h
return nil
}
}

487
audit/options_test.go Normal file
View File

@@ -0,0 +1,487 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package audit
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestOptions_WithFormat exercises WithFormat Option to ensure it performs as expected.
func TestOptions_WithFormat(t *testing.T) {
tests := map[string]struct {
Value string
IsErrorExpected bool
ExpectedErrorMessage string
ExpectedValue format
}{
"empty": {
Value: "",
IsErrorExpected: false,
ExpectedValue: format(""),
},
"whitespace": {
Value: " ",
IsErrorExpected: false,
ExpectedValue: format(""),
},
"invalid-test": {
Value: "test",
IsErrorExpected: true,
ExpectedErrorMessage: "audit.(format).validate: 'test' is not a valid format: invalid parameter",
},
"valid-json": {
Value: "json",
IsErrorExpected: false,
ExpectedValue: JSONFormat,
},
"valid-jsonx": {
Value: "jsonx",
IsErrorExpected: false,
ExpectedValue: JSONxFormat,
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
options := &options{}
applyOption := WithFormat(tc.Value)
err := applyOption(options)
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
default:
require.NoError(t, err)
require.Equal(t, tc.ExpectedValue, options.withFormat)
}
})
}
}
// TestOptions_WithSubtype exercises WithSubtype Option to ensure it performs as expected.
func TestOptions_WithSubtype(t *testing.T) {
tests := map[string]struct {
Value string
IsErrorExpected bool
ExpectedErrorMessage string
ExpectedValue subtype
}{
"empty": {
Value: "",
IsErrorExpected: true,
ExpectedErrorMessage: "subtype cannot be empty",
},
"whitespace": {
Value: " ",
IsErrorExpected: true,
ExpectedErrorMessage: "subtype cannot be empty",
},
"valid": {
Value: "AuditResponse",
IsErrorExpected: false,
ExpectedValue: ResponseType,
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
options := &options{}
applyOption := WithSubtype(tc.Value)
err := applyOption(options)
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
default:
require.NoError(t, err)
require.Equal(t, tc.ExpectedValue, options.withSubtype)
}
})
}
}
// TestOptions_WithNow exercises WithNow Option to ensure it performs as expected.
func TestOptions_WithNow(t *testing.T) {
tests := map[string]struct {
Value time.Time
IsErrorExpected bool
ExpectedErrorMessage string
ExpectedValue time.Time
}{
"default-time": {
Value: time.Time{},
IsErrorExpected: true,
ExpectedErrorMessage: "cannot specify 'now' to be the zero time instant",
},
"valid-time": {
Value: time.Date(2023, time.July, 4, 12, 3, 0, 0, time.Local),
IsErrorExpected: false,
ExpectedValue: time.Date(2023, time.July, 4, 12, 3, 0, 0, time.Local),
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
options := &options{}
applyOption := WithNow(tc.Value)
err := applyOption(options)
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
default:
require.NoError(t, err)
require.Equal(t, tc.ExpectedValue, options.withNow)
}
})
}
}
// TestOptions_WithID exercises WithID Option to ensure it performs as expected.
func TestOptions_WithID(t *testing.T) {
tests := map[string]struct {
Value string
IsErrorExpected bool
ExpectedErrorMessage string
ExpectedValue string
}{
"empty": {
Value: "",
IsErrorExpected: true,
ExpectedErrorMessage: "id cannot be empty",
},
"whitespace": {
Value: " ",
IsErrorExpected: true,
ExpectedErrorMessage: "id cannot be empty",
},
"valid": {
Value: "test",
IsErrorExpected: false,
ExpectedValue: "test",
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
options := &options{}
applyOption := WithID(tc.Value)
err := applyOption(options)
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
default:
require.NoError(t, err)
require.Equal(t, tc.ExpectedValue, options.withID)
}
})
}
}
// TestOptions_WithPrefix exercises WithPrefix Option to ensure it performs as expected.
func TestOptions_WithPrefix(t *testing.T) {
tests := map[string]struct {
Value string
IsErrorExpected bool
ExpectedErrorMessage string
ExpectedValue string
}{
"empty": {
Value: "",
IsErrorExpected: false,
ExpectedValue: "",
},
"whitespace": {
Value: " ",
IsErrorExpected: false,
ExpectedErrorMessage: "",
},
"valid": {
Value: "test",
IsErrorExpected: false,
ExpectedValue: "test",
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
options := &options{}
applyOption := WithPrefix(tc.Value)
err := applyOption(options)
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
default:
require.NoError(t, err)
require.Equal(t, tc.ExpectedValue, options.withPrefix)
}
})
}
}
// TestOptions_WithRaw exercises WithRaw Option to ensure it performs as expected.
func TestOptions_WithRaw(t *testing.T) {
tests := map[string]struct {
Value bool
ExpectedValue bool
}{
"true": {
Value: true,
ExpectedValue: true,
},
"false": {
Value: false,
ExpectedValue: false,
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
options := &options{}
applyOption := WithRaw(tc.Value)
err := applyOption(options)
require.NoError(t, err)
require.Equal(t, tc.ExpectedValue, options.withRaw)
})
}
}
// TestOptions_WithElision exercises WithElision Option to ensure it performs as expected.
func TestOptions_WithElision(t *testing.T) {
tests := map[string]struct {
Value bool
ExpectedValue bool
}{
"true": {
Value: true,
ExpectedValue: true,
},
"false": {
Value: false,
ExpectedValue: false,
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
options := &options{}
applyOption := WithElision(tc.Value)
err := applyOption(options)
require.NoError(t, err)
require.Equal(t, tc.ExpectedValue, options.withElision)
})
}
}
// TestOptions_WithHMACAccessor exercises WithHMACAccessor Option to ensure it performs as expected.
func TestOptions_WithHMACAccessor(t *testing.T) {
tests := map[string]struct {
Value bool
ExpectedValue bool
}{
"true": {
Value: true,
ExpectedValue: true,
},
"false": {
Value: false,
ExpectedValue: false,
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
options := &options{}
applyOption := WithHMACAccessor(tc.Value)
err := applyOption(options)
require.NoError(t, err)
require.Equal(t, tc.ExpectedValue, options.withHMACAccessor)
})
}
}
// TestOptions_WithOmitTime exercises WithOmitTime Option to ensure it performs as expected.
func TestOptions_WithOmitTime(t *testing.T) {
tests := map[string]struct {
Value bool
ExpectedValue bool
}{
"true": {
Value: true,
ExpectedValue: true,
},
"false": {
Value: false,
ExpectedValue: false,
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
options := &options{}
applyOption := WithOmitTime(tc.Value)
err := applyOption(options)
require.NoError(t, err)
require.Equal(t, tc.ExpectedValue, options.withOmitTime)
})
}
}
// TestOptions_Default exercises getDefaultOptions to assert the default values.
func TestOptions_Default(t *testing.T) {
opts := getDefaultOptions()
require.NotNil(t, opts)
require.True(t, time.Now().After(opts.withNow))
require.False(t, opts.withNow.IsZero())
}
// TestOptions_Opts exercises GetOpts with various Option values.
func TestOptions_Opts(t *testing.T) {
tests := map[string]struct {
opts []Option
IsErrorExpected bool
ExpectedErrorMessage string
ExpectedID string
ExpectedSubtype subtype
ExpectedFormat format
IsNowExpected bool
ExpectedNow time.Time
}{
"nil-options": {
opts: nil,
IsErrorExpected: false,
IsNowExpected: true,
ExpectedFormat: JSONFormat,
},
"empty-options": {
opts: []Option{},
IsErrorExpected: false,
IsNowExpected: true,
ExpectedFormat: JSONFormat,
},
"with-multiple-valid-id": {
opts: []Option{
WithID("qwerty"),
WithID("juan"),
},
IsErrorExpected: false,
ExpectedID: "juan",
IsNowExpected: true,
ExpectedFormat: JSONFormat,
},
"with-multiple-valid-subtype": {
opts: []Option{
WithSubtype("AuditRequest"),
WithSubtype("AuditResponse"),
},
IsErrorExpected: false,
ExpectedSubtype: ResponseType,
IsNowExpected: true,
ExpectedFormat: JSONFormat,
},
"with-multiple-valid-format": {
opts: []Option{
WithFormat("json"),
WithFormat("jsonx"),
},
IsErrorExpected: false,
ExpectedFormat: JSONxFormat,
IsNowExpected: true,
},
"with-multiple-valid-now": {
opts: []Option{
WithNow(time.Date(2023, time.July, 4, 12, 3, 0, 0, time.Local)),
WithNow(time.Date(2023, time.July, 4, 13, 3, 0, 0, time.Local)),
},
IsErrorExpected: false,
ExpectedNow: time.Date(2023, time.July, 4, 13, 3, 0, 0, time.Local),
IsNowExpected: false,
ExpectedFormat: JSONFormat,
},
"with-multiple-valid-then-invalid-now": {
opts: []Option{
WithNow(time.Date(2023, time.July, 4, 12, 3, 0, 0, time.Local)),
WithNow(time.Time{}),
},
IsErrorExpected: true,
ExpectedErrorMessage: "cannot specify 'now' to be the zero time instant",
ExpectedFormat: JSONFormat,
},
"with-multiple-valid-options": {
opts: []Option{
WithID("qwerty"),
WithSubtype("AuditRequest"),
WithFormat("json"),
WithNow(time.Date(2023, time.July, 4, 12, 3, 0, 0, time.Local)),
},
IsErrorExpected: false,
ExpectedID: "qwerty",
ExpectedSubtype: RequestType,
ExpectedFormat: JSONFormat,
ExpectedNow: time.Date(2023, time.July, 4, 12, 3, 0, 0, time.Local),
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
opts, err := getOpts(tc.opts...)
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
default:
require.NotNil(t, opts)
require.NoError(t, err)
require.Equal(t, tc.ExpectedID, opts.withID)
require.Equal(t, tc.ExpectedSubtype, opts.withSubtype)
require.Equal(t, tc.ExpectedFormat, opts.withFormat)
switch {
case tc.IsNowExpected:
require.True(t, time.Now().After(opts.withNow))
require.False(t, opts.withNow.IsZero())
default:
require.Equal(t, tc.ExpectedNow, opts.withNow)
}
}
})
}
}

View File

@@ -16,7 +16,7 @@ type JSONWriter struct {
Prefix string Prefix string
} }
func (f *JSONWriter) WriteRequest(w io.Writer, req *AuditRequestEntry) error { func (f *JSONWriter) WriteRequest(w io.Writer, req *RequestEntry) error {
if req == nil { if req == nil {
return fmt.Errorf("request entry was nil, cannot encode") return fmt.Errorf("request entry was nil, cannot encode")
} }
@@ -32,7 +32,7 @@ func (f *JSONWriter) WriteRequest(w io.Writer, req *AuditRequestEntry) error {
return enc.Encode(req) return enc.Encode(req)
} }
func (f *JSONWriter) WriteResponse(w io.Writer, resp *AuditResponseEntry) error { func (f *JSONWriter) WriteResponse(w io.Writer, resp *ResponseEntry) error {
if resp == nil { if resp == nil {
return fmt.Errorf("response entry was nil, cannot encode") return fmt.Errorf("response entry was nil, cannot encode")
} }

View File

@@ -98,38 +98,39 @@ func TestFormatJSON_formatRequest(t *testing.T) {
for name, tc := range cases { for name, tc := range cases {
var buf bytes.Buffer var buf bytes.Buffer
f, err := NewAuditFormatter(ss) cfg, err := NewFormatterConfig()
require.NoError(t, err) require.NoError(t, err)
formatter := AuditFormatterWriter{ f, err := NewEntryFormatter(cfg, ss)
require.NoError(t, err)
formatter := EntryFormatterWriter{
Formatter: f, Formatter: f,
Writer: &JSONWriter{ Writer: &JSONWriter{
Prefix: tc.Prefix, Prefix: tc.Prefix,
}, },
config: cfg,
} }
config := FormatterConfig{
HMACAccessor: false,
}
in := &logical.LogInput{ in := &logical.LogInput{
Auth: tc.Auth, Auth: tc.Auth,
Request: tc.Req, Request: tc.Req,
OuterErr: tc.Err, OuterErr: tc.Err,
} }
err = formatter.FormatAndWriteRequest(namespace.RootContext(nil), &buf, config, in) err = formatter.FormatAndWriteRequest(namespace.RootContext(nil), &buf, in)
require.NoErrorf(t, err, "bad: %s\nerr: %s", name, err) require.NoErrorf(t, err, "bad: %s\nerr: %s", name, err)
if !strings.HasPrefix(buf.String(), tc.Prefix) { if !strings.HasPrefix(buf.String(), tc.Prefix) {
t.Fatalf("no prefix: %s \n log: %s\nprefix: %s", name, expectedResultStr, tc.Prefix) t.Fatalf("no prefix: %s \n log: %s\nprefix: %s", name, expectedResultStr, tc.Prefix)
} }
expectedJSON := new(AuditRequestEntry) expectedJSON := new(RequestEntry)
if err := jsonutil.DecodeJSON([]byte(expectedResultStr), &expectedJSON); err != nil { if err := jsonutil.DecodeJSON([]byte(expectedResultStr), &expectedJSON); err != nil {
t.Fatalf("bad json: %s", err) t.Fatalf("bad json: %s", err)
} }
expectedJSON.Request.Namespace = &AuditNamespace{ID: "root"} expectedJSON.Request.Namespace = &Namespace{ID: "root"}
actualjson := new(AuditRequestEntry) actualjson := new(RequestEntry)
if err := jsonutil.DecodeJSON([]byte(buf.String())[len(tc.Prefix):], &actualjson); err != nil { if err := jsonutil.DecodeJSON([]byte(buf.String())[len(tc.Prefix):], &actualjson); err != nil {
t.Fatalf("bad json: %s", err) t.Fatalf("bad json: %s", err)
} }

View File

@@ -18,7 +18,7 @@ type JSONxWriter struct {
Prefix string Prefix string
} }
func (f *JSONxWriter) WriteRequest(w io.Writer, req *AuditRequestEntry) error { func (f *JSONxWriter) WriteRequest(w io.Writer, req *RequestEntry) error {
if req == nil { if req == nil {
return fmt.Errorf("request entry was nil, cannot encode") return fmt.Errorf("request entry was nil, cannot encode")
} }
@@ -44,7 +44,7 @@ func (f *JSONxWriter) WriteRequest(w io.Writer, req *AuditRequestEntry) error {
return err return err
} }
func (f *JSONxWriter) WriteResponse(w io.Writer, resp *AuditResponseEntry) error { func (f *JSONxWriter) WriteResponse(w io.Writer, resp *ResponseEntry) error {
if resp == nil { if resp == nil {
return fmt.Errorf("response entry was nil, cannot encode") return fmt.Errorf("response entry was nil, cannot encode")
} }

View File

@@ -113,24 +113,25 @@ func TestFormatJSONx_formatRequest(t *testing.T) {
for name, tc := range cases { for name, tc := range cases {
var buf bytes.Buffer var buf bytes.Buffer
f, err := NewAuditFormatter(tempStaticSalt) cfg, err := NewFormatterConfig(
WithOmitTime(true),
WithHMACAccessor(false),
WithFormat(JSONxFormat.String()),
)
require.NoError(t, err) require.NoError(t, err)
formatter := AuditFormatterWriter{ f, err := NewEntryFormatter(cfg, tempStaticSalt)
Formatter: f, require.NoError(t, err)
Writer: &JSONxWriter{ writer := &JSONxWriter{Prefix: tc.Prefix}
Prefix: tc.Prefix, formatter, err := NewEntryFormatterWriter(cfg, f, writer)
}, require.NoError(t, err)
} require.NotNil(t, formatter)
config := FormatterConfig{
OmitTime: true,
HMACAccessor: false,
}
in := &logical.LogInput{ in := &logical.LogInput{
Auth: tc.Auth, Auth: tc.Auth,
Request: tc.Req, Request: tc.Req,
OuterErr: tc.Err, OuterErr: tc.Err,
} }
if err := formatter.FormatAndWriteRequest(namespace.RootContext(nil), &buf, config, in); err != nil { if err := formatter.FormatAndWriteRequest(namespace.RootContext(nil), &buf, in); err != nil {
t.Fatalf("bad: %s\nerr: %s", name, err) t.Fatalf("bad: %s\nerr: %s", name, err)
} }

View File

@@ -107,17 +107,23 @@ func Factory(ctx context.Context, conf *audit.BackendConfig, useEventLogger bool
} }
cfg, err := audit.NewFormatterConfig(
audit.WithElision(elideListResponses),
audit.WithFormat(format),
audit.WithHMACAccessor(hmacAccessor),
audit.WithRaw(logRaw),
)
if err != nil {
return nil, err
}
b := &Backend{ b := &Backend{
path: path, path: path,
mode: mode, mode: mode,
saltConfig: conf.SaltConfig, saltConfig: conf.SaltConfig,
saltView: conf.SaltView, saltView: conf.SaltView,
salt: new(atomic.Value), salt: new(atomic.Value),
formatConfig: audit.FormatterConfig{ formatConfig: cfg,
Raw: logRaw,
HMACAccessor: hmacAccessor,
ElideListResponses: elideListResponses,
},
} }
// Ensure we are working with the right type by explicitly storing a nil of // Ensure we are working with the right type by explicitly storing a nil of
@@ -125,7 +131,7 @@ func Factory(ctx context.Context, conf *audit.BackendConfig, useEventLogger bool
b.salt.Store((*salt.Salt)(nil)) b.salt.Store((*salt.Salt)(nil))
// Configure the formatter for either case. // Configure the formatter for either case.
f, err := audit.NewAuditFormatter(b) f, err := audit.NewEntryFormatter(b.formatConfig, b, audit.WithPrefix(conf.Config["prefix"]))
if err != nil { if err != nil {
return nil, fmt.Errorf("error creating formatter: %w", err) return nil, fmt.Errorf("error creating formatter: %w", err)
} }
@@ -137,7 +143,7 @@ func Factory(ctx context.Context, conf *audit.BackendConfig, useEventLogger bool
w = &audit.JSONxWriter{Prefix: conf.Config["prefix"]} w = &audit.JSONxWriter{Prefix: conf.Config["prefix"]}
} }
fw, err := audit.NewAuditFormatterWriter(f, w) fw, err := audit.NewEntryFormatterWriter(b.formatConfig, f, w)
if err != nil { if err != nil {
return nil, fmt.Errorf("error creating formatter writer: %w", err) return nil, fmt.Errorf("error creating formatter writer: %w", err)
} }
@@ -166,7 +172,7 @@ func Factory(ctx context.Context, conf *audit.BackendConfig, useEventLogger bool
type Backend struct { type Backend struct {
path string path string
formatter *audit.AuditFormatterWriter formatter *audit.EntryFormatterWriter
formatConfig audit.FormatterConfig formatConfig audit.FormatterConfig
fileLock sync.RWMutex fileLock sync.RWMutex
@@ -224,7 +230,7 @@ func (b *Backend) LogRequest(ctx context.Context, in *logical.LogInput) error {
} }
buf := bytes.NewBuffer(make([]byte, 0, 2000)) buf := bytes.NewBuffer(make([]byte, 0, 2000))
err := b.formatter.FormatAndWriteRequest(ctx, buf, b.formatConfig, in) err := b.formatter.FormatAndWriteRequest(ctx, buf, in)
if err != nil { if err != nil {
return err return err
} }
@@ -280,7 +286,7 @@ func (b *Backend) LogResponse(ctx context.Context, in *logical.LogInput) error {
} }
buf := bytes.NewBuffer(make([]byte, 0, 6000)) buf := bytes.NewBuffer(make([]byte, 0, 6000))
err := b.formatter.FormatAndWriteResponse(ctx, buf, b.formatConfig, in) err := b.formatter.FormatAndWriteResponse(ctx, buf, in)
if err != nil { if err != nil {
return err return err
} }
@@ -298,8 +304,12 @@ func (b *Backend) LogTestMessage(ctx context.Context, in *logical.LogInput, conf
} }
var buf bytes.Buffer var buf bytes.Buffer
temporaryFormatter := audit.NewTemporaryFormatter(config["format"], config["prefix"]) temporaryFormatter, err := audit.NewTemporaryFormatter(config["format"], config["prefix"])
if err := temporaryFormatter.FormatAndWriteRequest(ctx, &buf, b.formatConfig, in); err != nil { if err != nil {
return err
}
if err = temporaryFormatter.FormatAndWriteRequest(ctx, &buf, in); err != nil {
return err return err
} }

View File

@@ -85,14 +85,20 @@ func Factory(ctx context.Context, conf *audit.BackendConfig, useEventLogger bool
elideListResponses = value elideListResponses = value
} }
cfg, err := audit.NewFormatterConfig(
audit.WithElision(elideListResponses),
audit.WithFormat(format),
audit.WithHMACAccessor(hmacAccessor),
audit.WithRaw(logRaw),
)
if err != nil {
return nil, err
}
b := &Backend{ b := &Backend{
saltConfig: conf.SaltConfig, saltConfig: conf.SaltConfig,
saltView: conf.SaltView, saltView: conf.SaltView,
formatConfig: audit.FormatterConfig{ formatConfig: cfg,
Raw: logRaw,
HMACAccessor: hmacAccessor,
ElideListResponses: elideListResponses,
},
writeDuration: writeDuration, writeDuration: writeDuration,
address: address, address: address,
@@ -100,7 +106,7 @@ func Factory(ctx context.Context, conf *audit.BackendConfig, useEventLogger bool
} }
// Configure the formatter for either case. // Configure the formatter for either case.
f, err := audit.NewAuditFormatter(b) f, err := audit.NewEntryFormatter(b.formatConfig, b)
if err != nil { if err != nil {
return nil, fmt.Errorf("error creating formatter: %w", err) return nil, fmt.Errorf("error creating formatter: %w", err)
} }
@@ -112,7 +118,7 @@ func Factory(ctx context.Context, conf *audit.BackendConfig, useEventLogger bool
w = &audit.JSONxWriter{Prefix: conf.Config["prefix"]} w = &audit.JSONxWriter{Prefix: conf.Config["prefix"]}
} }
fw, err := audit.NewAuditFormatterWriter(f, w) fw, err := audit.NewEntryFormatterWriter(b.formatConfig, f, w)
if err != nil { if err != nil {
return nil, fmt.Errorf("error creating formatter writer: %w", err) return nil, fmt.Errorf("error creating formatter writer: %w", err)
} }
@@ -126,7 +132,7 @@ func Factory(ctx context.Context, conf *audit.BackendConfig, useEventLogger bool
type Backend struct { type Backend struct {
connection net.Conn connection net.Conn
formatter *audit.AuditFormatterWriter formatter *audit.EntryFormatterWriter
formatConfig audit.FormatterConfig formatConfig audit.FormatterConfig
writeDuration time.Duration writeDuration time.Duration
@@ -153,7 +159,7 @@ func (b *Backend) GetHash(ctx context.Context, data string) (string, error) {
func (b *Backend) LogRequest(ctx context.Context, in *logical.LogInput) error { func (b *Backend) LogRequest(ctx context.Context, in *logical.LogInput) error {
var buf bytes.Buffer var buf bytes.Buffer
if err := b.formatter.FormatAndWriteRequest(ctx, &buf, b.formatConfig, in); err != nil { if err := b.formatter.FormatAndWriteRequest(ctx, &buf, in); err != nil {
return err return err
} }
@@ -176,7 +182,7 @@ func (b *Backend) LogRequest(ctx context.Context, in *logical.LogInput) error {
func (b *Backend) LogResponse(ctx context.Context, in *logical.LogInput) error { func (b *Backend) LogResponse(ctx context.Context, in *logical.LogInput) error {
var buf bytes.Buffer var buf bytes.Buffer
if err := b.formatter.FormatAndWriteResponse(ctx, &buf, b.formatConfig, in); err != nil { if err := b.formatter.FormatAndWriteResponse(ctx, &buf, in); err != nil {
return err return err
} }
@@ -199,15 +205,20 @@ func (b *Backend) LogResponse(ctx context.Context, in *logical.LogInput) error {
func (b *Backend) LogTestMessage(ctx context.Context, in *logical.LogInput, config map[string]string) error { func (b *Backend) LogTestMessage(ctx context.Context, in *logical.LogInput, config map[string]string) error {
var buf bytes.Buffer var buf bytes.Buffer
temporaryFormatter := audit.NewTemporaryFormatter(config["format"], config["prefix"])
if err := temporaryFormatter.FormatAndWriteRequest(ctx, &buf, b.formatConfig, in); err != nil { temporaryFormatter, err := audit.NewTemporaryFormatter(config["format"], config["prefix"])
if err != nil {
return err
}
if err = temporaryFormatter.FormatAndWriteRequest(ctx, &buf, in); err != nil {
return err return err
} }
b.Lock() b.Lock()
defer b.Unlock() defer b.Unlock()
err := b.write(ctx, buf.Bytes()) err = b.write(ctx, buf.Bytes())
if err != nil { if err != nil {
rErr := b.reconnect(ctx) rErr := b.reconnect(ctx)
if rErr != nil { if rErr != nil {

View File

@@ -81,19 +81,25 @@ func Factory(ctx context.Context, conf *audit.BackendConfig, useEventLogger bool
return nil, err return nil, err
} }
cfg, err := audit.NewFormatterConfig(
audit.WithElision(elideListResponses),
audit.WithFormat(format),
audit.WithHMACAccessor(hmacAccessor),
audit.WithRaw(logRaw),
)
if err != nil {
return nil, err
}
b := &Backend{ b := &Backend{
logger: logger, logger: logger,
saltConfig: conf.SaltConfig, saltConfig: conf.SaltConfig,
saltView: conf.SaltView, saltView: conf.SaltView,
formatConfig: audit.FormatterConfig{ formatConfig: cfg,
Raw: logRaw,
HMACAccessor: hmacAccessor,
ElideListResponses: elideListResponses,
},
} }
// Configure the formatter for either case. // Configure the formatter for either case.
f, err := audit.NewAuditFormatter(b) f, err := audit.NewEntryFormatter(b.formatConfig, b, audit.WithPrefix(conf.Config["prefix"]))
if err != nil { if err != nil {
return nil, fmt.Errorf("error creating formatter: %w", err) return nil, fmt.Errorf("error creating formatter: %w", err)
} }
@@ -106,7 +112,7 @@ func Factory(ctx context.Context, conf *audit.BackendConfig, useEventLogger bool
w = &audit.JSONxWriter{Prefix: conf.Config["prefix"]} w = &audit.JSONxWriter{Prefix: conf.Config["prefix"]}
} }
fw, err := audit.NewAuditFormatterWriter(f, w) fw, err := audit.NewEntryFormatterWriter(b.formatConfig, f, w)
if err != nil { if err != nil {
return nil, fmt.Errorf("error creating formatter writer: %w", err) return nil, fmt.Errorf("error creating formatter writer: %w", err)
} }
@@ -120,7 +126,7 @@ func Factory(ctx context.Context, conf *audit.BackendConfig, useEventLogger bool
type Backend struct { type Backend struct {
logger gsyslog.Syslogger logger gsyslog.Syslogger
formatter *audit.AuditFormatterWriter formatter *audit.EntryFormatterWriter
formatConfig audit.FormatterConfig formatConfig audit.FormatterConfig
saltMutex sync.RWMutex saltMutex sync.RWMutex
@@ -141,7 +147,7 @@ func (b *Backend) GetHash(ctx context.Context, data string) (string, error) {
func (b *Backend) LogRequest(ctx context.Context, in *logical.LogInput) error { func (b *Backend) LogRequest(ctx context.Context, in *logical.LogInput) error {
var buf bytes.Buffer var buf bytes.Buffer
if err := b.formatter.FormatAndWriteRequest(ctx, &buf, b.formatConfig, in); err != nil { if err := b.formatter.FormatAndWriteRequest(ctx, &buf, in); err != nil {
return err return err
} }
@@ -152,7 +158,7 @@ func (b *Backend) LogRequest(ctx context.Context, in *logical.LogInput) error {
func (b *Backend) LogResponse(ctx context.Context, in *logical.LogInput) error { func (b *Backend) LogResponse(ctx context.Context, in *logical.LogInput) error {
var buf bytes.Buffer var buf bytes.Buffer
if err := b.formatter.FormatAndWriteResponse(ctx, &buf, b.formatConfig, in); err != nil { if err := b.formatter.FormatAndWriteResponse(ctx, &buf, in); err != nil {
return err return err
} }
@@ -163,13 +169,18 @@ func (b *Backend) LogResponse(ctx context.Context, in *logical.LogInput) error {
func (b *Backend) LogTestMessage(ctx context.Context, in *logical.LogInput, config map[string]string) error { func (b *Backend) LogTestMessage(ctx context.Context, in *logical.LogInput, config map[string]string) error {
var buf bytes.Buffer var buf bytes.Buffer
temporaryFormatter := audit.NewTemporaryFormatter(config["format"], config["prefix"])
if err := temporaryFormatter.FormatAndWriteRequest(ctx, &buf, b.formatConfig, in); err != nil { temporaryFormatter, err := audit.NewTemporaryFormatter(config["format"], config["prefix"])
if err != nil {
return err
}
if err = temporaryFormatter.FormatAndWriteRequest(ctx, &buf, in); err != nil {
return err return err
} }
// Send to syslog // Send to syslog
_, err := b.logger.Write(buf.Bytes()) _, err = b.logger.Write(buf.Bytes())
return err return err
} }

View File

@@ -10,12 +10,13 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"fmt" "fmt"
"io/ioutil" "io"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"time" "time"
"github.com/hashicorp/eventlogger"
"github.com/hashicorp/go-hclog" "github.com/hashicorp/go-hclog"
"github.com/hashicorp/vault/audit" "github.com/hashicorp/vault/audit"
"github.com/hashicorp/vault/builtin/credential/approle" "github.com/hashicorp/vault/builtin/credential/approle"
@@ -246,12 +247,17 @@ func NewNoopAudit(config map[string]string) (*NoopAudit, error) {
}, },
} }
f, err := audit.NewAuditFormatter(n) cfg, err := audit.NewFormatterConfig()
if err != nil {
return nil, err
}
f, err := audit.NewEntryFormatter(cfg, n)
if err != nil { if err != nil {
return nil, fmt.Errorf("error creating formatter: %w", err) return nil, fmt.Errorf("error creating formatter: %w", err)
} }
fw, err := audit.NewAuditFormatterWriter(f, &audit.JSONWriter{}) fw, err := audit.NewEntryFormatterWriter(cfg, f, &audit.JSONWriter{})
if err != nil { if err != nil {
return nil, fmt.Errorf("error creating formatter writer: %w", err) return nil, fmt.Errorf("error creating formatter writer: %w", err)
} }
@@ -291,7 +297,7 @@ type NoopAudit struct {
RespReqNonHMACKeys [][]string RespReqNonHMACKeys [][]string
RespErrs []error RespErrs []error
formatter *audit.AuditFormatterWriter formatter *audit.EntryFormatterWriter
records [][]byte records [][]byte
l sync.RWMutex l sync.RWMutex
salt *salt.Salt salt *salt.Salt
@@ -303,7 +309,7 @@ func (n *NoopAudit) LogRequest(ctx context.Context, in *logical.LogInput) error
defer n.l.Unlock() defer n.l.Unlock()
if n.formatter != nil { if n.formatter != nil {
var w bytes.Buffer var w bytes.Buffer
err := n.formatter.FormatAndWriteRequest(ctx, &w, audit.FormatterConfig{}, in) err := n.formatter.FormatAndWriteRequest(ctx, &w, in)
if err != nil { if err != nil {
return err return err
} }
@@ -325,7 +331,7 @@ func (n *NoopAudit) LogResponse(ctx context.Context, in *logical.LogInput) error
if n.formatter != nil { if n.formatter != nil {
var w bytes.Buffer var w bytes.Buffer
err := n.formatter.FormatAndWriteResponse(ctx, &w, audit.FormatterConfig{}, in) err := n.formatter.FormatAndWriteResponse(ctx, &w, in)
if err != nil { if err != nil {
return err return err
} }
@@ -349,12 +355,19 @@ func (n *NoopAudit) LogTestMessage(ctx context.Context, in *logical.LogInput, co
n.l.Lock() n.l.Lock()
defer n.l.Unlock() defer n.l.Unlock()
var w bytes.Buffer var w bytes.Buffer
tempFormatter := audit.NewTemporaryFormatter(config["format"], config["prefix"])
err := tempFormatter.FormatAndWriteResponse(ctx, &w, audit.FormatterConfig{}, in) tempFormatter, err := audit.NewTemporaryFormatter(config["format"], config["prefix"])
if err != nil { if err != nil {
return err return err
} }
err = tempFormatter.FormatAndWriteResponse(ctx, &w, in)
if err != nil {
return err
}
n.records = append(n.records, w.Bytes()) n.records = append(n.records, w.Bytes())
return nil return nil
} }
@@ -370,32 +383,36 @@ func (n *NoopAudit) Salt(ctx context.Context) (*salt.Salt, error) {
if n.salt != nil { if n.salt != nil {
return n.salt, nil return n.salt, nil
} }
salt, err := salt.NewSalt(ctx, n.Config.SaltView, n.Config.SaltConfig) s, err := salt.NewSalt(ctx, n.Config.SaltView, n.Config.SaltConfig)
if err != nil { if err != nil {
return nil, err return nil, err
} }
n.salt = salt n.salt = s
return salt, nil return s, nil
} }
func (n *NoopAudit) GetHash(ctx context.Context, data string) (string, error) { func (n *NoopAudit) GetHash(ctx context.Context, data string) (string, error) {
salt, err := n.Salt(ctx) s, err := n.Salt(ctx)
if err != nil { if err != nil {
return "", err return "", err
} }
return salt.GetIdentifiedHMAC(data), nil return s.GetIdentifiedHMAC(data), nil
} }
func (n *NoopAudit) Reload(ctx context.Context) error { func (n *NoopAudit) Reload(_ context.Context) error {
return nil return nil
} }
func (n *NoopAudit) Invalidate(ctx context.Context) { func (n *NoopAudit) Invalidate(_ context.Context) {
n.saltMutex.Lock() n.saltMutex.Lock()
defer n.saltMutex.Unlock() defer n.saltMutex.Unlock()
n.salt = nil n.salt = nil
} }
func (n *NoopAudit) RegisterNodesAndPipeline(broker *eventlogger.Broker, _ string) error {
return nil
}
type TestLogger struct { type TestLogger struct {
hclog.Logger hclog.Logger
Path string Path string
@@ -427,7 +444,7 @@ func NewTestLogger(t testing.T) *TestLogger {
// We send nothing on the regular logger, that way we can later deregister // We send nothing on the regular logger, that way we can later deregister
// the sink to stop logging during cluster cleanup. // the sink to stop logging during cluster cleanup.
logger := hclog.NewInterceptLogger(&hclog.LoggerOptions{ logger := hclog.NewInterceptLogger(&hclog.LoggerOptions{
Output: ioutil.Discard, Output: io.Discard,
IndependentLevels: true, IndependentLevels: true,
}) })
sink := hclog.NewSinkAdapter(&hclog.LoggerOptions{ sink := hclog.NewSinkAdapter(&hclog.LoggerOptions{

View File

@@ -1,250 +0,0 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package event
import (
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"github.com/hashicorp/eventlogger"
)
// defaultFileMode is the default file permissions (read/write for everyone).
const (
defaultFileMode = 0o600
discard = "discard"
stdout = "stdout"
)
// AuditFileSink is a sink node which handles writing audit events to file.
type AuditFileSink struct {
file *os.File
fileLock sync.RWMutex
fileMode os.FileMode
path string
format auditFormat
prefix string
}
// NewAuditFileSink should be used to create a new AuditFileSink.
// Accepted options: WithFileMode and WithPrefix.
func NewAuditFileSink(path string, format auditFormat, opt ...Option) (*AuditFileSink, error) {
const op = "event.NewAuditFileSink"
// Parse and check path
p := strings.TrimSpace(path)
switch {
case p == "":
return nil, fmt.Errorf("%s: path is required", op)
case strings.EqualFold(path, stdout):
p = stdout
case strings.EqualFold(path, discard):
p = discard
}
// Validate format
if err := format.validate(); err != nil {
return nil, fmt.Errorf("%s: invalid format: %w", op, err)
}
opts, err := getOpts(opt...)
if err != nil {
return nil, fmt.Errorf("%s: error applying options: %w", op, err)
}
mode := os.FileMode(defaultFileMode)
// If we got an optional file mode supplied and our path isn't a special keyword
// then we should use the supplied file mode, or maintain the existing file mode.
if opts.withFileMode != nil {
switch {
case p == stdout:
case p == discard:
case *opts.withFileMode == 0: // Maintain the existing file's mode when set to "0000".
fileInfo, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("%s: unable to determine existing file mode: %w", op, err)
}
mode = fileInfo.Mode()
default:
mode = *opts.withFileMode
}
}
return &AuditFileSink{
file: nil,
fileLock: sync.RWMutex{},
fileMode: mode,
format: format,
path: p,
prefix: opts.withPrefix,
}, nil
}
// Process handles writing the event to the file sink.
func (f *AuditFileSink) Process(ctx context.Context, e *eventlogger.Event) (*eventlogger.Event, error) {
const op = "event.(AuditFileSink).Process"
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if e == nil {
return nil, fmt.Errorf("%s: event is nil: %w", op, ErrInvalidParameter)
}
// 'discard' path means we just do nothing and pretend we're done.
if f.path == discard {
return nil, nil
}
formatted, found := e.Format(f.format.String())
if !found {
return nil, fmt.Errorf("%s: unable to retrieve event formatted as %q", op, f.format)
}
err := f.log(formatted)
if err != nil {
return nil, fmt.Errorf("%s: error writing file for audit sink: %w", op, err)
}
// return nil for the event to indicate the pipeline is complete.
return nil, nil
}
// Reopen handles closing and reopening the file.
func (f *AuditFileSink) Reopen() error {
const op = "event.(AuditFileSink).Reopen"
switch f.path {
case stdout, discard:
return nil
}
f.fileLock.Lock()
defer f.fileLock.Unlock()
if f.file == nil {
return f.open()
}
err := f.file.Close()
// Set to nil here so that even if we error out, on the next access open() will be tried.
f.file = nil
if err != nil {
return fmt.Errorf("%s: unable to close file for re-opening on audit sink: %w", op, err)
}
return f.open()
}
// Type describes the type of this node (sink).
func (f *AuditFileSink) Type() eventlogger.NodeType {
return eventlogger.NodeTypeSink
}
// open attempts to open a file at the sink's path, with the sink's fileMode permissions
// if one is not already open.
// It doesn't have any locking and relies on calling functions of AuditFileSink to
// handle this (e.g. log and Reopen methods).
func (f *AuditFileSink) open() error {
const op = "event.(AuditFileSink).open"
if f.file != nil {
return nil
}
if err := os.MkdirAll(filepath.Dir(f.path), f.fileMode); err != nil {
return fmt.Errorf("%s: unable to create file %q: %w", op, f.path, err)
}
var err error
f.file, err = os.OpenFile(f.path, os.O_APPEND|os.O_WRONLY|os.O_CREATE, f.fileMode)
if err != nil {
return fmt.Errorf("%s: unable to open file for audit sink: %w", op, err)
}
// Change the file mode in case the log file already existed.
// We special case '/dev/null' since we can't chmod it, and bypass if the mode is zero.
switch f.path {
case "/dev/null":
default:
if f.fileMode != 0 {
err = os.Chmod(f.path, f.fileMode)
if err != nil {
return fmt.Errorf("%s: unable to change file %q permissions '%v' for audit sink: %w", op, f.path, f.fileMode, err)
}
}
}
return nil
}
// log writes the buffer to the file.
// It acquires a lock on the file to do this.
func (f *AuditFileSink) log(data []byte) error {
const op = "event.(AuditFileSink).log"
f.fileLock.Lock()
defer f.fileLock.Unlock()
reader := bytes.NewReader(data)
var writer io.Writer
switch {
case f.path == stdout:
writer = os.Stdout
default:
if err := f.open(); err != nil {
return fmt.Errorf("%s: unable to open file for audit sink: %w", op, err)
}
writer = f.file
}
// Write prefix before the data if required.
if f.prefix != "" {
_, err := writer.Write([]byte(f.prefix))
if err != nil {
return fmt.Errorf("%s: unable to write prefix %q for audit sink: %w", op, f.prefix, err)
}
}
if _, err := reader.WriteTo(writer); err == nil {
return nil
} else if f.path == stdout {
// If writing to stdout there's no real reason to think anything would change on retry.
return fmt.Errorf("%s: unable write to %q: %w", op, f.path, err)
}
// Otherwise, opportunistically try to re-open the FD, once per call (1 retry attempt).
err := f.file.Close()
if err != nil {
return fmt.Errorf("%s: unable to close file for audit sink: %w", op, err)
}
f.file = nil
if err := f.open(); err != nil {
return fmt.Errorf("%s: unable to re-open file for audit sink: %w", op, err)
}
_, err = reader.Seek(0, io.SeekStart)
if err != nil {
return fmt.Errorf("%s: unable to seek to start of file for audit sink: %w", op, err)
}
_, err = reader.WriteTo(writer)
if err != nil {
return fmt.Errorf("%s: unable to re-write to file for audit sink: %w", op, err)
}
return nil
}

View File

@@ -1,415 +0,0 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package event
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/hashicorp/vault/sdk/logical"
vaultaudit "github.com/hashicorp/vault/audit"
"github.com/hashicorp/vault/helper/namespace"
"github.com/hashicorp/eventlogger"
"github.com/stretchr/testify/require"
)
// TestAuditFileSink_Type ensures that the node is a 'sink' type.
func TestAuditFileSink_Type(t *testing.T) {
f, err := NewAuditFileSink(t.TempDir(), AuditFormatJSON)
require.NoError(t, err)
require.NotNil(t, f)
require.Equal(t, eventlogger.NodeTypeSink, f.Type())
}
// TestNewAuditFileSink tests creation of an AuditFileSink.
func TestNewAuditFileSink(t *testing.T) {
tests := map[string]struct {
IsTempDirPath bool // Path should contain the filename if temp dir is true
Path string
Format auditFormat
Options []Option
IsErrorExpected bool
ExpectedErrorMessage string
// Expected values of AuditFileSink
ExpectedFileMode os.FileMode
ExpectedFormat auditFormat
ExpectedPath string
ExpectedPrefix string
}{
"default-values": {
IsErrorExpected: true,
ExpectedErrorMessage: "event.NewAuditFileSink: path is required",
},
"spacey-path": {
Path: " ",
Format: AuditFormatJSON,
IsErrorExpected: true,
ExpectedErrorMessage: "event.NewAuditFileSink: path is required",
},
"bad-format": {
Path: "qwerty",
Format: "squirrels",
IsErrorExpected: true,
ExpectedErrorMessage: "event.NewAuditFileSink: invalid format: event.(auditFormat).validate: 'squirrels' is not a valid format: invalid parameter",
},
"path-not-exist-valid-format-file-mode": {
Path: "qwerty",
Format: AuditFormatJSON,
Options: []Option{WithFileMode("00755")},
IsErrorExpected: false,
ExpectedPath: "qwerty",
ExpectedFormat: AuditFormatJSON,
ExpectedPrefix: "",
ExpectedFileMode: os.FileMode(0o755),
},
"valid-path-no-format": {
IsTempDirPath: true,
Path: "vault.log",
IsErrorExpected: true,
ExpectedErrorMessage: "event.NewAuditFileSink: invalid format: event.(auditFormat).validate: '' is not a valid format: invalid parameter",
},
"valid-path-and-format": {
IsTempDirPath: true,
Path: "vault.log",
Format: AuditFormatJSON,
IsErrorExpected: false,
ExpectedFileMode: defaultFileMode,
ExpectedFormat: AuditFormatJSON,
ExpectedPrefix: "",
},
"file-mode-not-default-or-zero": {
Path: "vault.log",
Format: AuditFormatJSON,
Options: []Option{WithFileMode("0007")},
IsTempDirPath: true,
IsErrorExpected: false,
ExpectedFormat: AuditFormatJSON,
ExpectedPrefix: "",
ExpectedFileMode: 0o007,
},
"path-stdout": {
Path: "stdout",
Format: AuditFormatJSON,
Options: []Option{WithFileMode("0007")}, // Will be ignored as stdout
IsTempDirPath: false,
IsErrorExpected: false,
ExpectedPath: "stdout",
ExpectedFormat: AuditFormatJSON,
ExpectedPrefix: "",
ExpectedFileMode: defaultFileMode,
},
"path-discard": {
Path: "discard",
Format: AuditFormatJSON,
Options: []Option{WithFileMode("0007")},
IsTempDirPath: false,
IsErrorExpected: false,
ExpectedPath: "discard",
ExpectedFormat: AuditFormatJSON,
ExpectedPrefix: "",
ExpectedFileMode: defaultFileMode,
},
"prefix": {
IsTempDirPath: true,
Path: "vault.log",
Format: AuditFormatJSON,
Options: []Option{WithFileMode("0007"), WithPrefix("bleep")},
IsErrorExpected: false,
ExpectedPrefix: "bleep",
ExpectedFormat: AuditFormatJSON,
ExpectedFileMode: 0o007,
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
// t.Parallel()
// If we need a real directory as a path we can use a temp dir.
// but we should keep track of it for comparison in the new sink.
var tempDir string
tempPath := tc.Path
if tc.IsTempDirPath {
tempDir = t.TempDir()
tempPath = filepath.Join(tempDir, tempPath)
}
sink, err := NewAuditFileSink(tempPath, tc.Format, tc.Options...)
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
require.Nil(t, sink)
default:
require.NoError(t, err)
require.NotNil(t, sink)
// Assert properties are correct.
require.Equal(t, tc.ExpectedPrefix, sink.prefix)
require.Equal(t, tc.ExpectedFormat, sink.format)
require.Equal(t, tc.ExpectedFileMode, sink.fileMode)
switch {
case tc.IsTempDirPath:
require.Equal(t, tempPath, sink.path)
default:
require.Equal(t, tc.ExpectedPath, sink.path)
}
}
})
}
}
// TestAuditFileSink_Reopen tests that the sink reopens files as expected when requested to.
// stdout and discard paths are ignored.
// see: https://developer.hashicorp.com/vault/docs/audit/file#file_path
func TestAuditFileSink_Reopen(t *testing.T) {
tests := map[string]struct {
Path string
IsTempDirPath bool
ShouldCreateFile bool
Options []Option
IsErrorExpected bool
ExpectedErrorMessage string
ExpectedFileMode os.FileMode
}{
// Should be ignored by Reopen
"discard": {
Path: "discard",
},
// Should be ignored by Reopen
"stdout": {
Path: "stdout",
},
"permission-denied": {
Path: "/tmp/vault/test/foo.log",
IsErrorExpected: true,
ExpectedErrorMessage: "event.(AuditFileSink).open: unable to create file \"/tmp/vault/test/foo.log\": mkdir /tmp/vault/test: permission denied",
},
"happy": {
Path: "vault.log",
IsTempDirPath: true,
ExpectedFileMode: os.FileMode(defaultFileMode),
},
"filemode-existing": {
Path: "vault.log",
IsTempDirPath: true,
ShouldCreateFile: true,
Options: []Option{WithFileMode("0000")},
ExpectedFileMode: os.FileMode(defaultFileMode),
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
// If we need a real directory as a path we can use a temp dir.
// but we should keep track of it for comparison in the new sink.
var tempDir string
tempPath := tc.Path
if tc.IsTempDirPath {
tempDir = t.TempDir()
tempPath = filepath.Join(tempDir, tc.Path)
}
// If the file mode is 0 then we will need a pre-created file to stat.
// Only do this for paths that are not 'special keywords'
if tc.ShouldCreateFile && tc.Path != discard && tc.Path != stdout {
f, err := os.OpenFile(tempPath, os.O_CREATE, defaultFileMode)
require.NoError(t, err)
defer func() {
err = os.Remove(f.Name())
require.NoError(t, err)
}()
}
sink, err := NewAuditFileSink(tempPath, AuditFormatJSON, tc.Options...)
require.NoError(t, err)
require.NotNil(t, sink)
err = sink.Reopen()
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
case tempPath == discard:
require.NoError(t, err)
case tempPath == stdout:
require.NoError(t, err)
default:
require.NoError(t, err)
info, err := os.Stat(tempPath)
require.NoError(t, err)
require.NotNil(t, info)
require.Equal(t, tc.ExpectedFileMode, info.Mode())
}
})
}
}
// TestAuditFileSink_Process ensures that Process behaves as expected.
func TestAuditFileSink_Process(t *testing.T) {
tests := map[string]struct {
Path string
Format auditFormat
Data *logical.LogInput
ShouldIgnoreFormat bool
ShouldUseNilEvent bool
IsErrorExpected bool
ExpectedErrorMessage string
}{
"discard": {
Path: discard,
Format: AuditFormatJSON,
Data: &logical.LogInput{Request: &logical.Request{ID: "123"}},
IsErrorExpected: false,
},
"stdout": {
Path: stdout,
Format: AuditFormatJSON,
Data: &logical.LogInput{Request: &logical.Request{ID: "123"}},
IsErrorExpected: false,
},
"no-formatted-data": {
Path: "/dev/null",
Format: AuditFormatJSON,
Data: &logical.LogInput{Request: &logical.Request{ID: "123"}},
ShouldIgnoreFormat: true,
IsErrorExpected: true,
ExpectedErrorMessage: "event.(AuditFileSink).Process: unable to retrieve event formatted as \"json\"",
},
"nil": {
Path: "/dev/null",
Format: AuditFormatJSON,
Data: &logical.LogInput{Request: &logical.Request{ID: "123"}},
ShouldUseNilEvent: true,
IsErrorExpected: true,
ExpectedErrorMessage: "event.(AuditFileSink).Process: event is nil: invalid parameter",
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
// Setup a formatter
cfg := vaultaudit.FormatterConfig{}
ss := newStaticSalt(t)
formatter, err := NewAuditFormatterJSON(cfg, ss)
require.NoError(t, err)
require.NotNil(t, formatter)
// Setup a sink
sink, err := NewAuditFileSink(tc.Path, tc.Format)
require.NoError(t, err)
require.NotNil(t, sink)
// Generate a fake event
ctx := namespace.RootContext(nil)
event := fakeJSONAuditEvent(t, AuditRequest, tc.Data)
require.NotNil(t, event)
// Finesse the event into the correct shape.
event, err = formatter.Process(ctx, event)
require.NoError(t, err)
require.NotNil(t, event)
// Some conditional stuff 'per test' to exercise different parts of Process.
if tc.ShouldIgnoreFormat {
delete(event.Formatted, tc.Format.String())
}
if tc.ShouldUseNilEvent {
event = nil
}
// The actual exercising of the sink.
event, err = sink.Process(ctx, event)
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
require.Nil(t, event)
default:
require.NoError(t, err)
require.Nil(t, event)
}
})
}
}
// BenchmarkAuditFileSink_Process benchmarks the AuditFormatterJSON and then AuditFileSink calling Process.
// This should replicate the original benchmark testing which used to perform both of these roles together.
func BenchmarkAuditFileSink_Process(b *testing.B) {
// Base input
in := &logical.LogInput{
Auth: &logical.Auth{
ClientToken: "foo",
Accessor: "bar",
EntityID: "foobarentity",
DisplayName: "testtoken",
NoDefaultPolicy: true,
Policies: []string{"root"},
TokenType: logical.TokenTypeService,
},
Request: &logical.Request{
Operation: logical.UpdateOperation,
Path: "/foo",
Connection: &logical.Connection{
RemoteAddr: "127.0.0.1",
},
WrapInfo: &logical.RequestWrapInfo{
TTL: 60 * time.Second,
},
Headers: map[string][]string{
"foo": {"bar"},
},
},
}
ctx := namespace.RootContext(nil)
// Create the formatter node.
cfg := vaultaudit.FormatterConfig{}
ss := newStaticSalt(b)
formatter, err := NewAuditFormatterJSON(cfg, ss)
require.NoError(b, err)
require.NotNil(b, formatter)
// Create the sink node.
sink, err := NewAuditFileSink("/dev/null", AuditFormatJSON)
require.NoError(b, err)
require.NotNil(b, sink)
// Generate the event
event := fakeJSONAuditEvent(b, AuditRequest, in)
require.NotNil(b, event)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
event, err = formatter.Process(ctx, event)
if err != nil {
panic(err)
}
_, err := sink.Process(ctx, event)
if err != nil {
panic(err)
}
}
})
}

View File

@@ -1,135 +0,0 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package event
import (
"fmt"
"time"
"github.com/hashicorp/vault/sdk/logical"
)
// Audit subtypes.
const (
AuditRequest auditSubtype = "AuditRequest"
AuditResponse auditSubtype = "AuditResponse"
)
// Audit formats.
const (
AuditFormatJSON auditFormat = "json"
AuditFormatJSONx auditFormat = "jsonx"
)
// auditVersion defines the version of audit events.
const auditVersion = "v0.1"
// auditSubtype defines the type of audit event.
type auditSubtype string
// auditFormat defines types of format audit events support.
type auditFormat string
// audit is the audit event.
type audit struct {
ID string `json:"id"`
Version string `json:"version"`
Subtype auditSubtype `json:"subtype"` // the subtype of the audit event.
Timestamp time.Time `json:"timestamp"`
Data *logical.LogInput `json:"data"`
RequiredFormat auditFormat `json:"format"`
}
// newAudit should be used to create an audit event.
// auditSubtype and auditFormat are needed for audit.
// It will use the supplied options, generate an ID if required, and validate the event.
func newAudit(opt ...Option) (*audit, error) {
const op = "event.newAudit"
opts, err := getOpts(opt...)
if err != nil {
return nil, fmt.Errorf("%s: error applying options: %w", op, err)
}
if opts.withID == "" {
var err error
opts.withID, err = NewID(string(AuditType))
if err != nil {
return nil, fmt.Errorf("%s: error creating ID for event: %w", op, err)
}
}
audit := &audit{
ID: opts.withID,
Version: auditVersion,
Subtype: opts.withSubtype,
Timestamp: opts.withNow,
RequiredFormat: opts.withFormat,
}
if err := audit.validate(); err != nil {
return nil, fmt.Errorf("%s: %w", op, err)
}
return audit, nil
}
// validate attempts to ensure the event has the basic requirements of the event type configured.
func (a *audit) validate() error {
const op = "event.(audit).validate"
if a == nil {
return fmt.Errorf("%s: audit is nil: %w", op, ErrInvalidParameter)
}
if a.ID == "" {
return fmt.Errorf("%s: missing ID: %w", op, ErrInvalidParameter)
}
if a.Version != auditVersion {
return fmt.Errorf("%s: audit version unsupported: %w", op, ErrInvalidParameter)
}
if a.Timestamp.IsZero() {
return fmt.Errorf("%s: audit timestamp cannot be the zero time instant: %w", op, ErrInvalidParameter)
}
err := a.Subtype.validate()
if err != nil {
return fmt.Errorf("%s: %w", op, err)
}
err = a.RequiredFormat.validate()
if err != nil {
return fmt.Errorf("%s: %w", op, err)
}
return nil
}
// validate ensures that auditSubtype is one of the set of allowed event subtypes.
func (t auditSubtype) validate() error {
const op = "event.(auditSubtype).validate"
switch t {
case AuditRequest, AuditResponse:
return nil
default:
return fmt.Errorf("%s: '%s' is not a valid event subtype: %w", op, t, ErrInvalidParameter)
}
}
// validate ensures that auditFormat is one of the set of allowed event formats.
func (f auditFormat) validate() error {
const op = "event.(auditFormat).validate"
switch f {
case AuditFormatJSON, AuditFormatJSONx:
return nil
default:
return fmt.Errorf("%s: '%s' is not a valid format: %w", op, f, ErrInvalidParameter)
}
}
// String returns the string version of an auditFormat.
func (f auditFormat) String() string {
return string(f)
}

View File

@@ -1,104 +0,0 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package event
import (
"context"
"fmt"
vaultaudit "github.com/hashicorp/vault/audit"
"github.com/hashicorp/vault/sdk/helper/jsonutil"
"github.com/hashicorp/eventlogger"
)
var _ eventlogger.Node = (*AuditFormatterJSON)(nil)
// AuditFormatterJSON represents the formatter node which is used to handle
// formatting audit events as JSON.
type AuditFormatterJSON struct {
config vaultaudit.FormatterConfig
format auditFormat
formatter vaultaudit.Formatter
}
// NewAuditFormatterJSON should be used to create an AuditFormatterJSON.
func NewAuditFormatterJSON(config vaultaudit.FormatterConfig, salter vaultaudit.Salter) (*AuditFormatterJSON, error) {
const op = "event.NewAuditFormatterJSON"
f, err := vaultaudit.NewAuditFormatter(salter)
if err != nil {
return nil, fmt.Errorf("%s: unable to create new JSON audit formatter: %w", op, err)
}
jsonFormatter := &AuditFormatterJSON{
format: AuditFormatJSON,
config: config,
formatter: f,
}
return jsonFormatter, nil
}
// Reopen is a no-op for a formatter node.
func (_ *AuditFormatterJSON) Reopen() error {
return nil
}
// Type describes the type of this node (formatter).
func (_ *AuditFormatterJSON) Type() eventlogger.NodeType {
return eventlogger.NodeTypeFormatter
}
// Process will attempt to parse the incoming event data into a corresponding
// audit request/response entry which is serialized to JSON and stored within the event.
func (f *AuditFormatterJSON) Process(ctx context.Context, e *eventlogger.Event) (*eventlogger.Event, error) {
const op = "event.(AuditFormatterJSON).Process"
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if e == nil {
return nil, fmt.Errorf("%s: event is nil: %w", op, ErrInvalidParameter)
}
a, ok := e.Payload.(*audit)
if !ok {
return nil, fmt.Errorf("%s: cannot parse event payload: %w", op, ErrInvalidParameter)
}
var formatted []byte
switch a.Subtype {
case AuditRequest:
entry, err := f.formatter.FormatRequest(ctx, f.config, a.Data)
if err != nil {
return nil, fmt.Errorf("%s: unable to parse request from audit event: %w", op, err)
}
formatted, err = jsonutil.EncodeJSON(entry)
if err != nil {
return nil, fmt.Errorf("%s: unable to format request: %w", op, err)
}
case AuditResponse:
entry, err := f.formatter.FormatResponse(ctx, f.config, a.Data)
if err != nil {
return nil, fmt.Errorf("%s: unable to parse response from audit event: %w", op, err)
}
formatted, err = jsonutil.EncodeJSON(entry)
if err != nil {
return nil, fmt.Errorf("%s: unable to format response: %w", op, err)
}
default:
return nil, fmt.Errorf("%s: unknown audit event subtype: %q", op, a.Subtype)
}
e.FormattedAs(f.format.String(), formatted)
return e, nil
}

View File

@@ -1,242 +0,0 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package event
import (
"context"
"testing"
"time"
"github.com/hashicorp/vault/helper/namespace"
"github.com/hashicorp/vault/sdk/logical"
vaultaudit "github.com/hashicorp/vault/audit"
"github.com/hashicorp/vault/sdk/helper/salt"
"github.com/hashicorp/eventlogger"
"github.com/stretchr/testify/require"
)
// fakeJSONAuditEvent will return a new fake event containing audit data based
// on the specified auditSubtype and logical.LogInput.
func fakeJSONAuditEvent(tb testing.TB, subtype auditSubtype, input *logical.LogInput) *eventlogger.Event {
tb.Helper()
date := time.Date(2023, time.July, 11, 15, 49, 10, 0o0, time.Local)
auditEvent, err := newAudit(
WithID("123"),
WithSubtype(string(subtype)),
WithFormat(string(AuditFormatJSON)),
WithNow(date),
)
require.NoError(tb, err)
require.NotNil(tb, auditEvent)
require.Equal(tb, "123", auditEvent.ID)
require.Equal(tb, "v0.1", auditEvent.Version)
require.Equal(tb, AuditFormatJSON, auditEvent.RequiredFormat)
require.Equal(tb, subtype, auditEvent.Subtype)
require.Equal(tb, date, auditEvent.Timestamp)
auditEvent.Data = input
e := &eventlogger.Event{
Type: eventlogger.EventType(AuditType),
CreatedAt: auditEvent.Timestamp,
Formatted: make(map[string][]byte),
Payload: auditEvent,
}
return e
}
// newStaticSalt returns a new staticSalt for use in testing.
func newStaticSalt(tb testing.TB) *staticSalt {
s, err := salt.NewSalt(context.Background(), nil, nil)
require.NoError(tb, err)
return &staticSalt{salt: s}
}
// staticSalt is a struct which can be used to obtain a static salt.
// a salt must be assigned when the struct is initialized.
type staticSalt struct {
salt *salt.Salt
}
// Salt returns the static salt and no error.
func (s *staticSalt) Salt(_ context.Context) (*salt.Salt, error) {
return s.salt, nil
}
// TestNewAuditFormatterJSON ensures we can create new AuditFormatterJSONX structs.
func TestNewAuditFormatterJSON(t *testing.T) {
tests := map[string]struct {
UseStaticSalt bool
IsErrorExpected bool
ExpectedErrorMessage string
}{
"nil-salter": {
UseStaticSalt: false,
IsErrorExpected: true,
ExpectedErrorMessage: "event.NewAuditFormatterJSON: unable to create new JSON audit formatter: cannot create a new audit formatter with nil salter",
},
"static-salter": {
UseStaticSalt: true,
IsErrorExpected: false,
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
cfg := vaultaudit.FormatterConfig{}
var ss vaultaudit.Salter
if tc.UseStaticSalt {
ss = newStaticSalt(t)
}
f, err := NewAuditFormatterJSON(cfg, ss)
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
require.Nil(t, f)
default:
require.NoError(t, err)
require.NotNil(t, f)
}
})
}
}
// TestAuditFormatterJSONX_Reopen ensures that we do no get an error when calling Reopen.
func TestAuditFormatterJSON_Reopen(t *testing.T) {
ss := newStaticSalt(t)
cfg := vaultaudit.FormatterConfig{}
f, err := NewAuditFormatterJSON(cfg, ss)
require.NoError(t, err)
require.NotNil(t, f)
require.NoError(t, f.Reopen())
}
// TestAuditFormatterJSONX_Type ensures that the node is a 'formatter' type.
func TestAuditFormatterJSON_Type(t *testing.T) {
ss := newStaticSalt(t)
cfg := vaultaudit.FormatterConfig{}
f, err := NewAuditFormatterJSON(cfg, ss)
require.NoError(t, err)
require.NotNil(t, f)
require.Equal(t, eventlogger.NodeTypeFormatter, f.Type())
}
// TestAuditFormatterJSON_Process attempts to run the Process method to convert
// the logical.LogInput within an audit event to JSON (AuditRequestEntry or AuditResponseEntry).
func TestAuditFormatterJSON_Process(t *testing.T) {
tests := map[string]struct {
IsErrorExpected bool
ExpectedErrorMessage string
Subtype auditSubtype
Data *logical.LogInput
RootNamespace bool
}{
"request-no-data": {
IsErrorExpected: true,
ExpectedErrorMessage: "event.(AuditFormatterJSON).Process: unable to parse request from audit event: request to request-audit a nil request",
Subtype: AuditRequest,
Data: nil,
},
"response-no-data": {
IsErrorExpected: true,
ExpectedErrorMessage: "event.(AuditFormatterJSON).Process: unable to parse response from audit event: request to response-audit a nil request",
Subtype: AuditResponse,
Data: nil,
},
"request-basic-input": {
IsErrorExpected: true,
ExpectedErrorMessage: "event.(AuditFormatterJSON).Process: unable to parse request from audit event: request to request-audit a nil request",
Subtype: AuditRequest,
Data: &logical.LogInput{Type: "magic"},
},
"response-basic-input": {
IsErrorExpected: true,
ExpectedErrorMessage: "event.(AuditFormatterJSON).Process: unable to parse response from audit event: request to response-audit a nil request",
Subtype: AuditResponse,
Data: &logical.LogInput{Type: "magic"},
},
"request-basic-input-and-request-no-ns": {
IsErrorExpected: true,
ExpectedErrorMessage: "event.(AuditFormatterJSON).Process: unable to parse request from audit event: no namespace",
Subtype: AuditRequest,
Data: &logical.LogInput{Request: &logical.Request{ID: "123"}},
},
"response-basic-input-and-request-no-ns": {
IsErrorExpected: true,
ExpectedErrorMessage: "event.(AuditFormatterJSON).Process: unable to parse response from audit event: no namespace",
Subtype: AuditResponse,
Data: &logical.LogInput{Request: &logical.Request{ID: "123"}},
},
"request-basic-input-and-request-with-ns": {
IsErrorExpected: false,
Subtype: AuditRequest,
Data: &logical.LogInput{Request: &logical.Request{ID: "123"}},
RootNamespace: true,
},
"response-basic-input-and-request-with-ns": {
IsErrorExpected: false,
Subtype: AuditResponse,
Data: &logical.LogInput{Request: &logical.Request{ID: "123"}},
RootNamespace: true,
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
e := fakeJSONAuditEvent(t, tc.Subtype, tc.Data)
require.NotNil(t, e)
ss := newStaticSalt(t)
cfg := vaultaudit.FormatterConfig{}
f, err := NewAuditFormatterJSON(cfg, ss)
require.NoError(t, err)
require.NotNil(t, f)
var ctx context.Context
switch {
case tc.RootNamespace:
ctx = namespace.RootContext(context.Background())
default:
ctx = context.Background()
}
processed, err := f.Process(ctx, e)
b, found := e.Format(string(AuditFormatJSON))
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
require.Nil(t, processed)
require.False(t, found)
require.Nil(t, b)
default:
require.NoError(t, err)
require.NotNil(t, processed)
require.True(t, found)
require.NotNil(t, b)
}
})
}
}

View File

@@ -1,75 +0,0 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package event
import (
"context"
"fmt"
"github.com/hashicorp/eventlogger"
"github.com/jefferai/jsonx"
)
var _ eventlogger.Node = (*AuditFormatterJSONx)(nil)
// AuditFormatterJSONx represents a formatter node which will Process JSON to JSONx format.
type AuditFormatterJSONx struct {
format auditFormat
}
// NewAuditFormatterJSONx creates a formatter node which can be used to format
// incoming events to JSONx.
// This formatter node requires that a AuditFormatterJSON node exists earlier
// in the pipeline and will attempt to access the JSON encoded data stored by that
// formatter node.
func NewAuditFormatterJSONx() *AuditFormatterJSONx {
return &AuditFormatterJSONx{format: AuditFormatJSONx}
}
// Reopen is a no-op for this formatter node.
func (_ *AuditFormatterJSONx) Reopen() error {
return nil
}
// Type describes the type of this node.
func (_ *AuditFormatterJSONx) Type() eventlogger.NodeType {
return eventlogger.NodeTypeFormatter
}
// Process will attempt to retrieve pre-formatted JSON stored within the event
// and re-encode the data to JSONx.
func (f *AuditFormatterJSONx) Process(ctx context.Context, e *eventlogger.Event) (*eventlogger.Event, error) {
const op = "event.(AuditFormatterJSONx).Process"
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if e == nil {
return nil, fmt.Errorf("%s: event is nil: %w", op, ErrInvalidParameter)
}
// We expect that JSON has already been parsed for this event.
jsonBytes, ok := e.Format(AuditFormatJSON.String())
if !ok {
return nil, fmt.Errorf("%s: pre-formatted JSON required but not found: %w", op, ErrInvalidParameter)
}
if jsonBytes == nil {
return nil, fmt.Errorf("%s: pre-formatted JSON required but was nil: %w", op, ErrInvalidParameter)
}
xmlBytes, err := jsonx.EncodeJSONBytes(jsonBytes)
if err != nil {
return nil, fmt.Errorf("%s: unable to encode JSONx using JSON data: %w", op, err)
}
if xmlBytes == nil {
return nil, fmt.Errorf("%s: encoded JSONx was nil: %w", op, err)
}
e.FormattedAs(f.format.String(), xmlBytes)
return e, nil
}

View File

@@ -1,138 +0,0 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package event
import (
"context"
"testing"
"time"
"github.com/hashicorp/vault/sdk/logical"
"github.com/hashicorp/vault/sdk/helper/jsonutil"
"github.com/hashicorp/eventlogger"
"github.com/stretchr/testify/require"
)
// fakeJSONxAuditEvent will return a new fake event containing audit data based
// on the specified auditSubtype and logical.LogInput.
func fakeJSONxAuditEvent(t *testing.T, subtype auditSubtype, input *logical.LogInput) *eventlogger.Event {
t.Helper()
date := time.Date(2023, time.July, 11, 15, 49, 10, 0, time.Local)
auditEvent, err := newAudit(
WithID("123"),
WithSubtype(string(subtype)),
WithFormat(string(AuditFormatJSONx)),
WithNow(date),
)
require.NoError(t, err)
require.NotNil(t, auditEvent)
require.Equal(t, "123", auditEvent.ID)
require.Equal(t, "v0.1", auditEvent.Version)
require.Equal(t, AuditFormatJSONx, auditEvent.RequiredFormat)
require.Equal(t, subtype, auditEvent.Subtype)
require.Equal(t, date, auditEvent.Timestamp)
auditEvent.Data = input
e := &eventlogger.Event{
Type: eventlogger.EventType(AuditType),
CreatedAt: auditEvent.Timestamp,
Formatted: make(map[string][]byte),
Payload: auditEvent,
}
return e
}
// TestNewAuditFormatterJSONx ensures we can create new AuditFormatterJSONx structs.
func TestNewAuditFormatterJSONx(t *testing.T) {
f := NewAuditFormatterJSONx()
require.NotNil(t, f)
}
// TestAuditFormatterJSONx_Reopen ensures that we do no get an error when calling Reopen.
func TestAuditFormatterJSONx_Reopen(t *testing.T) {
require.NoError(t, NewAuditFormatterJSONx().Reopen())
}
// TestAuditFormatterJSONx_Type ensures that the node is a 'formatter' type.
func TestAuditFormatterJSONx_Type(t *testing.T) {
require.Equal(t, eventlogger.NodeTypeFormatter, NewAuditFormatterJSONx().Type())
}
// TestAuditFormatterJSONx_Process attempts to run the Process method to convert
// pre-formatted JSON to XML (JSONx).
func TestAuditFormatterJSONx_Process(t *testing.T) {
tests := map[string]struct {
IsErrorExpected bool
ExpectedErrorMessage string
Subtype auditSubtype
Data *logical.LogInput
}{
"request-no-formatted-json": {
IsErrorExpected: true,
ExpectedErrorMessage: "event.(AuditFormatterJSONx).Process: pre-formatted JSON required but not found: invalid parameter",
Subtype: AuditRequest,
Data: nil,
},
"response-no-formatted-json": {
IsErrorExpected: true,
ExpectedErrorMessage: "event.(AuditFormatterJSONx).Process: pre-formatted JSON required but not found: invalid parameter",
Subtype: AuditResponse,
Data: nil,
},
"request-basic-json": {
IsErrorExpected: false,
Subtype: AuditRequest,
Data: &logical.LogInput{Type: "magic"},
},
"response-basic-json": {
IsErrorExpected: false,
Subtype: AuditResponse,
Data: &logical.LogInput{Type: "magic"},
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
e := fakeJSONxAuditEvent(t, tc.Subtype, tc.Data)
require.NotNil(t, e)
// If we have data specified, then encode it and store as a format.
// This is faking the behavior of the JSON formatter node which is a
// pre-req for JSONx formatter node.
if tc.Data != nil {
jsonBytes, err := jsonutil.EncodeJSON(tc.Data)
require.NoError(t, err)
require.NotNil(t, jsonBytes)
e.FormattedAs(string(AuditFormatJSON), jsonBytes)
}
processed, err := NewAuditFormatterJSONx().Process(context.Background(), e)
b, found := e.Format(string(AuditFormatJSONx))
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
require.Nil(t, processed)
require.False(t, found)
require.Nil(t, b)
default:
require.NoError(t, err)
require.NotNil(t, processed)
require.True(t, found)
require.NotNil(t, b)
}
})
}
}

View File

@@ -19,21 +19,18 @@ import (
// Option is how Options are passed as arguments. // Option is how Options are passed as arguments.
type Option func(*options) error type Option func(*options) error
// options are used to represent configuration for an Event. // Options are used to represent configuration for an Event.
type options struct { type options struct {
withID string withID string
withNow time.Time withNow time.Time
withSubtype auditSubtype
withFormat auditFormat
withFileMode *os.FileMode
withPrefix string
withFacility string withFacility string
withTag string withTag string
withSocketType string withSocketType string
withMaxDuration time.Duration withMaxDuration time.Duration
withFileMode *os.FileMode
} }
// getDefaultOptions returns options with their default values. // getDefaultOptions returns Options with their default values.
func getDefaultOptions() options { func getDefaultOptions() options {
return options{ return options{
withNow: time.Now(), withNow: time.Now(),
@@ -44,7 +41,7 @@ func getDefaultOptions() options {
} }
} }
// getOpts applies all the supplied Option and returns configured options. // getOpts applies all the supplied Option and returns configured Options.
// Each Option is applied in the order it appears in the argument list, so it is // Each Option is applied in the order it appears in the argument list, so it is
// possible to supply the same Option numerous times and the 'last write wins'. // possible to supply the same Option numerous times and the 'last write wins'.
func getOpts(opt ...Option) (options, error) { func getOpts(opt ...Option) (options, error) {
@@ -110,51 +107,72 @@ func WithNow(now time.Time) Option {
} }
} }
// WithSubtype provides an option to represent the subtype. // WithFacility provides an Option to represent a 'facility' for a syslog sink.
func WithSubtype(subtype string) Option { func WithFacility(facility string) Option {
return func(o *options) error { return func(o *options) error {
s := strings.TrimSpace(subtype) facility = strings.TrimSpace(facility)
if s == "" {
return errors.New("subtype cannot be empty") if facility != "" {
o.withFacility = facility
} }
parsed := auditSubtype(s)
err := parsed.validate()
if err != nil {
return err
}
o.withSubtype = parsed
return nil return nil
} }
} }
// WithFormat provides an option to represent event format. // WithTag provides an Option to represent a 'tag' for a syslog sink.
func WithFormat(format string) Option { func WithTag(tag string) Option {
return func(o *options) error { return func(o *options) error {
f := strings.TrimSpace(format) tag = strings.TrimSpace(tag)
if f == "" {
return errors.New("format cannot be empty") if tag != "" {
o.withTag = tag
} }
parsed := auditFormat(f)
err := parsed.validate()
if err != nil {
return err
}
o.withFormat = parsed
return nil return nil
} }
} }
// WithFileMode provides an option to represent a file mode for a file sink. // WithSocketType provides an Option to represent the socket type for a socket sink.
// Supplying an empty string or whitespace will prevent this option from being func WithSocketType(socketType string) Option {
return func(o *options) error {
socketType = strings.TrimSpace(socketType)
if socketType != "" {
o.withSocketType = socketType
}
return nil
}
}
// WithMaxDuration provides an Option to represent the max duration for writing to a socket.
func WithMaxDuration(duration string) Option {
return func(o *options) error {
duration = strings.TrimSpace(duration)
if duration == "" {
return nil
}
parsed, err := parseutil.ParseDurationSecond(duration)
if err != nil {
return err
}
o.withMaxDuration = parsed
return nil
}
}
// WithFileMode provides an Option to represent a file mode for a file sink.
// Supplying an empty string or whitespace will prevent this Option from being
// applied, but it will not return an error in those circumstances. // applied, but it will not return an error in those circumstances.
func WithFileMode(mode string) Option { func WithFileMode(mode string) Option {
return func(o *options) error { return func(o *options) error {
// If supplied file mode is empty, just return early without setting anything. // If supplied file mode is empty, just return early without setting anything.
// We can assume that this option was called by something that didn't // We can assume that this Option was called by something that didn't
// parse the incoming value, perhaps from a config map etc. // parse the incoming value, perhaps from a config map etc.
mode = strings.TrimSpace(mode) mode = strings.TrimSpace(mode)
if mode == "" { if mode == "" {
@@ -176,70 +194,3 @@ func WithFileMode(mode string) Option {
return nil return nil
} }
} }
// WithPrefix provides an option to represent a prefix for a file sink.
func WithPrefix(prefix string) Option {
return func(o *options) error {
o.withPrefix = prefix
return nil
}
}
// WithFacility provides an option to represent a 'facility' for a syslog sink.
func WithFacility(facility string) Option {
return func(o *options) error {
facility = strings.TrimSpace(facility)
if facility != "" {
o.withFacility = facility
}
return nil
}
}
// WithTag provides an option to represent a 'tag' for a syslog sink.
func WithTag(tag string) Option {
return func(o *options) error {
tag = strings.TrimSpace(tag)
if tag != "" {
o.withTag = tag
}
return nil
}
}
// WithSocketType provides an option to represent the socket type for a socket sink.
func WithSocketType(socketType string) Option {
return func(o *options) error {
socketType = strings.TrimSpace(socketType)
if socketType != "" {
o.withSocketType = socketType
}
return nil
}
}
// WithMaxDuration provides an option to represent the max duration for writing to a socket sink.
func WithMaxDuration(duration string) Option {
return func(o *options) error {
duration = strings.TrimSpace(duration)
if duration == "" {
return nil
}
parsed, err := parseutil.ParseDurationSecond(duration)
if err != nil {
return err
}
o.withMaxDuration = parsed
return nil
}
}

View File

@@ -11,106 +11,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// TestOptions_WithFormat exercises WithFormat option to ensure it performs as expected.
func TestOptions_WithFormat(t *testing.T) {
tests := map[string]struct {
Value string
IsErrorExpected bool
ExpectedErrorMessage string
ExpectedValue auditFormat
}{
"empty": {
Value: "",
IsErrorExpected: true,
ExpectedErrorMessage: "format cannot be empty",
},
"whitespace": {
Value: " ",
IsErrorExpected: true,
ExpectedErrorMessage: "format cannot be empty",
},
"invalid-test": {
Value: "test",
IsErrorExpected: true,
ExpectedErrorMessage: "event.(auditFormat).validate: 'test' is not a valid format: invalid parameter",
},
"valid-json": {
Value: "json",
IsErrorExpected: false,
ExpectedValue: AuditFormatJSON,
},
"valid-jsonx": {
Value: "jsonx",
IsErrorExpected: false,
ExpectedValue: AuditFormatJSONx,
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
options := &options{}
applyOption := WithFormat(tc.Value)
err := applyOption(options)
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
default:
require.NoError(t, err)
require.Equal(t, tc.ExpectedValue, options.withFormat)
}
})
}
}
// TestOptions_WithSubtype exercises WithSubtype option to ensure it performs as expected.
func TestOptions_WithSubtype(t *testing.T) {
tests := map[string]struct {
Value string
IsErrorExpected bool
ExpectedErrorMessage string
ExpectedValue auditSubtype
}{
"empty": {
Value: "",
IsErrorExpected: true,
ExpectedErrorMessage: "subtype cannot be empty",
},
"whitespace": {
Value: " ",
IsErrorExpected: true,
ExpectedErrorMessage: "subtype cannot be empty",
},
"valid": {
Value: "AuditResponse",
IsErrorExpected: false,
ExpectedValue: AuditResponse,
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
options := &options{}
applyOption := WithSubtype(tc.Value)
err := applyOption(options)
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
default:
require.NoError(t, err)
require.Equal(t, tc.ExpectedValue, options.withSubtype)
}
})
}
}
// TestOptions_WithNow exercises WithNow option to ensure it performs as expected. // TestOptions_WithNow exercises WithNow option to ensure it performs as expected.
func TestOptions_WithNow(t *testing.T) { func TestOptions_WithNow(t *testing.T) {
tests := map[string]struct { tests := map[string]struct {
@@ -137,16 +37,16 @@ func TestOptions_WithNow(t *testing.T) {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel() t.Parallel()
options := &options{} opts := &options{}
applyOption := WithNow(tc.Value) applyOption := WithNow(tc.Value)
err := applyOption(options) err := applyOption(opts)
switch { switch {
case tc.IsErrorExpected: case tc.IsErrorExpected:
require.Error(t, err) require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage) require.EqualError(t, err, tc.ExpectedErrorMessage)
default: default:
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, tc.ExpectedValue, options.withNow) require.Equal(t, tc.ExpectedValue, opts.withNow)
} }
}) })
} }
@@ -197,7 +97,103 @@ func TestOptions_WithID(t *testing.T) {
} }
} }
// TestOptions_WithFacility exercises WithFacility option to ensure it performs as expected. // TestOptions_Default exercises getDefaultOptions to assert the default values.
func TestOptions_Default(t *testing.T) {
opts := getDefaultOptions()
require.NotNil(t, opts)
require.True(t, time.Now().After(opts.withNow))
require.False(t, opts.withNow.IsZero())
require.Equal(t, "AUTH", opts.withFacility)
require.Equal(t, "vault", opts.withTag)
require.Equal(t, 2*time.Second, opts.withMaxDuration)
}
// TestOptions_Opts exercises getOpts with various Option values.
func TestOptions_Opts(t *testing.T) {
tests := map[string]struct {
opts []Option
IsErrorExpected bool
ExpectedErrorMessage string
ExpectedID string
IsNowExpected bool
ExpectedNow time.Time
}{
"nil-options": {
opts: nil,
IsErrorExpected: false,
IsNowExpected: true,
},
"empty-options": {
opts: []Option{},
IsErrorExpected: false,
IsNowExpected: true,
},
"with-multiple-valid-id": {
opts: []Option{
WithID("qwerty"),
WithID("juan"),
},
IsErrorExpected: false,
ExpectedID: "juan",
IsNowExpected: true,
},
"with-multiple-valid-now": {
opts: []Option{
WithNow(time.Date(2023, time.July, 4, 12, 3, 0, 0, time.Local)),
WithNow(time.Date(2023, time.July, 4, 13, 3, 0, 0, time.Local)),
},
IsErrorExpected: false,
ExpectedNow: time.Date(2023, time.July, 4, 13, 3, 0, 0, time.Local),
IsNowExpected: false,
},
"with-multiple-valid-then-invalid-now": {
opts: []Option{
WithNow(time.Date(2023, time.July, 4, 12, 3, 0, 0, time.Local)),
WithNow(time.Time{}),
},
IsErrorExpected: true,
ExpectedErrorMessage: "cannot specify 'now' to be the zero time instant",
},
"with-multiple-valid-options": {
opts: []Option{
WithID("qwerty"),
WithNow(time.Date(2023, time.July, 4, 12, 3, 0, 0, time.Local)),
},
IsErrorExpected: false,
ExpectedID: "qwerty",
ExpectedNow: time.Date(2023, time.July, 4, 12, 3, 0, 0, time.Local),
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
opts, err := getOpts(tc.opts...)
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
default:
require.NotNil(t, opts)
require.NoError(t, err)
require.Equal(t, tc.ExpectedID, opts.withID)
switch {
case tc.IsNowExpected:
require.True(t, time.Now().After(opts.withNow))
require.False(t, opts.withNow.IsZero())
default:
require.Equal(t, tc.ExpectedNow, opts.withNow)
}
}
})
}
}
// TestOptions_WithFacility exercises WithFacility Option to ensure it performs as expected.
func TestOptions_WithFacility(t *testing.T) { func TestOptions_WithFacility(t *testing.T) {
tests := map[string]struct { tests := map[string]struct {
Value string Value string
@@ -235,7 +231,7 @@ func TestOptions_WithFacility(t *testing.T) {
} }
} }
// TestOptions_WithTag exercises WithTag option to ensure it performs as expected. // TestOptions_WithTag exercises WithTag Option to ensure it performs as expected.
func TestOptions_WithTag(t *testing.T) { func TestOptions_WithTag(t *testing.T) {
tests := map[string]struct { tests := map[string]struct {
Value string Value string
@@ -273,7 +269,7 @@ func TestOptions_WithTag(t *testing.T) {
} }
} }
// TestOptions_WithSocketType exercises WithSocketType option to ensure it performs as expected. // TestOptions_WithSocketType exercises WithSocketType Option to ensure it performs as expected.
func TestOptions_WithSocketType(t *testing.T) { func TestOptions_WithSocketType(t *testing.T) {
tests := map[string]struct { tests := map[string]struct {
Value string Value string
@@ -311,7 +307,7 @@ func TestOptions_WithSocketType(t *testing.T) {
} }
} }
// TestOptions_WithMaxDuration exercises WithMaxDuration option to ensure it performs as expected. // TestOptions_WithMaxDuration exercises WithMaxDuration Option to ensure it performs as expected.
func TestOptions_WithMaxDuration(t *testing.T) { func TestOptions_WithMaxDuration(t *testing.T) {
tests := map[string]struct { tests := map[string]struct {
Value string Value string
@@ -365,7 +361,7 @@ func TestOptions_WithMaxDuration(t *testing.T) {
} }
} }
// TestOptions_WithFileMode exercises WithFileMode option to ensure it performs as expected. // TestOptions_WithFileMode exercises WithFileMode Option to ensure it performs as expected.
func TestOptions_WithFileMode(t *testing.T) { func TestOptions_WithFileMode(t *testing.T) {
tests := map[string]struct { tests := map[string]struct {
Value string Value string
@@ -417,7 +413,7 @@ func TestOptions_WithFileMode(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
switch { switch {
case tc.IsNilExpected: case tc.IsNilExpected:
// Optional option 'not supplied' (i.e. was whitespace/empty string) // Optional Option 'not supplied' (i.e. was whitespace/empty string)
require.Nil(t, options.withFileMode) require.Nil(t, options.withFileMode)
default: default:
// Dereference the pointer, so we can examine the file mode. // Dereference the pointer, so we can examine the file mode.
@@ -427,125 +423,3 @@ func TestOptions_WithFileMode(t *testing.T) {
}) })
} }
} }
// TestOptions_Default exercises getDefaultOptions to assert the default values.
func TestOptions_Default(t *testing.T) {
opts := getDefaultOptions()
require.NotNil(t, opts)
require.True(t, time.Now().After(opts.withNow))
require.False(t, opts.withNow.IsZero())
require.Equal(t, "AUTH", opts.withFacility)
require.Equal(t, "vault", opts.withTag)
require.Equal(t, 2*time.Second, opts.withMaxDuration)
}
// TestOptions_Opts exercises getOpts with various Option values.
func TestOptions_Opts(t *testing.T) {
tests := map[string]struct {
opts []Option
IsErrorExpected bool
ExpectedErrorMessage string
ExpectedID string
ExpectedSubtype auditSubtype
ExpectedFormat auditFormat
IsNowExpected bool
ExpectedNow time.Time
}{
"nil-options": {
opts: nil,
IsErrorExpected: false,
IsNowExpected: true,
},
"empty-options": {
opts: []Option{},
IsErrorExpected: false,
IsNowExpected: true,
},
"with-multiple-valid-id": {
opts: []Option{
WithID("qwerty"),
WithID("juan"),
},
IsErrorExpected: false,
ExpectedID: "juan",
IsNowExpected: true,
},
"with-multiple-valid-subtype": {
opts: []Option{
WithSubtype("AuditRequest"),
WithSubtype("AuditResponse"),
},
IsErrorExpected: false,
ExpectedSubtype: AuditResponse,
IsNowExpected: true,
},
"with-multiple-valid-format": {
opts: []Option{
WithFormat("json"),
WithFormat("jsonx"),
},
IsErrorExpected: false,
ExpectedFormat: AuditFormatJSONx,
IsNowExpected: true,
},
"with-multiple-valid-now": {
opts: []Option{
WithNow(time.Date(2023, time.July, 4, 12, 3, 0, 0, time.Local)),
WithNow(time.Date(2023, time.July, 4, 13, 3, 0, 0, time.Local)),
},
IsErrorExpected: false,
ExpectedNow: time.Date(2023, time.July, 4, 13, 3, 0, 0, time.Local),
IsNowExpected: false,
},
"with-multiple-valid-then-invalid-now": {
opts: []Option{
WithNow(time.Date(2023, time.July, 4, 12, 3, 0, 0, time.Local)),
WithNow(time.Time{}),
},
IsErrorExpected: true,
ExpectedErrorMessage: "cannot specify 'now' to be the zero time instant",
},
"with-multiple-valid-options": {
opts: []Option{
WithID("qwerty"),
WithSubtype("AuditRequest"),
WithFormat("json"),
WithNow(time.Date(2023, time.July, 4, 12, 3, 0, 0, time.Local)),
},
IsErrorExpected: false,
ExpectedID: "qwerty",
ExpectedSubtype: AuditRequest,
ExpectedFormat: AuditFormatJSON,
ExpectedNow: time.Date(2023, time.July, 4, 12, 3, 0, 0, time.Local),
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
opts, err := getOpts(tc.opts...)
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
default:
require.NotNil(t, opts)
require.NoError(t, err)
require.Equal(t, tc.ExpectedID, opts.withID)
require.Equal(t, tc.ExpectedSubtype, opts.withSubtype)
require.Equal(t, tc.ExpectedFormat, opts.withFormat)
switch {
case tc.IsNowExpected:
require.True(t, time.Now().After(opts.withNow))
require.False(t, opts.withNow.IsZero())
default:
require.Equal(t, tc.ExpectedNow, opts.withNow)
}
}
})
}
}

View File

@@ -0,0 +1,226 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package event
import (
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"github.com/hashicorp/eventlogger"
)
// defaultFileMode is the default file permissions (read/write for everyone).
const (
defaultFileMode = 0o600
devnull = "/dev/null"
)
// FileSink is a sink node which handles writing events to file.
type FileSink struct {
file *os.File
fileLock sync.RWMutex
fileMode os.FileMode
path string
requiredFormat string
}
// NewFileSink should be used to create a new FileSink.
// Accepted options: WithFileMode.
func NewFileSink(path string, format string, opt ...Option) (*FileSink, error) {
const op = "event.NewFileSink"
// Parse and check path
p := strings.TrimSpace(path)
if p == "" {
return nil, fmt.Errorf("%s: path is required", op)
}
opts, err := getOpts(opt...)
if err != nil {
return nil, fmt.Errorf("%s: error applying options: %w", op, err)
}
mode := os.FileMode(defaultFileMode)
// If we got an optional file mode supplied and our path isn't a special keyword
// then we should use the supplied file mode, or maintain the existing file mode.
switch {
case path == devnull:
case opts.withFileMode == nil:
case *opts.withFileMode == 0: // Maintain the existing file's mode when set to "0000".
fileInfo, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("%s: unable to determine existing file mode: %w", op, err)
}
mode = fileInfo.Mode()
default:
mode = *opts.withFileMode
}
sink := &FileSink{
file: nil,
fileLock: sync.RWMutex{},
fileMode: mode,
requiredFormat: format,
path: p,
}
// Ensure that the file can be successfully opened for writing;
// otherwise it will be too late to catch later without problems
// (ref: https://github.com/hashicorp/vault/issues/550)
if err := sink.open(); err != nil {
return nil, fmt.Errorf("%s: sanity check failed; unable to open %q for writing: %w", op, path, err)
}
return sink, nil
}
// Process handles writing the event to the file sink.
func (f *FileSink) Process(ctx context.Context, e *eventlogger.Event) (*eventlogger.Event, error) {
const op = "event.(FileSink).Process"
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if e == nil {
return nil, fmt.Errorf("%s: event is nil: %w", op, ErrInvalidParameter)
}
// '/dev/null' path means we just do nothing and pretend we're done.
if f.path == devnull {
return nil, nil
}
formatted, found := e.Format(f.requiredFormat)
if !found {
return nil, fmt.Errorf("%s: unable to retrieve event formatted as %q", op, f.requiredFormat)
}
err := f.log(formatted)
if err != nil {
return nil, fmt.Errorf("%s: error writing file for sink: %w", op, err)
}
// return nil for the event to indicate the pipeline is complete.
return nil, nil
}
// Reopen handles closing and reopening the file.
func (f *FileSink) Reopen() error {
const op = "event.(FileSink).Reopen"
// '/dev/null' path means we just do nothing and pretend we're done.
if f.path == devnull {
return nil
}
f.fileLock.Lock()
defer f.fileLock.Unlock()
if f.file == nil {
return f.open()
}
err := f.file.Close()
// Set to nil here so that even if we error out, on the next access open() will be tried.
f.file = nil
if err != nil {
return fmt.Errorf("%s: unable to close file for re-opening on sink: %w", op, err)
}
return f.open()
}
// Type describes the type of this node (sink).
func (_ *FileSink) Type() eventlogger.NodeType {
return eventlogger.NodeTypeSink
}
// open attempts to open a file at the sink's path, with the sink's fileMode permissions
// if one is not already open.
// It doesn't have any locking and relies on calling functions of FileSink to
// handle this (e.g. log and Reopen methods).
func (f *FileSink) open() error {
const op = "event.(FileSink).open"
if f.file != nil {
return nil
}
if err := os.MkdirAll(filepath.Dir(f.path), f.fileMode); err != nil {
return fmt.Errorf("%s: unable to create file %q: %w", op, f.path, err)
}
var err error
f.file, err = os.OpenFile(f.path, os.O_APPEND|os.O_WRONLY|os.O_CREATE, f.fileMode)
if err != nil {
return fmt.Errorf("%s: unable to open file for sink: %w", op, err)
}
// Change the file mode in case the log file already existed.
// We special case '/dev/null' since we can't chmod it, and bypass if the mode is zero.
switch f.path {
case devnull:
default:
if f.fileMode != 0 {
err = os.Chmod(f.path, f.fileMode)
if err != nil {
return fmt.Errorf("%s: unable to change file %q permissions '%v' for sink: %w", op, f.path, f.fileMode, err)
}
}
}
return nil
}
// log writes the buffer to the file.
// It acquires a lock on the file to do this.
func (f *FileSink) log(data []byte) error {
const op = "event.(FileSink).log"
f.fileLock.Lock()
defer f.fileLock.Unlock()
reader := bytes.NewReader(data)
if err := f.open(); err != nil {
return fmt.Errorf("%s: unable to open file for sink: %w", op, err)
}
if _, err := reader.WriteTo(f.file); err == nil {
return nil
}
// Otherwise, opportunistically try to re-open the FD, once per call (1 retry attempt).
err := f.file.Close()
if err != nil {
return fmt.Errorf("%s: unable to close file for sink: %w", op, err)
}
f.file = nil
if err := f.open(); err != nil {
return fmt.Errorf("%s: unable to re-open file for sink: %w", op, err)
}
_, err = reader.Seek(0, io.SeekStart)
if err != nil {
return fmt.Errorf("%s: unable to seek to start of file for sink: %w", op, err)
}
_, err = reader.WriteTo(f.file)
if err != nil {
return fmt.Errorf("%s: unable to re-write to file for sink: %w", op, err)
}
return nil
}

View File

@@ -0,0 +1,301 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package event
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/hashicorp/vault/helper/namespace"
"github.com/hashicorp/eventlogger"
"github.com/stretchr/testify/require"
)
// TestFileSink_Type ensures that the node is a 'sink' type.
func TestFileSink_Type(t *testing.T) {
f, err := NewFileSink(filepath.Join(t.TempDir(), "vault.log"), "json")
require.NoError(t, err)
require.NotNil(t, f)
require.Equal(t, eventlogger.NodeTypeSink, f.Type())
}
// TestNewFileSink tests creation of an AuditFileSink.
func TestNewFileSink(t *testing.T) {
tests := map[string]struct {
ShouldUseAbsolutePath bool // Path should contain the filename if temp dir is true
Path string
Format string
Options []Option
IsErrorExpected bool
ExpectedErrorMessage string
// Expected values of AuditFileSink
ExpectedFileMode os.FileMode
ExpectedFormat string
ExpectedPath string
ExpectedPrefix string
}{
"default-values": {
ShouldUseAbsolutePath: true,
IsErrorExpected: true,
ExpectedErrorMessage: "event.NewFileSink: path is required",
},
"spacey-path": {
ShouldUseAbsolutePath: true,
Path: " ",
Format: "json",
IsErrorExpected: true,
ExpectedErrorMessage: "event.NewFileSink: path is required",
},
"valid-path-and-format": {
Path: "vault.log",
Format: "json",
IsErrorExpected: false,
ExpectedFileMode: defaultFileMode,
ExpectedFormat: "json",
ExpectedPrefix: "",
},
"file-mode-not-default-or-zero": {
Path: "vault.log",
Format: "json",
Options: []Option{WithFileMode("0007")},
IsErrorExpected: false,
ExpectedFormat: "json",
ExpectedPrefix: "",
ExpectedFileMode: 0o007,
},
"prefix": {
Path: "vault.log",
Format: "json",
Options: []Option{WithFileMode("0007")},
IsErrorExpected: false,
ExpectedPrefix: "bleep",
ExpectedFormat: "json",
ExpectedFileMode: 0o007,
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
// t.Parallel()
// If we need a real directory as a path we can use a temp dir.
// but we should keep track of it for comparison in the new sink.
var tempDir string
tempPath := tc.Path
if !tc.ShouldUseAbsolutePath {
tempDir = t.TempDir()
tempPath = filepath.Join(tempDir, tempPath)
}
sink, err := NewFileSink(tempPath, tc.Format, tc.Options...)
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
require.Nil(t, sink)
default:
require.NoError(t, err)
require.NotNil(t, sink)
// Assert properties are correct.
require.Equal(t, tc.ExpectedFormat, sink.requiredFormat)
require.Equal(t, tc.ExpectedFileMode, sink.fileMode)
switch {
case tc.ShouldUseAbsolutePath:
require.Equal(t, tc.ExpectedPath, sink.path)
default:
require.Equal(t, tempPath, sink.path)
}
}
})
}
}
// TestFileSink_Reopen tests that the sink reopens files as expected when requested to.
// stdout and discard paths are ignored.
// see: https://developer.hashicorp.com/vault/docs/audit/file#file_path
func TestFileSink_Reopen(t *testing.T) {
tests := map[string]struct {
Path string
ShouldUseAbsolutePath bool
ShouldCreateFile bool
ShouldIgnoreFileMode bool
Options []Option
IsErrorExpected bool
ExpectedErrorMessage string
ExpectedFileMode os.FileMode
}{
// Should be ignored by Reopen
"devnull": {
Path: "/dev/null",
ShouldUseAbsolutePath: true,
ShouldIgnoreFileMode: true,
},
"happy": {
Path: "vault.log",
ExpectedFileMode: os.FileMode(defaultFileMode),
},
"filemode-existing": {
Path: "vault.log",
ShouldCreateFile: true,
Options: []Option{WithFileMode("0000")},
ExpectedFileMode: os.FileMode(defaultFileMode),
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
// If we need a real directory as a path we can use a temp dir.
// but we should keep track of it for comparison in the new sink.
var tempDir string
tempPath := tc.Path
if !tc.ShouldUseAbsolutePath {
tempDir = t.TempDir()
tempPath = filepath.Join(tempDir, tc.Path)
}
// If the file mode is 0 then we will need a pre-created file to stat.
// Only do this for paths that are not 'special keywords'
if tc.ShouldCreateFile && tc.Path != devnull {
f, err := os.OpenFile(tempPath, os.O_CREATE, defaultFileMode)
require.NoError(t, err)
defer func() {
err = os.Remove(f.Name())
require.NoError(t, err)
}()
}
sink, err := NewFileSink(tempPath, "json", tc.Options...)
require.NoError(t, err)
require.NotNil(t, sink)
err = sink.Reopen()
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
default:
require.NoError(t, err)
info, err := os.Stat(tempPath)
require.NoError(t, err)
require.NotNil(t, info)
if !tc.ShouldIgnoreFileMode {
require.Equal(t, tc.ExpectedFileMode, info.Mode())
}
}
})
}
}
// TestFileSink_Process ensures that Process behaves as expected.
func TestFileSink_Process(t *testing.T) {
tests := map[string]struct {
ShouldUseAbsolutePath bool
Path string
ShouldCreateFile bool
Format string
ShouldIgnoreFormat bool
Data string
ShouldUseNilEvent bool
IsErrorExpected bool
ExpectedErrorMessage string
}{
"devnull": {
ShouldUseAbsolutePath: true,
Path: devnull,
Format: "json",
Data: "foo",
IsErrorExpected: false,
},
"no-formatted-data": {
ShouldCreateFile: true,
Path: "juan.log",
Format: "json",
Data: "foo",
ShouldIgnoreFormat: true,
IsErrorExpected: true,
ExpectedErrorMessage: "event.(FileSink).Process: unable to retrieve event formatted as \"json\"",
},
"nil": {
Path: "foo.log",
Format: "json",
Data: "foo",
ShouldUseNilEvent: true,
IsErrorExpected: true,
ExpectedErrorMessage: "event.(FileSink).Process: event is nil: invalid parameter",
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
// Temp dir for most testing unless we're trying to test an error
var tempDir string
tempPath := tc.Path
if !tc.ShouldUseAbsolutePath {
tempDir = t.TempDir()
tempPath = filepath.Join(tempDir, tc.Path)
}
// Create a file if we will need it there before Process kicks off.
if tc.ShouldCreateFile && tc.Path != devnull {
f, err := os.OpenFile(tempPath, os.O_CREATE, defaultFileMode)
require.NoError(t, err)
defer func() {
err = os.Remove(f.Name())
require.NoError(t, err)
}()
}
// Set up a sink
sink, err := NewFileSink(tempPath, tc.Format)
require.NoError(t, err)
require.NotNil(t, sink)
// Generate a fake event
ctx := namespace.RootContext(nil)
event := &eventlogger.Event{
Type: "audit",
CreatedAt: time.Now(),
Formatted: make(map[string][]byte),
Payload: struct{ ID string }{ID: "123"},
}
if !tc.ShouldIgnoreFormat {
event.FormattedAs(tc.Format, []byte(tc.Data))
}
if tc.ShouldUseNilEvent {
event = nil
}
// The actual exercising of the sink.
event, err = sink.Process(ctx, event)
switch {
case tc.IsErrorExpected:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedErrorMessage)
require.Nil(t, event)
default:
require.NoError(t, err)
require.Nil(t, event)
}
})
}
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package event
import (
"context"
"github.com/hashicorp/eventlogger"
)
// NoopSink is a sink node which handles ignores everything.
type NoopSink struct{}
// NewNoopSink should be used to create a new NoopSink.
func NewNoopSink() *NoopSink {
return &NoopSink{}
}
// Process is a no-op and always returns nil event and nil error.
func (_ *NoopSink) Process(ctx context.Context, _ *eventlogger.Event) (*eventlogger.Event, error) {
// return nil for the event to indicate the pipeline is complete.
return nil, nil
}
// Reopen is a no-op and always returns nil.
func (_ *NoopSink) Reopen() error {
return nil
}
// Type describes the type of this node (sink).
func (_ *NoopSink) Type() eventlogger.NodeType {
return eventlogger.NodeTypeSink
}

View File

@@ -15,41 +15,41 @@ import (
"github.com/hashicorp/eventlogger" "github.com/hashicorp/eventlogger"
) )
// AuditSocketSink is a sink node which handles writing audit events to socket. // SocketSink is a sink node which handles writing events to socket.
type AuditSocketSink struct { type SocketSink struct {
format auditFormat requiredFormat string
address string address string
socketType string socketType string
maxDuration time.Duration maxDuration time.Duration
socketLock sync.RWMutex socketLock sync.RWMutex
connection net.Conn connection net.Conn
} }
// NewAuditSocketSink should be used to create a new AuditSocketSink. // NewSocketSink should be used to create a new SocketSink.
// Accepted options: WithMaxDuration and WithSocketType. // Accepted options: WithMaxDuration and WithSocketType.
func NewAuditSocketSink(format auditFormat, address string, opt ...Option) (*AuditSocketSink, error) { func NewSocketSink(format string, address string, opt ...Option) (*SocketSink, error) {
const op = "event.NewAuditSocketSink" const op = "event.NewSocketSink"
opts, err := getOpts(opt...) opts, err := getOpts(opt...)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s: error applying options: %w", op, err) return nil, fmt.Errorf("%s: error applying options: %w", op, err)
} }
sink := &AuditSocketSink{ sink := &SocketSink{
format: format, requiredFormat: format,
address: address, address: address,
socketType: opts.withSocketType, socketType: opts.withSocketType,
maxDuration: opts.withMaxDuration, maxDuration: opts.withMaxDuration,
socketLock: sync.RWMutex{}, socketLock: sync.RWMutex{},
connection: nil, connection: nil,
} }
return sink, nil return sink, nil
} }
// Process handles writing the event to the socket. // Process handles writing the event to the socket.
func (s *AuditSocketSink) Process(ctx context.Context, e *eventlogger.Event) (*eventlogger.Event, error) { func (s *SocketSink) Process(ctx context.Context, e *eventlogger.Event) (*eventlogger.Event, error) {
const op = "event.(AuditSocketSink).Process" const op = "event.(SocketSink).Process"
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -64,9 +64,9 @@ func (s *AuditSocketSink) Process(ctx context.Context, e *eventlogger.Event) (*e
return nil, fmt.Errorf("%s: event is nil: %w", op, ErrInvalidParameter) return nil, fmt.Errorf("%s: event is nil: %w", op, ErrInvalidParameter)
} }
formatted, found := e.Format(s.format.String()) formatted, found := e.Format(s.requiredFormat)
if !found { if !found {
return nil, fmt.Errorf("%s: unable to retrieve event formatted as %q", op, s.format) return nil, fmt.Errorf("%s: unable to retrieve event formatted as %q", op, s.requiredFormat)
} }
// Try writing and return early if successful. // Try writing and return early if successful.
@@ -95,8 +95,8 @@ func (s *AuditSocketSink) Process(ctx context.Context, e *eventlogger.Event) (*e
} }
// Reopen handles reopening the connection for the socket sink. // Reopen handles reopening the connection for the socket sink.
func (s *AuditSocketSink) Reopen() error { func (s *SocketSink) Reopen() error {
const op = "event.(AuditSocketSink).Reopen" const op = "event.(SocketSink).Reopen"
s.socketLock.Lock() s.socketLock.Lock()
defer s.socketLock.Unlock() defer s.socketLock.Unlock()
@@ -110,13 +110,13 @@ func (s *AuditSocketSink) Reopen() error {
} }
// Type describes the type of this node (sink). // Type describes the type of this node (sink).
func (s *AuditSocketSink) Type() eventlogger.NodeType { func (_ *SocketSink) Type() eventlogger.NodeType {
return eventlogger.NodeTypeSink return eventlogger.NodeTypeSink
} }
// connect attempts to establish a connection using the socketType and address. // connect attempts to establish a connection using the socketType and address.
func (s *AuditSocketSink) connect(ctx context.Context) error { func (s *SocketSink) connect(ctx context.Context) error {
const op = "event.(AuditSocketSink).connect" const op = "event.(SocketSink).connect"
// If we're already connected, we should have disconnected first. // If we're already connected, we should have disconnected first.
if s.connection != nil { if s.connection != nil {
@@ -138,8 +138,8 @@ func (s *AuditSocketSink) connect(ctx context.Context) error {
} }
// disconnect attempts to close and clear an existing connection. // disconnect attempts to close and clear an existing connection.
func (s *AuditSocketSink) disconnect() error { func (s *SocketSink) disconnect() error {
const op = "event.(AuditSocketSink).disconnect" const op = "event.(SocketSink).disconnect"
// If we're already disconnected, we can return early. // If we're already disconnected, we can return early.
if s.connection == nil { if s.connection == nil {
@@ -156,8 +156,8 @@ func (s *AuditSocketSink) disconnect() error {
} }
// reconnect attempts to disconnect and then connect to the configured socketType and address. // reconnect attempts to disconnect and then connect to the configured socketType and address.
func (s *AuditSocketSink) reconnect(ctx context.Context) error { func (s *SocketSink) reconnect(ctx context.Context) error {
const op = "event.(AuditSocketSink).reconnect" const op = "event.(SocketSink).reconnect"
err := s.disconnect() err := s.disconnect()
if err != nil { if err != nil {
@@ -173,8 +173,8 @@ func (s *AuditSocketSink) reconnect(ctx context.Context) error {
} }
// write attempts to write the specified data using the established connection. // write attempts to write the specified data using the established connection.
func (s *AuditSocketSink) write(ctx context.Context, data []byte) error { func (s *SocketSink) write(ctx context.Context, data []byte) error {
const op = "event.(AuditSocketSink).write" const op = "event.(SocketSink).write"
// Ensure we're connected. // Ensure we're connected.
err := s.connect(ctx) err := s.connect(ctx)

View File

@@ -0,0 +1,66 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package event
import (
"context"
"fmt"
"os"
"github.com/hashicorp/eventlogger"
)
var _ eventlogger.Node = (*StdoutSink)(nil)
// StdoutSink is structure that implements the eventlogger.Node interface
// as a Sink node that writes the events to the standard output stream.
type StdoutSink struct {
requiredFormat string
}
// NewStdoutSinkNode creates a new StdoutSink that will persist the events
// it processes using the specified expected format.
func NewStdoutSinkNode(format string) *StdoutSink {
return &StdoutSink{
requiredFormat: format,
}
}
// Process persists the provided eventlogger.Event to the standard output stream.
func (n *StdoutSink) Process(ctx context.Context, event *eventlogger.Event) (*eventlogger.Event, error) {
const op = "event.(StdoutSink).Process"
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if event == nil {
return nil, fmt.Errorf("%s: event is nil: %w", op, ErrInvalidParameter)
}
formattedBytes, found := event.Format(n.requiredFormat)
if !found {
return nil, fmt.Errorf("%s: unable to retrieve event formatted as %q", op, n.requiredFormat)
}
_, err := os.Stdout.Write(formattedBytes)
if err != nil {
return nil, fmt.Errorf("%s: error writing to stdout: %w", op, err)
}
// Return nil, nil to indicate the pipeline is complete.
return nil, nil
}
// Reopen is a no-op for the StdoutSink type.
func (n *StdoutSink) Reopen() error {
return nil
}
// Type returns the eventlogger.NodeTypeSink constant.
func (n *StdoutSink) Type() eventlogger.NodeType {
return eventlogger.NodeTypeSink
}

View File

@@ -12,16 +12,16 @@ import (
"github.com/hashicorp/eventlogger" "github.com/hashicorp/eventlogger"
) )
// AuditSyslogSink is a sink node which handles writing audit events to syslog. // SyslogSink is a sink node which handles writing events to syslog.
type AuditSyslogSink struct { type SyslogSink struct {
format auditFormat requiredFormat string
logger gsyslog.Syslogger logger gsyslog.Syslogger
} }
// NewAuditSyslogSink should be used to create a new AuditSyslogSink. // NewSyslogSink should be used to create a new SyslogSink.
// Accepted options: WithFacility and WithTag. // Accepted options: WithFacility and WithTag.
func NewAuditSyslogSink(format auditFormat, opt ...Option) (*AuditSyslogSink, error) { func NewSyslogSink(format string, opt ...Option) (*SyslogSink, error) {
const op = "event.NewAuditSyslogSink" const op = "event.NewSyslogSink"
opts, err := getOpts(opt...) opts, err := getOpts(opt...)
if err != nil { if err != nil {
@@ -33,12 +33,12 @@ func NewAuditSyslogSink(format auditFormat, opt ...Option) (*AuditSyslogSink, er
return nil, fmt.Errorf("%s: error creating syslogger: %w", op, err) return nil, fmt.Errorf("%s: error creating syslogger: %w", op, err)
} }
return &AuditSyslogSink{format: format, logger: logger}, nil return &SyslogSink{requiredFormat: format, logger: logger}, nil
} }
// Process handles writing the event to the syslog. // Process handles writing the event to the syslog.
func (s *AuditSyslogSink) Process(ctx context.Context, e *eventlogger.Event) (*eventlogger.Event, error) { func (s *SyslogSink) Process(ctx context.Context, e *eventlogger.Event) (*eventlogger.Event, error) {
const op = "event.(AuditSyslogSink).Process" const op = "event.(SyslogSink).Process"
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -50,9 +50,9 @@ func (s *AuditSyslogSink) Process(ctx context.Context, e *eventlogger.Event) (*e
return nil, fmt.Errorf("%s: event is nil: %w", op, ErrInvalidParameter) return nil, fmt.Errorf("%s: event is nil: %w", op, ErrInvalidParameter)
} }
formatted, found := e.Format(s.format.String()) formatted, found := e.Format(s.requiredFormat)
if !found { if !found {
return nil, fmt.Errorf("%s: unable to retrieve event formatted as %q", op, s.format) return nil, fmt.Errorf("%s: unable to retrieve event formatted as %q", op, s.requiredFormat)
} }
_, err := s.logger.Write(formatted) _, err := s.logger.Write(formatted)
@@ -65,11 +65,11 @@ func (s *AuditSyslogSink) Process(ctx context.Context, e *eventlogger.Event) (*e
} }
// Reopen is a no-op for a syslog sink. // Reopen is a no-op for a syslog sink.
func (s *AuditSyslogSink) Reopen() error { func (_ *SyslogSink) Reopen() error {
return nil return nil
} }
// Type describes the type of this node (sink). // Type describes the type of this node (sink).
func (s *AuditSyslogSink) Type() eventlogger.NodeType { func (_ *SyslogSink) Type() eventlogger.NodeType {
return eventlogger.NodeTypeSink return eventlogger.NodeTypeSink
} }

View File

@@ -1,28 +0,0 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package event
import (
"fmt"
)
const (
FileSink SinkType = "file"
SocketSink SinkType = "socket"
SyslogSink SinkType = "syslog"
)
// SinkType defines the type of sink
type SinkType string
// Validate ensures that SinkType is one of the set of allowed sink types.
func (t SinkType) Validate() error {
const op = "event.(SinkType).Validate"
switch t {
case FileSink, SocketSink, SyslogSink:
return nil
default:
return fmt.Errorf("%s: '%s' is not a valid sink type: %w", op, t, ErrInvalidParameter)
}
}

View File

@@ -1,59 +0,0 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package event
import (
"testing"
"github.com/stretchr/testify/require"
)
// TestSinkType_Validate exercises the validation for a sink type.
func TestSinkType_Validate(t *testing.T) {
tests := map[string]struct {
Value string
IsValid bool
ExpectedError string
}{
"file": {
Value: "file",
IsValid: true,
},
"syslog": {
Value: "syslog",
IsValid: true,
},
"socket": {
Value: "socket",
IsValid: true,
},
"empty": {
Value: "",
IsValid: false,
ExpectedError: "event.(SinkType).Validate: '' is not a valid sink type: invalid parameter",
},
"random": {
Value: "random",
IsValid: false,
ExpectedError: "event.(SinkType).Validate: 'random' is not a valid sink type: invalid parameter",
},
}
for name, tc := range tests {
name := name
tc := tc
t.Run(name, func(t *testing.T) {
t.Parallel()
sinkType := SinkType(tc.Value)
err := sinkType.Validate()
switch {
case tc.IsValid:
require.NoError(t, err)
case !tc.IsValid:
require.Error(t, err)
require.EqualError(t, err, tc.ExpectedError)
}
})
}
}