Skip to content

Commit

Permalink
Access logging module (#90)
Browse files Browse the repository at this point in the history
Access logging module that allows keeping a record of incoming
operations.

---------

Co-authored-by: ldebruijn <[email protected]>
  • Loading branch information
ldebruijn and ldebruijn authored Jun 4, 2024
1 parent 0e731d9 commit cf8dc1c
Show file tree
Hide file tree
Showing 6 changed files with 243 additions and 11 deletions.
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ This section contains all the documentation about each protection feature.
* [Max Tokens](protections/max_tokens.md)
* [Enforce POST](protections/enforce_post.md)
* [Max Batch](protections/max_batch.md)
* [Access Logging](protections/access_logging.md)


## Run
Expand Down
31 changes: 31 additions & 0 deletions docs/protections/access_logging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Access Logging

In some cases you want to keep a record of what operations were performed against your landscape. The access logging protection can provide that for you.
Access logging is done to STDOUT.

<!-- TOC -->

## Configuration

You can configure `graphql-protect` to enable access logging for incoming operaitons.

```yaml
access_logging:
# Enable the feature,
enable: true
included_headers:
# Include any headers of interest here
- Authorization
# Include the operation name in the access log record
include_operation_name: true
# Include the variables in the access log record
include_variables: true
# Include the payload in the access log record
include_payload: true
```
## How does it work?
For each operation we'll produce an access log record according to your provided configuration.
If used in conjunction with persisted operations the access log will be produced after the operation is swapped for the payload, meaning you have full access to the operation name and payload.
2 changes: 2 additions & 0 deletions internal/app/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/ardanlabs/conf/v3"
"github.com/ardanlabs/conf/v3/yaml"
"github.com/ldebruijn/graphql-protect/internal/business/persistedoperations"
"github.com/ldebruijn/graphql-protect/internal/business/rules/accesslogging"
"github.com/ldebruijn/graphql-protect/internal/business/rules/aliases"
"github.com/ldebruijn/graphql-protect/internal/business/rules/batch"
"github.com/ldebruijn/graphql-protect/internal/business/rules/block_field_suggestions"
Expand Down Expand Up @@ -39,6 +40,7 @@ type Config struct {
EnforcePost enforce_post.Config `yaml:"enforce_post"`
MaxDepth max_depth.Config `yaml:"max_depth"`
MaxBatch batch.Config `yaml:"max_batch"`
AccessLogging accesslogging.Config `yaml:"access_logging"`
}

func NewConfig(configPath string) (*Config, error) {
Expand Down
28 changes: 17 additions & 11 deletions internal/business/protect/protect.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/ldebruijn/graphql-protect/internal/app/config"
"github.com/ldebruijn/graphql-protect/internal/business/gql"
"github.com/ldebruijn/graphql-protect/internal/business/persistedoperations"
"github.com/ldebruijn/graphql-protect/internal/business/rules/accesslogging"
"github.com/ldebruijn/graphql-protect/internal/business/rules/aliases"
"github.com/ldebruijn/graphql-protect/internal/business/rules/batch"
"github.com/ldebruijn/graphql-protect/internal/business/rules/enforce_post"
Expand Down Expand Up @@ -34,6 +35,7 @@ type GraphQLProtect struct {
schema *schema.Provider
tokens *tokens.MaxTokensRule
maxBatch *batch.MaxBatchRule
accessLogging *accesslogging.AccessLogging
next http.Handler
preFilterChain func(handler http.Handler) http.Handler
}
Expand All @@ -42,19 +44,21 @@ func NewGraphQLProtect(log *slog.Logger, cfg *config.Config, po *persistedoperat
aliases.NewMaxAliasesRule(cfg.MaxAliases)
max_depth.NewMaxDepthRule(cfg.MaxDepth)
maxBatch, err := batch.NewMaxBatch(cfg.MaxBatch)
accessLogging := accesslogging.NewAccessLogging(cfg.AccessLogging, log)
if err != nil {
log.Warn("Error initializing maximum batch protection", err)
}

enforcePostMethod := enforce_post.EnforcePostMethod(cfg.EnforcePost)

return &GraphQLProtect{
log: log,
cfg: cfg,
po: po,
schema: schema,
tokens: tokens.MaxTokens(cfg.MaxTokens),
maxBatch: maxBatch,
log: log,
cfg: cfg,
po: po,
schema: schema,
tokens: tokens.MaxTokens(cfg.MaxTokens),
maxBatch: maxBatch,
accessLogging: accessLogging,
preFilterChain: func(next http.Handler) http.Handler {
return enforcePostMethod(po.SwapHashForQuery(next))
},
Expand All @@ -69,7 +73,9 @@ func (p *GraphQLProtect) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}

func (p *GraphQLProtect) handle(w http.ResponseWriter, r *http.Request) {
errs := p.validateRequest(r)
payloads, errs := p.validateRequest(r)

p.accessLogging.Log(payloads, r.Header)

if len(errs) > 0 {
if p.cfg.ObfuscateValidationErrors {
Expand All @@ -92,10 +98,10 @@ func (p *GraphQLProtect) handle(w http.ResponseWriter, r *http.Request) {
p.next.ServeHTTP(w, r)
}

func (p *GraphQLProtect) validateRequest(r *http.Request) gqlerror.List {
func (p *GraphQLProtect) validateRequest(r *http.Request) ([]gql.RequestData, gqlerror.List) {
payload, err := gql.ParseRequestPayload(r)
if err != nil {
return gqlerror.List{gqlerror.Wrap(err)}
return nil, gqlerror.List{gqlerror.Wrap(err)}
}

var errs gqlerror.List
Expand All @@ -106,7 +112,7 @@ func (p *GraphQLProtect) validateRequest(r *http.Request) gqlerror.List {
}

if err != nil {
return errs
return nil, errs
}

for _, data := range payload {
Expand All @@ -116,7 +122,7 @@ func (p *GraphQLProtect) validateRequest(r *http.Request) gqlerror.List {
}
}

return errs
return payload, errs
}

func (p *GraphQLProtect) ValidateQuery(operation string) gqlerror.List {
Expand Down
59 changes: 59 additions & 0 deletions internal/business/rules/accesslogging/accesslogging.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package accesslogging

import (
"github.com/ldebruijn/graphql-protect/internal/business/gql"
"log/slog"
"net/http"
"slices"
)

type Config struct {
Enable bool `config:"default:true" yaml:"enabled"`
IncludedHeaders []string `yaml:"included_headers"`
IncludeOperationName bool `config:"default:true" yaml:"include_operation_name"`
IncludeVariables bool `config:"default:true" yaml:"include_variables"`
IncludePayload bool `config:"default:false" yaml:"include_payload"`
}

type AccessLogging struct {
log *slog.Logger
cfg Config
}

func NewAccessLogging(cfg Config, log *slog.Logger) *AccessLogging {
return &AccessLogging{
log: log.WithGroup("access-logging"),
cfg: cfg,
}
}

func (a *AccessLogging) Log(payloads []gql.RequestData, headers http.Header) {
if !a.cfg.Enable {
return
}

toLog := map[string]interface{}{}

for _, req := range payloads {
if a.cfg.IncludeOperationName {
toLog["operationName"] = req.OperationName
}
if a.cfg.IncludeVariables {
toLog["variables"] = req.Variables
}
if a.cfg.IncludePayload {
toLog["payload"] = req.Query
}

logHeaders := map[string]interface{}{}
for name, values := range headers {
if slices.Contains(a.cfg.IncludedHeaders, name) {
logHeaders[name] = values
}
}

toLog["headers"] = logHeaders

a.log.Info("access-logging", "payload", toLog)
}
}
133 changes: 133 additions & 0 deletions internal/business/rules/accesslogging/accesslogging_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package accesslogging

import (
"context"
"github.com/ldebruijn/graphql-protect/internal/business/gql"
"github.com/stretchr/testify/assert"
"log/slog"
"net/http"
"testing"
)

type testLogHandler struct {
assert func(ctx context.Context, record slog.Record) error
count int
}

func (t *testLogHandler) Enabled(context.Context, slog.Level) bool {
return true
}
func (t *testLogHandler) Handle(ctx context.Context, record slog.Record) error {
t.count++
return t.assert(ctx, record)
}
func (t *testLogHandler) WithAttrs(_ []slog.Attr) slog.Handler {
return t
}
func (t *testLogHandler) WithGroup(_ string) slog.Handler {
return t
}

func TestAccessLogging_Log(t *testing.T) {
type args struct {
cfg Config
payloads []gql.RequestData
headers http.Header
count int
}
tests := []struct {
name string
args args
want func(ctx context.Context, record slog.Record) error
}{
{
name: "logs expected fields when enabled",
args: args{
cfg: Config{
Enable: true,
IncludedHeaders: []string{"Authorization"},
IncludeOperationName: true,
IncludeVariables: true,
IncludePayload: true,
},
payloads: []gql.RequestData{
{
OperationName: "Foobar",
Variables: map[string]interface{}{
"foo": "bar",
},
Query: "query Foo { id name }",
},
},
headers: map[string][]string{
"Authorization": {"bearer hello"},
"Content-Type": {"application/json"},
},
count: 1,
},
want: func(_ context.Context, record slog.Record) error {
assert.Equal(t, 1, record.NumAttrs())
record.Attrs(func(a slog.Attr) bool {
assert.Equal(t, "payload", a.Key)
val := a.Value.Any().(map[string]interface{})
assert.Equal(t, "Foobar", val["operationName"])
assert.Equal(t, "query Foo { id name }", val["payload"])
assert.Equal(t, map[string]interface{}{
"foo": "bar",
}, val["variables"])
assert.Equal(t, map[string]interface{}{
"Authorization": []string{"bearer hello"},
}, val["headers"])

return true
})

return nil
},
},
{
name: "logs nothing when disabled",
args: args{
cfg: Config{
Enable: false,
IncludedHeaders: []string{"Authorization"},
IncludeOperationName: true,
IncludeVariables: true,
IncludePayload: true,
},
payloads: []gql.RequestData{
{
OperationName: "Foobar",
Variables: map[string]interface{}{
"foo": "bar",
},
Query: "query Foo { id name }",
},
},
headers: map[string][]string{
"Authorization": {"bearer hello"},
"Content-Type": {"application/json"},
},
count: 0,
},
want: func(_ context.Context, _ slog.Record) error {
assert.Fail(t, "should never reach here")
return nil
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := &testLogHandler{assert: tt.want}
log := slog.New(handler)

a := &AccessLogging{
log: log,
cfg: tt.args.cfg,
}
a.Log(tt.args.payloads, tt.args.headers)

assert.Equal(t, tt.args.count, a.log.Handler().(*testLogHandler).count)
})
}
}

0 comments on commit cf8dc1c

Please sign in to comment.