diff --git a/cmd/keys/main.go b/cmd/keys/main.go new file mode 100644 index 0000000..2b44685 --- /dev/null +++ b/cmd/keys/main.go @@ -0,0 +1,135 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package main + +import ( + "context" + "net/http" + "os" + "os/signal" + "runtime/debug" + "syscall" + "time" + + auth "github.com/eiffel-community/etos-api/internal/authorization" + "github.com/eiffel-community/etos-api/internal/config" + "github.com/eiffel-community/etos-api/internal/logging" + "github.com/eiffel-community/etos-api/internal/server" + "github.com/eiffel-community/etos-api/pkg/application" + v1alpha "github.com/eiffel-community/etos-api/pkg/keys/v1alpha" + "github.com/sirupsen/logrus" + "github.com/snowzach/rotatefilehook" + "go.elastic.co/ecslogrus" +) + +// main sets up logging and starts up the key webserver. +func main() { + cfg := config.NewKeyConfig() + ctx := context.Background() + + var hooks []logrus.Hook + if fileHook := fileLogging(cfg); fileHook != nil { + hooks = append(hooks, fileHook) + } + logger, err := logging.Setup(cfg.LogLevel(), hooks) + if err != nil { + logrus.Fatal(err.Error()) + } + + hostname, err := os.Hostname() + if err != nil { + logrus.Fatal(err.Error()) + } + log := logger.WithFields(logrus.Fields{ + "hostname": hostname, + "application": "ETOS API Key Server", + "version": vcsRevision(), + "name": "ETOS API", + }) + + pub, err := cfg.PublicKey() + if err != nil { + log.Fatal(err.Error()) + } + priv, err := cfg.PrivateKey() + if err != nil { + log.Fatal(err.Error()) + } + authorizer, err := auth.NewAuthorizer(pub, priv) + if err != nil { + log.Fatal(err.Error()) + } + v1AlphaKeys := v1alpha.New(ctx, cfg, log, authorizer) + defer v1AlphaKeys.Close() + + log.Info("Loading Key routes") + app := application.New(v1AlphaKeys) + srv := server.NewWebService(cfg, log, app) + + done := make(chan os.Signal, 1) + signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) + + go func() { + if err := srv.Start(); err != nil && err != http.ErrServerClosed { + log.Errorf("Webserver shutdown: %+v", err) + } + }() + + sig := <-done + log.Infof("%s received", sig.String()) + + ctx, cancel := context.WithTimeout(ctx, 1*time.Minute) + defer cancel() + + if err := srv.Close(ctx); err != nil { + log.Errorf("Webserver shutdown failed: %+v", err) + } + log.Info("Wait for shutdown to complete") +} + +// fileLogging adds a hook into a slice of hooks, if the filepath configuration is set +func fileLogging(cfg config.Config) logrus.Hook { + if filePath := cfg.LogFilePath(); filePath != "" { + // TODO: Make these parameters configurable. + // NewRotateFileHook cannot return an error which is why it's set to '_'. + rotateFileHook, _ := rotatefilehook.NewRotateFileHook(rotatefilehook.RotateFileConfig{ + Filename: filePath, + MaxSize: 10, // megabytes + MaxBackups: 3, + MaxAge: 0, // days + Level: logrus.DebugLevel, + Formatter: &ecslogrus.Formatter{ + DataKey: "labels", + }, + }) + return rotateFileHook + } + return nil +} + +// vcsRevision returns vcs revision from build info, if any. Otherwise '(unknown)'. +func vcsRevision() string { + buildInfo, ok := debug.ReadBuildInfo() + if !ok { + return "(unknown)" + } + for _, val := range buildInfo.Settings { + if val.Key == "vcs.revision" { + return val.Value + } + } + return "(unknown)" +} diff --git a/cmd/sse/main.go b/cmd/sse/main.go index 5037335..02762cf 100644 --- a/cmd/sse/main.go +++ b/cmd/sse/main.go @@ -24,12 +24,17 @@ import ( "syscall" "time" + auth "github.com/eiffel-community/etos-api/internal/authorization" "github.com/eiffel-community/etos-api/internal/config" "github.com/eiffel-community/etos-api/internal/logging" "github.com/eiffel-community/etos-api/internal/server" + "github.com/eiffel-community/etos-api/internal/stream" "github.com/eiffel-community/etos-api/pkg/application" v1 "github.com/eiffel-community/etos-api/pkg/sse/v1" v1alpha "github.com/eiffel-community/etos-api/pkg/sse/v1alpha" + v2alpha "github.com/eiffel-community/etos-api/pkg/sse/v2alpha" + "github.com/julienschmidt/httprouter" + rabbitMQStream "github.com/rabbitmq/rabbitmq-stream-go-client/pkg/stream" "github.com/sirupsen/logrus" "github.com/snowzach/rotatefilehook" "go.elastic.co/ecslogrus" @@ -66,7 +71,37 @@ func main() { v1SSE := v1.New(cfg, log, ctx) defer v1SSE.Close() - app := application.New(v1AlphaSSE, v1SSE) + pub, err := cfg.PublicKey() + if err != nil { + log.Fatal(err.Error()) + } + var app *httprouter.Router + // Only load v2alpha if a public key exists. + if pub != nil { + authorizer, err := auth.NewAuthorizer(pub, nil) + if err != nil { + log.Fatal(err.Error()) + } + + var streamer stream.Streamer + if cfg.RabbitMQURI() != "" { + log.Info("Starting up a RabbitMQStreamer") + streamer, err = stream.NewRabbitMQStreamer(*rabbitMQStream.NewEnvironmentOptions().SetUri(cfg.RabbitMQURI()), log) + } else { + log.Warning("RabbitMQURI is not set, defaulting to FileStreamer") + streamer, err = stream.NewFileStreamer(100*time.Millisecond, log) + } + if err != nil { + log.Fatal(err.Error()) + } + v2AlphaSSE := v2alpha.New(ctx, cfg, log, streamer, authorizer) + defer v2AlphaSSE.Close() + app = application.New(v1AlphaSSE, v1SSE, v2AlphaSSE) + } else { + log.Warning("Public key does not exist, won't enable v2alpha endpoint") + app = application.New(v1AlphaSSE, v1SSE) + } + srv := server.NewWebService(cfg, log, app) done := make(chan os.Signal, 1) diff --git a/go.mod b/go.mod index e85d504..07f49dc 100644 --- a/go.mod +++ b/go.mod @@ -7,20 +7,27 @@ toolchain go1.22.1 require ( github.com/eiffel-community/eiffelevents-sdk-go v0.0.0-20240807115026-5ca5c194b7dc github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611 + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/uuid v1.6.0 github.com/jmespath/go-jmespath v0.4.0 github.com/julienschmidt/httprouter v1.3.0 - github.com/machinebox/graphql v0.2.2 github.com/maxcnunes/httpfake v1.2.4 github.com/package-url/packageurl-go v0.1.3 + github.com/rabbitmq/amqp091-go v1.10.0 + github.com/rabbitmq/rabbitmq-stream-go-client v1.4.10 github.com/sethvargo/go-retry v0.3.0 github.com/sirupsen/logrus v1.9.3 github.com/snowzach/rotatefilehook v0.0.0-20220211133110-53752135082d github.com/stretchr/testify v1.9.0 go.elastic.co/ecslogrus v1.0.0 - go.etcd.io/etcd/api/v3 v3.5.15 go.etcd.io/etcd/client/v3 v3.5.15 go.etcd.io/etcd/server/v3 v3.5.14 + go.opentelemetry.io/otel v1.20.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.20.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.20.0 + go.opentelemetry.io/otel/sdk v1.20.0 + go.opentelemetry.io/otel/trace v1.20.0 + k8s.io/api v0.31.1 k8s.io/apimachinery v0.31.1 k8s.io/client-go v0.31.1 ) @@ -46,6 +53,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.4.2 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v0.0.4 // indirect github.com/google/btree v1.0.1 // indirect github.com/google/gnostic-models v0.6.8 // indirect github.com/google/go-cmp v0.6.0 // indirect @@ -59,21 +67,22 @@ require ( github.com/jonboulle/clockwork v0.2.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.9 // indirect github.com/magefile/mage v1.9.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/matryer/is v1.4.1 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pierrec/lz4 v2.6.1+incompatible // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.11.1 // indirect github.com/prometheus/client_model v0.2.0 // indirect github.com/prometheus/common v0.26.0 // indirect github.com/prometheus/procfs v0.6.0 // indirect - github.com/rabbitmq/amqp091-go v1.10.0 // indirect github.com/soheilhy/cmux v0.1.5 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/tidwall/gjson v1.17.1 // indirect github.com/tidwall/match v1.1.1 // indirect @@ -82,29 +91,24 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 // indirect go.etcd.io/bbolt v1.3.10 // indirect + go.etcd.io/etcd/api/v3 v3.5.15 // indirect go.etcd.io/etcd/client/pkg/v3 v3.5.15 // indirect go.etcd.io/etcd/client/v2 v2.305.14 // indirect go.etcd.io/etcd/pkg/v3 v3.5.14 // indirect go.etcd.io/etcd/raft/v3 v3.5.14 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.0 // indirect - go.opentelemetry.io/otel v1.20.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.20.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.20.0 // indirect go.opentelemetry.io/otel/metric v1.20.0 // indirect - go.opentelemetry.io/otel/sdk v1.20.0 // indirect - go.opentelemetry.io/otel/trace v1.20.0 // indirect go.opentelemetry.io/proto/otlp v1.0.0 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect go.uber.org/zap v1.17.0 // indirect - golang.org/x/crypto v0.24.0 // indirect - golang.org/x/net v0.26.0 // indirect + golang.org/x/crypto v0.26.0 // indirect + golang.org/x/net v0.28.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect - golang.org/x/sys v0.21.0 // indirect - golang.org/x/term v0.21.0 // indirect - golang.org/x/text v0.16.0 // indirect + golang.org/x/sys v0.23.0 // indirect + golang.org/x/term v0.23.0 // indirect + golang.org/x/text v0.17.0 // indirect golang.org/x/time v0.3.0 // indirect - google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d // indirect google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect @@ -114,7 +118,6 @@ require ( gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.31.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect diff --git a/go.sum b/go.sum index 5f0df3e..c2a4572 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.110.7 h1:rJyC7nWRg2jWGZ4wSJ5nY65GTdYJkg0cd/uXb+ACI6o= cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY= -cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= -cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= -cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= @@ -41,7 +39,6 @@ github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzA github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -49,8 +46,6 @@ github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/eiffel-community/eiffelevents-sdk-go v0.0.0-20240807115026-5ca5c194b7dc h1:yRg84ReJfuVCJ/TMzfCqL12Aoy4vUSrUUgcuE02mBJo= github.com/eiffel-community/eiffelevents-sdk-go v0.0.0-20240807115026-5ca5c194b7dc/go.mod h1:Lt487E8lrDd/5hkCEyKHU/xZrqDjIgRNIDaoK/F3Yk4= -github.com/emicklei/go-restful/v3 v3.9.0 h1:XwGDlfxEnQZzuopoqxwSEllNcCOM9DhhFyhFIIGKwxE= -github.com/emicklei/go-restful/v3 v3.9.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -61,6 +56,8 @@ github.com/envoyproxy/protoc-gen-validate v1.0.2 h1:QkIBuU5k+x7/QXPvPPnWXWlCdaBF github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE= github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611 h1:JwYtKJ/DVEoIA5dH45OEU7uoryZY/gjd/BQiwwAOImM= github.com/fernet/fernet-go v0.0.0-20240119011108-303da6aec611/go.mod h1:zHMNeYgqrTpKyjawjitDg0Osd1P/FmeA0SZLYK3RfLQ= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -70,10 +67,7 @@ github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vb github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= -github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -82,19 +76,20 @@ github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs= github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.1.2 h1:DVjP2PbBOzHyzA+dn3WhHIq4NdVu3Q+pvivFICf/7fo= github.com/golang/glog v1.1.2/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ= @@ -112,6 +107,8 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= @@ -128,14 +125,11 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= -github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM= +github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw= @@ -167,6 +161,8 @@ github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4d github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= @@ -178,14 +174,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/machinebox/graphql v0.2.2 h1:dWKpJligYKhYKO5A2gvNhkJdQMNZeChZYyBbrZkBZfo= -github.com/machinebox/graphql v0.2.2/go.mod h1:F+kbVMHuwrQ5tYgU9JXlnskM8nOaFxCAEolaQybkjWA= github.com/magefile/mage v1.9.0 h1:t3AU2wNwehMCW97vuqQLtw6puppWXHO+O2MHo5a50XE= github.com/magefile/mage v1.9.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= -github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/maxcnunes/httpfake v1.2.4 h1:l7s/N7zuG6XpzG+5dUolg5SSoR3hANQxqzAkv+lREko= @@ -201,20 +193,19 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/onsi/ginkgo/v2 v2.9.4 h1:xR7vG4IXt5RWx6FfIjyAtsoMAtnc3C/rFXBBd2AjZwE= -github.com/onsi/ginkgo/v2 v2.9.4/go.mod h1:gCQYp2Q+kSoIj7ykSVb9nskRSsR6PUj4AiLywzIhbKM= github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA= -github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= -github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= -github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= +github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= +github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk= +github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/package-url/packageurl-go v0.1.3 h1:4juMED3hHiz0set3Vq3KeQ75KD1avthoXLtmE3I0PLs= github.com/package-url/packageurl-go v0.1.3/go.mod h1:nKAWB8E6uk1MHqiS/lQb9pYBGH2+mdJ2PJc2s50dQY0= +github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM= +github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -239,10 +230,11 @@ github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3x github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= +github.com/rabbitmq/rabbitmq-stream-go-client v1.4.10 h1:1kDn/orisEbfMtxdZwWKpxX9+FahnzoRCuGCLZ66fAc= +github.com/rabbitmq/rabbitmq-stream-go-client v1.4.10/go.mod h1:SdWsW0K5FVo8lIx0lCH17wh7RItXEQb8bfpxVlTVqS8= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -255,6 +247,8 @@ github.com/snowzach/rotatefilehook v0.0.0-20220211133110-53752135082d h1:4660u5v github.com/snowzach/rotatefilehook v0.0.0-20220211133110-53752135082d/go.mod h1:ZLVe3VfhAuMYLYWliGEydMBoRnfib8EFSqkBYu1ck9E= github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -334,10 +328,8 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= -golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -351,7 +343,6 @@ golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -359,15 +350,11 @@ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= -golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.11.0 h1:vPL4xzxBM4niKCW6g9whtaWVXTJf1U5e4aZxxFx/gbU= -golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk= golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -377,9 +364,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= -golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -395,21 +381,15 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= -golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= -golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= +golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= +golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -420,16 +400,14 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y= -golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= @@ -455,8 +433,6 @@ google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miE google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= @@ -484,37 +460,21 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -k8s.io/api v0.28.2 h1:9mpl5mOb6vXZvqbQmankOfPIGiudghwCoLl1EYfUZbw= -k8s.io/api v0.28.2/go.mod h1:RVnJBsjU8tcMq7C3iaRSGMeaKt2TWEUXcpIt/90fjEg= k8s.io/api v0.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU= k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI= -k8s.io/apimachinery v0.28.2 h1:KCOJLrc6gu+wV1BYgwik4AF4vXOlVJPdiqn0yAWWwXQ= -k8s.io/apimachinery v0.28.2/go.mod h1:RdzF87y/ngqk9H4z3EL2Rppv5jj95vGS/HaFXrLDApU= k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U= k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= -k8s.io/client-go v0.28.2 h1:DNoYI1vGq0slMBN/SWKMZMw0Rq+0EQW6/AK4v9+3VeY= -k8s.io/client-go v0.28.2/go.mod h1:sMkApowspLuc7omj1FOSUxSoqjr+d5Q0Yc0LOFnYFJY= k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0= k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg= -k8s.io/klog/v2 v2.100.1 h1:7WCHKK6K8fNhTqfBhISHQ97KrnJNFZMcQvKp7gP/tmg= -k8s.io/klog/v2 v2.100.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9 h1:LyMgNKD2P8Wn1iAwQU5OhxCKlKJy0sHc+PcDwFB24dQ= -k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9/go.mod h1:wZK2AVp1uHCp4VamDVgBP2COHZjqD1T68Rf0CM3YjSM= k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= -k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 h1:qY1Ad8PODbnymg2pRbkyMT/ylpTrCM8P2RJ0yroCyIk= -k8s.io/utils v0.0.0-20230406110748-d93618cff8a2/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= -sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kFxnAMREiWFE= -sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= -sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= -sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/internal/authorization/auth.go b/internal/authorization/auth.go new file mode 100644 index 0000000..92b866e --- /dev/null +++ b/internal/authorization/auth.go @@ -0,0 +1,148 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package auth + +import ( + "context" + "crypto" + "errors" + "fmt" + "net/http" + "time" + + "github.com/eiffel-community/etos-api/internal/authorization/scope" + jwt "github.com/golang-jwt/jwt/v5" + "github.com/julienschmidt/httprouter" +) + +type Authorizer struct { + publicKey crypto.PublicKey + signingKey crypto.PrivateKey +} + +var ( + ErrTokenInvalid = errors.New("invalid token") + ErrTokenExpired = jwt.ErrTokenExpired +) + +// NewAuthorizer loads private and public pem keys and creates a new authorizer. +// The private key can be set to an empty []byte but it would only be possible to +// verify tokens and not create new ones. +func NewAuthorizer(pub, priv []byte) (*Authorizer, error) { + var private crypto.PrivateKey + var err error + // Private key is optional, only needed for signing. + if len(priv) > 0 { + private, err = jwt.ParseEdPrivateKeyFromPEM(priv) + if err != nil { + return nil, err + } + } + public, err := jwt.ParseEdPublicKeyFromPEM(pub) + if err != nil { + return nil, err + } + return &Authorizer{public, private}, nil +} + +// NewToken generates a new JWT for an identifier. +func (a Authorizer) NewToken(identifier string, tokenScope scope.Scope, expire time.Time) (string, error) { + if a.signingKey == nil { + return "", errors.New("a private key must be provided to the authorizer to create new tokens.") + } + token := jwt.NewWithClaims( + jwt.SigningMethodEdDSA, + jwt.MapClaims{ + "scope": tokenScope.Format(), // Custom scope type, similar to oauth2. Describes what a subject can do with this token + "sub": identifier, // Subject + "aud": "https://etos", // Audience. The service that can be accessed with this token. + "iss": "https://etos", // Issuer + "iat": time.Now().Unix(), // Issued At + "exp": expire.Unix(), // Expiration + }) + return token.SignedString(a.signingKey) +} + +// VerifyToken verifies that a token is properly signed with a specific signing key and has not expired. +func (a Authorizer) VerifyToken(tokenString string) (*jwt.Token, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return a.publicKey, nil + }) + if err != nil { + return nil, err + } + if !token.Valid { + return nil, ErrTokenInvalid + } + return token, nil +} + +// Middleware implements an httprouter middleware to use for verifying authorization header JWTs. +// Scope is added to the context of the request and can be accessed by +// +// s := r.Context().Value("scope") +// tokenScope, ok := s.(scope.Scope) +func (a Authorizer) Middleware( + permittedScope scope.Var, + fn func(http.ResponseWriter, *http.Request, httprouter.Params), +) func(http.ResponseWriter, *http.Request, httprouter.Params) { + return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + tokenString := r.Header.Get("Authorization") + if tokenString == "" { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, "Missing authorization header") + return + } + tokenString = tokenString[len("Bearer "):] + token, err := a.VerifyToken(tokenString) + if err != nil { + if errors.Is(err, ErrTokenExpired) { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, "token has expired") + return + } + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, "invalid token") + return + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, "no valid claims in token") + return + } + tokenScope, ok := claims["scope"] + if !ok { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, "no valid scope in token") + return + } + scopeString, ok := tokenScope.(string) + if !ok { + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, "no valid scope in token") + return + } + s := scope.Parse(scopeString) + if !s.Has(permittedScope) { + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, "no permission to view this page") + return + } + r = r.WithContext(context.WithValue(r.Context(), "scope", s)) + fn(w, r, ps) + } +} diff --git a/internal/authorization/auth_test.go b/internal/authorization/auth_test.go new file mode 100644 index 0000000..5cf9f89 --- /dev/null +++ b/internal/authorization/auth_test.go @@ -0,0 +1,128 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package auth + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/eiffel-community/etos-api/internal/authorization/scope" + "github.com/eiffel-community/etos-api/test" + jwt "github.com/golang-jwt/jwt/v5" + "github.com/julienschmidt/httprouter" + "github.com/stretchr/testify/assert" +) + +// TestNewToken tests that it is possible to sign a new token with the authorizer. +func TestNewToken(t *testing.T) { + priv, pub, err := test.NewKeys() + assert.NoError(t, err) + authorizer, err := NewAuthorizer(pub, priv) + assert.NoError(t, err) + + tests := []struct { + name string + scope scope.Scope + }{ + {name: "TestNewAnonymousToken", scope: scope.AnonymousAccess}, + {name: "TestNewAdminToken", scope: scope.AdminAccess}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + tokenString, err := authorizer.NewToken(testCase.name, testCase.scope, time.Now().Add(time.Second*5)) + assert.NoError(t, err) + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return pub, nil + }) + subject, err := token.Claims.GetSubject() + assert.NoError(t, err) + assert.Equal(t, subject, testCase.name) + claims, ok := token.Claims.(jwt.MapClaims) + assert.True(t, ok) + tokenScope, ok := claims["scope"] + assert.True(t, ok) + scopeString, ok := tokenScope.(string) + assert.True(t, ok) + s := scope.Parse(scopeString) + assert.Equal(t, s, testCase.scope) + }) + } +} + +// TestVerifyToken tests that the authorizer can verify a signed token with a public key. +func TestVerifyToken(t *testing.T) { + priv, pub, err := test.NewKeys() + assert.NoError(t, err) + authorizer, err := NewAuthorizer(pub, priv) + assert.NoError(t, err) + tokenString, err := authorizer.NewToken("TestVerifyToken", scope.Scope{}, time.Now().Add(time.Second*1)) + assert.NoError(t, err) + _, err = authorizer.VerifyToken(tokenString) + assert.NoError(t, err) + time.Sleep(2 * time.Second) + _, err = authorizer.VerifyToken(tokenString) + // Should have expired after 2 seconds + assert.Error(t, err) +} + +// TestMiddleware tests that the authorizer middleware blocks unauthorized requests to an endpoint and lets authorized requests through. +func TestMiddleware(t *testing.T) { + priv, pub, err := test.NewKeys() + assert.NoError(t, err) + authorizer, err := NewAuthorizer(pub, priv) + + tests := []struct { + name string + scope scope.Scope + permittedScope scope.Var + header bool + expire time.Time + expected int + }{ + {name: "TestMiddlewareNoHeader", header: false, expected: http.StatusUnauthorized, expire: time.Now().Add(5 * time.Second)}, + {name: "TestMiddlewareExpiredToken", header: true, expected: http.StatusUnauthorized, expire: time.Now()}, + {name: "TestMiddlewareNoScope", header: true, scope: scope.Scope{}, permittedScope: "not-this-one", expected: http.StatusForbidden, expire: time.Now().Add(5 * time.Second)}, + {name: "TestMiddlewareWrongScope", header: true, scope: scope.Scope{scope.Var("a-scope")}, permittedScope: "not-this-one", expected: http.StatusForbidden, expire: time.Now().Add(5 * time.Second)}, + {name: "TestMiddlewarePassThrough", header: true, scope: scope.Scope{scope.Var("a-scope")}, permittedScope: "a-scope", expected: http.StatusOK, expire: time.Now().Add(5 * time.Second)}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + var gotThrough bool + endpoint := func(http.ResponseWriter, *http.Request, httprouter.Params) { + gotThrough = true + } + token, err := authorizer.NewToken(testCase.name, testCase.scope, testCase.expire) + assert.NoError(t, err) + + responseRecorder := httptest.NewRecorder() + ps := httprouter.Params{} + request := httptest.NewRequest("GET", "/my/test", nil) + if testCase.header { + request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + } + authorizer.Middleware(testCase.permittedScope, endpoint)(responseRecorder, request, ps) + assert.Equal(t, responseRecorder.Code, testCase.expected, responseRecorder.Body.String()) + if testCase.expected >= 400 { + assert.False(t, gotThrough) + } else { + assert.True(t, gotThrough) + } + }) + } +} diff --git a/internal/authorization/scope/api.go b/internal/authorization/scope/api.go new file mode 100644 index 0000000..274a846 --- /dev/null +++ b/internal/authorization/scope/api.go @@ -0,0 +1,23 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package scope + +var ( + CreateTestrun Var = "post-testrun" + StopTestrun Var = "delete-testrun" +) + +var GetEnvironment Var = "get-environment" diff --git a/internal/authorization/scope/sse.go b/internal/authorization/scope/sse.go new file mode 100644 index 0000000..6e6483a --- /dev/null +++ b/internal/authorization/scope/sse.go @@ -0,0 +1,21 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package scope + +var ( + StreamSSE Var = "get-sse" + DefineSSE Var = "post-sse" +) diff --git a/internal/authorization/scope/types.go b/internal/authorization/scope/types.go new file mode 100644 index 0000000..df7d01f --- /dev/null +++ b/internal/authorization/scope/types.go @@ -0,0 +1,64 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package scope + +import ( + "strings" +) + +type ( + Var string + // Scope is a slice of actions and resources that a token can do. + // For example the string "post-testrun" means that the action "post" can + // be done on "testrun", i.e. create a new testrun for ETOS. + // The actions are HTTP methods and the resource is a name that the service + // described in the 'aud' claim has knowledge about. + Scope []Var +) + +// Format a scope to be used in a JWT. +func (s Scope) Format() string { + var str []string + for _, sc := range s { + str = append(str, string(sc)) + } + return strings.Join(str, " ") +} + +// Has checks if a scope has a specific action + resource. +func (s Scope) Has(scope Var) bool { + for _, sc := range s { + if scope == sc { + return true + } + } + return false +} + +// Parse a scope string and return a Scope. +func Parse(scope string) Scope { + split := strings.Split(scope, " ") + var s Scope + for _, sc := range split { + s = append(s, Var(sc)) + } + return s +} + +var ( + AnonymousAccess Scope = []Var{CreateTestrun, StopTestrun, StreamSSE} + AdminAccess Scope = []Var{CreateTestrun, StopTestrun, GetEnvironment, DefineSSE, StreamSSE} +) diff --git a/internal/config/base.go b/internal/config/base.go index 1ae8413..4e347bb 100644 --- a/internal/config/base.go +++ b/internal/config/base.go @@ -30,6 +30,7 @@ type Config interface { LogFilePath() string ETOSNamespace() string DatabaseURI() string + PublicKey() ([]byte, error) } // baseCfg implements the Config interface. @@ -42,6 +43,7 @@ type baseCfg struct { etosNamespace string databaseHost string databasePort string + publicKeyPath string } // load the command line vars for a base configuration. @@ -56,6 +58,7 @@ func load() Config { flag.StringVar(&conf.etosNamespace, "etosnamespace", ReadNamespaceOrEnv("ETOS_NAMESPACE"), "Path, including filename, for the log files to create.") flag.StringVar(&conf.databaseHost, "databasehost", EnvOrDefault("ETOS_ETCD_HOST", "etcd-client"), "Host to the database.") flag.StringVar(&conf.databasePort, "databaseport", EnvOrDefault("ETOS_ETCD_PORT", "2379"), "Port to the database.") + flag.StringVar(&conf.publicKeyPath, "publickeypath", os.Getenv("PUBLIC_KEY_PATH"), "Path to a public key to use for verifying JWTs.") return &conf } @@ -94,6 +97,14 @@ func (c *baseCfg) DatabaseURI() string { return fmt.Sprintf("%s:%s", c.databaseHost, c.databasePort) } +// PublicKey reads a public key from disk and returns the content. +func (c *baseCfg) PublicKey() ([]byte, error) { + if c.publicKeyPath == "" { + return nil, nil + } + return os.ReadFile(c.publicKeyPath) +} + // EnvOrDefault will look up key in environment variables and return if it exists, else return the fallback value. func EnvOrDefault(key, fallback string) string { if value, ok := os.LookupEnv(key); ok { diff --git a/internal/config/executionspace.go b/internal/config/executionspace.go index 7df44df..d258d0d 100644 --- a/internal/config/executionspace.go +++ b/internal/config/executionspace.go @@ -36,7 +36,6 @@ type ExecutionSpaceConfig interface { // executionSpaceCfg implements the ExecutionSpaceConfig interface. type executionSpaceCfg struct { Config - stripPrefix string hostname string timeout time.Duration executionSpaceWaitTimeout time.Duration diff --git a/internal/config/keys.go b/internal/config/keys.go new file mode 100644 index 0000000..6a0f044 --- /dev/null +++ b/internal/config/keys.go @@ -0,0 +1,52 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package config + +import ( + "flag" + "os" +) + +type KeyConfig interface { + Config + PrivateKey() ([]byte, error) +} + +// keyCfg implements the KeyConfig interface. +type keyCfg struct { + Config + privateKeyPath string +} + +// NewKeyConcifg creates a key config interface based on input parameters or environment variables. +func NewKeyConfig() KeyConfig { + var conf keyCfg + + flag.StringVar(&conf.privateKeyPath, "privatekeypath", os.Getenv("PRIVATE_KEY_PATH"), "Path to a private key to use for signing JWTs.") + base := load() + flag.Parse() + conf.Config = base + + return &conf +} + +// PrivateKey reads a private key from disk and returns the content. +func (c *keyCfg) PrivateKey() ([]byte, error) { + if c.privateKeyPath == "" { + return nil, nil + } + return os.ReadFile(c.privateKeyPath) +} diff --git a/internal/config/sse.go b/internal/config/sse.go index 7052089..41534ed 100644 --- a/internal/config/sse.go +++ b/internal/config/sse.go @@ -15,15 +15,33 @@ // limitations under the License. package config -import "flag" +import ( + "flag" + "os" +) type SSEConfig interface { Config + RabbitMQURI() string +} + +type sseCfg struct { + Config + rabbitmqURI string } // NewSSEConfig creates a sse config interface based on input parameters or environment variables. func NewSSEConfig() SSEConfig { - cfg := load() + var conf sseCfg + + flag.StringVar(&conf.rabbitmqURI, "rabbitmquri", os.Getenv("ETOS_RABBITMQ_URI"), "URI to the RabbitMQ ") + base := load() + conf.Config = base flag.Parse() - return cfg + return &conf +} + +// RabbitMQURI returns the RabbitMQ URI. +func (c *sseCfg) RabbitMQURI() string { + return c.rabbitmqURI } diff --git a/internal/stream/file.go b/internal/stream/file.go new file mode 100644 index 0000000..e04ed82 --- /dev/null +++ b/internal/stream/file.go @@ -0,0 +1,136 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package stream + +import ( + "bufio" + "context" + "errors" + "io" + "os" + "time" + + "github.com/sirupsen/logrus" +) + +// FileStreamer will create a stream that reads from a file and publishes them +// to a consumer. +type FileStreamer struct { + interval time.Duration + logger *logrus.Entry +} + +func NewFileStreamer(interval time.Duration, logger *logrus.Entry) (Streamer, error) { + return &FileStreamer{interval: interval, logger: logger}, nil +} + +// CreateStream does nothing. +func (s *FileStreamer) CreateStream(ctx context.Context, logger *logrus.Entry, name string) error { + return os.WriteFile(name, nil, 0644) +} + +// NewStream creates a new stream struct to consume from. +func (s *FileStreamer) NewStream(ctx context.Context, logger *logrus.Entry, name string) (Stream, error) { + file, err := os.Open(name) + if err != nil { + return nil, err + } + return &FileStream{ctx: ctx, file: file, interval: s.interval, logger: logger}, nil +} + +// Close does nothing. +func (s *FileStreamer) Close() {} + +// FileStream is a structure implementing the Stream interface. Used to consume events +// from a file. +type FileStream struct { + ctx context.Context + logger *logrus.Entry + file io.ReadCloser + interval time.Duration + offset int + channel chan<- []byte + filter []string +} + +// WithChannel adds a channel for receiving events from the stream. If no +// channel is added, then events will be logged. +func (s *FileStream) WithChannel(ch chan<- []byte) Stream { + s.channel = ch + return s +} + +// WithOffset adds an offset to the file stream. -1 means start from the beginning. +func (s *FileStream) WithOffset(offset int) Stream { + s.logger.Warning("offset is not yet supported by file stream") + return s +} + +// WithFilter adds a filter to the file stream. +func (s *FileStream) WithFilter(filter []string) Stream { + s.logger.Warning("filter is not yet supported by file stream") + return s +} + +// Consume will start consuming the file, non blocking. A channel is returned where +// an error is sent when the consumer closes down. +func (s *FileStream) Consume(ctx context.Context) (<-chan error, error) { + closed := make(chan error) + go func() { + scanner := bufio.NewReader(s.file) + interval := time.NewTicker(s.interval) + for { + select { + case <-ctx.Done(): + closed <- ctx.Err() + return + case <-interval.C: + var isPrefix bool = true + var err error + var line []byte + var event []byte + + for isPrefix && err == nil { + line, isPrefix, err = scanner.ReadLine() + event = append(event, line...) + } + if err != nil { + // Don't close the stream just because the file is empty. + if errors.Is(err, io.EOF) { + continue + } + closed <- err + return + } + if s.channel != nil { + s.channel <- event + } else { + s.logger.Info(event) + } + } + } + }() + return closed, nil +} + +// Close the file. +func (s *FileStream) Close() { + if s.file != nil { + if err := s.file.Close(); err != nil { + s.logger.WithError(err).Error("failed to close the file") + } + } +} diff --git a/internal/stream/rabbitmq.go b/internal/stream/rabbitmq.go new file mode 100644 index 0000000..2d625c7 --- /dev/null +++ b/internal/stream/rabbitmq.go @@ -0,0 +1,187 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package stream + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + "github.com/rabbitmq/rabbitmq-stream-go-client/pkg/amqp" + "github.com/rabbitmq/rabbitmq-stream-go-client/pkg/stream" + "github.com/sirupsen/logrus" +) + +const IgnoreUnfiltered = false + +// RabbitMQStreamer is a struct representing a single RabbitMQ connection. From a new connection new streams may be created. +// Normal case is to have a single connection with multiple streams. If multiple connections are needed then multiple +// instances of the program should be run. +type RabbitMQStreamer struct { + environment *stream.Environment + logger *logrus.Entry +} + +// NewRabbitMQStreamer creates a new RabbitMQ streamer. Only a single connection should be created. +func NewRabbitMQStreamer(options stream.EnvironmentOptions, logger *logrus.Entry) (Streamer, error) { + env, err := stream.NewEnvironment(&options) + if err != nil { + log.Fatal(err) + } + return &RabbitMQStreamer{environment: env, logger: logger}, err +} + +// CreateStream creates a new RabbitMQ stream. +func (s *RabbitMQStreamer) CreateStream(ctx context.Context, logger *logrus.Entry, name string) error { + logger.Info("Defining a new stream") + // This will create the stream if not already created. + return s.environment.DeclareStream(name, + &stream.StreamOptions{ + // TODO: More sane numbers + MaxLengthBytes: stream.ByteCapacity{}.GB(2), + MaxAge: time.Second * 10, + }, + ) +} + +// NewStream creates a new stream struct to consume from. +func (s *RabbitMQStreamer) NewStream(ctx context.Context, logger *logrus.Entry, name string) (Stream, error) { + exists, err := s.environment.StreamExists(name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.New("no stream exists, cannot stream events") + } + options := stream.NewConsumerOptions(). + SetClientProvidedName(name). + SetConsumerName(name). + SetCRCCheck(false). + SetOffset(stream.OffsetSpecification{}.First()) + return &RabbitMQStream{ctx: ctx, logger: logger, streamName: name, environment: s.environment, options: options}, nil +} + +// Close the RabbitMQ connection. +func (s *RabbitMQStreamer) Close() { + err := s.environment.Close() + if err != nil { + s.logger.WithError(err).Error("failed to close RabbitMQStreamer") + } +} + +// RabbitMQStream is a structure implementing the Stream interface. Used to consume events +// from a RabbitMQ stream. +type RabbitMQStream struct { + ctx context.Context + logger *logrus.Entry + streamName string + environment *stream.Environment + options *stream.ConsumerOptions + consumer *stream.Consumer + channel chan<- []byte + filter []string +} + +// WithChannel adds a channel for receiving events from the stream. If no +// channel is added, then events will be logged. +func (s *RabbitMQStream) WithChannel(ch chan<- []byte) Stream { + s.channel = ch + return s +} + +// WithOffset adds an offset to the RabbitMQ stream. -1 means start from the beginning. +func (s *RabbitMQStream) WithOffset(offset int) Stream { + if offset == -1 { + s.options = s.options.SetOffset(stream.OffsetSpecification{}.First()) + } else { + s.options = s.options.SetOffset(stream.OffsetSpecification{}.Offset(int64(offset))) + } + return s +} + +// WithFilter adds a filter to the RabbitMQ stream. +func (s *RabbitMQStream) WithFilter(filter []string) Stream { + s.filter = filter + if len(filter) > 0 { + s.options = s.options.SetFilter(stream.NewConsumerFilter(filter, IgnoreUnfiltered, s.postFilter)) + } + return s +} + +// Consume will start consuming the RabbitMQ stream, non blocking. A channel is returned where +// an error is sent when the consumer closes down. +func (s *RabbitMQStream) Consume(ctx context.Context) (<-chan error, error) { + handler := func(_ stream.ConsumerContext, message *amqp.Message) { + for _, d := range message.Data { + if s.channel != nil { + s.channel <- d + } else { + s.logger.Debug(d) + } + } + } + consumer, err := s.environment.NewConsumer(s.streamName, handler, s.options) + if err != nil { + return nil, err + } + s.consumer = consumer + closed := make(chan error) + go s.notifyClose(ctx, closed) + return closed, nil +} + +// notifyClose will keep track of context and the notify close channel from RabbitMQ and send +// error on a channel. +func (s *RabbitMQStream) notifyClose(ctx context.Context, ch chan<- error) { + closed := s.consumer.NotifyClose() + select { + case <-ctx.Done(): + ch <- ctx.Err() + case event := <-closed: + ch <- event.Err + } +} + +// Close the RabbitMQ stream consumer. +func (s *RabbitMQStream) Close() { + if s.consumer != nil { + if err := s.consumer.Close(); err != nil { + s.logger.WithError(err).Error("failed to close rabbitmq consumer") + } + } +} + +// postFilter applies client side filtering on all messages received from the RabbitMQ stream. +// The RabbitMQ server-side filtering is not perfect and will let through a few messages that don't +// match the filter, this is expected as the RabbitMQ unit of delivery is the chunk and there may +// be multiple messages in a chunk and those messages are not filtered. +func (s *RabbitMQStream) postFilter(message *amqp.Message) bool { + if s.filter == nil { + return true // Unfiltered + } + identifier := message.ApplicationProperties["identifier"] + eventType := message.ApplicationProperties["type"] + eventMeta := message.ApplicationProperties["meta"] + name := fmt.Sprintf("%s.%s.%s", identifier, eventType, eventMeta) + for _, filter := range s.filter { + if name == filter { + return true + } + } + return false +} diff --git a/internal/stream/stream.go b/internal/stream/stream.go new file mode 100644 index 0000000..c88bbe3 --- /dev/null +++ b/internal/stream/stream.go @@ -0,0 +1,36 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package stream + +import ( + "context" + + "github.com/sirupsen/logrus" +) + +type Streamer interface { + NewStream(context.Context, *logrus.Entry, string) (Stream, error) + CreateStream(context.Context, *logrus.Entry, string) error + Close() +} + +type Stream interface { + WithChannel(chan<- []byte) Stream + WithOffset(int) Stream + WithFilter([]string) Stream + Consume(context.Context) (<-chan error, error) + Close() +} diff --git a/pkg/keys/v1alpha/keys.go b/pkg/keys/v1alpha/keys.go new file mode 100644 index 0000000..4d26ab7 --- /dev/null +++ b/pkg/keys/v1alpha/keys.go @@ -0,0 +1,133 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package keys + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + auth "github.com/eiffel-community/etos-api/internal/authorization" + "github.com/eiffel-community/etos-api/internal/authorization/scope" + "github.com/eiffel-community/etos-api/internal/config" + "github.com/eiffel-community/etos-api/pkg/application" + "github.com/julienschmidt/httprouter" + + "github.com/sirupsen/logrus" +) + +const pingInterval = 15 * time.Second + +type Application struct { + logger *logrus.Entry + cfg config.KeyConfig + ctx context.Context + cancel context.CancelFunc + authorizer *auth.Authorizer +} + +type Handler struct { + logger *logrus.Entry + cfg config.KeyConfig + ctx context.Context + authorizer *auth.Authorizer +} + +// Close cancels the application context. +func (a *Application) Close() { + a.cancel() +} + +// New returns a new Application object/struct. +func New(ctx context.Context, cfg config.KeyConfig, log *logrus.Entry, authorizer *auth.Authorizer) application.Application { + ctx, cancel := context.WithCancel(ctx) + return &Application{ + logger: log, + cfg: cfg, + ctx: ctx, + cancel: cancel, + authorizer: authorizer, + } +} + +// LoadRoutes loads all the v2alpha routes. +func (a Application) LoadRoutes(router *httprouter.Router) { + handler := &Handler{a.logger, a.cfg, a.ctx, a.authorizer} + router.GET("/v1alpha/selftest/ping", handler.Selftest) + router.POST("/v1alpha/generate", handler.CreateNew) +} + +// Selftest is a handler to just return 204. +func (h Handler) Selftest(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusNoContent) +} + +// KeyResponse describes the response from the key server. +type KeyResponse struct { + Token string `json:"token,omitempty"` + Error string `json:"error,omitempty"` +} + +// KeyRequest describes the request to the key server. +type KeyRequest struct { + Scope string `json:"scope"` + Identity string `json:"identity"` +} + +// CreateNew is a handler that can create new access tokens. +func (h Handler) CreateNew(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + identifier := ps.ByName("identifier") + // Making it possible for us to correlate logs to a specific connection + logger := h.logger.WithField("identifier", identifier) + + var keyRequest KeyRequest + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&keyRequest); err != nil { + logger.WithError(err).Warning("failed to decode json") + w.WriteHeader(http.StatusBadRequest) + response, _ := json.Marshal(KeyResponse{Error: "must provide one or several scopes to access"}) + _, _ = w.Write(response) + return + } + logger.Info(keyRequest.Identity) + requestedScope := scope.Parse(keyRequest.Scope) + for _, s := range requestedScope { + if !scope.AnonymousAccess.Has(s) { + w.WriteHeader(http.StatusForbidden) + response, _ := json.Marshal(KeyResponse{Error: fmt.Sprintf("not allowed to request the '%s' scope", s)}) + _, _ = w.Write(response) + return + } + } + + w.Header().Set("Content-Type", "application/json") + token, err := h.authorizer.NewToken(identifier, requestedScope, time.Now().Add(30*time.Minute)) + if err != nil { + logger.WithError(err).Warning("failed to generate token") + w.WriteHeader(http.StatusInternalServerError) + response, _ := json.Marshal(KeyResponse{Error: "could not create a new token"}) + _, _ = w.Write(response) + return + } + logger.Info("generated a new key") + w.WriteHeader(http.StatusOK) + response, _ := json.Marshal(KeyResponse{Token: token}) + _, _ = w.Write(response) +} diff --git a/pkg/keys/v1alpha/keys_test.go b/pkg/keys/v1alpha/keys_test.go new file mode 100644 index 0000000..cb927ed --- /dev/null +++ b/pkg/keys/v1alpha/keys_test.go @@ -0,0 +1,93 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package keys + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + auth "github.com/eiffel-community/etos-api/internal/authorization" + "github.com/eiffel-community/etos-api/internal/authorization/scope" + "github.com/eiffel-community/etos-api/internal/config" + "github.com/eiffel-community/etos-api/test" + "github.com/julienschmidt/httprouter" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +type testcfg struct { + config.Config + private []byte + public []byte +} + +func (c testcfg) PrivateKey() ([]byte, error) { + return c.private, nil +} + +// TestCreateNew tests that it is possible to create a new token via the keys API. +func TestCreateNew(t *testing.T) { + log := logrus.WithFields(logrus.Fields{}) + priv, pub, err := test.NewKeys() + assert.NoError(t, err) + cfg := testcfg{private: priv, public: pub} + authorizer, err := auth.NewAuthorizer(pub, priv) + handler := Handler{log, cfg, context.Background(), authorizer} + + responseRecorder := httptest.NewRecorder() + ps := httprouter.Params{httprouter.Param{Key: "identifier", Value: "Hello"}} + requestData := []byte(fmt.Sprintf(`{"scope":"%s","identity":"TestCreateNew"}`, scope.StreamSSE)) + request := httptest.NewRequest("POST", "/v1alpha/generate", bytes.NewReader(requestData)) + handler.CreateNew(responseRecorder, request, ps) + assert.Equal(t, responseRecorder.Code, http.StatusOK, responseRecorder.Body.String()) + + var keyResponse KeyResponse + decoder := json.NewDecoder(responseRecorder.Body) + err = decoder.Decode(&keyResponse) + assert.NoError(t, err) + assert.Empty(t, keyResponse.Error) + _, err = authorizer.VerifyToken(keyResponse.Token) + assert.NoError(t, err) +} + +// TestCreateNewWrongScope tests that it is not possible to create a new token if the scope is outside of anonymous access. +func TestCreateNewWrongScope(t *testing.T) { + log := logrus.WithFields(logrus.Fields{}) + priv, pub, err := test.NewKeys() + assert.NoError(t, err) + cfg := testcfg{private: priv, public: pub} + authorizer, err := auth.NewAuthorizer(pub, priv) + handler := Handler{log, cfg, context.Background(), authorizer} + + responseRecorder := httptest.NewRecorder() + ps := httprouter.Params{httprouter.Param{Key: "identifier", Value: "Hello"}} + requestData := []byte(fmt.Sprintf(`{"scope":"%s","identity":"TestCreateNew"}`, scope.DefineSSE)) + request := httptest.NewRequest("POST", "/v1alpha/generate", bytes.NewReader(requestData)) + handler.CreateNew(responseRecorder, request, ps) + assert.Equal(t, responseRecorder.Code, http.StatusForbidden, responseRecorder.Body.String()) + + var keyResponse KeyResponse + decoder := json.NewDecoder(responseRecorder.Body) + err = decoder.Decode(&keyResponse) + assert.NoError(t, err) + assert.NotEmpty(t, keyResponse.Error) + assert.Empty(t, keyResponse.Token) +} diff --git a/pkg/sse/v2alpha/sse.go b/pkg/sse/v2alpha/sse.go new file mode 100644 index 0000000..4c2184f --- /dev/null +++ b/pkg/sse/v2alpha/sse.go @@ -0,0 +1,245 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package sse + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + auth "github.com/eiffel-community/etos-api/internal/authorization" + "github.com/eiffel-community/etos-api/internal/authorization/scope" + "github.com/eiffel-community/etos-api/internal/config" + "github.com/eiffel-community/etos-api/internal/stream" + "github.com/eiffel-community/etos-api/pkg/application" + "github.com/eiffel-community/etos-api/pkg/events" + "github.com/julienschmidt/httprouter" + + "github.com/sirupsen/logrus" +) + +const pingInterval = 15 * time.Second + +type Application struct { + logger *logrus.Entry + cfg config.SSEConfig + ctx context.Context + cancel context.CancelFunc + streamer stream.Streamer + authorizer *auth.Authorizer +} + +type Handler struct { + logger *logrus.Entry + cfg config.SSEConfig + ctx context.Context + streamer stream.Streamer +} + +// Close cancels the application context. +func (a *Application) Close() { + a.cancel() + a.streamer.Close() +} + +// New returns a new Application object/struct. +func New(ctx context.Context, cfg config.SSEConfig, log *logrus.Entry, streamer stream.Streamer, authorizer *auth.Authorizer) application.Application { + ctx, cancel := context.WithCancel(ctx) + return &Application{ + logger: log, + cfg: cfg, + ctx: ctx, + cancel: cancel, + streamer: streamer, + authorizer: authorizer, + } +} + +// LoadRoutes loads all the v2alpha routes. +func (a Application) LoadRoutes(router *httprouter.Router) { + handler := &Handler{a.logger, a.cfg, a.ctx, a.streamer} + router.GET("/v2alpha/selftest/ping", handler.Selftest) + router.GET("/v2alpha/events/:identifier", a.authorizer.Middleware(scope.StreamSSE, handler.GetEvents)) + router.POST("/v2alpha/stream/:identifier", a.authorizer.Middleware(scope.DefineSSE, handler.CreateStream)) +} + +// Selftest is a handler to just return 204. +func (h Handler) Selftest(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusNoContent) +} + +// cleanFilter will clean up the filters received from clients. +func (h Handler) cleanFilter(identifier string, filters []string) { + for i, filter := range filters { + if len(strings.Split(filter, ".")) != 3 { + filters[i] = fmt.Sprintf("%s.%s", identifier, filter) + } + } +} + +type ErrorEvent struct { + Retry bool `json:"retry"` + Reason string `json:"reason"` +} + +// subscribe subscribes to stream and gets logs and events from it and writes them to a channel. +func (h Handler) subscribe(ctx context.Context, logger *logrus.Entry, streamer stream.Stream, ch chan<- events.Event, counter int, filter []string) { + defer close(ch) + var err error + + consumeCh := make(chan []byte, 0) + + offset := -1 + if counter > 1 { + offset = counter + } + + closed, err := streamer.WithChannel(consumeCh).WithOffset(offset).WithFilter(filter).Consume(ctx) + if err != nil { + logger.WithError(err).Error("failed to start consuming stream") + b, _ := json.Marshal(ErrorEvent{Retry: false, Reason: err.Error()}) + ch <- events.Event{Event: "error", Data: string(b)} + return + } + defer streamer.Close() + + ping := time.NewTicker(pingInterval) + defer ping.Stop() + var event events.Event + for { + select { + case <-ctx.Done(): + logger.Info("Client lost, closing subscriber") + return + case <-ping.C: + ch <- events.Event{Event: "ping"} + case <-closed: + logger.Info("Stream closed, closing down") + b, _ := json.Marshal(ErrorEvent{Retry: true, Reason: "Streamer closed the connection"}) + ch <- events.Event{Event: "error", Data: string(b)} + return + case msg := <-consumeCh: + event, err = events.New(msg) + if err != nil { + logger.WithError(err).Error("failed to parse SSE event") + continue + } + // TODO: https://github.com/eiffel-community/etos/issues/299 + if event.JSONData == nil { + event = events.Event{ + Data: string(msg), + Event: "message", + } + } + event.ID = counter + ch <- event + counter++ + } + } +} + +// CreateStream creates a new RabbitMQ stream for use with the sse events stream. +func (h Handler) CreateStream(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + identifier := ps.ByName("identifier") + // Making it possible for us to correlate logs to a specific connection + logger := h.logger.WithField("identifier", identifier) + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + + err := h.streamer.CreateStream(r.Context(), logger, identifier) + if err != nil { + logger.WithError(err).Error("failed to create a rabbitmq stream") + w.WriteHeader(http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusCreated) +} + +// GetEvents is an endpoint for streaming events and logs from ETOS. +func (h Handler) GetEvents(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + identifier := ps.ByName("identifier") + // Filters may be passed multiple times (?filter=log.info&filter=log.debug) + // in order to parse multiple values into a slice r.ParseForm() is used. + // The filters are accessible in r.Form["filter"] after r.ParseForm() has been + // called. + r.ParseForm() + + // Making it possible for us to correlate logs to a specific connection + logger := h.logger.WithField("identifier", identifier) + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Transfer-Encoding", "chunked") + + lastID := 1 + lastEventID := r.Header.Get("Last-Event-ID") + if lastEventID != "" { + var err error + lastID, err = strconv.Atoi(lastEventID) + if err != nil { + logger.Error("Last-Event-ID header is not parsable") + } + } + + filter := r.Form["filter"] + h.cleanFilter(identifier, filter) + + streamer, err := h.streamer.NewStream(r.Context(), logger, identifier) + if err != nil { + logger.WithError(err).Error("Could not start a new stream") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(err.Error())) + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + http.NotFound(w, r) + return + } + logger.Info("Client connected to SSE") + + receiver := make(chan events.Event) // Channel is closed in Subscriber + go h.subscribe(r.Context(), logger, streamer, receiver, lastID, filter) + + for { + select { + case <-r.Context().Done(): + logger.Info("Client gone from SSE") + return + case <-h.ctx.Done(): + logger.Info("Shutting down") + return + case event, ok := <-receiver: + if !ok { + return + } + if err := event.Write(w); err != nil { + logger.Error(err) + continue + } + flusher.Flush() + } + } +} diff --git a/pkg/sse/v2alpha/sse_test.go b/pkg/sse/v2alpha/sse_test.go new file mode 100644 index 0000000..a600dfb --- /dev/null +++ b/pkg/sse/v2alpha/sse_test.go @@ -0,0 +1,92 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package sse + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/eiffel-community/etos-api/internal/config" + "github.com/eiffel-community/etos-api/internal/stream" + "github.com/julienschmidt/httprouter" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +type cfg struct { + config.Config +} + +func (c cfg) RabbitMQURI() string { + return "" +} + +// TestSSECreateStream tests that the CreateStream endpoint works. +// Does not test the authorization middleware! +func TestSSECreateStream(t *testing.T) { + log := logrus.WithFields(logrus.Fields{}) + streamer, err := stream.NewFileStreamer(100*time.Millisecond, log) + assert.NoError(t, err) + handler := Handler{log, &cfg{}, context.Background(), streamer} + responseRecorder := httptest.NewRecorder() + testrunID := "test_sse_create_stream" + request := httptest.NewRequest("GET", fmt.Sprintf("/v2alpha/stream/%s", testrunID), nil) + ps := httprouter.Params{httprouter.Param{Key: "identifier", Value: testrunID}} + handler.CreateStream(responseRecorder, request, ps) + defer func() { + os.Remove(testrunID) + }() + assert.Equal(t, http.StatusCreated, responseRecorder.Code) + assert.FileExists(t, testrunID) +} + +// TestSSEGetEvents tests that a client can subscribe to an SSE stream and get events. +func TestSSEGetEvents(t *testing.T) { + data := []byte(`{"data":"hello world","event":"message"}`) + testrunID := "test_sse_get_events" + os.WriteFile(testrunID, data, 0644) + defer func() { + os.Remove(testrunID) + }() + ctx, done := context.WithTimeout(context.Background(), time.Second*1) + defer done() + + log := logrus.WithFields(logrus.Fields{}) + streamer, err := stream.NewFileStreamer(100*time.Millisecond, log) + assert.NoError(t, err) + handler := Handler{log, &cfg{}, context.Background(), streamer} + responseRecorder := httptest.NewRecorder() + request := httptest.NewRequest("GET", fmt.Sprintf("/v2alpha/events/%s", testrunID), nil) + request = request.WithContext(ctx) + ps := httprouter.Params{httprouter.Param{Key: "identifier", Value: testrunID}} + handler.GetEvents(responseRecorder, request, ps) + + assert.Equal(t, http.StatusOK, responseRecorder.Code) + body, err := io.ReadAll(responseRecorder.Body) + assert.NoError(t, err) + fmt.Println(string(body)) + assert.Equal(t, body, []byte(`id: 1 +event: message +data: "hello world" + +`)) +} diff --git a/test/testconfig/testconfig.go b/test/testconfig/testconfig.go index 85b0e80..ec25fa3 100644 --- a/test/testconfig/testconfig.go +++ b/test/testconfig/testconfig.go @@ -79,3 +79,8 @@ func (c *cfg) ETOSNamespace() string { func (c *cfg) DatabaseURI() string { return "etcd-client:2379" } + +// PublicKey returns a public key. +func (c *cfg) PublicKey() ([]byte, error) { + return nil, nil +} diff --git a/test/utilities.go b/test/utilities.go new file mode 100644 index 0000000..b097136 --- /dev/null +++ b/test/utilities.go @@ -0,0 +1,50 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package test + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" +) + +// NewKeys creates a new public and private key for testing. +func NewKeys() ([]byte, []byte, error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, err + } + b, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return nil, nil, err + } + block := &pem.Block{ + Type: "PRIVATE KEY", + Bytes: b, + } + privateKey := pem.EncodeToMemory(block) + b, err = x509.MarshalPKIXPublicKey(pub) + if err != nil { + return nil, nil, err + } + block = &pem.Block{ + Type: "PUBLIC KEY", + Bytes: b, + } + publicKey := pem.EncodeToMemory(block) + return privateKey, publicKey, nil +}