diff --git a/go.mod b/go.mod index d74403eec7..b04e58aa05 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/hashicorp/cap v0.0.0-20210518163718-e72205e8eaae github.com/hashicorp/dbassert v0.0.0-20200930125617-6218396928df github.com/hashicorp/errwrap v1.1.0 + github.com/hashicorp/eventlogger v0.0.0-20210523164657-c216620e1746 github.com/hashicorp/go-bexpr v0.1.8 github.com/hashicorp/go-cleanhttp v0.5.2 github.com/hashicorp/go-hclog v0.16.1 diff --git a/go.sum b/go.sum index cbf29dc6a2..bc24a2eded 100644 --- a/go.sum +++ b/go.sum @@ -311,8 +311,9 @@ github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gG github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= -github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw= github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= +github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= @@ -449,6 +450,8 @@ github.com/hashicorp/dbassert v0.0.0-20200930125617-6218396928df/go.mod h1:+B5eZ github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/eventlogger v0.0.0-20210523164657-c216620e1746 h1:uFLAYWBCufq4idhqjltAWb4s2JuJt1oE1hDRixwWqwY= +github.com/hashicorp/eventlogger v0.0.0-20210523164657-c216620e1746/go.mod h1:LG0lqGlYKC9FD5m5Umh7FW8SeJlciNUZv400J4+j094= github.com/hashicorp/go-bexpr v0.1.8 h1:ETfuLF1bBAuHW/Qg6l1xCdV8WJ7lfatLtJ1N1w0IsfE= github.com/hashicorp/go-bexpr v0.1.8/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= @@ -984,6 +987,8 @@ go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.0.0 h1:qsup4IcBdlmsnGfqyLl4Ntn3C2XCCuKAE7DwHpScyUo= +go.uber.org/goleak v1.0.0/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= @@ -1232,6 +1237,7 @@ golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= diff --git a/internal/cmd/commands/accountscmd/accounts.gen.go b/internal/cmd/commands/accountscmd/accounts.gen.go index 1a8e8b7667..52cc2e27c1 100644 --- a/internal/cmd/commands/accountscmd/accounts.gen.go +++ b/internal/cmd/commands/accountscmd/accounts.gen.go @@ -1,4 +1,4 @@ -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package accountscmd import ( diff --git a/internal/cmd/commands/accountscmd/oidc_accounts.gen.go b/internal/cmd/commands/accountscmd/oidc_accounts.gen.go index ec8b1573fa..3c87cc52c5 100644 --- a/internal/cmd/commands/accountscmd/oidc_accounts.gen.go +++ b/internal/cmd/commands/accountscmd/oidc_accounts.gen.go @@ -1,4 +1,4 @@ -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package accountscmd import ( diff --git a/internal/cmd/commands/accountscmd/password_accounts.gen.go b/internal/cmd/commands/accountscmd/password_accounts.gen.go index 0a8e502ba7..7cf27fc0d8 100644 --- a/internal/cmd/commands/accountscmd/password_accounts.gen.go +++ b/internal/cmd/commands/accountscmd/password_accounts.gen.go @@ -1,4 +1,4 @@ -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package accountscmd import ( diff --git a/internal/cmd/commands/authmethodscmd/authmethods.gen.go b/internal/cmd/commands/authmethodscmd/authmethods.gen.go index 41a6671333..bb3a5c9040 100644 --- a/internal/cmd/commands/authmethodscmd/authmethods.gen.go +++ b/internal/cmd/commands/authmethodscmd/authmethods.gen.go @@ -1,4 +1,4 @@ -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package authmethodscmd import ( diff --git a/internal/cmd/commands/authmethodscmd/oidc_authmethods.gen.go b/internal/cmd/commands/authmethodscmd/oidc_authmethods.gen.go index 544769b1ef..b962acce59 100644 --- a/internal/cmd/commands/authmethodscmd/oidc_authmethods.gen.go +++ b/internal/cmd/commands/authmethodscmd/oidc_authmethods.gen.go @@ -1,4 +1,4 @@ -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package authmethodscmd import ( diff --git a/internal/cmd/commands/authmethodscmd/password_authmethods.gen.go b/internal/cmd/commands/authmethodscmd/password_authmethods.gen.go index 6dc05928a7..e242ddd3c8 100644 --- a/internal/cmd/commands/authmethodscmd/password_authmethods.gen.go +++ b/internal/cmd/commands/authmethodscmd/password_authmethods.gen.go @@ -1,4 +1,4 @@ -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package authmethodscmd import ( diff --git a/internal/cmd/commands/authtokenscmd/authtokens.gen.go b/internal/cmd/commands/authtokenscmd/authtokens.gen.go index 7fe2828e2c..8a80bef69b 100644 --- a/internal/cmd/commands/authtokenscmd/authtokens.gen.go +++ b/internal/cmd/commands/authtokenscmd/authtokens.gen.go @@ -1,4 +1,4 @@ -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package authtokenscmd import ( diff --git a/internal/cmd/commands/groupscmd/groups.gen.go b/internal/cmd/commands/groupscmd/groups.gen.go index 016100e8fd..8bffb3d9df 100644 --- a/internal/cmd/commands/groupscmd/groups.gen.go +++ b/internal/cmd/commands/groupscmd/groups.gen.go @@ -1,4 +1,4 @@ -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package groupscmd import ( diff --git a/internal/cmd/commands/hostcatalogscmd/hostcatalogs.gen.go b/internal/cmd/commands/hostcatalogscmd/hostcatalogs.gen.go index c2739db6af..e7692f6dca 100644 --- a/internal/cmd/commands/hostcatalogscmd/hostcatalogs.gen.go +++ b/internal/cmd/commands/hostcatalogscmd/hostcatalogs.gen.go @@ -1,4 +1,4 @@ -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package hostcatalogscmd import ( diff --git a/internal/cmd/commands/hostcatalogscmd/static_hostcatalogs.gen.go b/internal/cmd/commands/hostcatalogscmd/static_hostcatalogs.gen.go index 54ffa11c06..f4fb8970c8 100644 --- a/internal/cmd/commands/hostcatalogscmd/static_hostcatalogs.gen.go +++ b/internal/cmd/commands/hostcatalogscmd/static_hostcatalogs.gen.go @@ -1,4 +1,4 @@ -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package hostcatalogscmd import ( diff --git a/internal/cmd/commands/hostscmd/hosts.gen.go b/internal/cmd/commands/hostscmd/hosts.gen.go index 329d7bf8d0..e31d1c3652 100644 --- a/internal/cmd/commands/hostscmd/hosts.gen.go +++ b/internal/cmd/commands/hostscmd/hosts.gen.go @@ -1,4 +1,4 @@ -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package hostscmd import ( diff --git a/internal/cmd/commands/hostscmd/static_hosts.gen.go b/internal/cmd/commands/hostscmd/static_hosts.gen.go index f981916c3a..32dc0c26fd 100644 --- a/internal/cmd/commands/hostscmd/static_hosts.gen.go +++ b/internal/cmd/commands/hostscmd/static_hosts.gen.go @@ -1,4 +1,4 @@ -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package hostscmd import ( diff --git a/internal/cmd/commands/hostsetscmd/hostsets.gen.go b/internal/cmd/commands/hostsetscmd/hostsets.gen.go index 90757f0312..e66b8b4058 100644 --- a/internal/cmd/commands/hostsetscmd/hostsets.gen.go +++ b/internal/cmd/commands/hostsetscmd/hostsets.gen.go @@ -1,4 +1,4 @@ -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package hostsetscmd import ( diff --git a/internal/cmd/commands/hostsetscmd/static_hostsets.gen.go b/internal/cmd/commands/hostsetscmd/static_hostsets.gen.go index 30e0684fe5..08b1ddec11 100644 --- a/internal/cmd/commands/hostsetscmd/static_hostsets.gen.go +++ b/internal/cmd/commands/hostsetscmd/static_hostsets.gen.go @@ -1,4 +1,4 @@ -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package hostsetscmd import ( diff --git a/internal/cmd/commands/rolescmd/roles.gen.go b/internal/cmd/commands/rolescmd/roles.gen.go index 680c9b7aef..1db46b1222 100644 --- a/internal/cmd/commands/rolescmd/roles.gen.go +++ b/internal/cmd/commands/rolescmd/roles.gen.go @@ -1,4 +1,4 @@ -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package rolescmd import ( diff --git a/internal/cmd/commands/scopescmd/scopes.gen.go b/internal/cmd/commands/scopescmd/scopes.gen.go index 2ddbe47fae..5fab8d26de 100644 --- a/internal/cmd/commands/scopescmd/scopes.gen.go +++ b/internal/cmd/commands/scopescmd/scopes.gen.go @@ -1,4 +1,4 @@ -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package scopescmd import ( diff --git a/internal/cmd/commands/sessionscmd/sessions.gen.go b/internal/cmd/commands/sessionscmd/sessions.gen.go index f1f26d845f..78fca63925 100644 --- a/internal/cmd/commands/sessionscmd/sessions.gen.go +++ b/internal/cmd/commands/sessionscmd/sessions.gen.go @@ -1,4 +1,4 @@ -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package sessionscmd import ( diff --git a/internal/cmd/commands/targetscmd/targets.gen.go b/internal/cmd/commands/targetscmd/targets.gen.go index 4070e9b25e..97e4a14f51 100644 --- a/internal/cmd/commands/targetscmd/targets.gen.go +++ b/internal/cmd/commands/targetscmd/targets.gen.go @@ -1,4 +1,4 @@ -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package targetscmd import ( diff --git a/internal/cmd/commands/targetscmd/tcp_targets.gen.go b/internal/cmd/commands/targetscmd/tcp_targets.gen.go index 9777951046..fda35bbe83 100644 --- a/internal/cmd/commands/targetscmd/tcp_targets.gen.go +++ b/internal/cmd/commands/targetscmd/tcp_targets.gen.go @@ -1,4 +1,4 @@ -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package targetscmd import ( diff --git a/internal/cmd/commands/userscmd/users.gen.go b/internal/cmd/commands/userscmd/users.gen.go index 775811b437..7650d389f9 100644 --- a/internal/cmd/commands/userscmd/users.gen.go +++ b/internal/cmd/commands/userscmd/users.gen.go @@ -1,4 +1,4 @@ -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package userscmd import ( diff --git a/internal/cmd/gencli/templates.go b/internal/cmd/gencli/templates.go index 0f1e463a16..63a7ef8ce5 100644 --- a/internal/cmd/gencli/templates.go +++ b/internal/cmd/gencli/templates.go @@ -77,7 +77,7 @@ var cmdTemplate = template.Must(template.New("").Funcs( "hasAction": hasAction, }, ).Parse(`{{ $input := . }} -// Code generated by "make api"; DO NOT EDIT. +// Code generated by "make cli"; DO NOT EDIT. package {{ .Pkg }}cmd import ( diff --git a/internal/observability/event/context.go b/internal/observability/event/context.go new file mode 100644 index 0000000000..bcd104958e --- /dev/null +++ b/internal/observability/event/context.go @@ -0,0 +1,231 @@ +package event + +import ( + "context" + "fmt" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/go-hclog" +) + +type key int + +const ( + eventerKey key = iota + requestInfoKey +) + +// NewEventerContext will return a context containing a value of the provided Eventer +func NewEventerContext(ctx context.Context, eventer *Eventer) (context.Context, error) { + const op = "event.NewEventerContext" + if ctx == nil { + return nil, errors.New(errors.InvalidParameter, op, "missing context") + } + if eventer == nil { + return nil, errors.New(errors.InvalidParameter, op, "missing eventer") + } + return context.WithValue(ctx, eventerKey, eventer), nil +} + +// EventerFromContext attempts to get the eventer value from the context provided +func EventerFromContext(ctx context.Context) (*Eventer, bool) { + if ctx == nil { + return nil, false + } + eventer, ok := ctx.Value(eventerKey).(*Eventer) + return eventer, ok +} + +// NewRequestInfoContext will return a context containing a value for the +// provided RequestInfo +func NewRequestInfoContext(ctx context.Context, info *RequestInfo) (context.Context, error) { + const op = "event.NewRequestInfoContext" + if ctx == nil { + return nil, errors.New(errors.InvalidParameter, op, "missing context") + } + if info == nil { + return nil, errors.New(errors.InvalidParameter, op, "missing request info") + } + if info.Id == "" { + return nil, errors.New(errors.InvalidParameter, op, "missing request info id") + } + return context.WithValue(ctx, requestInfoKey, info), nil +} + +// RequestInfoFromContext attempts to get the RequestInfo value from the context +// provided +func RequestInfoFromContext(ctx context.Context) (*RequestInfo, bool) { + if ctx == nil { + return nil, false + } + reqInfo, ok := ctx.Value(requestInfoKey).(*RequestInfo) + return reqInfo, ok +} + +// WriteObservation will write an observation event. It will first check the +// ctx for an eventer, then try event.SysEventer() and if no eventer can be +// found an error is returned. +// +// At least one and any combination of the supported options may be used: +// WithHeader, WithDetails, WithId, WithFlush and WithRequestInfo. All other +// options are ignored. +func WriteObservation(ctx context.Context, caller Op, opt ...Option) error { + const op = "event.WriteObservation" + if ctx == nil { + return errors.New(errors.InvalidParameter, op, "missing context") + } + if caller == "" { + return errors.New(errors.InvalidParameter, op, "missing operation") + } + eventer, ok := EventerFromContext(ctx) + if !ok { + eventer = SysEventer() + if eventer == nil { + return errors.New(errors.InvalidParameter, op, "missing both context and system eventer") + } + } + opts := getOpts(opt...) + if opts.withDetails == nil && opts.withHeader == nil && !opts.withFlush { + return errors.New(errors.InvalidParameter, op, "you must specify either header or details options for an event payload") + } + if opts.withRequestInfo == nil { + var err error + if opt, err = addCtxOptions(ctx, opt...); err != nil { + return errors.Wrap(err, op) + } + } + e, err := newObservation(caller, opt...) + if err != nil { + return errors.Wrap(err, op) + } + if err := eventer.writeObservation(ctx, e); err != nil { + return errors.Wrap(err, op) + } + return nil +} + +// WriteError will write an error event. It will first check the +// ctx for an eventer, then try event.SysEventer() and if no eventer can be +// found an hclog.Logger will be created and used. +// +// The options WithId and WithRequestInfo are supported and all other options +// are ignored. +func WriteError(ctx context.Context, caller Op, e error, opt ...Option) { + const op = "event.WriteError" + // EventerFromContext will handle a nil ctx appropriately. If e or caller is + // missing, newError(...) will handle them appropriately. + eventer, ok := EventerFromContext(ctx) + if !ok { + eventer = SysEventer() + if eventer == nil { + logger := hclog.New(nil) + logger.Error(fmt.Sprintf("%s: no eventer available to write error: %s", op, e)) + return + } + } + opts := getOpts(opt...) + if opts.withRequestInfo == nil { + var err error + if opt, err = addCtxOptions(ctx, opt...); err != nil { + eventer.logger.Error(errors.Wrap(err, op).Error()) + eventer.logger.Error(fmt.Sprintf("%s: unable to process context options to write error: %s", op, e)) + return + } + } + ev, err := newError(caller, e, opt...) + if err != nil { + eventer.logger.Error(errors.Wrap(err, op).Error()) + eventer.logger.Error(fmt.Sprintf("%s: unable to create new error to write error: %s", op, e)) + return + } + if err := eventer.writeError(ctx, ev); err != nil { + eventer.logger.Error(errors.Wrap(err, op).Error()) + eventer.logger.Error(fmt.Sprintf("%s: unable to write error: %s", op, e)) + return + } +} + +// WriteAudit will write an audit event. It will first check the ctx for an +// eventer, then try event.SysEventer() and if no eventer can be found an error +// is returned. +// +// At least one and any combination of the supported options may be used: +// WithRequest, WithResponse, WithAuth, WithId, WithFlush and WithRequestInfo. +// All other options are ignored. +func WriteAudit(ctx context.Context, caller Op, opt ...Option) error { + const op = "event.WriteAudit" + if ctx == nil { + return errors.New(errors.InvalidParameter, op, "missing context") + } + if caller == "" { + return errors.New(errors.InvalidParameter, op, "missing operation") + } + eventer, ok := EventerFromContext(ctx) + if !ok { + eventer = SysEventer() + if eventer == nil { + return errors.New(errors.InvalidParameter, op, "missing both context and system eventer") + } + } + opts := getOpts(opt...) + if opts.withRequestInfo == nil { + var err error + if opt, err = addCtxOptions(ctx, opt...); err != nil { + return errors.Wrap(err, op) + } + } + e, err := newAudit(caller, opt...) + if err != nil { + return errors.Wrap(err, op) + } + if err := eventer.writeAudit(ctx, e); err != nil { + return errors.Wrap(err, op) + } + return nil +} + +func addCtxOptions(ctx context.Context, opt ...Option) ([]Option, error) { + const op = "event.addCtxOptions" + opts := getOpts(opt...) + retOpts := make([]Option, 0, len(opt)) + retOpts = append(retOpts, opt...) + if opts.withRequestInfo == nil { + reqInfo, ok := RequestInfoFromContext(ctx) + if !ok { + // there's no RequestInfo, so there's no id associated with the + // observation and we'll generate one and flush the observation + // since there will never be another with the same id + id, err := newId(string(ObservationType)) + if err != nil { + return nil, errors.Wrap(err, op) + } + retOpts = append(retOpts, WithId(id)) + if !opts.withFlush { + retOpts = append(retOpts, WithFlush()) + } + return retOpts, nil + } + retOpts = append(retOpts, WithRequestInfo(reqInfo)) + if reqInfo.Id != "" { + retOpts = append(retOpts, WithId(reqInfo.Id)) + } + switch reqInfo.Id { + case "": + // there's no RequestInfo.Id associated with the observation, so we'll + // generate one and flush the observation since there will never be + // another with the same id + id, err := newId(string(ObservationType)) + if err != nil { + return nil, errors.Wrap(err, op) + } + retOpts = append(retOpts, WithId(id)) + if !opts.withFlush { + retOpts = append(retOpts, WithFlush()) + } + return retOpts, nil + default: + retOpts = append(retOpts, WithId(reqInfo.Id)) + } + } + return retOpts, nil +} diff --git a/internal/observability/event/context_test.go b/internal/observability/event/context_test.go new file mode 100644 index 0000000000..d20bdd822f --- /dev/null +++ b/internal/observability/event/context_test.go @@ -0,0 +1,903 @@ +package event_test + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "os" + "testing" + "time" + + "github.com/golang/protobuf/ptypes/wrappers" + "github.com/hashicorp/boundary/internal/errors" + pb "github.com/hashicorp/boundary/internal/gen/controller/api/resources/hosts" + "github.com/hashicorp/boundary/internal/gen/controller/api/resources/scopes" + pbs "github.com/hashicorp/boundary/internal/gen/controller/api/services" + "github.com/hashicorp/boundary/internal/host/static" + "github.com/hashicorp/boundary/internal/observability/event" + "github.com/hashicorp/boundary/internal/types/scope" + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +const apiRequest = "APIRequest" + +const testAuditVersion = "v0.1" +const testErrorVersion = "v0.1" +const testObservationVersion = "v0.1" + +type testAudit struct { + Id string `json:"id"` // std audit/boundary field + Version string `json:"version"` // std audit/boundary field + Type string `json:"type"` // std audit field + Timestamp time.Time `json:"timestamp"` // std audit field + RequestInfo *event.RequestInfo `json:"request_info,omitempty"` // boundary field + Auth *event.Auth `json:"auth,omitempty"` // std audit field + Request *event.Request `json:"request,omitempty"` // std audit field + Response *event.Response `json:"response,omitempty"` // std audit field + SerializedHMAC string `json:"serialized_hmac"` // boundary field + Flush bool `json:"-"` +} + +func Test_NewRequestInfoContext(t *testing.T) { + testInfo := event.TestRequestInfo(t) + testInfoMissingId := event.TestRequestInfo(t) + testInfoMissingId.Id = "" + + tests := []struct { + name string + ctx context.Context + requestInfo *event.RequestInfo + wantErrMatch *errors.Template + wantErrContains string + }{ + { + name: "missing-ctx", + requestInfo: testInfo, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing context", + }, + { + name: "missing-request-info", + ctx: context.Background(), + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing request info", + }, + { + name: "missing-request-info-id", + ctx: context.Background(), + requestInfo: testInfoMissingId, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing request info id", + }, + { + name: "valid", + ctx: context.Background(), + requestInfo: testInfo, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + ctx, err := event.NewRequestInfoContext(tt.ctx, tt.requestInfo) + if tt.wantErrMatch != nil { + require.Errorf(err, "should have gotten an error") + assert.Nilf(ctx, "context should be nil") + assert.Truef(errors.Match(tt.wantErrMatch, err), "wanted %q and got %q", tt.wantErrMatch, err) + if tt.wantErrContains != "" { + assert.Contains(err.Error(), tt.wantErrContains) + } + return + } + require.NoErrorf(err, "should not have been a problem getting the request info") + require.NotNilf(ctx, "cxt returned shouldn't be nil") + got, ok := event.RequestInfoFromContext(ctx) + require.Truef(ok, "should be ok to get the request info") + assert.Equal(tt.requestInfo, got) + }) + } +} + +func Test_RequestInfoFromContext(t *testing.T) { + testInfo := event.TestRequestInfo(t) + + testCtx, err := event.NewRequestInfoContext(context.Background(), testInfo) + require.NoError(t, err) + + tests := []struct { + name string + ctx context.Context + wantInfo *event.RequestInfo + wantNotOk bool + }{ + { + name: "missing-ctx", + wantNotOk: true, + }, + { + name: "no-request-info", + ctx: context.Background(), + wantNotOk: true, + }, + { + name: "valid", + ctx: testCtx, + wantInfo: testInfo, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, ok := event.RequestInfoFromContext(tt.ctx) + if tt.wantNotOk { + require.Falsef(ok, "should not have returned ok for the request info") + assert.Nilf(got, "should not have returned %q request info", got) + return + } + require.Truef(ok, "should have been okay for getting the request info") + require.NotNilf(got, "request info should not be nil") + assert.Equal(tt.wantInfo, got) + }) + } +} + +func Test_NewEventerContext(t *testing.T) { + testSetup := event.TestEventerConfig(t, "Test_NewEventerContext") + testEventer, err := event.NewEventer(hclog.Default(), testSetup.EventerConfig) + require.NoError(t, err) + tests := []struct { + name string + ctx context.Context + eventer *event.Eventer + wantErrMatch *errors.Template + wantErrContains string + }{ + { + name: "missing-ctx", + eventer: testEventer, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing context", + }, + { + name: "missing-eventer", + ctx: context.Background(), + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing eventer", + }, + { + name: "valid", + ctx: context.Background(), + eventer: testEventer, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + ctx, err := event.NewEventerContext(tt.ctx, tt.eventer) + if tt.wantErrMatch != nil { + require.Errorf(err, "should have gotten an error") + assert.Nilf(ctx, "context should be nil") + assert.Truef(errors.Match(tt.wantErrMatch, err), "wanted %q and got %q", tt.wantErrMatch, err) + if tt.wantErrContains != "" { + assert.Contains(err.Error(), tt.wantErrContains) + } + return + } + require.NoErrorf(err, "should not have been a problem getting the eventer") + require.NotNilf(ctx, "cxt returned shouldn't be nil") + got, ok := event.EventerFromContext(ctx) + require.Truef(ok, "should be ok to get the eventer") + assert.Equal(tt.eventer, got) + }) + } +} + +func Test_EventerFromContext(t *testing.T) { + testSetup := event.TestEventerConfig(t, "Test_EventerFromContext") + + testEventer, err := event.NewEventer(hclog.Default(), testSetup.EventerConfig) + require.NoError(t, err) + + testEventerCtx, err := event.NewEventerContext(context.Background(), testEventer) + require.NoError(t, err) + + tests := []struct { + name string + ctx context.Context + wantEventer *event.Eventer + wantNotOk bool + }{ + { + name: "missing-ctx", + wantNotOk: true, + }, + { + name: "no-eventer", + ctx: context.Background(), + wantNotOk: true, + }, + { + name: "valid", + ctx: testEventerCtx, + wantEventer: testEventer, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, ok := event.EventerFromContext(tt.ctx) + if tt.wantNotOk { + require.Falsef(ok, "should not have returned ok for the eventer") + assert.Nilf(got, "should not have returned %q eventer", got) + return + } + require.Truef(ok, "should have been okay for getting an eventer") + require.NotNilf(got, "eventer should not be nil") + assert.Equal(tt.wantEventer, got) + }) + } +} + +func Test_WriteObservation(t *testing.T) { + // this test and its subtests cannot be run in parallel because of it's + // dependency on the sysEventer + logger := hclog.New(&hclog.LoggerOptions{ + Name: "test", + }) + + c := event.TestEventerConfig(t, "WriteObservation") + + e, err := event.NewEventer(logger, c.EventerConfig) + require.NoError(t, err) + + info := &event.RequestInfo{Id: "867-5309"} + + testCtx, err := event.NewEventerContext(context.Background(), e) + require.NoError(t, err) + testCtx, err = event.NewRequestInfoContext(testCtx, info) + require.NoError(t, err) + + testCtxNoInfoId, err := event.NewEventerContext(context.Background(), e) + require.NoError(t, err) + noId := &event.RequestInfo{Id: "867-5309"} + testCtxNoInfoId, err = event.NewRequestInfoContext(testCtxNoInfoId, noId) + require.NoError(t, err) + noId.Id = "" + + type observationPayload struct { + header map[string]interface{} + details map[string]interface{} + } + + testPayloads := []observationPayload{ + { + header: map[string]interface{}{ + "name": "bar", + }, + }, + { + header: map[string]interface{}{ + "list": []string{"1", "2"}, + }, + }, + { + details: map[string]interface{}{ + "file": "temp-file.txt", + }, + }, + } + + testWantHeader := map[string]interface{}{ + "name": "bar", + "list": []string{"1", "2"}, + } + + testWantDetails := map[string]interface{}{ + "file": "temp-file.txt", + } + + tests := []struct { + name string + noOperation bool + noFlush bool + observationPayload []observationPayload + header map[string]interface{} + details map[string]interface{} + ctx context.Context + observationSinkFileName string + setup func() error + cleanup func() + wantErrMatch *errors.Template + wantErrContains string + }{ + { + name: "no-info-id", + noFlush: true, + ctx: testCtxNoInfoId, + observationPayload: []observationPayload{ + { + header: map[string]interface{}{ + "name": "bar", + }, + }, + }, + header: map[string]interface{}{ + "name": "bar", + }, + observationSinkFileName: c.AllEvents.Name(), + setup: func() error { + return event.InitSysEventer(hclog.Default(), c.EventerConfig) + }, + cleanup: func() { event.TestResetSystEventer(t) }, + }, + { + name: "missing-ctx", + observationPayload: testPayloads, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing context", + }, + { + name: "missing-op", + ctx: testCtx, + noOperation: true, + observationPayload: testPayloads, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing operation", + }, + { + name: "no-header-or-details-in-payload", + noFlush: true, + ctx: testCtx, + observationPayload: []observationPayload{ + {}, + }, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "you must specify either header or details options", + }, + { + name: "no-ctx-eventer-and-syseventer-not-initialized", + ctx: context.Background(), + observationPayload: testPayloads, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing both context and system eventer", + }, + { + name: "use-syseventer", + noFlush: true, + ctx: context.Background(), + observationPayload: []observationPayload{ + { + header: map[string]interface{}{ + "name": "bar", + }, + }, + }, + header: map[string]interface{}{ + "name": "bar", + }, + observationSinkFileName: c.AllEvents.Name(), + setup: func() error { + return event.InitSysEventer(hclog.Default(), c.EventerConfig) + }, + cleanup: func() { event.TestResetSystEventer(t) }, + }, + { + name: "simple", + ctx: testCtx, + observationPayload: testPayloads, + header: testWantHeader, + details: testWantDetails, + observationSinkFileName: c.AllEvents.Name(), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + if tt.setup != nil { + require.NoError(tt.setup()) + } + if tt.cleanup != nil { + defer tt.cleanup() + } + op := tt.name + if tt.noOperation { + op = "" + } + require.Greater(len(tt.observationPayload), 0) + for _, p := range tt.observationPayload { + err := event.WriteObservation(tt.ctx, event.Op(op), event.WithHeader(p.header), event.WithDetails(p.details)) + if tt.wantErrMatch != nil { + require.Errorf(err, "wanted error %q", tt.wantErrMatch) + if tt.wantErrContains != "" { + assert.Contains(err.Error(), tt.wantErrContains) + } + return + } + require.NoError(err) + } + if !tt.noFlush { + require.NoError(event.WriteObservation(tt.ctx, event.Op(tt.name), event.WithFlush())) + } + + if tt.observationSinkFileName != "" { + defer func() { _ = os.WriteFile(tt.observationSinkFileName, nil, 0o666) }() + b, err := ioutil.ReadFile(tt.observationSinkFileName) + assert.NoError(err) + + gotObservation := &eventJson{} + err = json.Unmarshal(b, gotObservation) + require.NoErrorf(err, "json: %s", string(b)) + + actualJson, err := json.Marshal(gotObservation) + require.NoError(err) + wantJson := testObservationJsonFromCtx(t, tt.ctx, event.Op(tt.name), gotObservation, tt.header, tt.details) + + assert.JSONEq(string(wantJson), string(actualJson)) + } + }) + } + t.Run("not-enabled", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + logger := hclog.New(&hclog.LoggerOptions{ + Name: "test", + }) + + c := event.TestEventerConfig(t, "WriteObservation") + c.EventerConfig.ObservationsEnabled = false + + e, err := event.NewEventer(logger, c.EventerConfig) + require.NoError(err) + + testCtx, err := event.NewEventerContext(context.Background(), e) + require.NoError(err) + testCtx, err = event.NewRequestInfoContext(testCtx, info) + require.NoError(err) + + hdr := map[string]interface{}{ + "list": []string{"1", "2"}, + } + require.NoError(event.WriteObservation(testCtx, "not-enabled", event.WithHeader(hdr), event.WithFlush())) + + b, err := ioutil.ReadFile(c.AllEvents.Name()) + assert.NoError(err) + assert.Len(b, 0) + }) +} + +func testObservationJsonFromCtx(t *testing.T, ctx context.Context, caller event.Op, got *eventJson, hdr, details map[string]interface{}) []byte { + t.Helper() + require := require.New(t) + + reqInfo, _ := event.RequestInfoFromContext(ctx) + // require.Truef(ok, "missing reqInfo in ctx") + + j := eventJson{ + CreatedAt: got.CreatedAt, + EventType: string(event.ObservationType), + Payload: map[string]interface{}{ + event.IdField: got.Payload[event.IdField].(string), + event.HeaderField: map[string]interface{}{ + event.RequestInfoField: reqInfo, + event.VersionField: testObservationVersion, + }, + }, + } + if hdr != nil { + h := j.Payload[event.HeaderField].(map[string]interface{}) + for k, v := range hdr { + h[k] = v + } + } + if details != nil { + details[event.OpField] = string(caller) + d := got.Payload[event.DetailsField].([]interface{})[0].(map[string]interface{}) + j.Payload[event.DetailsField] = []struct { + CreatedAt string `json:"created_at"` + Type string `json:"type"` + Payload map[string]interface{} `json:"payload"` + }{ + { + CreatedAt: d[event.CreatedAtField].(string), + Type: d[event.TypeField].(string), + Payload: details, + }, + } + } + b, err := json.Marshal(j) + require.NoError(err) + return b +} + +type eventJson struct { + CreatedAt string `json:"created_at"` + EventType string `json:"event_type"` + Payload map[string]interface{} `json:"payload"` +} + +func Test_WriteAudit(t *testing.T) { + // this test and its subtests cannot be run in parallel because of it's + // dependency on the sysEventer + now := time.Now() + + logger := hclog.New(&hclog.LoggerOptions{ + Name: "test", + }) + + c := event.TestEventerConfig(t, "WriteAudit") + + e, err := event.NewEventer(logger, c.EventerConfig, event.WithNow(now)) + require.NoError(t, err) + + info := &event.RequestInfo{Id: "867-5309"} + + ctx, err := event.NewEventerContext(context.Background(), e) + require.NoError(t, err) + ctx, err = event.NewRequestInfoContext(ctx, info) + require.NoError(t, err) + + testAuth := &event.Auth{} + testReq := &event.Request{ + Operation: "POST", + Endpoint: "/v1/hosts", + Details: &pbs.CreateHostRequest{Item: &pb.Host{ + HostCatalogId: "hc_1234567890", + Name: &wrappers.StringValue{Value: "name"}, + Description: &wrappers.StringValue{Value: "desc"}, + Type: "static", + Attributes: &structpb.Struct{Fields: map[string]*structpb.Value{ + "address": structpb.NewStringValue("123.456.789"), + }}, + }}, + } + testAuthorizedActions := []string{"no-op", "read", "update", "delete"} + + testResp := &event.Response{ + StatusCode: 200, + Details: &pbs.CreateHostResponse{ + Uri: fmt.Sprintf("hosts/%s_", static.HostPrefix), + Item: &pb.Host{ + HostCatalogId: "hc_1234567890", + Scope: &scopes.ScopeInfo{Id: "proj_1234567890", Type: scope.Project.String(), ParentScopeId: "org_1234567890"}, + Name: &wrappers.StringValue{Value: "name"}, + Description: &wrappers.StringValue{Value: "desc"}, + Type: "static", + Attributes: &structpb.Struct{Fields: map[string]*structpb.Value{ + "address": structpb.NewStringValue("123.456.789"), + }}, + AuthorizedActions: testAuthorizedActions, + }, + }, + } + tests := []struct { + name string + auditOpts [][]event.Option + wantAudit *testAudit + ctx context.Context + auditSinkFileName string + setup func() error + cleanup func() + noOperation bool + noFlush bool + wantErrMatch *errors.Template + wantErrContains string + }{ + { + name: "missing-ctx", + auditOpts: [][]event.Option{ + { + event.WithAuth(testAuth), + event.WithRequest(testReq), + }, + }, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing context", + }, + { + name: "missing-op", + ctx: ctx, + auditOpts: [][]event.Option{ + { + event.WithAuth(testAuth), + event.WithRequest(testReq), + }, + }, + noOperation: true, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing operation", + }, + { + name: "no-ctx-eventer-and-syseventer-not-initialized", + ctx: context.Background(), + auditOpts: [][]event.Option{ + { + event.WithAuth(testAuth), + event.WithRequest(testReq), + }, + }, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing both context and system eventer", + }, + { + name: "use-syseventer", + noFlush: true, + ctx: context.Background(), + auditOpts: [][]event.Option{ + { + event.WithAuth(testAuth), + event.WithRequest(testReq), + }, + }, + wantAudit: &testAudit{ + Auth: testAuth, + Request: testReq, + }, + setup: func() error { + return event.InitSysEventer(hclog.Default(), c.EventerConfig) + }, + cleanup: func() { event.TestResetSystEventer(t) }, + auditSinkFileName: c.AllEvents.Name(), + }, + { + name: "simple", + ctx: ctx, + auditOpts: [][]event.Option{ + { + event.WithAuth(testAuth), + event.WithRequest(testReq), + }, + { + event.WithResponse(testResp), + }, + }, + wantAudit: &testAudit{ + Id: "867-5309", + Auth: testAuth, + Request: testReq, + Response: testResp, + }, + auditSinkFileName: c.AllEvents.Name(), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + if tt.setup != nil { + require.NoError(tt.setup()) + } + if tt.cleanup != nil { + defer tt.cleanup() + } + op := tt.name + if tt.noOperation { + op = "" + } + require.Greater(len(tt.auditOpts), 0) + for _, opts := range tt.auditOpts { + opts := append(opts, event.WithNow(now)) + err := event.WriteAudit(tt.ctx, event.Op(op), opts...) + if tt.wantErrMatch != nil { + require.Errorf(err, "wanted error %q", tt.wantErrMatch) + if tt.wantErrContains != "" { + assert.Contains(err.Error(), tt.wantErrContains) + } + return + } + require.NoError(err) + } + if !tt.noFlush { + require.NoError(event.WriteAudit(tt.ctx, event.Op(op), event.WithFlush(), event.WithNow(now))) + } + if tt.auditSinkFileName != "" { + defer func() { _ = os.WriteFile(tt.auditSinkFileName, nil, 0o666) }() + + b, err := ioutil.ReadFile(tt.auditSinkFileName) + require.NoError(err) + gotAudit := &eventJson{} + err = json.Unmarshal(b, gotAudit) + require.NoErrorf(err, "json: %s", string(b)) + + actualJson, err := json.Marshal(gotAudit) + require.NoError(err) + + wantEvent := eventJson{ + CreatedAt: gotAudit.CreatedAt, + EventType: string(gotAudit.EventType), + Payload: map[string]interface{}{ + "auth": tt.wantAudit.Auth, + "id": gotAudit.Payload["id"], + "timestamp": now, + "request": tt.wantAudit.Request, + "serialized_hmac": "", + "type": apiRequest, + "version": testAuditVersion, + }, + } + if tt.wantAudit.Id != "" { + wantEvent.Payload["id"] = tt.wantAudit.Id + wantEvent.Payload["request_info"] = event.RequestInfo{ + Id: tt.wantAudit.Id, + } + } + if tt.wantAudit.Response != nil { + wantEvent.Payload["response"] = tt.wantAudit.Response + } + wantJson, err := json.Marshal(wantEvent) + require.NoError(err) + + assert.JSONEq(string(wantJson), string(actualJson)) + } + }) + } + t.Run("not-enabled", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + logger := hclog.New(&hclog.LoggerOptions{ + Name: "test", + }) + + c := event.TestEventerConfig(t, "WriteAudit") + c.EventerConfig.AuditEnabled = false + + e, err := event.NewEventer(logger, c.EventerConfig) + require.NoError(err) + + testCtx, err := event.NewEventerContext(context.Background(), e) + require.NoError(err) + testCtx, err = event.NewRequestInfoContext(testCtx, info) + require.NoError(err) + + require.NoError(event.WriteAudit(testCtx, "not-enabled", event.WithRequest(testReq), event.WithFlush())) + b, err := ioutil.ReadFile(c.AllEvents.Name()) + assert.NoError(err) + assert.Len(b, 0) + }) +} + +func Test_WriteError(t *testing.T) { + // this test and its subtests cannot be run in parallel because of it's + // dependency on the sysEventer + now := time.Now() + + logger := hclog.New(&hclog.LoggerOptions{ + Name: "test", + }) + + c := event.TestEventerConfig(t, "WriteAudit") + + e, err := event.NewEventer(logger, c.EventerConfig, event.WithNow(now)) + require.NoError(t, err) + + info := &event.RequestInfo{Id: "867-5309"} + + testCtx, err := event.NewEventerContext(context.Background(), e) + require.NoError(t, err) + testCtx, err = event.NewRequestInfoContext(testCtx, info) + require.NoError(t, err) + + testCtxNoInfoId, err := event.NewEventerContext(context.Background(), e) + require.NoError(t, err) + noId := &event.RequestInfo{Id: "867-5309"} + testCtxNoInfoId, err = event.NewRequestInfoContext(testCtxNoInfoId, noId) + require.NoError(t, err) + noId.Id = "" + + testError := errors.New(errors.InvalidParameter, "test", "you lookin at me?") + + tests := []struct { + name string + ctx context.Context + e error + info *event.RequestInfo + setup func() error + cleanup func() + noOperation bool + errSinkFileName string + noOutput bool + }{ + { + name: "missing-caller", + ctx: testCtx, + e: testError, + noOperation: true, + noOutput: true, + }, + { + name: "no-ctx-eventer-and-syseventer-not-initialized", + ctx: context.Background(), + e: testError, + errSinkFileName: c.ErrorEvents.Name(), + noOutput: true, + }, + { + name: "use-syseventer", + ctx: context.Background(), + e: testError, + setup: func() error { + return event.InitSysEventer(hclog.Default(), c.EventerConfig) + }, + cleanup: func() { event.TestResetSystEventer(t) }, + errSinkFileName: c.ErrorEvents.Name(), + }, + { + name: "no-info-id", + ctx: testCtxNoInfoId, + e: testError, + info: &event.RequestInfo{}, + setup: func() error { + return event.InitSysEventer(hclog.Default(), c.EventerConfig) + }, + cleanup: func() { event.TestResetSystEventer(t) }, + errSinkFileName: c.ErrorEvents.Name(), + }, + { + name: "simple", + ctx: testCtx, + e: testError, + info: info, + errSinkFileName: c.ErrorEvents.Name(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + if tt.setup != nil { + require.NoError(tt.setup()) + } + if tt.cleanup != nil { + defer tt.cleanup() + } + op := tt.name + if tt.noOperation { + op = "" + } + event.WriteError(tt.ctx, event.Op(op), tt.e) + if tt.errSinkFileName != "" { + defer func() { _ = os.WriteFile(tt.errSinkFileName, nil, 0666) }() + b, err := ioutil.ReadFile(tt.errSinkFileName) + require.NoError(err) + + if tt.noOutput { + assert.Lenf(b, 0, "should be an empty file: %s", string(b)) + return + } + + gotError := &eventJson{} + err = json.Unmarshal(b, gotError) + require.NoErrorf(err, "json: %s", string(b)) + + actualJson, err := json.Marshal(gotError) + require.NoError(err) + + wantError := eventJson{ + CreatedAt: gotError.CreatedAt, + EventType: string(gotError.EventType), + Payload: map[string]interface{}{ + "error": map[string]interface{}{ + "Code": tt.e.(*errors.Err).Code, + "Msg": tt.e.(*errors.Err).Msg, + "Op": tt.e.(*errors.Err).Op, + "Wrapped": tt.e.(*errors.Err).Wrapped, + }, + "id": gotError.Payload["id"], + "op": op, + "version": testErrorVersion, + }, + } + if tt.info != nil && tt.info.Id != "" { + wantError.Payload["id"] = tt.info.Id + } + if tt.info != nil { + wantError.Payload["request_info"] = tt.info + + } + wantJson, err := json.Marshal(wantError) + require.NoError(err) + + assert.JSONEq(string(wantJson), string(actualJson)) + } + }) + } +} diff --git a/internal/observability/event/event.go b/internal/observability/event/event.go new file mode 100644 index 0000000000..8443e0ed83 --- /dev/null +++ b/internal/observability/event/event.go @@ -0,0 +1,51 @@ +package event + +import "google.golang.org/protobuf/proto" + +type ( + Id string + Op string +) + +// RequestInfo defines the fields captured about a Boundary request. +type RequestInfo struct { + Id string `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + PublicId string `json:"public_id,omitempty"` +} + +// UserInfo defines the fields captured about a user for a Boundary request. +type UserInfo struct { + UserId string `json:"id,omitempty"` + AuthAccountId string `json:"auth_account_id,omitempty"` +} + +type GrantsInfo struct { + Grants []GrantsPair `json:"grants_pair,omitempty"` +} + +type GrantsPair struct { + Grant string `json:"grant,omitempty"` + ScopeId string `json:"scope_id,omitempty"` +} + +type Auth struct { + // AccessorId is a std audit field == auth_token public_id + AccessorId string `json:"accessor_id"` + UserInfo *UserInfo `json:"user_info,omitempty"` // boundary field + GrantsInfo *GrantsInfo `json:"grants_info,omitempty"` + UserEmail string `json:"email,omitempty"` + UserName string `json:"name,omitempty"` +} + +type Request struct { + Operation string `json:"operation"` // std audit field + Endpoint string `json:"endpoint"` // std audit field + Details proto.Message `json:"details"` // boundary field +} + +type Response struct { + StatusCode int `json:"status_code,omitempty"` // std audit + Details proto.Message `json:"details,omitempty"` // boundary field +} diff --git a/internal/observability/event/event_audit.go b/internal/observability/event/event_audit.go new file mode 100644 index 0000000000..fb1ab5e508 --- /dev/null +++ b/internal/observability/event/event_audit.go @@ -0,0 +1,148 @@ +package event + +import ( + "fmt" + "time" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/eventlogger" +) + +// auditVersion defines the version of audit events +const auditVersion = "v0.1" + +// auditEventType defines the type of audit event +type auditEventType string + +const ( + apiRequest auditEventType = "APIRequest" // ApiRequest defines an API request audit event type +) + +// audit defines the data of audit events +type audit struct { + Id string `json:"id"` // std audit/boundary field + Version string `json:"version"` // std audit/boundary field + Type string `json:"type"` // std audit field + Timestamp time.Time `json:"timestamp"` // std audit field + RequestInfo *RequestInfo `json:"request_info,omitempty"` // boundary field + Auth *Auth `json:"auth,omitempty"` // std audit field + Request *Request `json:"request,omitempty"` // std audit field + Response *Response `json:"response,omitempty"` // std audit field + SerializedHMAC string `json:"serialized_hmac"` // boundary field + Flush bool `json:"-"` +} + +func newAudit(fromOperation Op, opt ...Option) (*audit, error) { + const op = "event.newAudit" + if fromOperation == "" { + return nil, errors.New(errors.InvalidParameter, op, "missing from operation") + } + opts := getOpts(opt...) + if opts.withId == "" { + var err error + opts.withId, err = newId(string(AuditType)) + if err != nil { + return nil, errors.Wrap(err, op) + } + } + var dtm time.Time + switch opts.withNow.IsZero() { + case false: + dtm = opts.withNow + default: + dtm = time.Now() + } + + a := &audit{ + Id: opts.withId, + Version: auditVersion, + Type: string(apiRequest), + Timestamp: dtm, + RequestInfo: opts.withRequestInfo, + Auth: opts.withAuth, + Request: opts.withRequest, + Response: opts.withResponse, + Flush: opts.withFlush, + } + if err := a.validate(); err != nil { + return nil, errors.Wrap(err, op) + } + return a, nil +} + +// EventType is required for all event types by the eventlogger broker +func (a *audit) EventType() string { return string(AuditType) } + +func (a *audit) validate() error { + const op = "event.(audit).validate" + if a.Id == "" { + return errors.New(errors.InvalidParameter, op, "missing id") + } + return nil +} + +// GetID is part of the eventlogger.Gateable interface and returns the audit +// event's id. +func (a *audit) GetID() string { + return a.Id +} + +// FlushEvent is part of the eventlogger.Gateable interface and returns the +// value of the audit event's flush field +func (a *audit) FlushEvent() bool { + return a.Flush +} + +// ComposedFrom is part of the eventlogger.Gatable interface. It's important to +// remember that the receiver will always be nil when this is called by the eventlogger.GatedFilter +func (a *audit) ComposeFrom(events []*eventlogger.Event) (eventlogger.EventType, interface{}, error) { + const op = "event.(audit).ComposedFrom" + if len(events) == 0 { + return "", nil, errors.New(errors.InvalidParameter, op, "missing events") + } + var validId string + payload := audit{} + for i, v := range events { + gated, ok := v.Payload.(*audit) + if !ok { + return "", nil, errors.New(errors.InvalidParameter, op, fmt.Sprintf("event %d is not an audit payload", i)) + } + if gated.Id == "" { + // can't really happen since it has to have an id to be gated, but + // I'll add this check in the name of completeness + return "", nil, errors.New(errors.InvalidParameter, op, fmt.Sprintf("event %d: id is required", i)) + } + if validId == "" { + validId = gated.Id + } + if gated.Id != validId { + return "", nil, errors.New(errors.InvalidParameter, op, fmt.Sprintf("event %d has an invalid id: %s != %s", i, gated.Id, validId)) + } + if gated.Version != auditVersion { + return "", nil, errors.New(errors.InvalidParameter, op, fmt.Sprintf("event %d has an invalid version: %s != %s", i, gated.Version, auditVersion)) + } + if gated.Type != string(apiRequest) { + return "", nil, errors.New(errors.InvalidParameter, op, fmt.Sprintf("event %d has an invalid type: %s != %s", i, gated.Type, string(AuditType))) + } + if gated.RequestInfo != nil { + payload.RequestInfo = gated.RequestInfo + } + if gated.Auth != nil { + payload.Auth = gated.Auth + } + if gated.Request != nil { + payload.Request = gated.Request + } + if gated.Response != nil { + payload.Response = gated.Response + } + if !gated.Timestamp.IsZero() { + payload.Timestamp = gated.Timestamp + } + + } + payload.Id = validId + payload.Version = auditVersion + payload.Type = string(apiRequest) + return eventlogger.EventType(a.EventType()), payload, nil +} diff --git a/internal/observability/event/event_audit_test.go b/internal/observability/event/event_audit_test.go new file mode 100644 index 0000000000..9cf0d0bdee --- /dev/null +++ b/internal/observability/event/event_audit_test.go @@ -0,0 +1,278 @@ +package event + +import ( + "testing" + "time" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/eventlogger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_newAudit(t *testing.T) { + t.Parallel() + testNow := time.Now() + + tests := []struct { + name string + fromOp Op + opts []Option + want *audit + wantErrMatch *errors.Template + }{ + { + name: "missing-op", + wantErrMatch: errors.T(errors.InvalidParameter), + }, + { + name: "valid-no-opts", + fromOp: "valid-no-opts", + want: &audit{ + Version: auditVersion, + Type: string(apiRequest), + }, + }, + { + name: "all-opts", + fromOp: "all-opts", + opts: []Option{ + WithId("all-opts"), + WithNow(testNow), + WithRequestInfo(TestRequestInfo(t)), + WithAuth(testAuth(t)), + WithRequest(testRequest(t)), + WithResponse(testResponse(t)), + WithFlush(), + }, + want: &audit{ + Id: "all-opts", + Version: auditVersion, + Type: string(apiRequest), + Timestamp: testNow, + RequestInfo: TestRequestInfo(t), + Auth: testAuth(t), + Request: testRequest(t), + Response: testResponse(t), + Flush: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, err := newAudit(tt.fromOp, tt.opts...) + if tt.wantErrMatch != nil { + require.Error(err) + assert.Nil(got) + assert.True(errors.Match(tt.wantErrMatch, err)) + return + } + require.NoError(err) + require.NotNil(got) + opts := getOpts(tt.opts...) + if opts.withId == "" { + tt.want.Id = got.Id + } + if opts.withNow.IsZero() { + tt.want.Timestamp = got.Timestamp + } + assert.Equal(tt.want, got) + }) + } +} + +func TestAudit_validate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + id string + wantErrMatch *errors.Template + wantErrContains string + }{ + { + name: "missing-id", + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing id", + }, + { + name: "valid", + id: "valid", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + a := audit{Id: tt.id} + err := a.validate() + if tt.wantErrMatch != nil { + require.Error(err) + assert.True(errors.Match(tt.wantErrMatch, err)) + if tt.wantErrContains != "" { + assert.Contains(err.Error(), tt.wantErrContains) + } + return + } + assert.NoError(err) + }) + } +} + +func TestAudit_EventType(t *testing.T) { + t.Parallel() + a := &audit{} + assert.Equal(t, string(AuditType), a.EventType()) +} + +func TestAudit_GetID(t *testing.T) { + t.Parallel() + a := &audit{Id: "test"} + assert.Equal(t, "test", a.GetID()) +} + +func TestAudit_FlushEvent(t *testing.T) { + t.Parallel() + a := &audit{Flush: true} + assert.True(t, a.FlushEvent()) + a.Flush = false + assert.False(t, a.FlushEvent()) +} + +func TestAudit_ComposeFrom(t *testing.T) { + t.Parallel() + testNow := time.Now() + tests := []struct { + name string + events []*eventlogger.Event + want audit + wantErrMatch *errors.Template + wantErrContains string + }{ + { + name: "missing-events", + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing events", + }, + { + name: "not-an-audit", + events: []*eventlogger.Event{{ + Payload: struct{}{}, + }}, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "not an audit payload", + }, + { + name: "invalid-type", + events: []*eventlogger.Event{ + { + Payload: &audit{ + Id: "test-id", + Version: auditVersion, + Type: "invalid-type", + }, + }, + }, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "invalid type", + }, + { + name: "invalid-version", + events: []*eventlogger.Event{ + { + Payload: &audit{ + Id: "test-id", + Version: "invalid-version", + Type: string(apiRequest), + }, + }, + }, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "invalid version", + }, + { + name: "invalid-id", + events: []*eventlogger.Event{ + { + Payload: &audit{ + Id: "invalid-id", + Version: auditVersion, + Type: string(apiRequest), + }, + }, + { + Payload: &audit{ + Id: "bad-id", + Version: auditVersion, + Type: string(apiRequest), + }, + }, + }, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "invalid id", + }, + { + name: "valid", + events: []*eventlogger.Event{ + { + Payload: &audit{ + Id: "valid", + Version: auditVersion, + Type: string(apiRequest), + Timestamp: testNow, + Auth: testAuth(t), + RequestInfo: TestRequestInfo(t), + }, + }, + { + Payload: &audit{ + Id: "valid", + Version: auditVersion, + Type: string(apiRequest), + Timestamp: testNow, + Request: testRequest(t), + }, + }, + { + Payload: &audit{ + Id: "valid", + Version: auditVersion, + Type: string(apiRequest), + Timestamp: testNow, + Response: testResponse(t), + }, + }, + }, + want: audit{ + Id: "valid", + Version: auditVersion, + Type: string(apiRequest), + Timestamp: testNow, + Auth: testAuth(t), + Request: testRequest(t), + Response: testResponse(t), + RequestInfo: TestRequestInfo(t), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + a := &audit{} + gotType, gotAudit, err := a.ComposeFrom(tt.events) + if tt.wantErrMatch != nil { + require.Error(err) + assert.Nil(gotAudit) + assert.True(errors.Match(tt.wantErrMatch, err)) + if tt.wantErrContains != "" { + assert.Contains(err.Error(), tt.wantErrContains) + } + return + } + require.NoError(err) + require.NotNil(gotAudit) + assert.Equal(eventlogger.EventType(a.EventType()), gotType) + tt.want.Timestamp = gotAudit.(audit).Timestamp + assert.Equal(tt.want, gotAudit) + }) + } +} diff --git a/internal/observability/event/event_delivery_guarantee.go b/internal/observability/event/event_delivery_guarantee.go new file mode 100644 index 0000000000..8950a25fd4 --- /dev/null +++ b/internal/observability/event/event_delivery_guarantee.go @@ -0,0 +1,25 @@ +package event + +import ( + "fmt" + + "github.com/hashicorp/boundary/internal/errors" +) + +const ( + DefaultDeliveryGuarantee DeliveryGuarantee = "" // DefaultDeliveryGuarantee will be BestEffort + Enforced DeliveryGuarantee = "enforced" // Enforced means that a delivery guarantee is enforced + BestEffort DeliveryGuarantee = "best-effort" // BestEffort means that a best effort will be made to deliver an event +) + +type DeliveryGuarantee string // DeliveryGuarantee defines the guarantees around delivery of an event type within config + +func (g DeliveryGuarantee) validate() error { + const op = "event.(DeliveryGuarantee).validate" + switch g { + case DefaultDeliveryGuarantee, BestEffort, Enforced: + return nil + default: + return errors.New(errors.InvalidParameter, op, fmt.Sprintf("%s is not a valid delivery guarantee", g)) + } +} diff --git a/internal/observability/event/event_delivery_guarantee_test.go b/internal/observability/event/event_delivery_guarantee_test.go new file mode 100644 index 0000000000..de679cc0ae --- /dev/null +++ b/internal/observability/event/event_delivery_guarantee_test.go @@ -0,0 +1,53 @@ +package event + +import ( + "testing" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeliveryGuarantee_validate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + g DeliveryGuarantee + wantErrMatch *errors.Template + wantErrContains string + }{ + { + name: "invalid", + g: "invalid", + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "not a valid delivery guarantee", + }, + { + name: "BestEffort", + g: BestEffort, + }, + { + name: "Default", + g: DefaultDeliveryGuarantee, + }, + { + name: "Enforced", + g: Enforced, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + err := tt.g.validate() + if tt.wantErrMatch != nil { + require.Error(err) + assert.Truef(errors.Match(tt.wantErrMatch, err), "wanted %q and got %q", tt.wantErrMatch, err) + if tt.wantErrContains != "" { + assert.Contains(err.Error(), tt.wantErrContains) + } + return + } + require.NoError(err) + }) + } +} diff --git a/internal/observability/event/event_error.go b/internal/observability/event/event_error.go new file mode 100644 index 0000000000..ddb874b99f --- /dev/null +++ b/internal/observability/event/event_error.go @@ -0,0 +1,60 @@ +package event + +import "github.com/hashicorp/boundary/internal/errors" + +// errorVersion defines the version of error events +const errorVersion = "v0.1" + +type err struct { + Error error `json:"error"` + Id Id `json:"id,omitempty"` + Version string `json:"version"` + Op Op `json:"op,omitempty"` + RequestInfo *RequestInfo `json:"request_info,omitempty"` +} + +func newError(fromOperation Op, e error, opt ...Option) (*err, error) { + const op = "event.newError" + if fromOperation == "" { + return nil, errors.New(errors.InvalidParameter, op, "missing operation") + } + if e == nil { + return nil, errors.New(errors.InvalidParameter, op, "missing error") + } + opts := getOpts(opt...) + if opts.withId == "" { + var err error + opts.withId, err = newId(string(ErrorType)) + if err != nil { + return nil, errors.Wrap(err, op) + } + } + newErr := &err{ + Id: Id(opts.withId), + Op: fromOperation, + Version: errorVersion, + RequestInfo: opts.withRequestInfo, + Error: e, + } + if err := newErr.validate(); err != nil { + return nil, errors.Wrap(err, op) + } + return newErr, nil +} + +// EventType is required for all event types by the eventlogger broker +func (e *err) EventType() string { return string(ErrorType) } + +func (e *err) validate() error { + const op = "event.(err).validate" + if e.Id == "" { + return errors.New(errors.InvalidParameter, op, "missing id") + } + if e.Op == "" { + return errors.New(errors.InvalidParameter, op, "missing operation") + } + if e.Error == nil { + return errors.New(errors.InvalidParameter, op, "missing error") + } + return nil +} diff --git a/internal/observability/event/event_error_test.go b/internal/observability/event/event_error_test.go new file mode 100644 index 0000000000..53982c1e3e --- /dev/null +++ b/internal/observability/event/event_error_test.go @@ -0,0 +1,147 @@ +package event + +import ( + "testing" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_newError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fromOp Op + e error + opts []Option + want *err + wantErrMatch *errors.Template + wantErrContains string + }{ + { + name: "missing-op", + e: errors.New(errors.InvalidParameter, "missing-operation", "missing operation"), + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing operation", + }, + { + name: "missing-error", + fromOp: Op("missing-error"), + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing error", + }, + { + name: "valid-no-opts", + fromOp: Op("valid-no-opts"), + e: errors.New(errors.InvalidParameter, "valid-no-opts", "valid no opts"), + want: &err{ + Error: errors.New(errors.InvalidParameter, "valid-no-opts", "valid no opts"), + Version: errorVersion, + Op: Op("valid-no-opts"), + }, + }, + { + name: "valid-all-opts", + fromOp: Op("valid-all-opts"), + e: errors.New(errors.InvalidParameter, "valid-all-opts", "valid all opts"), + opts: []Option{ + WithId("valid-all-opts"), + WithRequestInfo(TestRequestInfo(t)), + }, + want: &err{ + Error: errors.New(errors.InvalidParameter, "valid-all-opts", "valid all opts"), + Version: errorVersion, + Op: Op("valid-all-opts"), + Id: "valid-all-opts", + RequestInfo: TestRequestInfo(t), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, err := newError(tt.fromOp, tt.e, tt.opts...) + if tt.wantErrMatch != nil { + require.Error(err) + assert.Nil(got) + assert.True(errors.Match(tt.wantErrMatch, err)) + if tt.wantErrContains != "" { + assert.Contains(err.Error(), tt.wantErrContains) + } + return + } + require.NoError(err) + require.NotNil(got) + opts := getOpts(tt.opts...) + if opts.withId == "" { + tt.want.Id = got.Id + } + if opts.withRequestInfo != nil { + tt.want.RequestInfo = got.RequestInfo + } + assert.Equal(tt.want, got) + }) + } +} + +func Test_errvalidate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + id string + op Op + want error + wantErrMatch *errors.Template + wantErrContains string + }{ + { + name: "missing-id", + op: Op("missing-id"), + want: errors.New(errors.InvalidParameter, "missing-id", "missing id"), + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing id", + }, + { + name: "missing-operation", + id: "missing-operation", + want: errors.New(errors.InvalidParameter, "missing-operation", "missing operation"), + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing operation", + }, + { + name: "valid", + op: Op("valid"), + id: "valid", + want: errors.New(errors.InvalidParameter, "valid", "valid error"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + e := err{ + Op: tt.op, + Id: Id(tt.id), + Error: tt.want, + } + err := e.validate() + if tt.wantErrMatch != nil { + require.Error(err) + assert.True(errors.Match(tt.wantErrMatch, err)) + if tt.wantErrContains != "" { + assert.Contains(err.Error(), tt.wantErrContains) + } + return + } + assert.NoError(err) + assert.Equal(tt.want, e.Error) + }) + } +} + +func Test_errEventType(t *testing.T) { + t.Parallel() + e := &err{} + assert.Equal(t, string(ErrorType), e.EventType()) +} diff --git a/internal/observability/event/event_observation.go b/internal/observability/event/event_observation.go new file mode 100644 index 0000000000..21fe06e27e --- /dev/null +++ b/internal/observability/event/event_observation.go @@ -0,0 +1,68 @@ +package event + +import ( + "fmt" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/eventlogger" + "github.com/hashicorp/vault/sdk/helper/strutil" +) + +// observationVersion defines the version of observation events +const observationVersion = "v0.1" + +type observation struct { + *eventlogger.SimpleGatedPayload + Version string `json:"version"` + Op Op `json:"op,omitempty"` + RequestInfo *RequestInfo `json:"request_info,omitempty"` +} + +func newObservation(fromOperation Op, opt ...Option) (*observation, error) { + const op = "event.newObservation" + if fromOperation == "" { + return nil, errors.New(errors.InvalidParameter, op, "missing operation") + } + opts := getOpts(opt...) + if opts.withId == "" { + var err error + opts.withId, err = newId(string(ObservationType)) + if err != nil { + return nil, errors.Wrap(err, op) + } + } + for k := range opts.withHeader { + if strutil.StrListContains([]string{OpField, VersionField, RequestInfoField}, k) { + return nil, errors.New(errors.InvalidParameter, op, fmt.Sprintf("%s is a reserved field name", k)) + } + } + i := &observation{ + SimpleGatedPayload: &eventlogger.SimpleGatedPayload{ + ID: opts.withId, + Header: opts.withHeader, + Detail: opts.withDetails, + Flush: opts.withFlush, + }, + Op: fromOperation, + RequestInfo: opts.withRequestInfo, + Version: observationVersion, + } + if err := i.validate(); err != nil { + return nil, errors.Wrap(err, op) + } + return i, nil +} + +// EventType is required for all event types by the eventlogger broker +func (i *observation) EventType() string { return string(ObservationType) } + +func (i *observation) validate() error { + const op = "event.(Observation).validate" + if i.ID == "" { + return errors.New(errors.InvalidParameter, op, "missing id") + } + if i.Op == "" { + return errors.New(errors.InvalidParameter, op, "missing operation") + } + return nil +} diff --git a/internal/observability/event/event_observation_test.go b/internal/observability/event/event_observation_test.go new file mode 100644 index 0000000000..f932d5fecf --- /dev/null +++ b/internal/observability/event/event_observation_test.go @@ -0,0 +1,150 @@ +package event + +import ( + "testing" + "time" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/eventlogger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_newObservation(t *testing.T) { + t.Parallel() + + now := time.Now() + + testHeader := map[string]interface{}{ + "public-id": "public-id", + "now": now, + } + + testDetails := map[string]interface{}{ + "file_name": "tmpfile-name", + } + + tests := []struct { + name string + fromOp Op + opts []Option + want *observation + wantErrMatch *errors.Template + wantErrContains string + }{ + { + name: "missing-op", + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing operation", + }, + { + name: "valid-no-opts", + fromOp: Op("valid-no-opts"), + want: &observation{ + SimpleGatedPayload: &eventlogger.SimpleGatedPayload{}, + Version: errorVersion, + Op: Op("valid-no-opts"), + }, + }, + { + name: "valid-all-opts", + fromOp: Op("valid-all-opts"), + opts: []Option{ + WithId("valid-all-opts"), + WithRequestInfo(TestRequestInfo(t)), + WithHeader(testHeader), + WithDetails(testDetails), + WithFlush(), + }, + want: &observation{ + SimpleGatedPayload: &eventlogger.SimpleGatedPayload{ + ID: "valid-all-opts", + Header: testHeader, + Detail: testDetails, + Flush: true, + }, + Version: errorVersion, + Op: Op("valid-all-opts"), + RequestInfo: TestRequestInfo(t), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, err := newObservation(tt.fromOp, tt.opts...) + if tt.wantErrMatch != nil { + require.Error(err) + assert.Nil(got) + assert.True(errors.Match(tt.wantErrMatch, err)) + if tt.wantErrContains != "" { + assert.Contains(err.Error(), tt.wantErrContains) + } + return + } + require.NoError(err) + require.NotNil(got) + opts := getOpts(tt.opts...) + if opts.withId == "" { + tt.want.ID = got.ID + } + assert.Equal(tt.want, got) + }) + } +} + +func Test_observationvalidate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + id string + op Op + wantErrMatch *errors.Template + wantErrContains string + }{ + { + name: "missing-id", + op: Op("missing-id"), + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing id", + }, + { + name: "missing-operation", + id: "missing-operation", + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing operation", + }, + { + name: "valid", + op: Op("valid"), + id: "valid", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + e := observation{ + Op: tt.op, + SimpleGatedPayload: &eventlogger.SimpleGatedPayload{ + ID: tt.id, + }, + } + err := e.validate() + if tt.wantErrMatch != nil { + require.Error(err) + assert.True(errors.Match(tt.wantErrMatch, err)) + if tt.wantErrContains != "" { + assert.Contains(err.Error(), tt.wantErrContains) + } + return + } + assert.NoError(err) + }) + } +} + +func Test_observationEventType(t *testing.T) { + t.Parallel() + e := &observation{} + assert.Equal(t, string(ObservationType), e.EventType()) +} diff --git a/internal/observability/event/event_type.go b/internal/observability/event/event_type.go new file mode 100644 index 0000000000..4f60ee60c1 --- /dev/null +++ b/internal/observability/event/event_type.go @@ -0,0 +1,27 @@ +package event + +import ( + "fmt" + + "github.com/hashicorp/boundary/internal/errors" +) + +// Type represents the event's type +type Type string + +const ( + EveryType Type = "*" // EveryType represents every (all) types of events + ObservationType Type = "observation" // ObservationType represents observation events + AuditType Type = "audit" // AuditType represents audit events + ErrorType Type = "error" // ErrorType represents error events +) + +func (et Type) validate() error { + const op = "event.(Type).validate" + switch et { + case EveryType, ObservationType, AuditType, ErrorType: + return nil + default: + return errors.New(errors.InvalidParameter, op, fmt.Sprintf("'%s' is not a valid event type", et)) + } +} diff --git a/internal/observability/event/eventer.go b/internal/observability/event/eventer.go new file mode 100644 index 0000000000..1c9197aefb --- /dev/null +++ b/internal/observability/event/eventer.go @@ -0,0 +1,398 @@ +package event + +import ( + "context" + "fmt" + "os" + "sync" + "time" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/eventlogger" + "github.com/hashicorp/go-hclog" +) + +const ( + OpField = "op" // OpField in an event. + RequestInfoField = "request_info" // RequestInfoField in an event. + VersionField = "version" // VersionField in an event + DetailsField = "details" // Details field in an event. + HeaderField = "header" // HeaderField in an event. + IdField = "id" // IdField in an event. + CreatedAtField = "created_at" // CreatedAtField in an event. + TypeField = "type" // TypeField in an event. + + auditPipeline = "audit-pipeline" // auditPipeline is a pipeline for audit events + observationPipeline = "observation-pipeline" // observationPipeline is a pipeline for observation events + errPipeline = "err-pipeline" // errPipeline is a pipeline for error events +) + +// flushable defines an interface that all eventlogger Nodes must implement if +// they are "flushable" +type flushable interface { + FlushAll(ctx context.Context) error +} + +// broker defines an interface for an eventlogger Broker... which will allow us +// to substitute our testing broker when needed to write tests for things +// like event send retrying. +type broker interface { + Send(ctx context.Context, t eventlogger.EventType, payload interface{}) (eventlogger.Status, error) + Reopen(ctx context.Context) error + StopTimeAt(now time.Time) + RegisterNode(id eventlogger.NodeID, node eventlogger.Node) error + SetSuccessThreshold(t eventlogger.EventType, successThreshold int) error + RegisterPipeline(def eventlogger.Pipeline) error +} + +// Eventer provides a method to send events to pipelines of sinks +type Eventer struct { + broker broker + flushableNodes []flushable + conf EventerConfig + logger hclog.Logger +} + +var ( + sysEventer *Eventer // sysEventer is the system-wide Eventer + sysEventerOnce sync.Once // sysEventerOnce ensures that the system-wide Eventer is only initialized once. +) + +// InitSysEventer provides a mechanism to initialize a "system wide" eventer +// singleton for Boundary +func InitSysEventer(log hclog.Logger, c EventerConfig) error { + const op = "event.InitSysEventer" + if log == nil { + return errors.New(errors.InvalidParameter, op, "missing hclog") + } + // the order of operations is important here. we want to determine if + // there's an error before setting the singleton sysEventer which can only + // be done one time. + eventer, err := NewEventer(log, c) + if err != nil { + return errors.Wrap(err, op) + } + sysEventerOnce.Do(func() { + sysEventer = eventer + }) + return nil +} + +// SysEventer returns the "system wide" eventer for Boundary. +func SysEventer() *Eventer { + return sysEventer +} + +// NewEventer creates a new Eventer using the config. Supports options: WithNow +func NewEventer(log hclog.Logger, c EventerConfig, opt ...Option) (*Eventer, error) { + const op = "event.NewEventer" + if log == nil { + return nil, errors.New(errors.InvalidParameter, op, "missing logger") + } + + // if there are no sinks in config, then we'll default to just one stdout + // sink. + if len(c.Sinks) == 0 { + c.Sinks = append(c.Sinks, SinkConfig{ + Name: "default", + EventTypes: []Type{EveryType}, + Format: JSONSinkFormat, + SinkType: StdoutSink, + }) + } + + if err := c.validate(); err != nil { + return nil, errors.Wrap(err, op) + } + + type pipeline struct { + eventType Type + fmtId eventlogger.NodeID + sinkId eventlogger.NodeID + } + var auditPipelines, observationPipelines, errPipelines []pipeline + + opts := getOpts(opt...) + var b broker + switch { + case opts.withBroker != nil: + b = opts.withBroker + default: + b = eventlogger.NewBroker() + } + + e := &Eventer{ + logger: log, + conf: c, + broker: b, + } + + if !opts.withNow.IsZero() { + e.broker.StopTimeAt(opts.withNow) + } + + // Create JSONFormatter node + id, err := newId("json") + if err != nil { + return nil, errors.Wrap(err, op) + } + jsonfmtId := eventlogger.NodeID(id) + fmtNode := &eventlogger.JSONFormatter{} + err = e.broker.RegisterNode(jsonfmtId, fmtNode) + if err != nil { + return nil, errors.Wrap(err, "failed to register json node") + } + + for _, s := range c.Sinks { + var sinkId eventlogger.NodeID + var sinkNode eventlogger.Node + switch s.SinkType { + case StdoutSink: + sinkNode = &eventlogger.WriterSink{ + Format: string(s.Format), + Writer: os.Stdout, + } + id, err = newId("stdout") + if err != nil { + return nil, errors.Wrap(err, op) + } + sinkId = eventlogger.NodeID(id) + default: + sinkNode = &eventlogger.FileSink{ + Format: string(s.Format), + Path: s.Path, + FileName: s.FileName, + MaxBytes: s.RotateBytes, + MaxDuration: s.RotateDuration, + MaxFiles: s.RotateMaxFiles, + } + id, err = newId(fmt.Sprintf("file_%s_%s_", s.Path, s.FileName)) + if err != nil { + return nil, errors.Wrap(err, op) + } + sinkId = eventlogger.NodeID(id) + } + err = e.broker.RegisterNode(sinkId, sinkNode) + if err != nil { + return nil, errors.Wrap(err, op, errors.WithMsg(fmt.Sprintf("failed to register sink node %s", sinkId))) + } + var addToAudit, addToObservation, addToErr bool + for _, t := range s.EventTypes { + switch t { + case EveryType: + addToAudit = true + addToObservation = true + addToErr = true + case ErrorType: + addToErr = true + case AuditType: + addToAudit = true + case ObservationType: + addToObservation = true + } + } + if addToAudit { + auditPipelines = append(auditPipelines, pipeline{ + eventType: AuditType, + fmtId: jsonfmtId, + sinkId: sinkId, + }) + } + if addToObservation { + observationPipelines = append(observationPipelines, pipeline{ + eventType: ObservationType, + fmtId: jsonfmtId, + sinkId: sinkId, + }) + } + if addToErr { + errPipelines = append(errPipelines, pipeline{ + eventType: ErrorType, + fmtId: jsonfmtId, + sinkId: sinkId, + }) + } + } + if c.AuditEnabled && len(auditPipelines) == 0 { + return nil, errors.New(errors.InvalidParameter, op, "audit events enabled but no sink defined for it") + } + if c.ObservationsEnabled && len(observationPipelines) == 0 { + return nil, errors.New(errors.InvalidParameter, op, "observation events enabled but no sink defined for it") + } + + auditNodeIds := make([]eventlogger.NodeID, 0, len(auditPipelines)) + for _, p := range auditPipelines { + gatedFilterNode := eventlogger.GatedFilter{} + e.flushableNodes = append(e.flushableNodes, &gatedFilterNode) + gateId, err := newId("gated-audit") + if err != nil { + return nil, errors.Wrap(err, op) + } + gatedFilterNodeId := eventlogger.NodeID(gateId) + if err := e.broker.RegisterNode(gatedFilterNodeId, &gatedFilterNode); err != nil { + return nil, errors.Wrap(err, op, errors.WithMsg("unable to register audit gated filter")) + } + + pipeId, err := newId(auditPipeline) + if err != nil { + return nil, errors.Wrap(err, op) + } + err = e.broker.RegisterPipeline(eventlogger.Pipeline{ + EventType: eventlogger.EventType(p.eventType), + PipelineID: eventlogger.PipelineID(pipeId), + NodeIDs: []eventlogger.NodeID{gatedFilterNodeId, p.fmtId, p.sinkId}, + }) + if err != nil { + return nil, errors.Wrap(err, "failed to register audit pipeline") + } + auditNodeIds = append(auditNodeIds, p.sinkId) + } + observationNodeIds := make([]eventlogger.NodeID, 0, len(observationPipelines)) + for _, p := range observationPipelines { + gatedFilterNode := eventlogger.GatedFilter{} + e.flushableNodes = append(e.flushableNodes, &gatedFilterNode) + gateId, err := newId("gated-observation") + if err != nil { + return nil, errors.Wrap(err, op) + } + gatedFilterNodeId := eventlogger.NodeID(gateId) + if err := e.broker.RegisterNode(gatedFilterNodeId, &gatedFilterNode); err != nil { + return nil, errors.Wrap(err, op, errors.WithMsg("unable to register audit gated filter")) + } + + pipeId, err := newId(observationPipeline) + if err != nil { + return nil, errors.Wrap(err, op) + } + err = e.broker.RegisterPipeline(eventlogger.Pipeline{ + EventType: eventlogger.EventType(p.eventType), + PipelineID: eventlogger.PipelineID(pipeId), + NodeIDs: []eventlogger.NodeID{gatedFilterNodeId, p.fmtId, p.sinkId}, + }) + if err != nil { + return nil, errors.Wrap(err, "failed to register observation pipeline") + } + observationNodeIds = append(observationNodeIds, p.sinkId) + } + errNodeIds := make([]eventlogger.NodeID, 0, len(errPipelines)) + for _, p := range errPipelines { + pipeId, err := newId(errPipeline) + if err != nil { + return nil, errors.Wrap(err, op) + } + err = e.broker.RegisterPipeline(eventlogger.Pipeline{ + EventType: eventlogger.EventType(p.eventType), + PipelineID: eventlogger.PipelineID(pipeId), + NodeIDs: []eventlogger.NodeID{p.fmtId, p.sinkId}, + }) + if err != nil { + return nil, errors.Wrap(err, "failed to register err pipeline") + } + errNodeIds = append(errNodeIds, p.sinkId) + } + + // TODO(jimlambrt) go-eventlogger SetSuccessThreshold currently does not + // specify which sink passed and which hasn't so we are unable to + // support multiple sinks with different delivery guarantees + if c.AuditDelivery == Enforced { + err = e.broker.SetSuccessThreshold(eventlogger.EventType(AuditType), len(auditNodeIds)) + if err != nil { + return nil, errors.Wrap(err, "failed to set success threshold for audit events") + } + } + if c.ObservationDelivery == Enforced { + err = e.broker.SetSuccessThreshold(eventlogger.EventType(ObservationType), len(observationNodeIds)) + if err != nil { + return nil, errors.Wrap(err, "failed to set success threshold for observation events") + } + } + // always enforce delivery of errors + err = e.broker.SetSuccessThreshold(eventlogger.EventType(ErrorType), len(errNodeIds)) + if err != nil { + return nil, errors.Wrap(err, "failed to set success threshold for error events") + } + + return e, nil +} + +// writeObservation writes/sends an Observation event. +func (e *Eventer) writeObservation(ctx context.Context, event *observation) error { + const op = "event.(Eventer).writeObservation" + if event == nil { + return errors.New(errors.InvalidParameter, op, "missing event") + } + if !e.conf.ObservationsEnabled { + return nil + } + err := e.retrySend(ctx, stdRetryCount, expBackoff{}, func() (eventlogger.Status, error) { + if event.Header != nil { + event.Header[RequestInfoField] = event.RequestInfo + event.Header[VersionField] = event.Version + } + if event.Detail != nil { + event.Detail[OpField] = string(event.Op) + } + return e.broker.Send(ctx, eventlogger.EventType(ObservationType), event.SimpleGatedPayload) + }) + if err != nil { + e.logger.Error("encountered an error sending an observation event", "error:", err.Error()) + return errors.Wrap(err, op) + } + return nil +} + +// writeError writes/sends an Err event +func (e *Eventer) writeError(ctx context.Context, event *err) error { + const op = "event.(Eventer).writeError" + if event == nil { + return errors.New(errors.InvalidParameter, op, "missing event") + } + err := e.retrySend(ctx, stdRetryCount, expBackoff{}, func() (eventlogger.Status, error) { + return e.broker.Send(ctx, eventlogger.EventType(ErrorType), event) + }) + if err != nil { + e.logger.Error("encountered an error sending an error event", "error:", err.Error()) + return errors.Wrap(err, op) + } + return nil +} + +// writeAudit writes/send an audit event +func (e *Eventer) writeAudit(ctx context.Context, event *audit) error { + const op = "event.(Eventer).writeAudit" + if event == nil { + return errors.New(errors.InvalidParameter, op, "missing event") + } + if !e.conf.AuditEnabled { + return nil + } + err := e.retrySend(ctx, stdRetryCount, expBackoff{}, func() (eventlogger.Status, error) { + return e.broker.Send(ctx, eventlogger.EventType(AuditType), event) + }) + if err != nil { + e.logger.Error("encountered an error sending an audit event", "error:", err.Error()) + return errors.Wrap(err, op) + } + return nil +} + +// Reopen can used during a SIGHUP to reopen nodes, most importantly the underlying +// file sinks. +func (e *Eventer) Reopen() error { + if e.broker != nil { + return e.broker.Reopen(context.Background()) + } + return nil +} + +// FlushNodes will flush any of the eventer's flushable nodes. This +// needs to be called whenever Boundary is stopping (aka shutting down). +func (e *Eventer) FlushNodes(ctx context.Context) error { + const op = "event.(Eventer).FlushNodes" + for _, n := range e.flushableNodes { + if err := n.FlushAll(ctx); err != nil { + return errors.Wrap(err, op) + } + } + return nil +} diff --git a/internal/observability/event/eventer_config.go b/internal/observability/event/eventer_config.go new file mode 100644 index 0000000000..6587614cc4 --- /dev/null +++ b/internal/observability/event/eventer_config.go @@ -0,0 +1,35 @@ +package event + +import ( + "fmt" + + "github.com/hashicorp/boundary/internal/errors" +) + +// EventerConfig supplies all the configuration needed to create/config an Eventer. +type EventerConfig struct { + AuditDelivery DeliveryGuarantee // AuditDelivery specifies the delivery guarantees for audit events (enforced or best effort). + ObservationDelivery DeliveryGuarantee // ObservationDelivery specifies the delivery guarantees for observation events (enforced or best effort). + AuditEnabled bool // AuditEnabled specifies if audit events should be emitted. + ObservationsEnabled bool // ObservationsEnabled specifies if observation events should be emitted. + Sinks []SinkConfig // Sinks are all the configured sinks +} + +// validate will validate the config. BTW, a config isn't required to have any +// sinks to be valid. +func (c *EventerConfig) validate() error { + const op = "event.(EventerConfig).validate" + + if err := c.AuditDelivery.validate(); err != nil { + return errors.Wrap(err, op) + } + if err := c.ObservationDelivery.validate(); err != nil { + return errors.Wrap(err, op) + } + for i, s := range c.Sinks { + if err := s.validate(); err != nil { + return errors.Wrap(err, op, errors.WithMsg(fmt.Sprintf("sink %d is invalid", i))) + } + } + return nil +} diff --git a/internal/observability/event/eventer_config_test.go b/internal/observability/event/eventer_config_test.go new file mode 100644 index 0000000000..0be23e102d --- /dev/null +++ b/internal/observability/event/eventer_config_test.go @@ -0,0 +1,79 @@ +package event + +import ( + "testing" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEventerConfig_validate(t *testing.T) { + tests := []struct { + name string + c EventerConfig + wantErrMatch *errors.Template + wantErrContains string + }{ + { + name: "invalid-audit-delivery", + c: EventerConfig{ + AuditDelivery: "invalid", + }, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "is not a valid delivery guarantee", + }, + { + name: "valid-audit-delivery", + c: EventerConfig{ + AuditDelivery: Enforced, + }, + }, + { + name: "invalid-observation-delivery", + c: EventerConfig{ + ObservationDelivery: "invalid", + }, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "is not a valid delivery guarantee", + }, + { + name: "valid-observation-delivery", + c: EventerConfig{ + ObservationDelivery: Enforced, + }, + }, + { + name: "invalid-sink", + c: EventerConfig{ + Sinks: []SinkConfig{ + { + SinkType: "invalid", + }, + }, + }, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "is not a valid sink type", + }, + { + name: "valid-with-all-defaults", + c: EventerConfig{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + err := tt.c.validate() + if tt.wantErrMatch != nil { + require.Error(err) + assert.Truef(errors.Match(tt.wantErrMatch, err), "wanted %q and got %q", tt.wantErrMatch, err) + if tt.wantErrContains != "" { + assert.Contains(err.Error(), tt.wantErrContains) + } + return + } + assert.NoError(err) + }) + } +} diff --git a/internal/observability/event/eventer_retry.go b/internal/observability/event/eventer_retry.go new file mode 100644 index 0000000000..af1444b956 --- /dev/null +++ b/internal/observability/event/eventer_retry.go @@ -0,0 +1,82 @@ +package event + +import ( + "context" + "fmt" + "math" + "math/rand" + "time" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/eventlogger" + "github.com/hashicorp/go-multierror" +) + +const ( + // stdRetryCount is the standard number of times for retry when sending events + stdRetryCount = 3 +) + +type backoff interface { + duration(attemptNumber uint) time.Duration +} + +type expBackoff struct{} + +// duration returns an exponential backing off time duration +func (b expBackoff) duration(attempt uint) time.Duration { + r := rand.Float64() + return time.Millisecond * time.Duration(math.Exp2(float64(attempt))*5*(r+0.5)) +} + +type retryInfo struct { + retries int + backoff time.Duration +} + +type sendHandler func() (eventlogger.Status, error) + +// retrySend will attempt sendHandler (which is intended to be a closure that +// sends an event) the specified number of retries using the specified backoff. +func (e *Eventer) retrySend(ctx context.Context, retries uint, backOff backoff, handler sendHandler) error { + const op = "event.(Eventer).retrySend" + if backOff == nil { + return errors.New(errors.InvalidParameter, op, "missing backoff") + } + if handler == nil { + return errors.New(errors.InvalidParameter, op, "missing handler") + } + success := false + var retryErrors error + var attemptStatus eventlogger.Status + info := retryInfo{} + for attempts := uint(1); ; attempts++ { + if attempts > retries+1 { + retryErrors = multierror.Append(retryErrors, errors.New(errors.MaxRetries, op, fmt.Sprintf("Too many retries: reached max of %d", retries))) + return retryErrors + } + var err error + attemptStatus, err = handler() + if len(attemptStatus.Warnings) > 0 { + var retryWarnings error + for _, w := range attemptStatus.Warnings { + retryWarnings = multierror.Append(retryWarnings, w) + } + e.logger.Error("unable to send event", "operation", op, "warning", retryWarnings) + } + if err != nil { + retryErrors = multierror.Append(retryErrors, errors.Wrap(err, op)) + d := backOff.duration(attempts) + info.retries++ + info.backoff = info.backoff + d + time.Sleep(d) + continue + } + success = true + break + } + if !success { + return errors.Wrap(retryErrors, op, errors.WithMsg("failed to send event")) + } + return nil +} diff --git a/internal/observability/event/eventer_retry_test.go b/internal/observability/event/eventer_retry_test.go new file mode 100644 index 0000000000..b17cb0ec13 --- /dev/null +++ b/internal/observability/event/eventer_retry_test.go @@ -0,0 +1,116 @@ +package event + +import ( + "context" + "os" + "testing" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/eventlogger" + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/go-multierror" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEventer_retrySend(t *testing.T) { + t.Parallel() + ctx := context.Background() + + testConfig := TestEventerConfig(t, "TestEventer_retrySend") + + eventer, err := NewEventer(hclog.Default(), testConfig.EventerConfig) + require.NoError(t, err) + + testError := errors.New(errors.InvalidParameter, "missing-operation", "missing operation") + testEvent, err := newError("TestEventer_retrySend", testError, WithId("test-error")) + require.NoError(t, err) + + tests := []struct { + name string + retries uint + backOff backoff + handler sendHandler + wantErrMatch *errors.Template + wantErrContain string + }{ + { + name: "missing-backoff", + retries: 1, + handler: func() (eventlogger.Status, error) { + return eventer.broker.Send(ctx, eventlogger.EventType(ErrorType), testEvent) + }, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContain: "missing backoff", + }, + { + name: "missing-handler", + retries: 1, + backOff: expBackoff{}, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContain: "missing handler", + }, + { + name: "too-many-retries", + retries: 3, + backOff: expBackoff{}, + handler: func() (eventlogger.Status, error) { + return eventlogger.Status{}, errors.New(errors.InvalidParameter, "TestEventer_retrySend", "will never work") + }, + wantErrMatch: errors.T(errors.MaxRetries), + wantErrContain: "Too many retries", + }, + { + name: "success-with-warnings", + retries: 3, + backOff: expBackoff{}, + handler: func() (eventlogger.Status, error) { + return eventlogger.Status{ + Warnings: []error{errors.New(errors.RecordNotFound, "TestEventer_retrySend", "not found")}, + }, nil + }, + }, + { + name: "success", + retries: 1, + backOff: expBackoff{}, + handler: func() (eventlogger.Status, error) { + return eventlogger.Status{}, nil + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + defer os.Remove(testConfig.AllEvents.Name()) + defer os.Remove(testConfig.ErrorEvents.Name()) + + err := eventer.retrySend(ctx, tt.retries, tt.backOff, tt.handler) + if tt.wantErrMatch != nil { + require.Error(err) + multi, isMultiError := err.(*multierror.Error) + switch isMultiError { + case true: + matched := false + for _, e := range multi.WrappedErrors() { + if errors.Match(tt.wantErrMatch, e) { + if tt.wantErrContain != "" { + assert.Contains(err.Error(), tt.wantErrContain) + } + matched = true + } + } + assert.True(matched) + default: + assert.True(errors.Match(tt.wantErrMatch, err)) + if tt.wantErrContain != "" { + assert.Contains(err.Error(), tt.wantErrContain) + } + } + return + } + require.NoError(err) + }) + } +} diff --git a/internal/observability/event/eventer_test.go b/internal/observability/event/eventer_test.go new file mode 100644 index 0000000000..eee94e0ff3 --- /dev/null +++ b/internal/observability/event/eventer_test.go @@ -0,0 +1,492 @@ +package event + +import ( + "context" + "strings" + "testing" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/eventlogger" + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_InitSysEventer(t *testing.T) { + // this test and its subtests cannot be run in parallel because of it's + // dependency on the sysEventer + testConfig := TestEventerConfig(t, "InitSysEventer") + + tests := []struct { + name string + log hclog.Logger + config EventerConfig + want *Eventer + wantErrMatch *errors.Template + }{ + + { + name: "missing-hclog", + config: testConfig.EventerConfig, + wantErrMatch: errors.T(errors.InvalidParameter), + }, + { + name: "success", + config: testConfig.EventerConfig, + log: hclog.Default(), + want: &Eventer{ + logger: hclog.Default(), + conf: testConfig.EventerConfig, + }, + }, + { + name: "success-with-default-config", + config: EventerConfig{}, + log: hclog.Default(), + want: &Eventer{ + logger: hclog.Default(), + conf: EventerConfig{ + Sinks: []SinkConfig{ + { + Name: "default", + EventTypes: []Type{EveryType}, + Format: JSONSinkFormat, + SinkType: StdoutSink, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer TestResetSystEventer(t) + + assert, require := assert.New(t), require.New(t) + + err := InitSysEventer(tt.log, tt.config) + got := SysEventer() + if tt.wantErrMatch != nil { + require.Nil(got) + require.Error(err) + if tt.wantErrMatch != nil { + assert.True(errors.Match(tt.wantErrMatch, err)) + } + return + } + require.NoError(err) + require.NotNil(got) + tt.want.broker = got.broker + tt.want.flushableNodes = got.flushableNodes + assert.Equal(tt.want, got) + }) + } +} + +func TestEventer_writeObservation(t *testing.T) { + t.Parallel() + ctx := context.Background() + testSetup := TestEventerConfig(t, "TestEventer_writeObservation") + eventer, err := NewEventer(hclog.Default(), testSetup.EventerConfig) + require.NoError(t, err) + + testHeader := map[string]interface{}{"name": "header"} + testDetail := map[string]interface{}{"name": "details"} + testObservation, err := newObservation("Test_NewEventer", WithHeader(testHeader), WithDetails(testDetail)) + require.NoError(t, err) + + tests := []struct { + name string + broker broker + observation *observation + wantErrMatch *errors.Template + }{ + { + name: "missing-observation", + broker: &testMockBroker{}, + wantErrMatch: errors.T(errors.InvalidParameter), + }, + { + name: "send-fails", + broker: &testMockBroker{errorOnSend: errors.New(errors.Io, "test", "no msg")}, + observation: testObservation, + wantErrMatch: errors.T(errors.Io), + }, + { + name: "success", + broker: &testMockBroker{}, + observation: testObservation, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + eventer.broker = tt.broker + + err = eventer.writeObservation(ctx, tt.observation) + if tt.wantErrMatch != nil { + require.Error(err) + assert.Truef(errors.Match(tt.wantErrMatch, err), "got %q and wanted %q", err.Error(), tt.wantErrMatch) + return + } + require.NoError(err) + }) + } + t.Run("e2e", func(t *testing.T) { + require := require.New(t) + + logger := hclog.New(&hclog.LoggerOptions{ + Name: "test", + }) + c := EventerConfig{ + ObservationsEnabled: true, + } + // with no defined config, it will default to a stdout sink + e, err := NewEventer(logger, c) + require.NoError(err) + + m := map[string]interface{}{ + "name": "bar", + "list": []string{"1", "2"}, + } + observationEvent, err := newObservation("Test_NewEventer", WithHeader(m)) + require.NoError(err) + + require.NoError(e.writeObservation(context.Background(), observationEvent)) + }) +} + +func TestEventer_writeAudit(t *testing.T) { + t.Parallel() + ctx := context.Background() + testSetup := TestEventerConfig(t, "Test_NewEventer") + eventer, err := NewEventer(hclog.Default(), testSetup.EventerConfig) + require.NoError(t, err) + + testAudit, err := newAudit( + "TestEventer_writeAudit", + WithRequestInfo(TestRequestInfo(t)), + WithAuth(testAuth(t)), + WithRequest(testRequest(t)), + WithResponse(testResponse(t))) + require.NoError(t, err) + + tests := []struct { + name string + broker broker + audit *audit + wantErrMatch *errors.Template + }{ + { + name: "missing-audit", + broker: &testMockBroker{}, + wantErrMatch: errors.T(errors.InvalidParameter), + }, + { + name: "send-fails", + broker: &testMockBroker{errorOnSend: errors.New(errors.Io, "test", "no msg")}, + audit: testAudit, + wantErrMatch: errors.T(errors.Io), + }, + { + name: "success", + broker: &testMockBroker{}, + audit: testAudit, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + eventer.broker = tt.broker + + err = eventer.writeAudit(ctx, tt.audit) + if tt.wantErrMatch != nil { + require.Error(err) + assert.Truef(errors.Match(tt.wantErrMatch, err), "got %q and wanted %q", err.Error(), tt.wantErrMatch) + return + } + require.NoError(err) + }) + } +} + +func TestEventer_writeError(t *testing.T) { + t.Parallel() + ctx := context.Background() + testSetup := TestEventerConfig(t, "Test_NewEventer") + eventer, er := NewEventer(hclog.Default(), testSetup.EventerConfig) + require.NoError(t, er) + + testError, er := newError("TestEventer_writeError", errors.New(errors.Io, "test", "no msg")) + require.NoError(t, er) + + tests := []struct { + name string + broker broker + err *err + wantErrMatch *errors.Template + }{ + { + name: "missing-error", + broker: &testMockBroker{}, + wantErrMatch: errors.T(errors.InvalidParameter), + }, + { + name: "send-fails", + broker: &testMockBroker{errorOnSend: errors.New(errors.Io, "test", "no msg")}, + err: testError, + wantErrMatch: errors.T(errors.Io), + }, + { + name: "success", + broker: &testMockBroker{}, + err: testError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + eventer.broker = tt.broker + + err := eventer.writeError(ctx, tt.err) + if tt.wantErrMatch != nil { + require.Error(err) + assert.Truef(errors.Match(tt.wantErrMatch, err), "got %q and wanted %q", err.Error(), tt.wantErrMatch) + return + } + require.NoError(err) + }) + } +} + +func Test_NewEventer(t *testing.T) { + t.Parallel() + testSetup := TestEventerConfig(t, "Test_NewEventer") + + testSetupWithOpts := TestEventerConfig(t, "Test_NewEventer", testWithAuditSink(), testWithObservationSink()) + + tests := []struct { + name string + config EventerConfig + opts []Option + logger hclog.Logger + want *Eventer + wantRegistered []string + wantPipelines []string + wantThresholds map[eventlogger.EventType]int + wantErrMatch *errors.Template + }{ + { + name: "invalid-config", + config: EventerConfig{ + AuditDelivery: "invalid", + }, + logger: hclog.Default(), + wantErrMatch: errors.T(errors.InvalidParameter), + }, + { + name: "missing-logger", + config: testSetup.EventerConfig, + wantErrMatch: errors.T(errors.InvalidParameter), + }, + { + name: "success-with-default-config", + config: EventerConfig{}, + logger: hclog.Default(), + want: &Eventer{ + logger: hclog.Default(), + conf: EventerConfig{ + Sinks: []SinkConfig{ + { + Name: "default", + EventTypes: []Type{EveryType}, + Format: JSONSinkFormat, + SinkType: StdoutSink, + }, + }, + }, + }, + wantRegistered: []string{ + "json", // fmt for everything + "stdout", // stdout + "gated-observation", // stdout + "gated-audit", // stdout + }, + wantPipelines: []string{ + "audit", // stdout + "observation", // stdout + "error", // stdout + }, + wantThresholds: map[eventlogger.EventType]int{ + "error": 1, + }, + }, + { + name: "testSetup", + config: testSetup.EventerConfig, + logger: hclog.Default(), + want: &Eventer{ + logger: hclog.Default(), + conf: testSetup.EventerConfig, + }, + wantRegistered: []string{ + "json", // fmt for everything + "stdout", // stdout + "gated-observation", // stdout + "gated-audit", // stdout + "tmp-all-events", // every-type-file-sync + "gated-observation", // every-type-file-sync + "gated-audit", // every-type-file-sync + "tmp-errors", // error-file-sink + }, + wantPipelines: []string{ + "audit", // every-type-file-sync + "audit", // stdout + "observation", // every-type-file-sync + "observation", // stdout + "error", // every-type-file-sync + "error", // stdout + "error", // error-file-sink + }, + wantThresholds: map[eventlogger.EventType]int{ + "audit": 2, + "error": 3, + "observation": 2, + }, + }, + { + name: "testSetup-with-all-opts", + config: testSetupWithOpts.EventerConfig, + logger: hclog.Default(), + want: &Eventer{ + logger: hclog.Default(), + conf: testSetupWithOpts.EventerConfig, + }, + wantRegistered: []string{ + "json", // fmt for everything + "stdout", // stdout + "gated-observation", // stdout + "gated-audit", // stdout + "tmp-all-events", // every-type-file-sync + "gated-observation", // every-type-file-sync + "gated-audit", // every-type-file-sync + "tmp-errors", // error-file-sink + "gated-observation", // observation-file-sink + "tmp-observation", // observations-file-sink + "gated-audit", // audit-file-sink + "tmp-audit", // audit-file-sink + }, + wantPipelines: []string{ + "audit", // every-type-file-sync + "audit", // stdout + "observation", // every-type-file-sync + "observation", // stdout + "error", // every-type-file-sync + "error", // stdout + "error", // error-file-sink + "audit", // audit-file-sink + "observation", // observation-file-sink + }, + wantThresholds: map[eventlogger.EventType]int{ + "audit": 3, + "error": 3, + "observation": 3, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + testBroker := &testMockBroker{} + got, err := NewEventer(tt.logger, tt.config, testWithBroker(testBroker)) + if tt.wantErrMatch != nil { + require.Error(err) + require.Nil(got) + assert.True(errors.Match(tt.wantErrMatch, err)) + return + } + require.NoError(err) + require.NotNil(got) + tt.want.broker = got.broker + tt.want.flushableNodes = got.flushableNodes + assert.Equal(tt.want, got) + + assert.Lenf(testBroker.registeredNodeIds, len(tt.wantRegistered), "got nodes: %q", testBroker.registeredNodeIds) + for _, want := range tt.wantRegistered { + found := false + for _, got := range testBroker.registeredNodeIds { + if strings.Contains(string(got), want) { + found = true + break + } + } + assert.Truef(found, "did not find %s in the registered nodes: %s", want, testBroker.registeredNodeIds) + } + assert.Lenf(testBroker.pipelines, len(tt.wantPipelines), "got pipelines: %q", testBroker.pipelines) + for _, want := range tt.wantPipelines { + found := false + for _, got := range testBroker.pipelines { + if strings.Contains(string(got.EventType), want) { + found = true + break + } + } + assert.Truef(found, "did not find %s in the registered pipelines: %s", want, testBroker.pipelines) + } + + assert.Equal(tt.wantThresholds, testBroker.successThresholds) + }) + } +} + +func TestEventer_Reopen(t *testing.T) { + t.Parallel() + t.Run("simple", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + e, err := NewEventer(hclog.Default(), EventerConfig{}) + require.NoError(err) + + e.broker = nil + require.NoError(e.Reopen()) + + e.broker = &testMockBroker{} + require.NoError(e.Reopen()) + assert.True(e.broker.(*testMockBroker).reopened) + }) +} + +func TestEventer_FlushNodes(t *testing.T) { + t.Parallel() + t.Run("simple", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + e, err := NewEventer(hclog.Default(), EventerConfig{}) + require.NoError(err) + + node := &testFlushNode{} + e.flushableNodes = append(e.flushableNodes, node) + require.NoError(e.FlushNodes(context.Background())) + + node.raiseError = true + require.Error(e.FlushNodes(context.Background())) + assert.True(node.flushed) + }) +} + +type testFlushNode struct { + flushed bool + raiseError bool +} + +func (t *testFlushNode) FlushAll(_ context.Context) error { + t.flushed = true + if t.raiseError { + return errors.New(errors.InvalidParameter, "flush-all", "test error") + } + return nil +} diff --git a/internal/observability/event/id.go b/internal/observability/event/id.go new file mode 100644 index 0000000000..07cc0be779 --- /dev/null +++ b/internal/observability/event/id.go @@ -0,0 +1,15 @@ +package event + +import ( + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/errors" +) + +func newId(prefix string) (string, error) { + const op = "event.newId" + id, err := db.NewPublicId(prefix) + if err != nil { + return "", errors.Wrap(err, op) + } + return id, nil +} diff --git a/internal/observability/event/id_test.go b/internal/observability/event/id_test.go new file mode 100644 index 0000000000..f634371cdc --- /dev/null +++ b/internal/observability/event/id_test.go @@ -0,0 +1,18 @@ +package event + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_newId(t *testing.T) { + t.Run("basics", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, err := newId("pre") + require.NoError(err) + assert.True(strings.HasPrefix(got, "pre")) + }) +} diff --git a/internal/observability/event/options.go b/internal/observability/event/options.go new file mode 100644 index 0000000000..e1a9eb0673 --- /dev/null +++ b/internal/observability/event/options.go @@ -0,0 +1,101 @@ +package event + +import ( + "time" +) + +// getOpts - iterate the inbound Options and return a struct. +func getOpts(opt ...Option) options { + opts := getDefaultOptions() + for _, o := range opt { + o(&opts) + } + return opts +} + +// Option - how Options are passed as arguments. +type Option func(*options) + +// options = how options are represented +type options struct { + withId string + withDetails map[string]interface{} + withHeader map[string]interface{} + withFlush bool + withRequestInfo *RequestInfo + withNow time.Time + withRequest *Request + withResponse *Response + withAuth *Auth + + withBroker broker // test only option + withAuditSink bool // test only option + withObservationSink bool // test only option +} + +func getDefaultOptions() options { + return options{} +} + +// WithId allows an optional Id +func WithId(id string) Option { + return func(o *options) { + o.withId = id + } +} + +// WithDetails allows an optional map as details +func WithDetails(d map[string]interface{}) Option { + return func(o *options) { + o.withDetails = d + } +} + +// WithHeader allows an optional map as a header +func WithHeader(d map[string]interface{}) Option { + return func(o *options) { + o.withHeader = d + } +} + +// WithFlush allows an optional flush option. +func WithFlush() Option { + return func(o *options) { + o.withFlush = true + } +} + +// WithRequestInfo allows an optional RequestInfo +func WithRequestInfo(i *RequestInfo) Option { + return func(o *options) { + o.withRequestInfo = i + } +} + +// WithNow allows an option time.Time to represent now. +func WithNow(now time.Time) Option { + return func(o *options) { + o.withNow = now + } +} + +// WithRequest allows an optional request +func WithRequest(r *Request) Option { + return func(o *options) { + o.withRequest = r + } +} + +// WithResponse allows an optional response +func WithResponse(r *Response) Option { + return func(o *options) { + o.withResponse = r + } +} + +// WithAuth allows an optional Auth +func WithAuth(a *Auth) Option { + return func(o *options) { + o.withAuth = a + } +} diff --git a/internal/observability/event/options_test.go b/internal/observability/event/options_test.go new file mode 100644 index 0000000000..17863ab8d3 --- /dev/null +++ b/internal/observability/event/options_test.go @@ -0,0 +1,87 @@ +package event + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// Test_GetOpts provides unit tests for GetOpts and all the options +func Test_GetOpts(t *testing.T) { + t.Parallel() + t.Run("WithId", func(t *testing.T) { + assert := assert.New(t) + opts := getOpts(WithId("test")) + testOpts := getDefaultOptions() + testOpts.withId = "test" + assert.Equal(opts, testOpts) + }) + t.Run("WithDetails", func(t *testing.T) { + assert := assert.New(t) + d := map[string]interface{}{ + "name": "alice", + } + opts := getOpts(WithDetails(d)) + testOpts := getDefaultOptions() + testOpts.withDetails = d + assert.Equal(opts, testOpts) + }) + t.Run("WithHeader", func(t *testing.T) { + assert := assert.New(t) + h := map[string]interface{}{ + "name": "alice", + } + opts := getOpts(WithHeader(h)) + testOpts := getDefaultOptions() + testOpts.withHeader = h + assert.Equal(opts, testOpts) + }) + t.Run("WithFlush", func(t *testing.T) { + assert := assert.New(t) + opts := getOpts(WithFlush()) + testOpts := getDefaultOptions() + testOpts.withFlush = true + assert.Equal(opts, testOpts) + }) + t.Run("WithRequestInfo", func(t *testing.T) { + assert := assert.New(t) + info := TestRequestInfo(t) + opts := getOpts(WithRequestInfo(info)) + testOpts := getDefaultOptions() + testOpts.withRequestInfo = info + assert.Equal(opts, testOpts) + }) + t.Run("WithNow", func(t *testing.T) { + assert := assert.New(t) + now := time.Now() + opts := getOpts(WithNow(now)) + testOpts := getDefaultOptions() + testOpts.withNow = now + assert.Equal(opts, testOpts) + }) + t.Run("WithRequest", func(t *testing.T) { + assert := assert.New(t) + r := testRequest(t) + opts := getOpts(WithRequest(r)) + testOpts := getDefaultOptions() + testOpts.withRequest = r + assert.Equal(opts, testOpts) + }) + t.Run("WithResponse", func(t *testing.T) { + assert := assert.New(t) + r := testResponse(t) + opts := getOpts(WithResponse(r)) + testOpts := getDefaultOptions() + testOpts.withResponse = r + assert.Equal(opts, testOpts) + }) + t.Run("WithAuth", func(t *testing.T) { + assert := assert.New(t) + auth := testAuth(t) + opts := getOpts(WithAuth(auth)) + testOpts := getDefaultOptions() + testOpts.withAuth = auth + assert.Equal(opts, testOpts) + }) +} diff --git a/internal/observability/event/sink_config.go b/internal/observability/event/sink_config.go new file mode 100644 index 0000000000..0acba1376d --- /dev/null +++ b/internal/observability/event/sink_config.go @@ -0,0 +1,45 @@ +package event + +import ( + "time" + + "github.com/hashicorp/boundary/internal/errors" +) + +// SinkConfig defines the configuration for a Eventer sink +type SinkConfig struct { + Name string // Name defines a name for the sink. + EventTypes []Type // EventTypes defines a list of event types that will be sent to the sink. See the docs for EventTypes for a list of accepted values. + SinkType SinkType // SinkType defines the type of sink (StdoutSink or FileSink) + Format SinkFormat // Format defines the format for the sink (JSONSinkFormat) + Path string // Path defines the file path for the sink + FileName string // FileName defines the file name for the sink + RotateBytes int // RotateByes defines the number of bytes that should trigger rotation of a FileSink + RotateDuration time.Duration // RotateDuration defines how often a FileSink should be rotated + RotateMaxFiles int // RotateMaxFiles defines how may historical rotated files should be kept for a FileSink +} + +func (sc *SinkConfig) validate() error { + const op = "event.(SinkConfig).validate" + if err := sc.SinkType.validate(); err != nil { + return errors.Wrap(err, op) + } + if err := sc.Format.validate(); err != nil { + return errors.Wrap(err, op) + } + if sc.SinkType == FileSink && sc.FileName == "" { + return errors.New(errors.InvalidParameter, op, "missing sink file name") + } + if sc.Name == "" { + return errors.New(errors.InvalidParameter, op, "missing sink name") + } + if len(sc.EventTypes) == 0 { + return errors.New(errors.InvalidParameter, op, "missing event types") + } + for _, et := range sc.EventTypes { + if err := et.validate(); err != nil { + return errors.Wrap(err, op) + } + } + return nil +} diff --git a/internal/observability/event/sink_config_test.go b/internal/observability/event/sink_config_test.go new file mode 100644 index 0000000000..188b6ce6ce --- /dev/null +++ b/internal/observability/event/sink_config_test.go @@ -0,0 +1,135 @@ +package event + +import ( + "testing" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSinkConfig_validate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + sc SinkConfig + wantErrMatch *errors.Template + wantErrContains string + }{ + { + name: "missing-name", + sc: SinkConfig{ + EventTypes: []Type{EveryType}, + SinkType: FileSink, + FileName: "tmp.file", + Format: JSONSinkFormat, + }, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing sink name", + }, + { + name: "missing-EventType", + sc: SinkConfig{ + Name: "sink-name", + SinkType: FileSink, + FileName: "tmp.file", + Format: JSONSinkFormat, + }, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing event types", + }, + { + name: "invalid-EventType", + sc: SinkConfig{ + Name: "sink-name", + EventTypes: []Type{"invalid"}, + SinkType: FileSink, + FileName: "tmp.file", + Format: JSONSinkFormat, + }, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "not a valid event type", + }, + { + name: "missing-sink-type", + sc: SinkConfig{ + Name: "sink-name", + EventTypes: []Type{EveryType}, + FileName: "tmp.file", + Format: JSONSinkFormat, + }, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "not a valid sink type", + }, + { + name: "invalid-sink-type", + sc: SinkConfig{ + Name: "sink-name", + EventTypes: []Type{EveryType}, + SinkType: "invalid", + FileName: "tmp.file", + Format: JSONSinkFormat, + }, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "not a valid sink type", + }, + { + name: "missing-format", + sc: SinkConfig{ + Name: "sink-name", + SinkType: FileSink, + EventTypes: []Type{EveryType}, + FileName: "tmp.file", + }, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "not a valid sink format", + }, + { + name: "invalid-format", + sc: SinkConfig{ + Name: "sink-name", + Format: "invalid", + SinkType: FileSink, + EventTypes: []Type{EveryType}, + FileName: "tmp.file", + }, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "not a valid sink format", + }, + { + name: "file-sink-with-no-file-name", + sc: SinkConfig{ + EventTypes: []Type{EveryType}, + SinkType: FileSink, + Format: JSONSinkFormat, + }, + wantErrMatch: errors.T(errors.InvalidParameter), + wantErrContains: "missing sink file name", + }, + { + name: "valid", + sc: SinkConfig{ + Name: "valid", + EventTypes: []Type{EveryType}, + SinkType: FileSink, + FileName: "tmp.file", + Format: JSONSinkFormat, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + err := tt.sc.validate() + if tt.wantErrMatch != nil { + require.Error(err) + require.Truef(errors.Match(tt.wantErrMatch, err), "wanted %q and got %q", tt.wantErrMatch, err) + if tt.wantErrContains != "" { + assert.Contains(err.Error(), tt.wantErrContains) + } + return + } + assert.NoError(err) + }) + } +} diff --git a/internal/observability/event/sink_format.go b/internal/observability/event/sink_format.go new file mode 100644 index 0000000000..4b482b52d4 --- /dev/null +++ b/internal/observability/event/sink_format.go @@ -0,0 +1,23 @@ +package event + +import ( + "fmt" + + "github.com/hashicorp/boundary/internal/errors" +) + +const ( + JSONSinkFormat SinkFormat = "json" // JSONSinkFormat means the event is formatted as JSON +) + +type SinkFormat string // SinkFormat defines the formatting for a sink in a config file stanza (json) + +func (f SinkFormat) validate() error { + const op = "event.(SinkFormat).validate" + switch f { + case JSONSinkFormat: + return nil + default: + return errors.New(errors.InvalidParameter, op, fmt.Sprintf("'%s' is not a valid sink format", f)) + } +} diff --git a/internal/observability/event/sink_type.go b/internal/observability/event/sink_type.go new file mode 100644 index 0000000000..aa5ac50c34 --- /dev/null +++ b/internal/observability/event/sink_type.go @@ -0,0 +1,24 @@ +package event + +import ( + "fmt" + + "github.com/hashicorp/boundary/internal/errors" +) + +const ( + StdoutSink SinkType = "stdout" // StdoutSink is written to stdout + FileSink SinkType = "file" // FileSink is written to a file +) + +type SinkType string // SinkType defines the type of sink in a config stanza (file, stdout) + +func (t SinkType) validate() error { + const op = "event.(SinkType).validate" + switch t { + case StdoutSink, FileSink: + return nil + default: + return errors.New(errors.InvalidParameter, op, fmt.Sprintf("'%s' is not a valid sink type", t)) + } +} diff --git a/internal/observability/event/testing.go b/internal/observability/event/testing.go new file mode 100644 index 0000000000..191f2e946b --- /dev/null +++ b/internal/observability/event/testing.go @@ -0,0 +1,230 @@ +package event + +import ( + "context" + "io/ioutil" + "os" + "sync" + "testing" + "time" + + "github.com/hashicorp/boundary/internal/gen/controller/api/resources/groups" + pbs "github.com/hashicorp/boundary/internal/gen/controller/api/services" + "github.com/hashicorp/eventlogger" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +var testSysEventerLock sync.Mutex + +// TestResetSysEventer will reset event.syseventer to an uninitialized state. +func TestResetSystEventer(t *testing.T) { + t.Helper() + testSysEventerLock.Lock() + defer testSysEventerLock.Unlock() + sysEventerOnce = sync.Once{} + sysEventer = nil +} + +type TestConfig struct { + EventerConfig EventerConfig + AllEvents *os.File + ErrorEvents *os.File + ObservationEvents *os.File + AuditEvents *os.File +} + +// TestEventerConfig creates a test config and registers a cleanup func for its +// test tmp files. +func TestEventerConfig(t *testing.T, testName string, opt ...Option) TestConfig { + t.Helper() + require := require.New(t) + tmpAllFile, err := ioutil.TempFile("./", "tmp-all-events-"+testName) + require.NoError(err) + + tmpErrFile, err := ioutil.TempFile("./", "tmp-errors-"+testName) + require.NoError(err) + + t.Cleanup(func() { + os.Remove(tmpAllFile.Name()) + os.Remove(tmpErrFile.Name()) + }) + + c := TestConfig{ + EventerConfig: EventerConfig{ + ObservationsEnabled: true, + ObservationDelivery: Enforced, + AuditEnabled: true, + AuditDelivery: Enforced, + Sinks: []SinkConfig{ + { + Name: "every-type-file-sink", + SinkType: FileSink, + EventTypes: []Type{EveryType}, + Format: JSONSinkFormat, + Path: "./", + FileName: tmpAllFile.Name(), + }, + { + Name: "stdout", + SinkType: StdoutSink, + EventTypes: []Type{EveryType}, + Format: JSONSinkFormat, + }, + { + Name: "err-file-sink", + SinkType: FileSink, + EventTypes: []Type{ErrorType}, + Format: JSONSinkFormat, + Path: "./", + FileName: tmpErrFile.Name(), + }, + }, + }, + AllEvents: tmpAllFile, + ErrorEvents: tmpErrFile, + } + opts := getOpts(opt...) + if opts.withAuditSink { + tmpFile, err := ioutil.TempFile("./", "tmp-audit-"+testName) + require.NoError(err) + t.Cleanup(func() { + os.Remove(tmpFile.Name()) + }) + c.EventerConfig.Sinks = append(c.EventerConfig.Sinks, SinkConfig{ + Name: "audit-file-sink", + SinkType: FileSink, + EventTypes: []Type{AuditType}, + Format: JSONSinkFormat, + Path: "./", + FileName: tmpFile.Name(), + }) + } + if opts.withObservationSink { + tmpFile, err := ioutil.TempFile("./", "tmp-observation-"+testName) + require.NoError(err) + t.Cleanup(func() { + os.Remove(tmpFile.Name()) + }) + c.EventerConfig.Sinks = append(c.EventerConfig.Sinks, SinkConfig{ + Name: "err-observation-sink", + SinkType: FileSink, + EventTypes: []Type{ObservationType}, + Format: JSONSinkFormat, + Path: "./", + FileName: tmpFile.Name(), + }) + } + return c +} + +// TestRequestInfo provides a test RequestInfo +func TestRequestInfo(t *testing.T) *RequestInfo { + t.Helper() + return &RequestInfo{ + Id: "test-request-info", + Method: "POST", + Path: "/test/request/info", + PublicId: "public-id", + } +} + +func testAuth(t *testing.T) *Auth { + t.Helper() + return &Auth{ + UserEmail: "test-auth@example.com", + UserName: "test-auth-user-name", + } +} + +func testRequest(t *testing.T) *Request { + t.Helper() + return &Request{ + Operation: "op", + Endpoint: "/group/", + Details: &pbs.GetGroupRequest{ + Id: "group-id", + }, + } +} + +func testResponse(t *testing.T) *Response { + t.Helper() + return &Response{ + StatusCode: 200, + Details: &pbs.GetGroupResponse{ + Item: &groups.Group{ + Id: "group-id", + ScopeId: "org-id", + Name: &wrapperspb.StringValue{ + Value: "group-name", + }, + }, + }, + } +} + +// testWithBroker is an unexported and a test option for passing in an optional broker +func testWithBroker(b broker) Option { + return func(o *options) { + o.withBroker = b + } +} + +// testWithObservationSink is an unexported and a test option +func testWithObservationSink() Option { + return func(o *options) { + o.withObservationSink = true + } +} + +// testWithAuditSink is an unexported and a test option +func testWithAuditSink() Option { + return func(o *options) { + o.withAuditSink = true + } +} + +type testMockBroker struct { + reopened bool + stopTimeAt time.Time + registeredNodeIds []eventlogger.NodeID + successThresholds map[eventlogger.EventType]int + pipelines []eventlogger.Pipeline + + errorOnSend error +} + +func (b *testMockBroker) Reopen(ctx context.Context) error { + b.reopened = true + return nil +} + +func (b *testMockBroker) RegisterPipeline(def eventlogger.Pipeline) error { + b.pipelines = append(b.pipelines, def) + return nil +} + +func (b *testMockBroker) Send(ctx context.Context, t eventlogger.EventType, payload interface{}) (eventlogger.Status, error) { + if b.errorOnSend != nil { + return eventlogger.Status{}, b.errorOnSend + } + return eventlogger.Status{}, nil +} + +func (b *testMockBroker) StopTimeAt(t time.Time) { + b.stopTimeAt = t +} + +func (b *testMockBroker) RegisterNode(id eventlogger.NodeID, node eventlogger.Node) error { + b.registeredNodeIds = append(b.registeredNodeIds, id) + return nil +} + +func (b *testMockBroker) SetSuccessThreshold(t eventlogger.EventType, successThreshold int) error { + if b.successThresholds == nil { + b.successThresholds = map[eventlogger.EventType]int{} + } + b.successThresholds[t] = successThreshold + return nil +} diff --git a/internal/servers/controller/handlers/authmethods/authmethod_service.go b/internal/servers/controller/handlers/authmethods/authmethod_service.go index 6be950ad11..69affed460 100644 --- a/internal/servers/controller/handlers/authmethods/authmethod_service.go +++ b/internal/servers/controller/handlers/authmethods/authmethod_service.go @@ -994,7 +994,7 @@ func validateUpdateRequest(req *pbs.UpdateAuthMethodRequest) error { if err != nil { badFields[issuerField] = fmt.Sprintf("Cannot be parsed as a url. %v", err) } - if !strutil.StrListContains([]string{"http", "https"}, iss.Scheme) { + if iss != nil && !strutil.StrListContains([]string{"http", "https"}, iss.Scheme) { badFields[issuerField] = fmt.Sprintf("Must have schema %q or %q specified", "http", "https") } } diff --git a/internal/servers/controller/handlers/authmethods/oidc_test.go b/internal/servers/controller/handlers/authmethods/oidc_test.go index bcafd774be..ffa5e17180 100644 --- a/internal/servers/controller/handlers/authmethods/oidc_test.go +++ b/internal/servers/controller/handlers/authmethods/oidc_test.go @@ -430,6 +430,25 @@ func TestUpdate_OIDC(t *testing.T) { }, }, }, + { + name: "invalid-issuer-port", + req: &pbs.UpdateAuthMethodRequest{ + UpdateMask: &field_mask.FieldMask{ + Paths: []string{"attributes.issuer"}, + }, + Item: &pb.AuthMethod{ + Attributes: &structpb.Struct{ + Fields: func() map[string]*structpb.Value { + f := defaultAttributeFields() + f["issuer"] = structpb.NewStringValue("http://localhost:7dddd") + f["disable_discovered_config_validation"] = structpb.NewBoolValue(true) + return f + }(), + }, + }, + }, + err: handlers.ApiErrorWithCode(codes.InvalidArgument), + }, { name: "No Update Mask", req: &pbs.UpdateAuthMethodRequest{ diff --git a/website/.env b/website/.env index 3387c049eb..e50ad5d301 100644 --- a/website/.env +++ b/website/.env @@ -1,3 +1,3 @@ NEXT_PUBLIC_ALGOLIA_APP_ID=YY0FFNI7MF NEXT_PUBLIC_ALGOLIA_INDEX=product_BOUNDARY -NEXT_PUBLIC_ALGOLIA_SEARCH_ONLY_API_KEY=5037da4824714676226913c65e961ca0 +NEXT_PUBLIC_ALGOLIA_SEARCH_ONLY_API_KEY=8308498decdf72e11590fc6356e5fdde