diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml new file mode 100644 index 0000000..69006fe --- /dev/null +++ b/.buildkite/pipeline.yml @@ -0,0 +1,82 @@ +env: + ARTIFACT_NAME: bk-infra9-metal-bridge + BIN_NAME: infra9-metal-bridge + DEPLOYMENT_REPO: k8s-infra9-metal-bridge + GOPRIVATE: go.equinixmetal.net/* + IMAGE_TAG: ${BUILDKITE_BUILD_NUMBER}-${BUILDKITE_COMMIT:0:8} + QUAY_REPO: quay.io/equinixmetal/infra9-metal-bridge + +steps: + - label: ":golangci-lint: lint" + key: "lint" + command: | + make lint + plugins: + - docker#v5.3.0: + image: "golangci/golangci-lint:v1.51.2" + volumes: + - "/var/lib/buildkite-agent/.gitconfig/:/root/.gitconfig/" + + - label: ":test_tube: test" + key: "test" + command: | + make test + plugins: + - docker#v5.3.0: + image: "golang:1.20" + volumes: + - "/var/lib/buildkite-agent/.gitconfig/:/root/.gitconfig/" + + - label: ":golang: build" + key: "gobuild" + commands: | + CGO_ENABLED=0 go build -o ${ARTIFACT_NAME} -buildvcs=false ./main.go + artifact_paths: "${ARTIFACT_NAME}" + plugins: + - docker#v5.6.0: + image: "golang:1.20" + volumes: + - "/var/lib/buildkite-agent/.gitconfig:/root/.gitconfig" + + - label: ":whale: docker build" + key: "build" + depends_on: ["lint", "test", "gobuild"] + commands: | + #!/bin/bash + echo --- Retrieve artifacts + buildkite-agent artifact download "${ARTIFACT_NAME}" . + mv "${ARTIFACT_NAME}" "${BIN_NAME}" + + # make sure it is executable + chmod +x ${BIN_NAME} + + echo --- Build Docker Image + docker build . -t "$QUAY_REPO:$IMAGE_TAG" + + echo --- Push Docker Image + docker push "$QUAY_REPO:$IMAGE_TAG" + + buildkite-agent annotate --style "success" "Image pushed to quay [$QUAY_REPO:$IMAGE_TAG](https://$QUAY_REPO:$IMAGE_TAG)" + + # For main commits, pull-requests will be created to bump the image in the deployment manifest + - label: "Bump image tag for main branch builds" + depends_on: + - "build" + if: build.branch == 'main' + plugins: + - first-aml/git-clone: + repository: git@github.com:equinixmetal/$DEPLOYMENT_REPO.git + - ssh://git@github.com/equinixmetal/ssm-buildkite-plugin#v1.0.3: + parameters: + GITHUB_TOKEN: /buildkite/github/personal-access-token/v1 + - ssh://git@github.com/packethost/yaml-update-buildkite-plugin#v1.0.1: + dir: $DEPLOYMENT_REPO + file: values.yaml + values: + - .bridge.image.tag=$IMAGE_TAG + - ssh://git@github.com/equinixmetal/github-pr-template-buildkite-plugin#v0.2.0: {} + # Create Pull Request to main using commit from previous step + - envato/github-pull-request#v0.4.0: + title: '[buildkite] bump image tag to $IMAGE_TAG' + head: buildkite-yaml-update-$BUILDKITE_BUILD_NUMBER + base: main diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..9875d33 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,56 @@ +linters-settings: + gofumpt: + extra-rules: true + +linters: + enable: + # default linters + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - typecheck + - unused + + # additional linters + - bodyclose + - gocritic + - gocyclo + # - goerr113 + - gofmt + - goimports + - revive + - gomnd + - govet + - misspell + - noctx + - stylecheck + - whitespace + - wsl + + # - bod +issues: + exclude: + # Default excludes from `golangci-lint run --help` with EXC0002 removed + # EXC0001 errcheck: Almost all programs ignore errors on these functions and in most cases it's ok + - Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*print(f|ln)?|os\.(Un)?Setenv). is not checked + # EXC0002 golint: Annoying issue about not having a comment. The rare codebase has such comments + # - (comment on exported (method|function|type|const)|should have( a package)? comment|comment should be of the form) + # EXC0003 golint: False positive when tests are defined in package 'test' + - func name will be used as test\.Test.* by other packages, and that stutters; consider calling this + # EXC0004 govet: Common false positives + - (possible misuse of unsafe.Pointer|should have signature) + # EXC0005 staticcheck: Developers tend to write in C-style with an explicit 'break' in a 'switch', so it's ok to ignore + - ineffective break statement. Did you mean to break out of the outer loop + # EXC0006 gosec: Too many false-positives on 'unsafe' usage + - Use of unsafe calls should be audited + # EXC0007 gosec: Too many false-positives for parametrized shell calls + - Subprocess launch(ed with variable|ing should be audited) + # EXC0008 gosec: Duplicated errcheck checks + - (G104|G307) + # EXC0009 gosec: Too many issues in popular repos + - (Expect directory permissions to be 0750 or less|Expect file permissions to be 0600 or less) + # EXC0010 gosec: False positive is triggered by 'src, err := ioutil.ReadFile(filename)' + - Potential file inclusion via variable + exclude-use-default: false diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b7b13c6 --- /dev/null +++ b/Makefile @@ -0,0 +1,60 @@ +GOPKG=go.equinixmetal.net/infra9-metal-bridge + +ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) +TOOLS_DIR := .tools + +GO_FILES=$(shell git ls-files '*.go') + +GOTOOLS_REPO = golang.org/x/tools +GOTOOLS_VERSION = v0.9.3 + +GOLANGCI_LINT_REPO = github.com/golangci/golangci-lint +GOLANGCI_LINT_VERSION = v1.51.2 + +.PHONY: all help lint test golint fix-lint unit-test coverage + + +help: Makefile ## Print help. + @grep -h "##" $(MAKEFILE_LIST) | grep -v grep | sed -e 's/:.*##/#/' | column -c 2 -t -s# + +all: lint test ## Lints and tests. + +lint: golint ## Runs all lint checks. + +golint: | $(TOOLS_DIR)/golangci-lint ## Runs Go lint checks. + @echo Linting Go files... + @$(TOOLS_DIR)/golangci-lint run --timeout=5m + +fix-lint: $(GO_FILES) | $(TOOLS_DIR)/goimports ## Runs goimports on all go files. + @echo Linting go files... + @$(TOOLS_DIR)/goimports -w -local $(GOPKG) $^ + +test: | unit-test ## Regenerate files and run unit tests. + +unit-test: ## Runs unit tests. + @echo Running unit tests... + @go test -timeout 30s -cover -short ./... + +coverage: ## Generates a test coverage report. + @echo Generating coverage report... + @go test -timeout 30s ./... -coverprofile=coverage.out -covermode=atomic + @go tool cover -func=coverage.out + @go tool cover -html=coverage.out + +bin: ## Builds the binary + @echo Building binary + @go build -o infra9-metal-bridge ./main.go + +# Tools setup +$(TOOLS_DIR): + mkdir -p $(TOOLS_DIR) + +$(TOOLS_DIR)/goimports: | $(TOOLS_DIR) + @echo "Installing $(GOTOOLS_REPO)/cmd/goimports@$(GOTOOLS_VERSION)" + @GOBIN=$(ROOT_DIR)/$(TOOLS_DIR) go install $(GOTOOLS_REPO)/cmd/goimports@$(GOTOOLS_VERSION) + +$(TOOLS_DIR)/golangci-lint: | $(TOOLS_DIR) + @echo "Installing $(GOLANGCI_LINT_REPO)/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)" + @GOBIN=$(ROOT_DIR)/$(TOOLS_DIR) go install $(GOLANGCI_LINT_REPO)/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) + $@ version + $@ linters diff --git a/cmd/serve.go b/cmd/serve.go index 5fb707a..fc9b058 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -5,6 +5,7 @@ import ( "github.com/spf13/viper" "go.infratographer.com/x/echox" "go.infratographer.com/x/events" + "go.infratographer.com/x/oauth2x" "go.infratographer.com/x/otelx" "go.infratographer.com/x/versionx" "go.infratographer.com/x/viperx" @@ -13,7 +14,6 @@ import ( "go.equinixmetal.net/infra9-metal-bridge/internal/metal" "go.equinixmetal.net/infra9-metal-bridge/internal/permissions" "go.equinixmetal.net/infra9-metal-bridge/internal/pubsub" - "go.equinixmetal.net/infra9-metal-bridge/internal/routes" "go.equinixmetal.net/infra9-metal-bridge/internal/service" ) @@ -33,9 +33,11 @@ func init() { events.MustViperFlagsForSubscriber(viper.GetViper(), serveCmd.Flags()) serveCmd.PersistentFlags().StringSlice("events-topics", []string{}, "event topics to subscribe to") - viperx.MustBindFlag(viper.GetViper(), "events.topics", serveCmd.PersistentFlags().Lookup("events-topics")) + viperx.MustBindFlag(viper.GetViper(), "events.subscriber.topics", serveCmd.PersistentFlags().Lookup("events-topics")) + oauth2x.MustViperFlags(viper.GetViper(), serveCmd.Flags()) permissions.MustViperFlags(viper.GetViper(), serveCmd.Flags()) + metal.MustViperFlags(viper.GetViper(), serveCmd.Flags()) } func serve(cmd *cobra.Command, _ []string) { @@ -57,9 +59,21 @@ func serve(cmd *cobra.Command, _ []string) { logger.Fatalw("error initializing Metal client", "error", err) } + permHTTPClient := permissions.DefaultHTTPClient + + if config.AppConfig.OIDC.Client.ID != "" { + tokenSrc, err := oauth2x.NewClientCredentialsTokenSrc(cmd.Context(), config.AppConfig.OIDC.Client) + if err != nil { + logger.Fatalw("error initializing oauth2 client", "error", err) + } + + permHTTPClient = oauth2x.NewClient(cmd.Context(), tokenSrc) + } + perms, err := permissions.NewClient("", permissions.WithLogger(logger), permissions.WithConfig(config.AppConfig.Permissions), + permissions.WithHTTPClient(permHTTPClient), ) if err != nil { logger.Fatalw("error initializing Permissions client", "error", err) @@ -74,27 +88,19 @@ func serve(cmd *cobra.Command, _ []string) { logger.Fatalw("error initializing service", "error", err) } - subscriber, err := pubsub.NewSubscriber(cmd.Context(), config.AppConfig.Events.Subscriber, service, + subscriber, err := pubsub.NewSubscriber(cmd.Context(), config.AppConfig.Events.Subscriber.SubscriberConfig, service, pubsub.WithLogger(logger), ) if err != nil { logger.Fatalw("unable to initialize event subscriber", "error", err) } - for _, topic := range viper.GetStringSlice("events.topics") { + for _, topic := range config.AppConfig.Events.Subscriber.Topics { if err := subscriber.Subscribe(topic); err != nil { logger.Fatalw("error subscribing to topic: "+topic, "topic", topic, "error", err) } } - router, err := routes.NewRouter( - routes.WithLogger(logger.Desugar()), - routes.WithService(service), - ) - if err != nil { - logger.Fatalw("error initializing router", "error", err) - } - srv, err := echox.NewServer( logger.Desugar(), echox.ConfigFromViper(viper.GetViper()), @@ -104,8 +110,6 @@ func serve(cmd *cobra.Command, _ []string) { logger.Fatalw("failed to initialize new server", "error", err) } - srv.AddHandler(router) - defer subscriber.Close() logger.Info("Listening for events") diff --git a/config-example.yaml b/config-example.yaml index fdc4060..2e0cf0a 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -1,7 +1,13 @@ +oidc: + client: + id: idntcli-abc123 + secret: somesecret + issuer: http://mock-oauth2-server:8081/default + events: - topics: - - '*.*' subscriber: + topics: + - '*.*' url: nats://nats:4222 prefix: com.equinixmetal queueGroup: metal-bridge diff --git a/go.mod b/go.mod index 0efda62..cfb8dce 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,16 @@ go 1.20 require ( github.com/ThreeDotsLabs/watermill v1.2.0 github.com/labstack/echo/v4 v4.10.2 - github.com/nats-io/nats.go v1.26.0 + github.com/nats-io/nats.go v1.27.1 github.com/spf13/cobra v1.7.0 + github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.16.0 - go.infratographer.com/x v0.3.2 + github.com/stretchr/testify v1.8.4 + go.infratographer.com/x v0.3.3 go.opentelemetry.io/otel v1.16.0 go.opentelemetry.io/otel/trace v1.16.0 go.uber.org/zap v1.24.0 + golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 ) require ( @@ -20,6 +23,7 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/garsue/watermillzap v1.2.0 // indirect github.com/go-logr/logr v1.2.4 // indirect @@ -33,14 +37,14 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jaevor/go-nanoid v1.3.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.16.5 // indirect + github.com/klauspost/compress v1.16.7 // indirect github.com/labstack/echo-contrib v0.15.0 // indirect github.com/labstack/echo-jwt/v4 v4.2.0 // indirect github.com/labstack/gommon v0.4.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -50,14 +54,15 @@ require ( github.com/oklog/ulid v1.3.1 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.14.0 // indirect github.com/prometheus/client_model v0.3.0 // indirect github.com/prometheus/common v0.40.0 // indirect - github.com/prometheus/procfs v0.9.0 // indirect + github.com/prometheus/procfs v0.11.0 // indirect github.com/spf13/afero v1.9.5 // indirect github.com/spf13/cast v1.5.1 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/objx v0.5.0 // indirect github.com/subosito/gotenv v1.4.2 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect @@ -73,15 +78,16 @@ require ( go.opentelemetry.io/proto/otlp v0.19.0 // indirect go.uber.org/atomic v1.10.0 // indirect go.uber.org/multierr v1.9.0 // indirect - golang.org/x/crypto v0.9.0 // indirect - golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect - golang.org/x/net v0.10.0 // indirect - golang.org/x/sys v0.8.0 // indirect - golang.org/x/text v0.9.0 // indirect + golang.org/x/crypto v0.11.0 // indirect + golang.org/x/net v0.12.0 // indirect + golang.org/x/oauth2 v0.10.0 // indirect + golang.org/x/sys v0.10.0 // indirect + golang.org/x/text v0.11.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-20230410155749-daa745c078e1 // indirect google.golang.org/grpc v1.55.0 // indirect - google.golang.org/protobuf v1.30.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 608e23d..b7826d7 100644 --- a/go.sum +++ b/go.sum @@ -49,7 +49,7 @@ github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kd github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/brianvoe/gofakeit/v6 v6.21.0 h1:tNkm9yxEbpuPK8Bx39tT4sSc5i9SUGiciLdNix+VDQY= +github.com/brianvoe/gofakeit/v6 v6.23.0 h1:pgVhyWpYq4e0GEVCh2gdZnS/nBX+8SnyTBliHg5xjks= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -187,8 +187,8 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI= -github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -212,8 +212,8 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= -github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA/g= @@ -226,8 +226,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/nats-io/jwt/v2 v2.4.1 h1:Y35W1dgbbz2SQUYDPCaclXcuqleVmpbRa7646Jf2EX4= github.com/nats-io/nats-server/v2 v2.9.17 h1:gFpUQ3hqIDJrnqog+Bl5vaXg+RhhYEZIElasEuRn2tw= -github.com/nats-io/nats.go v1.26.0 h1:fWJTYPnZ8DzxIaqIHOAMfColuznchnd5Ab5dbJpgPIE= -github.com/nats-io/nats.go v1.26.0/go.mod h1:XpbWUlOElGwTYbMR7imivs7jJj9GtK7ypv321Wp6pjc= +github.com/nats-io/nats.go v1.27.1 h1:OuYnal9aKVSnOzLQIzf7554OXMCG7KbaTkCSBHRcSoo= +github.com/nats-io/nats.go v1.27.1/go.mod h1:XpbWUlOElGwTYbMR7imivs7jJj9GtK7ypv321Wp6pjc= github.com/nats-io/nkeys v0.4.4 h1:xvBJ8d69TznjcQl9t6//Q5xXuVhyYiSos6RPtvQNTwA= github.com/nats-io/nkeys v0.4.4/go.mod h1:XUkxdLPTufzlihbamfzQ7mw/VGx6ObUs+0bN5sNvt64= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= @@ -248,8 +248,8 @@ github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvq github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= github.com/prometheus/common v0.40.0 h1:Afz7EVRqGg2Mqqf4JuF9vdvp1pi220m55Pi9T2JnO4Q= github.com/prometheus/common v0.40.0/go.mod h1:L65ZJPSmfn/UBWLQIHV7dBrKFidB/wPlF1y5TlSt9OE= -github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= -github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= +github.com/prometheus/procfs v0.11.0 h1:5EAgkfkMl659uZPbe9AS2N68a7Cc1TJbPEuGzFuRbyk= +github.com/prometheus/procfs v0.11.0/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= @@ -280,6 +280,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -291,8 +292,8 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.infratographer.com/x v0.3.2 h1:AxHY77AGhWcRNcO7ENP/4Cj0xg6KGKpxFn/yZn+rPOs= -go.infratographer.com/x v0.3.2/go.mod h1:GvOhGwi/1Dp5qAQSudHUdLfFmiXzzc27KBfkH0nxnEQ= +go.infratographer.com/x v0.3.3 h1:dTaLEp75RgL0JxKJhrcuQTP4a2x/MrevvZ3OdtkEhCs= +go.infratographer.com/x v0.3.3/go.mod h1:pXXSdeJBisAK3AdED5EFj7Yo8z8td7fOWDkNl4Dkp0s= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= @@ -339,8 +340,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= -golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= +golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -351,8 +352,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc= -golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -409,8 +410,8 @@ golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= +golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= 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-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -421,7 +422,8 @@ golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8= +golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8= +golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -475,8 +477,8 @@ golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -487,8 +489,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -647,8 +649,8 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= -google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/internal/config/config.go b/internal/config/config.go index 9acbab2..cb7ee37 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "go.infratographer.com/x/echox" "go.infratographer.com/x/events" "go.infratographer.com/x/loggingx" + "go.infratographer.com/x/oauth2x" "go.infratographer.com/x/otelx" "go.equinixmetal.net/infra9-metal-bridge/internal/metal" @@ -15,6 +16,7 @@ import ( // AppConfig is the main application configuration. var AppConfig struct { Logging loggingx.Config + OIDC OIDCClientConfig EquinixMetal metal.Config OTel otelx.Config Server echox.Config @@ -27,5 +29,16 @@ var AppConfig struct { // EventsConfig defines the configuration setting up both subscriptions and publishing type EventsConfig struct { Publisher events.PublisherConfig - Subscriber events.SubscriberConfig + Subscriber SubscriberConfig +} + +// OIDCClientConfig defines the configuration for OIDC Client Credentials. +type OIDCClientConfig struct { + Client oauth2x.Config +} + +// SubscriberConfig extends events SubscriberConfig by adding topics. +type SubscriberConfig struct { + events.SubscriberConfig `mapstructure:",squash"` + Topics []string } diff --git a/internal/metal/config.go b/internal/metal/config.go index 9f1d89d..1464abc 100644 --- a/internal/metal/config.go +++ b/internal/metal/config.go @@ -1,6 +1,9 @@ package metal import ( + "github.com/spf13/pflag" + "github.com/spf13/viper" + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers/emapi" "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers/emgql" ) @@ -13,3 +16,9 @@ type Config struct { // EMAPI sets the provider to Equinix Metal API. EMAPI emapi.Config } + +// MustViperFlags registers command flags along with the viper bindings. +func MustViperFlags(v *viper.Viper, flags *pflag.FlagSet) { + emgql.MustViperFlags(v, flags) + emapi.MustViperFlags(v, flags) +} diff --git a/internal/metal/errors.go b/internal/metal/errors.go index 106ca78..f368680 100644 --- a/internal/metal/errors.go +++ b/internal/metal/errors.go @@ -5,4 +5,7 @@ import "errors" var ( // ErrUnauthorized is returned when the token provided did not validate to a user. ErrUnauthorized = errors.New("unauthorized key") + + // ErrMetalProviderRequired is returned when no provider has been configured for the metal client. + ErrMetalProviderRequired = errors.New("metal provider required") ) diff --git a/internal/metal/metal.go b/internal/metal/metal.go index f883070..81786ae 100644 --- a/internal/metal/metal.go +++ b/internal/metal/metal.go @@ -3,34 +3,53 @@ package metal import ( "context" - "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" - provider "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers" "go.infratographer.com/x/gidx" "go.uber.org/zap" + + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers" ) -// Client is the Equinix Metal API Client struct. -type Client struct { - logger *zap.Logger - - provider provider.Provider +// Client is the Equinix Metal Client Interface. +type Client interface { + providers.Provider } -func (c *Client) GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) { +// client is the Equinix Metal Client struct. +type client struct { + logger *zap.Logger + + provider providers.Provider +} + +// GetOrganizationDetails fetches the organization id provided with its memberships. +func (c *client) GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) { return c.provider.GetOrganizationDetails(ctx, id) } -func (c *Client) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) { +// GetProjectDetails fetchs the provided project id with membership information. +func (c *client) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) { return c.provider.GetProjectDetails(ctx, id) } -func (c *Client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) { +// GetUserDetails fetches the provided user id. +func (c *client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) { return c.provider.GetUserDetails(ctx, id) } -// New creates a new Client. -func New(options ...Option) (*Client, error) { - client := new(Client) +// GetUserOrganizationRole returns the role for the user in the organization. +func (c *client) GetUserOrganizationRole(ctx context.Context, userID, orgID gidx.PrefixedID) (string, error) { + return c.provider.GetUserOrganizationRole(ctx, userID, orgID) +} + +// GetUserProjectRole returns the role for the user in the project. +func (c *client) GetUserProjectRole(ctx context.Context, userID, projID gidx.PrefixedID) (string, error) { + return c.provider.GetUserProjectRole(ctx, userID, projID) +} + +// New creates a new Equinix Metal Client. +func New(options ...Option) (Client, error) { + client := new(client) for _, opt := range options { if err := opt(client); err != nil { @@ -42,5 +61,9 @@ func New(options ...Option) (*Client, error) { client.logger = zap.NewNop() } + if client.provider == nil { + return nil, ErrMetalProviderRequired + } + return client, nil } diff --git a/internal/metal/models/doc.go b/internal/metal/models/doc.go new file mode 100644 index 0000000..f03f4f7 --- /dev/null +++ b/internal/metal/models/doc.go @@ -0,0 +1,2 @@ +// Package models defines generic models each provider must be able to return. +package models diff --git a/internal/metal/models/idprefix.go b/internal/metal/models/idprefix.go index 8dd3a59..39f99a7 100644 --- a/internal/metal/models/idprefix.go +++ b/internal/metal/models/idprefix.go @@ -9,4 +9,7 @@ const ( // IDPrefixUser defines the ID Prefix for a User. IDPrefixUser = "metlusr" + + // IdentityPrefixUser defines the ID Prefix for a User created with Identity API. + IdentityPrefixUser = "idntusr" ) diff --git a/internal/metal/models/memberships.go b/internal/metal/models/memberships.go index be61413..ad5446b 100644 --- a/internal/metal/models/memberships.go +++ b/internal/metal/models/memberships.go @@ -1,5 +1,6 @@ package models +// Membership contains metal membership details. type Membership[T any] struct { ID string `json:"id"` User *UserDetails `json:"user"` diff --git a/internal/metal/models/organizations.go b/internal/metal/models/organizations.go index c2cd095..e99055b 100644 --- a/internal/metal/models/organizations.go +++ b/internal/metal/models/organizations.go @@ -2,6 +2,7 @@ package models import "go.infratographer.com/x/gidx" +// OrganizationDetails contains the organization and membership information. type OrganizationDetails struct { ID string `json:"id"` Name string `json:"name"` @@ -9,6 +10,7 @@ type OrganizationDetails struct { Projects []*ProjectDetails `json:"projects"` } +// PrefixedID returns the prefixed id for the organization. func (d *OrganizationDetails) PrefixedID() gidx.PrefixedID { if d.ID == "" { return gidx.NullPrefixedID diff --git a/internal/metal/models/projects.go b/internal/metal/models/projects.go index f2a0869..656137f 100644 --- a/internal/metal/models/projects.go +++ b/internal/metal/models/projects.go @@ -2,6 +2,7 @@ package models import "go.infratographer.com/x/gidx" +// ProjectDetails contains project and membership information. type ProjectDetails struct { ID string `json:"id"` Name string `json:"name"` @@ -9,6 +10,7 @@ type ProjectDetails struct { Organization *OrganizationDetails `json:"organization"` } +// PrefixedID returns the prefixed id for the project. func (d *ProjectDetails) PrefixedID() gidx.PrefixedID { if d.ID == "" { return gidx.NullPrefixedID diff --git a/internal/metal/models/users.go b/internal/metal/models/users.go index 5852cf2..c8e425c 100644 --- a/internal/metal/models/users.go +++ b/internal/metal/models/users.go @@ -1,12 +1,23 @@ package models -import "go.infratographer.com/x/gidx" +import ( + "crypto/sha256" + "encoding/base64" -const ( - MetalUserPrefix = "metlusr" + "go.infratographer.com/x/gidx" ) +const ( + // MetalUserIssuer is the issuer that is used for metal api token users. + MetalUserIssuer = "https://auth.equinix.com/" + + // MetalUserIssuerIDPrefix is the issuer id prefix added by the issuer. + MetalUserIssuerIDPrefix = "auth0|" +) + +// UserDetails contains the user information. type UserDetails struct { + id *gidx.PrefixedID ID string `json:"id"` FullName string `json:"full_name"` Organizations []*OrganizationDetails `json:"organizations"` @@ -14,10 +25,40 @@ type UserDetails struct { Roles []string `json:"roles"` } +// PrefixedID returns the identity prefixed id for the user. func (d *UserDetails) PrefixedID() gidx.PrefixedID { - if d.ID == "" { - return gidx.NullPrefixedID + if d.id != nil { + return *d.id } - return gidx.PrefixedID(IDPrefixUser + "-" + d.ID) + nullID := gidx.NullPrefixedID + + d.id = &nullID + + if d.ID == "" { + return nullID + } + + id, err := GenerateSubjectID(IdentityPrefixUser, MetalUserIssuer, MetalUserIssuerIDPrefix+d.ID) + if err != nil { + return nullID + } + + d.id = &id + + return *d.id +} + +// GenerateSubjectID builds a identity prefixed id with the provided prefix for the issuer and subject. +func GenerateSubjectID(prefix, iss, sub string) (gidx.PrefixedID, error) { + // Concatenate the iss and sub values, then hash them + issSub := iss + sub + issSubHash := sha256.Sum256([]byte(issSub)) + + digest := base64.RawURLEncoding.EncodeToString(issSubHash[:]) + + // Concatenate the prefix with the digest + out := prefix + "-" + digest + + return gidx.Parse(out) } diff --git a/internal/metal/options.go b/internal/metal/options.go index 9360840..2b78af9 100644 --- a/internal/metal/options.go +++ b/internal/metal/options.go @@ -1,18 +1,19 @@ package metal import ( - provider "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers" + "go.uber.org/zap" + + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers" "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers/emapi" "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers/emgql" - "go.uber.org/zap" ) // Option is a Client configuration Option definition. -type Option func(c *Client) error +type Option func(c *client) error // WithProvider sets the provider on the client. -func WithProvider(provider provider.Provider) Option { - return func(c *Client) error { +func WithProvider(provider providers.Provider) Option { + return func(c *client) error { c.provider = provider return nil @@ -21,7 +22,7 @@ func WithProvider(provider provider.Provider) Option { // WithLogger sets the logger for the client. func WithLogger(logger *zap.Logger) Option { - return func(c *Client) error { + return func(c *client) error { c.logger = logger return nil @@ -30,7 +31,7 @@ func WithLogger(logger *zap.Logger) Option { // WithConfig applies all configurations defined in the config. func WithConfig(config Config) Option { - return func(c *Client) error { + return func(c *client) error { var options []Option if config.EMGQL.Populated() { diff --git a/internal/metal/providers/emapi/client.go b/internal/metal/providers/emapi/client.go index b9372c7..c8a14e1 100644 --- a/internal/metal/providers/emapi/client.go +++ b/internal/metal/providers/emapi/client.go @@ -12,7 +12,7 @@ import ( "go.uber.org/zap" - provider "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers" + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers" ) const ( @@ -25,12 +25,14 @@ const ( staffHeaderValue = "true" ) +// DefaultHTTPClient is the default http client used if no client is provided. var DefaultHTTPClient = &http.Client{ Timeout: defaultHTTPTimeout, } -var _ provider.Provider = &Client{} +var _ providers.Provider = &Client{} +// Client is the client to interact with the equinix metal api. type Client struct { logger *zap.SugaredLogger httpClient *http.Client @@ -39,6 +41,8 @@ type Client struct { consumerToken string } +// Do executes the provided request. +// If the out value is provided, the response will attempt to be json decoded. func (c *Client) Do(req *http.Request, out any) (*http.Response, error) { if c.authToken != "" { req.Header.Set(authHeader, c.authToken) @@ -68,6 +72,7 @@ func (c *Client) Do(req *http.Request, out any) (*http.Response, error) { return resp, nil } +// DoRequest creates a new request from the provided parameters and executes the request. func (c *Client) DoRequest(ctx context.Context, method, path string, body io.Reader, out any) (*http.Response, error) { path = strings.TrimPrefix(path, c.baseURL.Path) diff --git a/internal/metal/providers/emapi/config.go b/internal/metal/providers/emapi/config.go index bbb939a..cfffe30 100644 --- a/internal/metal/providers/emapi/config.go +++ b/internal/metal/providers/emapi/config.go @@ -3,6 +3,10 @@ package emapi import ( "fmt" "net/url" + + "github.com/spf13/pflag" + "github.com/spf13/viper" + "go.infratographer.com/x/viperx" ) // Config provides configuration for connecting to the Equinix Metal API provider. @@ -17,6 +21,19 @@ type Config struct { ConsumerToken string } +// MustViperFlags registers command flags along with the viper bindings. +func MustViperFlags(v *viper.Viper, flags *pflag.FlagSet) { + flags.String("emapi-base-url", "", "Equinix Metal Rest API Base URL") + viperx.MustBindFlag(v, "equinixmetal.emapi.baseurl", flags.Lookup("emapi-base-url")) + + flags.String("emapi-auth-token", "", "Equinix Metal Rest Auth Token") + viperx.MustBindFlag(v, "equinixmetal.emapi.authtoken", flags.Lookup("emapi-auth-token")) + + flags.String("emapi-consumer-token", "", "Equinix Metal Rest Consumer Token") + viperx.MustBindFlag(v, "equinixmetal.emapi.consumertoken", flags.Lookup("emapi-consumer-token")) +} + +// Populated checks if any field has been populated. func (c Config) Populated() bool { return c.AuthToken != "" || c.ConsumerToken != "" || c.BaseURL != "" } diff --git a/internal/metal/providers/emapi/doc.go b/internal/metal/providers/emapi/doc.go new file mode 100644 index 0000000..6b38602 --- /dev/null +++ b/internal/metal/providers/emapi/doc.go @@ -0,0 +1,2 @@ +// Package emapi implement a metal provider which fetches details from the Equinix Metal API. +package emapi diff --git a/internal/metal/providers/emapi/errors.go b/internal/metal/providers/emapi/errors.go index 5eb2931..ab6ded5 100644 --- a/internal/metal/providers/emapi/errors.go +++ b/internal/metal/providers/emapi/errors.go @@ -2,4 +2,5 @@ package emapi import "errors" +// ErrBaseURLRequired is returned if no base url is provided. var ErrBaseURLRequired = errors.New("emapi base url required") diff --git a/internal/metal/providers/emapi/helpers.go b/internal/metal/providers/emapi/helpers.go new file mode 100644 index 0000000..5b80e43 --- /dev/null +++ b/internal/metal/providers/emapi/helpers.go @@ -0,0 +1,27 @@ +package emapi + +import ( + "net/url" + "strings" +) + +// idOrLinkID returns the id if not empty, otherwise it plucks the last subpath from the link. +// An empty string is returned if nothing is defined or an error occurs. +func idOrLinkID(id, link string) string { + if id != "" || link == "" { + return id + } + + url, err := url.Parse(link) + if err != nil { + return "" + } + + parts := strings.Split(strings.TrimRight(url.Path, "/"), "/") + + if len(parts) != 0 { + return parts[len(parts)-1] + } + + return "" +} diff --git a/internal/metal/providers/emapi/memberships.go b/internal/metal/providers/emapi/memberships.go index 4a36662..6e2b565 100644 --- a/internal/metal/providers/emapi/memberships.go +++ b/internal/metal/providers/emapi/memberships.go @@ -4,10 +4,13 @@ import ( "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" ) +// Roles contains a list of roles. type Roles []string +// Memberships contains a list of memberships type Memberships []*Membership +// ToDetailsWithOrganizationDetails converts the memberships to generic membership models with organization details. func (m Memberships) ToDetailsWithOrganizationDetails(orgDetails *models.OrganizationDetails) []*models.Membership[models.OrganizationDetails] { memberships := make([]*models.Membership[models.OrganizationDetails], len(m)) @@ -28,6 +31,7 @@ func (m Memberships) ToDetailsWithOrganizationDetails(orgDetails *models.Organiz return memberships } +// ToDetailsWithProjectDetails converts the memberships to generic membership models with project details. func (m Memberships) ToDetailsWithProjectDetails(projDetails *models.ProjectDetails) []*models.Membership[models.ProjectDetails] { memberships := make([]*models.Membership[models.ProjectDetails], len(m)) @@ -48,15 +52,15 @@ func (m Memberships) ToDetailsWithProjectDetails(projDetails *models.ProjectDeta return memberships } +// Membership contains membership information. type Membership struct { - client *Client - HREF string `json:"href"` ID string `json:"id"` Roles Roles `json:"roles"` User *User `json:"user"` } +// ToDetailsWithOrganizationDetails converts the membership to generic membership model with organization details. func (m *Membership) ToDetailsWithOrganizationDetails(orgDetails *models.OrganizationDetails) *models.Membership[models.OrganizationDetails] { if m.ID == "" { return nil @@ -70,6 +74,7 @@ func (m *Membership) ToDetailsWithOrganizationDetails(orgDetails *models.Organiz } } +// ToDetailsWithProjectDetails converts the membership to generic membership model with organization details. func (m *Membership) ToDetailsWithProjectDetails(projDetails *models.ProjectDetails) *models.Membership[models.ProjectDetails] { if m.ID == "" { return nil diff --git a/internal/metal/providers/emapi/organizations.go b/internal/metal/providers/emapi/organizations.go index 3b72944..35c1124 100644 --- a/internal/metal/providers/emapi/organizations.go +++ b/internal/metal/providers/emapi/organizations.go @@ -14,8 +14,10 @@ const ( organizationsPath = "/organizations" ) +// Organizations contains a list of organizations. type Organizations []*Organization +// ToDetails converts to a generic model organization details. func (o Organizations) ToDetails() []*models.OrganizationDetails { orgs := make([]*models.OrganizationDetails, len(o)) @@ -36,9 +38,8 @@ func (o Organizations) ToDetails() []*models.OrganizationDetails { return orgs } +// Organization contains organization information. type Organization struct { - client *Client - HREF string `json:"href"` ID string `json:"id"` Name string `json:"name"` @@ -47,13 +48,20 @@ type Organization struct { Projects Projects `json:"projects"` } +// ToDetails converts the object to a generic orgnization details. func (o *Organization) ToDetails() *models.OrganizationDetails { - if o == nil || o.ID == "" { + var id string + + if o != nil { + id = idOrLinkID(o.ID, o.HREF) + } + + if id == "" { return nil } details := &models.OrganizationDetails{ - ID: o.ID, + ID: id, Name: o.Name, Projects: o.Projects.ToDetails(), } @@ -63,10 +71,11 @@ func (o *Organization) ToDetails() *models.OrganizationDetails { return details } +// getOrganizationWithMemberships fetches an organization from the equinix metal api with membership user information. func (c *Client) getOrganizationWithMemberships(ctx context.Context, id string) (*Organization, error) { var org Organization - _, err := c.DoRequest(ctx, http.MethodGet, organizationsPath+"/"+id+"?include=memberships.user,projects.memberships.user", nil, &org) + _, err := c.DoRequest(ctx, http.MethodGet, organizationsPath+"/"+id+"?include=memberships.user", nil, &org) // nolint:bodyclose // body is closed by Do json decode. if err != nil { return nil, fmt.Errorf("error loading organization: %w", err) } @@ -74,6 +83,7 @@ func (c *Client) getOrganizationWithMemberships(ctx context.Context, id string) return &org, nil } +// GetOrganizationDetails fetches the organization id provided with its memberships. func (c *Client) GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) { org, err := c.getOrganizationWithMemberships(ctx, id.String()[gidx.PrefixPartLength+1:]) if err != nil { diff --git a/internal/metal/providers/emapi/projects.go b/internal/metal/providers/emapi/projects.go index f2c3e7c..5dc27c5 100644 --- a/internal/metal/providers/emapi/projects.go +++ b/internal/metal/providers/emapi/projects.go @@ -14,8 +14,10 @@ const ( projectsPath = "/projects" ) +// Projects contains a list of projects. type Projects []*Project +// ToDetails converts the objects to generic project details. func (p Projects) ToDetails() []*models.ProjectDetails { projects := make([]*models.ProjectDetails, len(p)) @@ -36,9 +38,8 @@ func (p Projects) ToDetails() []*models.ProjectDetails { return projects } +// Project contains project information. type Project struct { - client *Client - HREF string `json:"href"` ID string `json:"id"` Name string `json:"name"` @@ -47,13 +48,20 @@ type Project struct { Organization *Organization `json:"organization"` } +// ToDetails converts the project to generic project details. func (p *Project) ToDetails() *models.ProjectDetails { - if p == nil || p.ID == "" { + var id string + + if p != nil { + id = idOrLinkID(p.ID, p.HREF) + } + + if id == "" { return nil } details := &models.ProjectDetails{ - ID: p.ID, + ID: id, Name: p.Name, Organization: p.Organization.ToDetails(), } @@ -63,19 +71,21 @@ func (p *Project) ToDetails() *models.ProjectDetails { return details } -func (c *Client) getProject(ctx context.Context, id string) (*Project, error) { +// getProjectWithMemberships fetches the provided project with membership information. +func (c *Client) getProjectWithMemberships(ctx context.Context, id string) (*Project, error) { var project Project - _, err := c.DoRequest(ctx, http.MethodGet, c.baseURL.JoinPath(projectsPath, id).String(), nil, &project) + _, err := c.DoRequest(ctx, http.MethodGet, projectsPath+"/"+id+"?include=memberships.user", nil, &project) // nolint:bodyclose // body is closed by Do json decode. if err != nil { - return nil, fmt.Errorf("error loading project: %w", err) + return nil, fmt.Errorf("error loading organization: %w", err) } return &project, nil } +// GetProjectDetails fetchs the provided project id with membership information. func (c *Client) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) { - project, err := c.getProject(ctx, id.String()[gidx.PrefixPartLength+1:]) + project, err := c.getProjectWithMemberships(ctx, id.String()[gidx.PrefixPartLength+1:]) if err != nil { return nil, err } diff --git a/internal/metal/providers/emapi/users.go b/internal/metal/providers/emapi/users.go index 354985d..96742f5 100644 --- a/internal/metal/providers/emapi/users.go +++ b/internal/metal/providers/emapi/users.go @@ -14,8 +14,10 @@ const ( usersPath = "/users" ) +// Users contains a list of users. type Users []*User +// ToDetails converts the objects to generic user details. func (u Users) ToDetails() []*models.UserDetails { users := make([]*models.UserDetails, len(u)) @@ -36,9 +38,8 @@ func (u Users) ToDetails() []*models.UserDetails { return users } +// User contains user information. type User struct { - client *Client - HREF string `json:"href"` ID string `json:"id"` FullName string `json:"full_name"` @@ -46,30 +47,39 @@ type User struct { Projects Projects `json:"projects"` } +// ToDetails converts the user to generic user details. func (u *User) ToDetails() *models.UserDetails { - if u.ID == "" { + var id string + + if u != nil { + id = idOrLinkID(u.ID, u.HREF) + } + + if id == "" { return nil } return &models.UserDetails{ - ID: u.ID, + ID: id, FullName: u.FullName, Organizations: nil, Projects: u.Projects.ToDetails(), } } +// getUser fetches the provided user. func (c *Client) getUser(ctx context.Context, id string) (*User, error) { var user User - _, err := c.DoRequest(ctx, http.MethodGet, c.baseURL.JoinPath(usersPath, id).String(), nil, &user) + _, err := c.DoRequest(ctx, http.MethodGet, usersPath+"/"+id, nil, &user) // nolint:bodyclose // body is closed by Do json decode. if err != nil { - return nil, fmt.Errorf("error loading user: %w", err) + return nil, fmt.Errorf("error loading organization: %w", err) } return &user, nil } +// GetUserDetails fetches the provided user id. func (c *Client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) { user, err := c.getUser(ctx, id.String()[gidx.PrefixPartLength+1:]) if err != nil { @@ -78,3 +88,13 @@ func (c *Client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*model return user.ToDetails(), nil } + +// GetUserOrganizationRole returns collaborator for all organizations. +func (c *Client) GetUserOrganizationRole(ctx context.Context, userID, orgID gidx.PrefixedID) (string, error) { + return "collaborator", nil +} + +// GetUserProjectRole returns collaborator for all projects. +func (c *Client) GetUserProjectRole(ctx context.Context, userID, projectID gidx.PrefixedID) (string, error) { + return "collaborator", nil +} diff --git a/internal/metal/providers/emgql/client.go b/internal/metal/providers/emgql/client.go index 965fb9a..5b5fec3 100644 --- a/internal/metal/providers/emgql/client.go +++ b/internal/metal/providers/emgql/client.go @@ -5,22 +5,23 @@ import ( "net/url" "time" - provider "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers" "go.uber.org/zap" + + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers" ) const ( defaultHTTPTimeout = 5 * time.Second ) -var ( - DefaultHTTPClient = &http.Client{ - Timeout: defaultHTTPTimeout, - } -) +// DefaultHTTPClient is the default http client used if no client is provided. +var DefaultHTTPClient = &http.Client{ + Timeout: defaultHTTPTimeout, +} -var _ provider.Provider = &Client{} +var _ providers.Provider = &Client{} +// Client is the client to interact with the equinix metal graphql service. type Client struct { logger *zap.SugaredLogger httpClient *http.Client diff --git a/internal/metal/providers/emgql/config.go b/internal/metal/providers/emgql/config.go index 86f8ba9..de207dc 100644 --- a/internal/metal/providers/emgql/config.go +++ b/internal/metal/providers/emgql/config.go @@ -1,12 +1,24 @@ package emgql +import ( + "github.com/spf13/pflag" + "github.com/spf13/viper" + "go.infratographer.com/x/viperx" +) + // Config provides configuration for connecting to the Equinix Metal API provider. type Config struct { - // BaseURL is the baseurl to use when connecting to the Equinix Metal API Provider. BaseURL string } +// MustViperFlags registers command flags along with the viper bindings. +func MustViperFlags(v *viper.Viper, flags *pflag.FlagSet) { + flags.String("emgql-base-url", "", "Equinix Metal GraphQL Base URL") + viperx.MustBindFlag(v, "equinixmetal.emgql.baseurl", flags.Lookup("emgql-base-url")) +} + +// Populated checks if any field has been populated. func (c Config) Populated() bool { return c.BaseURL != "" } diff --git a/internal/metal/providers/emgql/doc.go b/internal/metal/providers/emgql/doc.go new file mode 100644 index 0000000..9dba58f --- /dev/null +++ b/internal/metal/providers/emgql/doc.go @@ -0,0 +1,2 @@ +// Package emgql implements a metal provider which fetches details from the Equinix Metal GraphQL. +package emgql diff --git a/internal/metal/providers/emgql/organizations.go b/internal/metal/providers/emgql/organizations.go index b3b354a..8338f21 100644 --- a/internal/metal/providers/emgql/organizations.go +++ b/internal/metal/providers/emgql/organizations.go @@ -3,10 +3,12 @@ package emgql import ( "context" - "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" "go.infratographer.com/x/gidx" + + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" ) +// GetOrganizationDetails fetches the organization id provided with its memberships. func (c *Client) GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) { return nil, nil } diff --git a/internal/metal/providers/emgql/projects.go b/internal/metal/providers/emgql/projects.go index f0caf29..9b449d2 100644 --- a/internal/metal/providers/emgql/projects.go +++ b/internal/metal/providers/emgql/projects.go @@ -3,10 +3,12 @@ package emgql import ( "context" - "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" "go.infratographer.com/x/gidx" + + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" ) +// GetProjectDetails fetchs the provided project id with membership information. func (c *Client) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) { return nil, nil } diff --git a/internal/metal/providers/emgql/users.go b/internal/metal/providers/emgql/users.go index 04e2b4a..2f4ec4b 100644 --- a/internal/metal/providers/emgql/users.go +++ b/internal/metal/providers/emgql/users.go @@ -3,10 +3,22 @@ package emgql import ( "context" - "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" "go.infratographer.com/x/gidx" + + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" ) +// GetUserDetails fetches the provided user id. func (c *Client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) { return nil, nil } + +// GetUserOrganizationRole returns collaborator for all organizations. +func (c *Client) GetUserOrganizationRole(ctx context.Context, userID, orgID gidx.PrefixedID) (string, error) { + return "collaborator", nil +} + +// GetUserProjectRole returns collaborator for all projects. +func (c *Client) GetUserProjectRole(ctx context.Context, userID, projID gidx.PrefixedID) (string, error) { + return "collaborator", nil +} diff --git a/internal/metal/providers/mock.go b/internal/metal/providers/mock.go new file mode 100644 index 0000000..91ee7a9 --- /dev/null +++ b/internal/metal/providers/mock.go @@ -0,0 +1,52 @@ +package providers + +import ( + "context" + + "github.com/stretchr/testify/mock" + "go.infratographer.com/x/gidx" + + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" +) + +var _ Provider = &MockProvider{} + +// MockProvider implements Provider used for testing. +type MockProvider struct { + mock.Mock +} + +// GetOrganizationDetails implements Provider used for testing. +func (p *MockProvider) GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) { + args := p.Called(id) + + return args.Get(0).(*models.OrganizationDetails), args.Error(1) +} + +// GetProjectDetails implements Provider used for testing. +func (p *MockProvider) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) { + args := p.Called(id) + + return args.Get(0).(*models.ProjectDetails), args.Error(1) +} + +// GetUserDetails implements Provider used for testing. +func (p *MockProvider) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) { + args := p.Called(id) + + return args.Get(0).(*models.UserDetails), args.Error(1) +} + +// GetUserOrganizationRole implements Provider used for testing. +func (p *MockProvider) GetUserOrganizationRole(ctx context.Context, userID, orgID gidx.PrefixedID) (string, error) { + args := p.Called(userID, orgID) + + return args.String(0), args.Error(1) +} + +// GetUserProjectRole implements Provider used for testing. +func (p *MockProvider) GetUserProjectRole(ctx context.Context, userID, projID gidx.PrefixedID) (string, error) { + args := p.Called(userID, projID) + + return args.String(0), args.Error(1) +} diff --git a/internal/metal/providers/provider.go b/internal/metal/providers/provider.go index dba8598..5b7b2d6 100644 --- a/internal/metal/providers/provider.go +++ b/internal/metal/providers/provider.go @@ -1,14 +1,19 @@ -package provider +// Package providers defines the provider interface for fetching metal resources. +package providers import ( "context" - "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" "go.infratographer.com/x/gidx" + + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" ) +// Provider defines the provider implementation. type Provider interface { GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) + GetUserOrganizationRole(ctx context.Context, userID, orgID gidx.PrefixedID) (string, error) + GetUserProjectRole(ctx context.Context, userID, projID gidx.PrefixedID) (string, error) } diff --git a/internal/permissions/assignments.go b/internal/permissions/assignments.go index 5c7f953..2c8eadf 100644 --- a/internal/permissions/assignments.go +++ b/internal/permissions/assignments.go @@ -8,22 +8,26 @@ import ( "go.infratographer.com/x/gidx" ) +// RoleAssign is the role assignment request body. type RoleAssign struct { SubjectID string `json:"subject_id"` } +// RoleAssignResponse is the response from a role assignment. type RoleAssignResponse struct { Success bool `json:"success"` } +// roleAssignmentData is the response from listing a role assignment type roleAssignmentData struct { Data []struct { SubjectID string `json:"subject_id"` } `json:"data"` } -func (c *Client) AssignRole(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) error { - path := fmt.Sprintf("/api/v1/roles/%s/assignments", roleID.String()) +// AssignRole assigns the provided member ID to the given role ID. +func (c *client) AssignRole(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) error { + path := fmt.Sprintf(permsPathRoleAssignmentsFormat, roleID.String()) body, err := encodeJSON(RoleAssign{ SubjectID: memberID.String(), @@ -34,7 +38,7 @@ func (c *Client) AssignRole(ctx context.Context, roleID gidx.PrefixedID, memberI var response RoleAssignResponse - if _, err = c.DoRequest(ctx, http.MethodPost, path, body, &response); err != nil { + if _, err = c.DoRequest(ctx, http.MethodPost, path, body, &response); err != nil { // nolint:bodyclose // body is closed by Do json decode. return err } @@ -45,12 +49,37 @@ func (c *Client) AssignRole(ctx context.Context, roleID gidx.PrefixedID, memberI return nil } -func (c *Client) ListRoleAssignments(ctx context.Context, roleID gidx.PrefixedID) ([]gidx.PrefixedID, error) { - path := fmt.Sprintf("/api/v1/roles/%s/assignments", roleID.String()) +// UnassignRole removes the provided member ID from the given role ID. +func (c *client) UnassignRole(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) error { + path := fmt.Sprintf(permsPathRoleAssignmentsFormat, roleID.String()) + + body, err := encodeJSON(RoleAssign{ + SubjectID: memberID.String(), + }) + if err != nil { + return err + } + + var response RoleAssignResponse + + if _, err = c.DoRequest(ctx, http.MethodDelete, path, body, &response); err != nil { // nolint:bodyclose // body is closed by Do json decode. + return err + } + + if !response.Success { + return ErrUnassignmentFailed + } + + return nil +} + +// ListRoleAssignments lists all assignments for the given role. +func (c *client) ListRoleAssignments(ctx context.Context, roleID gidx.PrefixedID) ([]gidx.PrefixedID, error) { + path := fmt.Sprintf(permsPathRoleAssignmentsFormat, roleID.String()) var response roleAssignmentData - if _, err := c.DoRequest(ctx, http.MethodGet, path, nil, &response); err != nil { + if _, err := c.DoRequest(ctx, http.MethodGet, path, nil, &response); err != nil { // nolint:bodyclose // body is closed by Do json decode. return nil, err } @@ -68,7 +97,8 @@ func (c *Client) ListRoleAssignments(ctx context.Context, roleID gidx.PrefixedID return assignments, nil } -func (c *Client) RoleHasAssignment(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) (bool, error) { +// RoleHasAssignment gets the assignments for the given role and check for the provided member id. +func (c *client) RoleHasAssignment(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) (bool, error) { assignments, err := c.ListRoleAssignments(ctx, roleID) if err != nil { return false, err diff --git a/internal/permissions/client.go b/internal/permissions/client.go index 38d3106..8aa75bb 100644 --- a/internal/permissions/client.go +++ b/internal/permissions/client.go @@ -12,16 +12,46 @@ import ( "time" "github.com/labstack/echo/v4" + "go.infratographer.com/x/gidx" "go.uber.org/zap" ) -const defaultPermissionsURL = "https://permissions-api.hollow-a.sv15.metalkube.net" +const ( + defaultHTTPClientTimeout = 5 * time.Second -var defaultHTTPClient = &http.Client{ - Timeout: 5 * time.Second, + defaultPermissionsURL = "https://permissions-api.hollow-a.sv15.metalkube.net" + permsPathAllow = "/api/v1/allow" + + permsPathResourceRelationshipsFormat = "/api/v1/resources/%s/relationships" + permsPathResourceRelationshipsFrom = "/api/v1/relationships/from/" + permsPathResourceRelationshipsTo = "/api/v1/relationships/to/" + permsPathResourceRolesFormat = "/api/v1/resources/%s/roles" + + permsPathRoleAssignmentsFormat = "/api/v1/roles/%s/assignments" +) + +// DefaultHTTPClient is the default HTTP client for the Permissions Client. +var DefaultHTTPClient = &http.Client{ + Timeout: defaultHTTPClientTimeout, } -type Client struct { +// Client defines the Permissions API client interface. +type Client interface { + AssignRole(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) error + CreateRole(ctx context.Context, resourceID gidx.PrefixedID, actions []string) (gidx.PrefixedID, error) + DeleteResourceRelationship(ctx context.Context, resourceID gidx.PrefixedID, relation string, relatedResourceID gidx.PrefixedID) error + DeleteRole(ctx context.Context, roleID gidx.PrefixedID) error + FindResourceRoleByActions(ctx context.Context, resourceID gidx.PrefixedID, actions []string) (ResourceRole, error) + ListResourceRelationshipsFrom(ctx context.Context, resourceID gidx.PrefixedID) ([]ResourceRelationship, error) + ListResourceRelationshipsTo(ctx context.Context, resourceID gidx.PrefixedID) ([]ResourceRelationship, error) + ListResourceRoles(ctx context.Context, resourceID gidx.PrefixedID) (ResourceRoles, error) + ListRoleAssignments(ctx context.Context, roleID gidx.PrefixedID) ([]gidx.PrefixedID, error) + RoleHasAssignment(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) (bool, error) + UnassignRole(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) error +} + +// client is the permissions client. +type client struct { logger *zap.SugaredLogger httpClient *http.Client @@ -32,7 +62,9 @@ type Client struct { allowURL *url.URL } -func (c *Client) Do(req *http.Request, out any) (*http.Response, error) { +// Do executes the provided request. +// If the out value is provided, the response will attempt to be json decoded. +func (c *client) Do(req *http.Request, out any) (*http.Response, error) { if c.token != "" { req.Header.Set(echo.HeaderAuthorization, "Bearer "+c.token) } @@ -55,7 +87,8 @@ func (c *Client) Do(req *http.Request, out any) (*http.Response, error) { return resp, nil } -func (c *Client) DoRequest(ctx context.Context, method, path string, body io.Reader, out any) (*http.Response, error) { +// DoRequest creates a new request from the provided parameters and executes the request. +func (c *client) DoRequest(ctx context.Context, method, path string, body io.Reader, out any) (*http.Response, error) { path = strings.TrimPrefix(path, c.baseURL.Path) pathURL, err := url.Parse(path) @@ -100,10 +133,11 @@ func encodeJSON(v any) (*bytes.Buffer, error) { return &buff, nil } -func NewClient(token string, options ...Option) (*Client, error) { - client := &Client{ +// NewClient creats a new permissions client. +func NewClient(token string, options ...Option) (Client, error) { + client := &client{ logger: zap.NewNop().Sugar(), - httpClient: defaultHTTPClient, + httpClient: DefaultHTTPClient, token: token, } diff --git a/internal/permissions/config.go b/internal/permissions/config.go index ccc21d7..7a3e1bd 100644 --- a/internal/permissions/config.go +++ b/internal/permissions/config.go @@ -18,6 +18,7 @@ type Config struct { BearerToken string } +// MustViperFlags registers command flags along with the viper bindings. func MustViperFlags(v *viper.Viper, flags *pflag.FlagSet) { flags.String("permissions-baseurl", "", "permissions base url") viperx.MustBindFlag(v, "permissions.baseurl", flags.Lookup("permissions-baseurl")) @@ -28,7 +29,7 @@ func MustViperFlags(v *viper.Viper, flags *pflag.FlagSet) { // WithConfig applies all configurations defined in the config. func WithConfig(config Config) Option { - return func(c *Client) error { + return func(c *client) error { var options []Option if config.BaseURL != "" { @@ -51,7 +52,7 @@ func WithConfig(config Config) Option { // WithBaseURL updates the baseurl used by the client. func WithBaseURL(baseURL string) Option { - return func(c *Client) error { + return func(c *client) error { u, err := url.Parse(baseURL) if err != nil { return fmt.Errorf("failed to parse emapi base url %s: %w", baseURL, err) @@ -65,7 +66,7 @@ func WithBaseURL(baseURL string) Option { // WithBearerToken sets the bearer token to authenticate the request with. func WithBearerToken(token string) Option { - return func(c *Client) error { + return func(c *client) error { c.token = token return nil diff --git a/internal/permissions/doc.go b/internal/permissions/doc.go new file mode 100644 index 0000000..5a481e1 --- /dev/null +++ b/internal/permissions/doc.go @@ -0,0 +1,2 @@ +// Package permissions implements a Permissions API client for fetching and manipulating relationships and role assignments. +package permissions diff --git a/internal/permissions/errors.go b/internal/permissions/errors.go index 1c1c72c..0a179eb 100644 --- a/internal/permissions/errors.go +++ b/internal/permissions/errors.go @@ -3,6 +3,18 @@ package permissions import "errors" var ( - ErrRoleNotFound = errors.New("role not found") + // ErrRoleNotFound is returned when no role is found for a given list of actions. + ErrRoleNotFound = errors.New("role not found") + + // ErrAssignmentFailed is returned when a user assignment to a role fails. ErrAssignmentFailed = errors.New("assignment failed") + + // ErrUnassignmentFailed is returned when a user assignment is removed from a role fails. + ErrUnassignmentFailed = errors.New("unassignment failed") + + // ErrUnexpectedRoleDeleteFailed is returned when an unknown error is returned when deleting a role. + ErrUnexpectedRoleDeleteFailed = errors.New("unknown role delete error") + + // ErrUnexpectedRelationshipDeleteFailed is returned when an unknown error is returned when deleting a relationship. + ErrUnexpectedRelationshipDeleteFailed = errors.New("unknown relationship delete error") ) diff --git a/internal/permissions/mock.go b/internal/permissions/mock.go new file mode 100644 index 0000000..8dac744 --- /dev/null +++ b/internal/permissions/mock.go @@ -0,0 +1,90 @@ +package permissions + +import ( + "context" + + "github.com/stretchr/testify/mock" + "go.infratographer.com/x/gidx" +) + +// MockClient implements Client for testing. +type MockClient struct { + mock.Mock +} + +// AssignRole implements Client for testing. +func (c *MockClient) AssignRole(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) error { + args := c.Called(roleID, memberID) + + return args.Error(0) +} + +// CreateRole implements Client for testing. +func (c *MockClient) CreateRole(ctx context.Context, resourceID gidx.PrefixedID, actions []string) (gidx.PrefixedID, error) { + args := c.Called(resourceID, actions) + + return args.Get(0).(gidx.PrefixedID), args.Error(1) +} + +// DeleteResourceRelationship implements Client for testing. +func (c *MockClient) DeleteResourceRelationship(ctx context.Context, resourceID gidx.PrefixedID, relation string, relatedResourceID gidx.PrefixedID) error { + args := c.Called(resourceID, relation, relatedResourceID) + + return args.Error(0) +} + +// DeleteRole implements Client for testing. +func (c *MockClient) DeleteRole(ctx context.Context, roleID gidx.PrefixedID) error { + args := c.Called(roleID) + + return args.Error(0) +} + +// FindResourceRoleByActions implements Client for testing. +func (c *MockClient) FindResourceRoleByActions(ctx context.Context, resourceID gidx.PrefixedID, actions []string) (ResourceRole, error) { + args := c.Called(resourceID, actions) + + return args.Get(0).(ResourceRole), args.Error(1) +} + +// ListResourceRelationshipsFrom implements Client for testing. +func (c *MockClient) ListResourceRelationshipsFrom(ctx context.Context, resourceID gidx.PrefixedID) ([]ResourceRelationship, error) { + args := c.Called(resourceID) + + return args.Get(0).([]ResourceRelationship), args.Error(1) +} + +// ListResourceRelationshipsTo implements Client for testing. +func (c *MockClient) ListResourceRelationshipsTo(ctx context.Context, resourceID gidx.PrefixedID) ([]ResourceRelationship, error) { + args := c.Called(resourceID) + + return args.Get(0).([]ResourceRelationship), args.Error(1) +} + +// ListResourceRoles implements Client for testing. +func (c *MockClient) ListResourceRoles(ctx context.Context, resourceID gidx.PrefixedID) (ResourceRoles, error) { + args := c.Called(resourceID) + + return args.Get(0).(ResourceRoles), args.Error(1) +} + +// ListRoleAssignments implements Client for testing. +func (c *MockClient) ListRoleAssignments(ctx context.Context, roleID gidx.PrefixedID) ([]gidx.PrefixedID, error) { + args := c.Called(roleID) + + return args.Get(0).([]gidx.PrefixedID), args.Error(1) +} + +// RoleHasAssignment implements Client for testing. +func (c *MockClient) RoleHasAssignment(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) (bool, error) { + args := c.Called(roleID, memberID) + + return args.Bool(0), args.Error(1) +} + +// UnassignRole implements Client for testing. +func (c *MockClient) UnassignRole(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) error { + args := c.Called(roleID, memberID) + + return args.Error(0) +} diff --git a/internal/permissions/options.go b/internal/permissions/options.go index 30942f5..3b8a3b2 100644 --- a/internal/permissions/options.go +++ b/internal/permissions/options.go @@ -1,15 +1,28 @@ package permissions import ( + "net/http" + "go.uber.org/zap" ) -type Option func(*Client) error +// Option is a client configuration option definition. +type Option func(*client) error +// WithLogger sets the logger for the client. func WithLogger(logger *zap.SugaredLogger) Option { - return func(c *Client) error { + return func(c *client) error { c.logger = logger return nil } } + +// WithHTTPClient sets the http client to be used by the client. +func WithHTTPClient(httpClient *http.Client) Option { + return func(c *client) error { + c.httpClient = httpClient + + return nil + } +} diff --git a/internal/permissions/relationships.go b/internal/permissions/relationships.go new file mode 100644 index 0000000..fa59927 --- /dev/null +++ b/internal/permissions/relationships.go @@ -0,0 +1,112 @@ +package permissions + +import ( + "context" + "fmt" + "net/http" + + "go.infratographer.com/x/gidx" +) + +type resourceRelationship struct { + ResourceID string `json:"resource_id"` + Relation string `json:"relation"` + SubjectID string `json:"subject_id"` +} + +// ResourceRelationship defines the resource to subject relationship. +type ResourceRelationship struct { + ResourceID gidx.PrefixedID + Relation string + SubjectID gidx.PrefixedID +} + +// ResourceRelationshipRequest defines the request to relate to a subject. +type ResourceRelationshipRequest struct { + Relation string `json:"relation"` + SubjectID string `json:"subject_id"` +} + +// ResourceRelationshipDeleteResponse defines the response for a delete of a relationship. +type ResourceRelationshipDeleteResponse struct { + Success bool `json:"success"` +} + +// DeleteResourceRelationship deletes the provided resources relationship to the given subject id. +func (c *client) DeleteResourceRelationship(ctx context.Context, resourceID gidx.PrefixedID, relation string, relatedResourceID gidx.PrefixedID) error { + path := fmt.Sprintf(permsPathResourceRelationshipsFormat, resourceID.String()) + + body, err := encodeJSON(ResourceRelationshipRequest{ + Relation: relation, + SubjectID: relatedResourceID.String(), + }) + if err != nil { + return err + } + + var response ResourceRelationshipDeleteResponse + + if _, err := c.DoRequest(ctx, http.MethodDelete, path, body, &response); err != nil { // nolint:bodyclose // closed by Do on json decode. + return err + } + + if !response.Success { + return ErrUnexpectedRelationshipDeleteFailed + } + + return nil +} + +// ListResourceRelationshipsFrom returns resources related to the given id. +func (c *client) ListResourceRelationshipsFrom(ctx context.Context, resourceID gidx.PrefixedID) ([]ResourceRelationship, error) { + var response struct { + Data []resourceRelationship `json:"data"` + } + + if _, err := c.DoRequest(ctx, http.MethodGet, permsPathResourceRelationshipsFrom+resourceID.String(), nil, &response); err != nil { // nolint:bodyclose // closed by Do on json decode. + return nil, err + } + + data := make([]ResourceRelationship, len(response.Data)) + + for i, entry := range response.Data { + subID, err := gidx.Parse(entry.SubjectID) + if err != nil { + return nil, err + } + + data[i] = ResourceRelationship{ + Relation: entry.Relation, + SubjectID: subID, + } + } + + return data, nil +} + +// ListResourceRelationshipsTo returns resources related to the given id. +func (c *client) ListResourceRelationshipsTo(ctx context.Context, resourceID gidx.PrefixedID) ([]ResourceRelationship, error) { + var response struct { + Data []resourceRelationship `json:"data"` + } + + if _, err := c.DoRequest(ctx, http.MethodGet, permsPathResourceRelationshipsTo+resourceID.String(), nil, &response); err != nil { // nolint:bodyclose // closed by Do on json decode. + return nil, err + } + + data := make([]ResourceRelationship, len(response.Data)) + + for i, entry := range response.Data { + resID, err := gidx.Parse(entry.ResourceID) + if err != nil { + return nil, err + } + + data[i] = ResourceRelationship{ + ResourceID: resID, + Relation: entry.Relation, + } + } + + return data, nil +} diff --git a/internal/permissions/roles.go b/internal/permissions/roles.go index 6bc8e5a..89a0ea2 100644 --- a/internal/permissions/roles.go +++ b/internal/permissions/roles.go @@ -9,23 +9,33 @@ import ( "golang.org/x/exp/slices" ) +// ResourceRoleCreate is the role create request. type ResourceRoleCreate struct { Actions []string `json:"actions"` } +// ResourceRoleCreateResponse is the role creation response. type ResourceRoleCreateResponse struct { ID string `json:"id"` } +// ResourceRoleDeleteResponse is the role deletion response. +type ResourceRoleDeleteResponse struct { + Success bool `json:"success"` +} + +// ResourceRoles is a listg of resource roles. type ResourceRoles []ResourceRole +// ResourceRole contains the role id and its actions. type ResourceRole struct { ID gidx.PrefixedID `json:"id"` Actions []string `json:"actions"` } -func (c *Client) CreateRole(ctx context.Context, resourceID gidx.PrefixedID, actions []string) (gidx.PrefixedID, error) { - path := fmt.Sprintf("/api/v1/resources/%s/roles", resourceID.String()) +// CreateRole creates a role on the given resource id with the provided actions. +func (c *client) CreateRole(ctx context.Context, resourceID gidx.PrefixedID, actions []string) (gidx.PrefixedID, error) { + path := fmt.Sprintf(permsPathResourceRolesFormat, resourceID.String()) body, err := encodeJSON(ResourceRoleCreate{ Actions: actions, @@ -36,7 +46,7 @@ func (c *Client) CreateRole(ctx context.Context, resourceID gidx.PrefixedID, act var response ResourceRoleCreateResponse - if _, err = c.DoRequest(ctx, http.MethodPost, path, body, &response); err != nil { + if _, err = c.DoRequest(ctx, http.MethodPost, path, body, &response); err != nil { // nolint:bodyclose // closed by Do on json decode. return gidx.NullPrefixedID, err } @@ -48,19 +58,40 @@ func (c *Client) CreateRole(ctx context.Context, resourceID gidx.PrefixedID, act return roleID, nil } -func (c *Client) ListResourceRoles(ctx context.Context, resourceID gidx.PrefixedID) (ResourceRoles, error) { - path := fmt.Sprintf("/api/v1/resources/%s/roles", resourceID.String()) +// DeleteRole deletes the provided role. +func (c *client) DeleteRole(ctx context.Context, roleID gidx.PrefixedID) error { + path := fmt.Sprintf("/api/v1/roles/%s", roleID.String()) - var response ResourceRoles + var response ResourceRoleDeleteResponse - if _, err := c.DoRequest(ctx, http.MethodGet, path, nil, &response); err != nil { + if _, err := c.DoRequest(ctx, http.MethodDelete, path, nil, &response); err != nil { // nolint:bodyclose // closed by Do on json decode. + return err + } + + if !response.Success { + return ErrUnexpectedRoleDeleteFailed + } + + return nil +} + +// ListResourceRoles fetches all roles assigned to the provided resource. +func (c *client) ListResourceRoles(ctx context.Context, resourceID gidx.PrefixedID) (ResourceRoles, error) { + path := fmt.Sprintf(permsPathResourceRolesFormat, resourceID.String()) + + var response struct { + Data ResourceRoles `json:"data"` + } + + if _, err := c.DoRequest(ctx, http.MethodGet, path, nil, &response); err != nil { // nolint:bodyclose // closed by Do on json decode. return nil, err } - return response, nil + return response.Data, nil } -func (c *Client) FindResourceRoleByActions(ctx context.Context, resourceID gidx.PrefixedID, actions []string) (ResourceRole, error) { +// FindResourceRoleByActions fetches roles assigned to the provided resource and finds the first role where the actions match the provided actions. +func (c *client) FindResourceRoleByActions(ctx context.Context, resourceID gidx.PrefixedID, actions []string) (ResourceRole, error) { roles, err := c.ListResourceRoles(ctx, resourceID) if err != nil { return ResourceRole{}, err diff --git a/internal/pubsub/nats.go b/internal/pubsub/nats.go new file mode 100644 index 0000000..22e4cd0 --- /dev/null +++ b/internal/pubsub/nats.go @@ -0,0 +1,132 @@ +package pubsub + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "strings" + + "github.com/nats-io/nats.go" + "github.com/pkg/errors" + "go.infratographer.com/x/events" + "go.uber.org/zap" +) + +const subscriptionBufferSize = 10 + +type changeEvent struct { + *nats.Msg + events.ChangeMessage + + Error error +} + +type subscriber struct { + logger *zap.SugaredLogger + + nats *nats.Conn + jetstream nats.JetStreamContext + + topicPrefix string + queueGroup string + subscriptionOptions []nats.SubOpt + + subscriptions []*nats.Subscription + + context context.Context + cancelCtx func() +} + +func (s *subscriber) durableName(topic string) string { + hash := md5.Sum([]byte(topic)) + + return s.queueGroup + hex.EncodeToString(hash[:]) +} + +// SubscribeChanges subscribes to a topic, returning a channel which change events will be sent to. +func (s *subscriber) SubscribeChanges(ctx context.Context, topic string) (<-chan *changeEvent, error) { + subject := strings.Join([]string{s.topicPrefix, "changes", topic}, ".") + + opts := s.subscriptionOptions + + opts = append(opts, nats.Durable(s.durableName(subject)), nats.AckExplicit(), nats.ManualAck()) + + msgCh := make(chan *changeEvent, subscriptionBufferSize) + + sub, err := s.jetstream.QueueSubscribe(subject, s.queueGroup, func(msg *nats.Msg) { + event := &changeEvent{ + Msg: msg, + } + + if err := json.NewDecoder(bytes.NewBuffer(msg.Data)).Decode(&event.ChangeMessage); err != nil { + event.Error = err + } + + msgCh <- event + }, opts...) + if err != nil { + return nil, err + } + + s.subscriptions = append(s.subscriptions, sub) + + go func(subscriber *nats.Subscription) { + select { + case <-ctx.Done(): + case <-s.context.Done(): + } + + if err := sub.Unsubscribe(); err != nil { + s.logger.Errorw("unable to unsubscribe", "error", err, "subject", subject) + } + }(sub) + + return msgCh, nil +} + +// Close closes the underlying nats connection. +func (s *subscriber) Close() { + s.cancelCtx() + + s.nats.Close() +} + +func newSubscriber(ctx context.Context, config events.SubscriberConfig, logger *zap.SugaredLogger, subOptions ...nats.SubOpt) (*subscriber, error) { + options := []nats.Option{ + nats.Timeout(config.Timeout), + } + + switch { + case config.NATSConfig.CredsFile != "": + options = append(options, nats.UserCredentials(config.NATSConfig.CredsFile)) + case config.NATSConfig.Token != "": + options = append(options, nats.Token(config.NATSConfig.Token)) + } + + conn, err := nats.Connect(config.URL, options...) + if err != nil { + return nil, errors.Wrap(err, "cannot connect to NATS") + } + + js, err := conn.JetStream() + if err != nil { + conn.Close() + + return nil, errors.Wrap(err, "cannot initialize JetStream") + } + + ctx, cancel := context.WithCancel(ctx) + + return &subscriber{ + logger: logger, + nats: conn, + jetstream: js, + queueGroup: config.QueueGroup, + topicPrefix: config.Prefix, + subscriptionOptions: subOptions, + context: ctx, + cancelCtx: cancel, + }, nil +} diff --git a/internal/pubsub/subscriber.go b/internal/pubsub/subscriber.go index 75949cb..1095516 100644 --- a/internal/pubsub/subscriber.go +++ b/internal/pubsub/subscriber.go @@ -3,27 +3,30 @@ package pubsub import ( "context" "sync" + "time" nc "github.com/nats-io/nats.go" "go.infratographer.com/x/events" + "go.infratographer.com/x/gidx" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "go.uber.org/zap" + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" "go.equinixmetal.net/infra9-metal-bridge/internal/service" - - "github.com/ThreeDotsLabs/watermill/message" ) -var tracer = otel.Tracer("go.infratographer.com/permissions-api/internal/pubsub") +const defaultNakDelay = 10 * time.Second + +var tracer = otel.Tracer("go.equinixmetal.net/infra9-metal-bridge") // Subscriber is the subscriber client type Subscriber struct { ctx context.Context - changeChannels []<-chan *message.Message + changeChannels []<-chan *changeEvent logger *zap.SugaredLogger - subscriber *events.Subscriber + subscriber *subscriber subOpts []nc.SubOpt svc service.Service } @@ -57,7 +60,7 @@ func NewSubscriber(ctx context.Context, cfg events.SubscriberConfig, service ser opt(s) } - sub, err := events.NewSubscriber(cfg, s.subOpts...) + sub, err := newSubscriber(ctx, cfg, s.logger, s.subOpts...) if err != nil { return nil, err } @@ -100,50 +103,60 @@ func (s Subscriber) Listen() error { } // listen listens for messages on a channel and calls the registered message handler -func (s Subscriber) listen(messages <-chan *message.Message, wg *sync.WaitGroup) { +func (s Subscriber) listen(messages <-chan *changeEvent, wg *sync.WaitGroup) { defer wg.Done() for msg := range messages { - s.logger.Infow("processing event", "event.id", msg.UUID) - if err := s.processEvent(msg); err != nil { - s.logger.Warn("Failed to process msg: ", err) + mlogger := s.logger.With( + "nats.subject", msg.Subject, + "event.subject.id", msg.SubjectID, + "event.type", msg.EventType, + ) - s.logger.Infow("message nacked", "event.id", msg.UUID) - msg.Nack() + mlogger.Infow("processing event") + + if err := s.processEvent(msg); err != nil { + mlogger.Errorw("Failed to process msg: ", "error", err) + + if err = msg.NakWithDelay(defaultNakDelay); err != nil { + mlogger.Errorw("error naking failed message", "error", err) + } } else { - s.logger.Infow("message acked", "event.id", msg.UUID) - msg.Ack() + if err = msg.Ack(); err != nil { + mlogger.Warnw("error acking message", "error", err) + } } } } // Close closes the subscriber connection and unsubscribes from all subscriptions -func (s *Subscriber) Close() error { - return s.subscriber.Close() +func (s *Subscriber) Close() { + s.subscriber.Close() } // processEvent event message handler -func (s *Subscriber) processEvent(msg *message.Message) error { - changeMsg, err := events.UnmarshalChangeMessage(msg.Payload) - if err != nil { - s.logger.Errorw("failed to process data in msg", zap.Error(err)) +func (s *Subscriber) processEvent(msg *changeEvent) error { + mlogger := s.logger.With( + "nats.subject", msg.Subject, + "event.subject.id", msg.SubjectID, + "event.type", msg.EventType, + ) - return err - } - - ctx, span := tracer.Start(context.Background(), "pubsub.receive", trace.WithAttributes(attribute.String("pubsub.subject", changeMsg.SubjectID.String()))) + ctx, span := tracer.Start(context.Background(), "pubsub.receive", trace.WithAttributes(attribute.String("pubsub.subject", msg.SubjectID.String()))) defer span.End() - switch events.ChangeType(changeMsg.EventType) { + var err error + + switch events.ChangeType(msg.EventType) { case events.CreateChangeType: - err = s.handleTouchEvent(ctx, msg, changeMsg) + err = s.handleTouchEvent(ctx, msg) case events.UpdateChangeType: - err = s.handleTouchEvent(ctx, msg, changeMsg) + err = s.handleTouchEvent(ctx, msg) case events.DeleteChangeType: - err = s.handleDeleteEvent(ctx, msg, changeMsg) + err = s.handleDeleteEvent(ctx, msg) default: - s.logger.Warnw("ignoring msg, not a create, update or delete event", "event_type", changeMsg.EventType) + mlogger.Warn("ignoring msg, not a create, update or delete event") } if err != nil { @@ -153,9 +166,15 @@ func (s *Subscriber) processEvent(msg *message.Message) error { return nil } -func (s *Subscriber) handleTouchEvent(ctx context.Context, msg *message.Message, changeMsg events.ChangeMessage) error { - if s.svc.IsOrganizationID(changeMsg.SubjectID) { - if err := s.svc.TouchOrganization(ctx, changeMsg.SubjectID); err != nil { +func (s *Subscriber) handleTouchEvent(ctx context.Context, msg *changeEvent) error { + mlogger := s.logger.With( + "nats.subject", msg.Subject, + "event.subject.id", msg.SubjectID, + "event.type", msg.EventType, + ) + + if s.svc.IsOrganizationID(msg.SubjectID) { + if err := s.svc.TouchOrganization(ctx, msg.SubjectID); err != nil { // TODO: only return errors on retryable errors return err } @@ -163,8 +182,8 @@ func (s *Subscriber) handleTouchEvent(ctx context.Context, msg *message.Message, return nil } - if s.svc.IsProjectID(changeMsg.SubjectID) { - if err := s.svc.TouchProject(ctx, changeMsg.SubjectID); err != nil { + if s.svc.IsProjectID(msg.SubjectID) { + if err := s.svc.TouchProject(ctx, msg.SubjectID); err != nil { // TODO: only return errors on retryable errors return err } @@ -172,8 +191,17 @@ func (s *Subscriber) handleTouchEvent(ctx context.Context, msg *message.Message, return nil } - if s.svc.IsUser(changeMsg.SubjectID) { - if err := s.svc.TouchUser(ctx, changeMsg.SubjectID); err != nil { + if s.svc.IsUser(msg.SubjectID) { + userUUID := msg.SubjectID.String()[gidx.PrefixPartLength+1:] + + subjID, err := models.GenerateSubjectID(models.IdentityPrefixUser, models.MetalUserIssuer, models.MetalUserIssuerIDPrefix+userUUID) + if err != nil { + mlogger.Errorw("failed to convert user id to identity id", "error", err) + + return nil + } + + if err := s.svc.AssignUser(ctx, subjID, msg.AdditionalSubjectIDs...); err != nil { // TODO: only return errors on retryable errors return err } @@ -181,14 +209,20 @@ func (s *Subscriber) handleTouchEvent(ctx context.Context, msg *message.Message, return nil } - s.logger.Warnw("unknown subject id", "subject.id", changeMsg.SubjectID) + mlogger.Warnw("unknown subject id") return nil } -func (s *Subscriber) handleDeleteEvent(ctx context.Context, msg *message.Message, changeMsg events.ChangeMessage) error { - if s.svc.IsOrganizationID(changeMsg.SubjectID) { - if err := s.svc.DeleteOrganization(ctx, changeMsg.SubjectID); err != nil { +func (s *Subscriber) handleDeleteEvent(ctx context.Context, msg *changeEvent) error { + mlogger := s.logger.With( + "nats.subject", msg.Subject, + "event.subject.id", msg.SubjectID, + "event.type", msg.EventType, + ) + + if s.svc.IsOrganizationID(msg.SubjectID) { + if err := s.svc.DeleteOrganization(ctx, msg.SubjectID); err != nil { // TODO: only return errors on retryable errors return err } @@ -196,8 +230,8 @@ func (s *Subscriber) handleDeleteEvent(ctx context.Context, msg *message.Message return nil } - if s.svc.IsProjectID(changeMsg.SubjectID) { - if err := s.svc.DeleteProject(ctx, changeMsg.SubjectID); err != nil { + if s.svc.IsProjectID(msg.SubjectID) { + if err := s.svc.DeleteProject(ctx, msg.SubjectID); err != nil { // TODO: only return errors on retryable errors return err } @@ -205,8 +239,17 @@ func (s *Subscriber) handleDeleteEvent(ctx context.Context, msg *message.Message return nil } - if s.svc.IsUser(changeMsg.SubjectID) { - if err := s.svc.DeleteUser(ctx, changeMsg.SubjectID); err != nil { + if s.svc.IsUser(msg.SubjectID) { + userUUID := msg.SubjectID.String()[gidx.PrefixPartLength+1:] + + subjID, err := models.GenerateSubjectID(models.IdentityPrefixUser, models.MetalUserIssuer, models.MetalUserIssuerIDPrefix+userUUID) + if err != nil { + mlogger.Errorw("failed to convert user id to identity id", "error", err) + + return nil + } + + if err := s.svc.UnassignUser(ctx, subjID, msg.AdditionalSubjectIDs...); err != nil { // TODO: only return errors on retryable errors return err } @@ -214,7 +257,7 @@ func (s *Subscriber) handleDeleteEvent(ctx context.Context, msg *message.Message return nil } - s.logger.Warnw("unknown subject id", "subject.id", changeMsg.SubjectID) + mlogger.Warnw("unknown subject id") return nil } diff --git a/internal/routes/errors.go b/internal/routes/errors.go deleted file mode 100644 index 007c34f..0000000 --- a/internal/routes/errors.go +++ /dev/null @@ -1,22 +0,0 @@ -package routes - -import ( - "errors" - "net/http" - - "github.com/labstack/echo/v4" -) - -var ( - // ErrInvalidJWTPrivateKeyType is returned when the private key type is not of an expected value. - ErrInvalidJWTPrivateKeyType = errors.New("invalid JWT private key provided") - - // ErrAuthTokenHeaderRequired is returned when a token check request is made, but the Authorization header is missing. - ErrAuthTokenHeaderRequired = echo.NewHTTPError(http.StatusBadRequest, "header Authorization missing or invalid") - - // ErrInvalidSigningMethod is returned when defined jwt signing method is not recognized. - ErrInvalidSigningMethod = errors.New("unrecognized jwt signing method provided") - - // ErrMissingIssuer is returned when the jwt issuer is not defined in the config. - ErrMissingIssuer = errors.New("jwt issuer required") -) diff --git a/internal/routes/options.go b/internal/routes/options.go deleted file mode 100644 index 817ec29..0000000 --- a/internal/routes/options.go +++ /dev/null @@ -1,27 +0,0 @@ -package routes - -import ( - "go.equinixmetal.net/infra9-metal-bridge/internal/service" - "go.uber.org/zap" -) - -// Option is a functional configuration option for the router. -type Option func(r *Router) error - -// WithLogger sets the logger for the router. -func WithLogger(logger *zap.Logger) Option { - return func(r *Router) error { - r.logger = logger - - return nil - } -} - -// WithService sets the service handler. -func WithService(svc service.Service) Option { - return func(r *Router) error { - r.svc = svc - - return nil - } -} diff --git a/internal/routes/routes.go b/internal/routes/routes.go deleted file mode 100644 index d297525..0000000 --- a/internal/routes/routes.go +++ /dev/null @@ -1,32 +0,0 @@ -// Package routes provides the routes for the application. -package routes - -import ( - "github.com/labstack/echo/v4" - "go.equinixmetal.net/infra9-metal-bridge/internal/service" - "go.uber.org/zap" -) - -// Router is the router for the application. -type Router struct { - logger *zap.Logger - svc service.Service -} - -// Routes registers the routes for the application. -func (r *Router) Routes(g *echo.Group) {} - -// NewRouter creates a new router -func NewRouter(opts ...Option) (*Router, error) { - router := Router{ - logger: zap.NewNop(), - } - - for _, opt := range opts { - if err := opt(&router); err != nil { - return nil, err - } - } - - return &router, nil -} diff --git a/internal/service/options.go b/internal/service/options.go index 63b093e..8ac12a9 100644 --- a/internal/service/options.go +++ b/internal/service/options.go @@ -3,6 +3,7 @@ package service import ( "go.infratographer.com/x/gidx" "go.uber.org/zap" + "golang.org/x/exp/slices" "go.equinixmetal.net/infra9-metal-bridge/internal/metal" "go.equinixmetal.net/infra9-metal-bridge/internal/permissions" @@ -21,7 +22,7 @@ func WithLogger(logger *zap.SugaredLogger) Option { } // WithMetalClient sets the Equinix Metal client used by the service. -func WithMetalClient(client *metal.Client) Option { +func WithMetalClient(client metal.Client) Option { return func(s *service) error { s.metal = client @@ -30,7 +31,7 @@ func WithMetalClient(client *metal.Client) Option { } // WithPermissionsClient sets the permissions client used by the service. -func WithPermissionsClient(client *permissions.Client) Option { +func WithPermissionsClient(client permissions.Client) Option { return func(s *service) error { s.perms = client @@ -39,7 +40,7 @@ func WithPermissionsClient(client *permissions.Client) Option { } // WithPrefixMap sets the id prefix map relating id prefixes to type names. -func WithPrefixMap(idMap map[string]string) Option { +func WithPrefixMap(idMap map[string]ObjectType) Option { return func(s *service) error { s.idPrefixMap = idMap @@ -55,7 +56,7 @@ func WithRootTenant(sid string) Option { return err } - s.rootResource = rootResource{id} + s.rootResource = prefixedID{id} return nil } @@ -64,7 +65,13 @@ func WithRootTenant(sid string) Option { // WithRoles defines the role to action mapping. func WithRoles(roles map[string][]string) Option { return func(s *service) error { - s.roles = roles + s.roles = make(map[string][]string, len(roles)) + + for role, actions := range roles { + slices.Sort(actions) + + s.roles[role] = actions + } return nil } diff --git a/internal/service/organizations.go b/internal/service/organizations.go index 4b41e00..f83c605 100644 --- a/internal/service/organizations.go +++ b/internal/service/organizations.go @@ -3,21 +3,23 @@ package service import ( "context" + "go.infratographer.com/x/events" "go.infratographer.com/x/gidx" "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" ) +const organizationEvent = "metal_organization" + +// buildOrganizationRelationships compiles all relations into a relationships object to be processed by the processors. func (s *service) buildOrganizationRelationships(org *models.OrganizationDetails) (Relationships, error) { relations := Relationships{ - Relationships: []Relationship{ - // Related org to the root tenant. - { - Resource: org, - Relation: RelateParent, - RelatedResource: s.rootResource, - }, + Resource: org, + Parent: Relation{ + Relation: RelateParent, + Resource: s.rootResource, }, + SubjectRelation: RelateParent, } for _, member := range org.Memberships { @@ -29,40 +31,23 @@ func (s *service) buildOrganizationRelationships(org *models.OrganizationDetails } relations.Memberships = append(relations.Memberships, ResourceMemberships{ - Resource: org, - Role: role, - Member: member.User, + Role: role, + Member: member.User, }) } } for _, project := range org.Projects { - relations.Relationships = append(relations.Relationships, Relationship{ - Resource: project, - Relation: RelateParent, - RelatedResource: org, + relations.SubjectRelationships = append(relations.SubjectRelationships, Relation{ + Resource: project, + Relation: RelateParent, }) - - for _, member := range project.Memberships { - for _, role := range member.Roles { - if _, ok := s.roles[role]; !ok { - s.logger.Warnf("unrecognized project role '%s' for %s on %s", role, member.User.PrefixedID(), project.PrefixedID()) - - continue - } - - relations.Memberships = append(relations.Memberships, ResourceMemberships{ - Resource: project, - Role: role, - Member: member.User, - }) - } - } } return relations, nil } +// IsOrganizationID checks if the provided id has the metal organization prefix. func (s *service) IsOrganizationID(id gidx.PrefixedID) bool { if idType, ok := s.idPrefixMap[id.Prefix()]; ok { return idType == TypeOrganization @@ -71,6 +56,7 @@ func (s *service) IsOrganizationID(id gidx.PrefixedID) bool { return false } +// TouchOrganization initializes a sync for the provided organization id for relationships and memberships. func (s *service) TouchOrganization(ctx context.Context, id gidx.PrefixedID) error { logger := s.logger.With("organization.id", id.String()) @@ -88,14 +74,32 @@ func (s *service) TouchOrganization(ctx context.Context, id gidx.PrefixedID) err return err } - s.processRelationships(ctx, "metal-relation", relationships.Relationships) - s.processMemberships(ctx, relationships.Memberships) + relationshipChanges := s.processRelationships(ctx, organizationEvent, relationships) + rolesChanged, assignmentsChanged := s.processMemberships(ctx, relationships, false) - s.logger.Infow("organization sync complete", "relationships", len(relationships.Relationships), "memberships", len(relationships.Memberships)) + s.logger.Infow("organization sync complete", + "resource.id", org.PrefixedID(), + "relationships.changed", relationshipChanges, + "membership.roles_changed", rolesChanged, + "membership.assignments_changed", assignmentsChanged, + ) return nil } +// DeleteOrganization deletes the provided organization id. func (s *service) DeleteOrganization(ctx context.Context, id gidx.PrefixedID) error { + err := s.publisher.PublishChange(ctx, organizationEvent, events.ChangeMessage{ + SubjectID: id, + EventType: string(events.DeleteChangeType), + }) + if err != nil { + s.logger.Errorw("error publishing organization delete", + "subject_type", organizationEvent, + "resource.id", id, + "error", err, + ) + } + return nil } diff --git a/internal/service/organizations_test.go b/internal/service/organizations_test.go new file mode 100644 index 0000000..9448fbb --- /dev/null +++ b/internal/service/organizations_test.go @@ -0,0 +1,357 @@ +package service_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.infratographer.com/x/events" + "go.infratographer.com/x/gidx" + + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers" + "go.equinixmetal.net/infra9-metal-bridge/internal/permissions" + "go.equinixmetal.net/infra9-metal-bridge/internal/service" +) + +type mockPublisher struct { + mock.Mock +} + +func (p *mockPublisher) PublishChange(ctx context.Context, subjectType string, change events.ChangeMessage) error { + args := p.Called(subjectType, change) + + return args.Error(0) +} + +func TestTouchOrganizationEmpty(t *testing.T) { + rootTenantID := gidx.PrefixedID("tnntten-root1") + + roleMap := map[string][]string{ + "owner": { + "action1", + "action2", + }, + } + + userID := gidx.PrefixedID("idntusr-2aPXqbgfYSlld36XLfvNjEqUIIL9ekRZzgs2eBGOCBw") + user := &models.UserDetails{ + ID: "usr1", + } + + projectID := gidx.PrefixedID("metlprj-prj1") + project := &models.ProjectDetails{ + ID: "prj1", + } + + orgID := gidx.PrefixedID("metlorg-org1") + org := &models.OrganizationDetails{ + ID: "org1", + Projects: []*models.ProjectDetails{ + project, + }, + Memberships: []*models.Membership[models.OrganizationDetails]{ + { + User: user, + Roles: []string{ + "owner", + }, + }, + }, + } + + var ( + mMetal = new(providers.MockProvider) + mPerms = new(permissions.MockClient) + mPublisher = new(mockPublisher) + ) + + // Relationships + + mMetal.On("GetOrganizationDetails", orgID).Return(org, nil) + + mPerms.On("ListResourceRelationshipsFrom", orgID).Return([]permissions.ResourceRelationship{}, nil) + mPerms.On("ListResourceRelationshipsTo", orgID).Return([]permissions.ResourceRelationship{}, nil) + + orgTenantChangeMessage := events.ChangeMessage{ + SubjectID: orgID, + EventType: string(events.CreateChangeType), + AdditionalSubjectIDs: []gidx.PrefixedID{ + rootTenantID, + }, + } + relateProjectChangeMessage := events.ChangeMessage{ + SubjectID: projectID, + EventType: string(events.CreateChangeType), + AdditionalSubjectIDs: []gidx.PrefixedID{ + orgID, + }, + } + + mPublisher.On("PublishChange", "metal_organization", orgTenantChangeMessage).Return(nil) + mPublisher.On("PublishChange", "metal_organization", relateProjectChangeMessage).Return(nil) + + // Memberships + + newRoleID := gidx.PrefixedID("permrol-role1") + + mPerms.On("ListResourceRoles", orgID).Return(permissions.ResourceRoles{}, nil) + mPerms.On("CreateRole", orgID, roleMap["owner"]).Return(newRoleID, nil) + + mPerms.On("AssignRole", newRoleID, userID).Return(nil) + + // Run scenario + + svc, err := service.New(mPublisher, mMetal, mPerms, + service.WithRootTenant(rootTenantID.String()), + service.WithRoles(roleMap), + ) + + require.NoError(t, err) + + err = svc.TouchOrganization(context.Background(), orgID) + + require.NoError(t, err) + + require.True(t, mMetal.AssertExpectations(t), "unexpected calls to metal provider") + require.True(t, mPerms.AssertExpectations(t), "unexpected calls to permissions client") + require.True(t, mPublisher.AssertExpectations(t), "unexpected calls to events publisher") +} + +func TestTouchOrganizationCleanup(t *testing.T) { + oldRootTenantID := gidx.PrefixedID("tnntten-root1") + newRootTenantID := gidx.PrefixedID("tnntten-root2") + + roleMap := map[string][]string{ + "owner": { + "action1", + "action2", + }, + "collaborator": { + "action1", + }, + } + + oldUserID := gidx.PrefixedID("idntusr-2aPXqbgfYSlld36XLfvNjEqUIIL9ekRZzgs2eBGOCBw") + deadProjectID := gidx.PrefixedID("metlprj-prj1") + + userID := gidx.PrefixedID("idntusr-Ewj_PJUue9eDIDoyCoWG48GtwlysqTj2Y4qWPiJPN1s") + user := &models.UserDetails{ + ID: "usr2", + } + + projectID := gidx.PrefixedID("metlprj-prj2") + project := &models.ProjectDetails{ + ID: "prj2", + } + + orgID := gidx.PrefixedID("metlorg-org1") + org := &models.OrganizationDetails{ + ID: "org1", + Projects: []*models.ProjectDetails{ + project, + }, + Memberships: []*models.Membership[models.OrganizationDetails]{ + { + User: user, + Roles: []string{ + "owner", + }, + }, + }, + } + + var ( + mMetal = new(providers.MockProvider) + mPerms = new(permissions.MockClient) + mPublisher = new(mockPublisher) + ) + + // Relationships + + mMetal.On("GetOrganizationDetails", orgID).Return(org, nil) + + existingRelsFrom := []permissions.ResourceRelationship{ + { + Relation: string(service.RelateParent), + SubjectID: oldRootTenantID, + }, + } + + mPerms.On("ListResourceRelationshipsFrom", orgID).Return(existingRelsFrom, nil) + + existingRelsTo := []permissions.ResourceRelationship{ + { + ResourceID: deadProjectID, + Relation: string(service.RelateParent), + }, + } + + mPerms.On("ListResourceRelationshipsTo", orgID).Return(existingRelsTo, nil) + + mPerms.On("DeleteResourceRelationship", orgID, string(service.RelateParent), oldRootTenantID).Return(nil) + mPerms.On("DeleteResourceRelationship", deadProjectID, string(service.RelateParent), orgID).Return(nil) + + orgTenantChangeMessage := events.ChangeMessage{ + SubjectID: orgID, + EventType: string(events.CreateChangeType), + AdditionalSubjectIDs: []gidx.PrefixedID{ + newRootTenantID, + }, + } + relateProjectChangeMessage := events.ChangeMessage{ + SubjectID: projectID, + EventType: string(events.CreateChangeType), + AdditionalSubjectIDs: []gidx.PrefixedID{ + orgID, + }, + } + + mPublisher.On("PublishChange", "metal_organization", orgTenantChangeMessage).Return(nil) + mPublisher.On("PublishChange", "metal_organization", relateProjectChangeMessage).Return(nil) + + // Memberships + + oldRoleID := gidx.PrefixedID("permrol-role1") + newRoleID := gidx.PrefixedID("permrol-role2") + + existingRoles := permissions.ResourceRoles{ + { + ID: oldRoleID, + Actions: roleMap["collaborator"], + }, + } + + mPerms.On("ListResourceRoles", orgID).Return(existingRoles, nil) + + existingRoleAssignments := []gidx.PrefixedID{ + oldUserID, + } + + mPerms.On("ListRoleAssignments", oldRoleID).Return(existingRoleAssignments, nil) + + mPerms.On("CreateRole", orgID, roleMap["owner"]).Return(newRoleID, nil) + mPerms.On("DeleteRole", oldRoleID).Return(nil) + + mPerms.On("AssignRole", newRoleID, userID).Return(nil) + mPerms.On("UnassignRole", oldRoleID, oldUserID).Return(nil) + + // Run scenario + + svc, err := service.New(mPublisher, mMetal, mPerms, + service.WithRootTenant(newRootTenantID.String()), + service.WithRoles(roleMap), + ) + + require.NoError(t, err) + + err = svc.TouchOrganization(context.Background(), orgID) + + require.NoError(t, err) + + require.True(t, mMetal.AssertExpectations(t), "unexpected calls to metal provider") + require.True(t, mPerms.AssertExpectations(t), "unexpected calls to permissions client") + require.True(t, mPublisher.AssertExpectations(t), "unexpected calls to events publisher") +} + +func TestTouchOrganizationNoChange(t *testing.T) { + rootTenantID := gidx.PrefixedID("tnntten-root1") + + roleMap := map[string][]string{ + "owner": { + "action1", + "action2", + }, + } + + userID := gidx.PrefixedID("idntusr-2aPXqbgfYSlld36XLfvNjEqUIIL9ekRZzgs2eBGOCBw") + user := &models.UserDetails{ + ID: "usr1", + } + + projectID := gidx.PrefixedID("metlprj-prj1") + project := &models.ProjectDetails{ + ID: "prj1", + } + + orgID := gidx.PrefixedID("metlorg-org1") + org := &models.OrganizationDetails{ + ID: "org1", + Projects: []*models.ProjectDetails{ + project, + }, + Memberships: []*models.Membership[models.OrganizationDetails]{ + { + User: user, + Roles: []string{ + "owner", + }, + }, + }, + } + + var ( + mMetal = new(providers.MockProvider) + mPerms = new(permissions.MockClient) + mPublisher = new(mockPublisher) + ) + + // Relationships + + mMetal.On("GetOrganizationDetails", orgID).Return(org, nil) + + existingRelsFrom := []permissions.ResourceRelationship{ + { + Relation: string(service.RelateParent), + SubjectID: rootTenantID, + }, + } + + mPerms.On("ListResourceRelationshipsFrom", orgID).Return(existingRelsFrom, nil) + + existingRelsTo := []permissions.ResourceRelationship{ + { + ResourceID: projectID, + Relation: string(service.RelateParent), + }, + } + + mPerms.On("ListResourceRelationshipsTo", orgID).Return(existingRelsTo, nil) + + // Memberships + + roleID := gidx.PrefixedID("permrol-role1") + + existingRoles := permissions.ResourceRoles{ + { + ID: roleID, + Actions: roleMap["owner"], + }, + } + + mPerms.On("ListResourceRoles", orgID).Return(existingRoles, nil) + + existingRoleAssignments := []gidx.PrefixedID{ + userID, + } + + mPerms.On("ListRoleAssignments", roleID).Return(existingRoleAssignments, nil) + + // Run scenario + + svc, err := service.New(mPublisher, mMetal, mPerms, + service.WithRootTenant(rootTenantID.String()), + service.WithRoles(roleMap), + ) + + require.NoError(t, err) + + err = svc.TouchOrganization(context.Background(), orgID) + + require.NoError(t, err) + + require.True(t, mMetal.AssertExpectations(t), "unexpected calls to metal provider") + require.True(t, mPerms.AssertExpectations(t), "unexpected calls to permissions client") + require.True(t, mPublisher.AssertExpectations(t), "unexpected calls to events publisher") +} diff --git a/internal/service/process.go b/internal/service/process.go deleted file mode 100644 index f310f51..0000000 --- a/internal/service/process.go +++ /dev/null @@ -1,137 +0,0 @@ -package service - -import ( - "context" - - "go.infratographer.com/x/events" - "go.infratographer.com/x/gidx" -) - -func (s *service) processRelationships(ctx context.Context, subjectType string, relationships []Relationship) { - var err error - - for _, rel := range relationships { - err = s.publisher.PublishChange(ctx, subjectType, events.ChangeMessage{ - SubjectID: rel.Resource.PrefixedID(), - EventType: string(events.CreateChangeType), - AdditionalSubjectIDs: []gidx.PrefixedID{ - rel.RelatedResource.PrefixedID(), - }, - }) - - if err != nil { - s.logger.Errorw("error publishing change", - "subject_type", subjectType, - "resource.id", rel.Resource.PrefixedID(), - "related_resource.id", rel.RelatedResource.PrefixedID(), - "error", err, - ) - } - } -} - -func (s *service) processMemberships(ctx context.Context, memberships []ResourceMemberships) { - resourceRoleID := make(map[gidx.PrefixedID]map[string]gidx.PrefixedID) - resourceRoleMembers := make(map[gidx.PrefixedID]map[string]map[gidx.PrefixedID]bool) - roleActions := make(map[string][]string) - - for _, membership := range memberships { - resourceID := membership.Resource.PrefixedID() - role := membership.Role - memberID := membership.Member.PrefixedID() - - if _, ok := resourceRoleMembers[resourceID]; !ok { - resourceRoleID[resourceID] = make(map[string]gidx.PrefixedID) - resourceRoleMembers[resourceID] = make(map[string]map[gidx.PrefixedID]bool) - } - - if _, ok := resourceRoleMembers[resourceID][role]; !ok { - resourceRoleMembers[resourceID][role] = make(map[gidx.PrefixedID]bool) - roleActions[role] = s.roles[role] - } - - resourceRoleMembers[resourceID][role][memberID] = true - } - - resourceRoleAssignments := make(map[gidx.PrefixedID]map[gidx.PrefixedID]map[gidx.PrefixedID]bool) - - for resourceID, roles := range resourceRoleID { - resourceRoleAssignments[resourceID] = make(map[gidx.PrefixedID]map[gidx.PrefixedID]bool) - - for role := range roles { - actions := roleActions[role] - - resourceRole, err := s.perms.FindResourceRoleByActions(ctx, resourceID, actions) - if err != nil { - s.logger.Warnw("failed to find role by actions for resource", "resource.id", resourceID, "role", role, "actions", actions, "error", err) - - continue - } - - resourceRoleID[resourceID][role] = resourceRole.ID - resourceRoleAssignments[resourceID][resourceRole.ID] = make(map[gidx.PrefixedID]bool) - - assignments, err := s.perms.ListRoleAssignments(ctx, resourceRole.ID) - if err != nil { - s.logger.Warnw("failed to get role assignments for resource", "resource.id", resourceID, "role", role, "error", err) - - continue - } - - for _, assignment := range assignments { - resourceRoleAssignments[resourceID][resourceRole.ID][assignment] = true - } - } - } - - for resourceID, roles := range resourceRoleMembers { - for role, members := range roles { - roleID := resourceRoleID[resourceID][role] - actions := roleActions[role] - - logger := s.logger.With("resource.id", resourceID, "role.name", role, "actions", actions) - - var createdRole bool - - if roleID == gidx.NullPrefixedID { - logger.Infow("creating role for resource") - - resourceRoleID, err := s.perms.CreateRole(ctx, resourceID, actions) - if err != nil { - logger.Errorw("failed to create role for resource", "error", err) - - continue - } - - createdRole = true - roleID = resourceRoleID - } - - logger = logger.With("role.id", roleID) - - assignments := make(map[gidx.PrefixedID]bool) - - if !createdRole { - assignments = resourceRoleAssignments[resourceID][roleID] - } - - for memberID := range members { - mlogger := logger.With("member.id", memberID) - - if _, ok := assignments[memberID]; ok { - mlogger.Infow("skipping already assigned member") - - continue - } - - if err := s.perms.AssignRole(ctx, roleID, memberID); err != nil { - mlogger.Errorw("failed to assign member to role", "error", err) - - continue - } - - mlogger.Infow("role assigned to member") - } - } - } -} diff --git a/internal/service/process_memberships.go b/internal/service/process_memberships.go new file mode 100644 index 0000000..611c4dc --- /dev/null +++ b/internal/service/process_memberships.go @@ -0,0 +1,235 @@ +package service + +import ( + "context" + "strings" + + "go.infratographer.com/x/gidx" + "golang.org/x/exp/slices" + + "go.equinixmetal.net/infra9-metal-bridge/internal/permissions" +) + +// processMemberships determines the changes between what is wanted and what is live and executes on the differences. +// If skipDeletions is true, no deletes will be executed. +func (s *service) processMemberships(ctx context.Context, relationships Relationships, skipDeletions bool) (int, int) { + if len(relationships.Memberships) == 0 { + return 0, 0 + } + + rlogger := s.logger.With("resource.id", relationships.Resource.PrefixedID()) + + roleIDs := make(map[string]gidx.PrefixedID) + + var ( + totalRoleCreate, totalRoleDelete int + totalRoleAssign, totalRoleUnassign int + ) + + wantRoles, wantAssignments := s.mapResourceWants(relationships.Memberships) + + liveRoles, liveAssignments, err := s.mapResourceDetails(ctx, relationships.Resource.PrefixedID()) + if err != nil { + rlogger.Errorw("failed to get membership resource details map", + "error", err, + ) + + return 0, 0 + } + + roleCreations := make(map[string][]string) + roleDeletions := make([]gidx.PrefixedID, 0) + + for roleKey, actions := range wantRoles { + if _, ok := liveRoles[roleKey]; !ok { + roleCreations[roleKey] = actions + } + } + + for roleKey, role := range liveRoles { + if !skipDeletions { + if _, ok := wantRoles[roleKey]; !ok { + roleDeletions = append(roleDeletions, role.ID) + } + } + + roleIDs[roleKey] = role.ID + } + + roleMembershipsAdd := make(map[string][]gidx.PrefixedID) + roleMembershipsRemove := make(map[string][]gidx.PrefixedID) + + for roleKey, assignments := range wantAssignments { + for memberID := range assignments { + if _, ok := liveAssignments[roleKey]; ok { + if _, ok := liveAssignments[roleKey][memberID]; ok { + continue + } + } + + roleMembershipsAdd[roleKey] = append(roleMembershipsAdd[roleKey], memberID) + totalRoleAssign++ + } + } + + if !skipDeletions { + for roleKey, assignments := range liveAssignments { + for memberID := range assignments { + if _, ok := wantAssignments[roleKey]; ok { + if _, ok := wantAssignments[roleKey][memberID]; ok { + continue + } + } + + roleMembershipsRemove[roleKey] = append(roleMembershipsRemove[roleKey], memberID) + totalRoleUnassign++ + } + } + } + + totalRoleCreate += len(roleCreations) + totalRoleDelete += len(roleDeletions) + + rlogger.Debugw("processing memberships", + "role.create", totalRoleCreate, + "role.delete", totalRoleDelete, + "role.assign", totalRoleAssign, + "role.unassign", totalRoleUnassign, + ) + + var ( + rolesCreated, rolesDeleted int + roleAssignments, roleUnassignments int + ) + + for roleKey, actions := range roleCreations { + roleID, err := s.perms.CreateRole(ctx, relationships.Resource.PrefixedID(), actions) + if err != nil { + rlogger.Errorw("error creating role", "actions", actions, "error", err) + + continue + } + + roleIDs[roleKey] = roleID + rolesCreated++ + } + + for _, roleID := range roleDeletions { + if err := s.perms.DeleteRole(ctx, roleID); err != nil { + rlogger.Errorw("error deleting role", "role.id", roleID, "error", err) + + continue + } + + rolesDeleted++ + } + + for roleKey, members := range roleMembershipsAdd { + roleID, ok := roleIDs[roleKey] + if !ok { + rlogger.Errorw("role id not found for role actions key", "role_actions_key", roleKey, "members", len(members)) + + continue + } + + for _, memberID := range members { + if err := s.perms.AssignRole(ctx, roleID, memberID); err != nil { + rlogger.Errorw("error assigning member to role", "role.id", roleID, "member.id", memberID, "error", err) + + continue + } + + roleAssignments++ + } + } + + for roleKey, members := range roleMembershipsRemove { + roleID, ok := roleIDs[roleKey] + if !ok { + rlogger.Errorw("role id not found for role actions key", "role_actions_key", roleKey, "members", len(members)) + + continue + } + + for _, memberID := range members { + if err := s.perms.UnassignRole(ctx, roleID, memberID); err != nil { + rlogger.Errorw("error removing member from role", "role.id", roleID, "member.id", memberID, "error", err) + + continue + } + + roleUnassignments++ + } + } + + rlogger.Debugw("memberships processed", + "role.create", rolesCreated, + "role.delete", rolesDeleted, + "role.assign", roleAssignments, + "role.unassign", roleUnassignments, + ) + + return rolesCreated + rolesDeleted, roleAssignments + roleUnassignments +} + +// mapResourceWants processes the provided memberships and returns two maps. +// A Role Key is computed based on a sorted slice of actions for each role. +// The first map is of Role Key -> list of actions +// The second map is of Role Key -> Member ID -> true +func (s *service) mapResourceWants(memberships []ResourceMemberships) (map[string][]string, map[string]map[gidx.PrefixedID]bool) { + roleActionsKey := make(map[string]string) + + for role, actions := range s.roles { + roleActionsKey[role] = strings.Join(actions, "|") + } + + wantRoles := make(map[string][]string) + wantAssignments := make(map[string]map[gidx.PrefixedID]bool) + + for _, membership := range memberships { + roleKey := roleActionsKey[membership.Role] + + if _, ok := wantRoles[roleKey]; !ok { + wantRoles[roleKey] = s.roles[membership.Role] + wantAssignments[roleKey] = make(map[gidx.PrefixedID]bool) + } + + wantAssignments[roleKey][membership.Member.PrefixedID()] = true + } + + return wantRoles, wantAssignments +} + +// mapResourceDetails fetches the provided ResourceID's live state and returns two maps and an error. +// A Role Key is computed based on a sorted slice of actions for each role. +// The first map is of Role Key -> Permissions Resource Role +// The second map is of Role Key -> Member ID -> true +func (s *service) mapResourceDetails(ctx context.Context, resourceID gidx.PrefixedID) (map[string]permissions.ResourceRole, map[string]map[gidx.PrefixedID]bool, error) { + roles := make(map[string]permissions.ResourceRole) + assignments := make(map[string]map[gidx.PrefixedID]bool) + + liveRoles, err := s.perms.ListResourceRoles(ctx, resourceID) + if err != nil { + return nil, nil, err + } + + for _, role := range liveRoles { + slices.Sort(role.Actions) + + roleKey := strings.Join(role.Actions, "|") + roles[roleKey] = role + + liveAssignments, err := s.perms.ListRoleAssignments(ctx, role.ID) + if err != nil { + return nil, nil, err + } + + assignments[roleKey] = make(map[gidx.PrefixedID]bool) + + for _, assignment := range liveAssignments { + assignments[roleKey][assignment] = true + } + } + + return roles, assignments, nil +} diff --git a/internal/service/process_relationships.go b/internal/service/process_relationships.go new file mode 100644 index 0000000..e0a7ec2 --- /dev/null +++ b/internal/service/process_relationships.go @@ -0,0 +1,216 @@ +package service + +import ( + "context" + + "go.infratographer.com/x/events" + "go.infratographer.com/x/gidx" + + "go.equinixmetal.net/infra9-metal-bridge/internal/permissions" +) + +// processRelationships determines the changes between what is wanted and what is live and executes on the differences. +// Relationship creations use events. +// Relationship deletions use the api, as delete events delete all related resources and not just the provided ones. +func (s *service) processRelationships(ctx context.Context, eventType string, relationships Relationships) int { + rlogger := s.logger.With("resource.id", relationships.Resource.PrefixedID()) + + wantParentRelationship, wantSubjectRelationships := s.mapRelationWants(relationships) + + liveParentRelationships, liveSubjectRelationships, err := s.getRelationshipMap(ctx, relationships.Resource, relationships.SubjectRelation) + if err != nil { + rlogger.Errorw("failed to get relationship map", + "relationships.subject_relation", relationships.SubjectRelation, + "error", err, + ) + + return 0 + } + + var ( + createParentRelationship *Relation + deleteParentRelationships []gidx.PrefixedID + + foundParent bool + + createSubjectRelationships []Relation + deleteSubjectRelationships []Relation + ) + + if wantParentRelationship != nil { + for subjID := range liveParentRelationships { + if subjID == wantParentRelationship.Resource.PrefixedID() { + foundParent = true + + continue + } + + deleteParentRelationships = append(deleteParentRelationships, subjID) + } + + if !foundParent { + createParentRelationship = wantParentRelationship + } + } else { + for subjID := range liveParentRelationships { + deleteParentRelationships = append(deleteParentRelationships, subjID) + } + } + + for resID, relation := range wantSubjectRelationships { + if _, ok := liveSubjectRelationships[resID]; ok { + continue + } + + createSubjectRelationships = append(createSubjectRelationships, Relation{ + Resource: prefixedID{resID}, + Relation: relation, + }) + } + + for resID, relation := range liveSubjectRelationships { + if _, ok := wantSubjectRelationships[resID]; ok { + continue + } + + deleteSubjectRelationships = append(deleteSubjectRelationships, Relation{ + Resource: prefixedID{resID}, + Relation: relation, + }) + } + + var processEvents []events.ChangeMessage + + rlogger.Debugw("processing relationships", + "parent.create", createParentRelationship != nil, + "parent.delete", len(deleteParentRelationships), + "subject.create", len(createSubjectRelationships), + "subject.delete", len(deleteSubjectRelationships), + ) + + if createParentRelationship != nil { + processEvents = append(processEvents, events.ChangeMessage{ + SubjectID: relationships.Resource.PrefixedID(), + EventType: string(events.CreateChangeType), + AdditionalSubjectIDs: []gidx.PrefixedID{ + createParentRelationship.Resource.PrefixedID(), + }, + }) + } + + for _, relation := range createSubjectRelationships { + processEvents = append(processEvents, events.ChangeMessage{ + SubjectID: relation.Resource.PrefixedID(), + EventType: string(events.CreateChangeType), + AdditionalSubjectIDs: []gidx.PrefixedID{ + relationships.Resource.PrefixedID(), + }, + }) + } + + for _, relatedResourceID := range deleteParentRelationships { + err = s.perms.DeleteResourceRelationship(ctx, relationships.Resource.PrefixedID(), string(RelateParent), relatedResourceID) + if err != nil { + rlogger.Errorw("error deleting parent relationship", + "parent.resource.id", relatedResourceID.String(), + ) + } + } + + for _, relation := range deleteSubjectRelationships { + err = s.perms.DeleteResourceRelationship(ctx, relation.Resource.PrefixedID(), string(relation.Relation), relationships.Resource.PrefixedID()) + if err != nil { + rlogger.Errorw("error deleting relationship", + "relation", relation.Relation, + "subject.id", relation.Resource.PrefixedID().String(), + ) + } + } + + for _, event := range processEvents { + err = s.publisher.PublishChange(ctx, eventType, event) + + if err != nil { + rlogger.Errorw("error publishing change", + "subject_type", eventType, + "subject.id", event.SubjectID, + "event.type", event.EventType, + "additional_subject_ids", event.AdditionalSubjectIDs, + "error", err, + ) + } + } + + rlogger.Debugw("relationships processed", + "parent.create", createParentRelationship != nil, + "parent.delete", len(deleteParentRelationships), + "subject.create", len(createSubjectRelationships), + "subject.delete", len(deleteSubjectRelationships), + ) + + changes := len(deleteParentRelationships) + len(createSubjectRelationships) + len(deleteSubjectRelationships) + + if createParentRelationship != nil { + changes++ + } + + return changes +} + +// mapRelationWants returns the parent relation if provided and a map of Subjects -> relation. +func (s *service) mapRelationWants(relationships Relationships) (*Relation, map[gidx.PrefixedID]RelationshipType) { + var wantParent *Relation + + wantSubject := make(map[gidx.PrefixedID]RelationshipType) + + if relationships.Parent.Resource != nil { + wantParent = &relationships.Parent + } + + for _, relationship := range relationships.SubjectRelationships { + wantSubject[relationship.Resource.PrefixedID()] = relationship.Relation + } + + return wantParent, wantSubject +} + +// getRelationshipMap fetches the provided resources relationships, as the source resource and the destination subject. +// Returned are two maps, the first maps Subject IDs -> Relationship +// The second map, maps Resource IDs -> relationship +func (s *service) getRelationshipMap(ctx context.Context, resource IDPrefixableResource, relation RelationshipType) (map[gidx.PrefixedID]RelationshipType, map[gidx.PrefixedID]RelationshipType, error) { + liveResource, err := s.perms.ListResourceRelationshipsFrom(ctx, resource.PrefixedID()) + if err != nil { + return nil, nil, err + } + + var liveSubject []permissions.ResourceRelationship + + if relation != "" { + liveSubject, err = s.perms.ListResourceRelationshipsTo(ctx, resource.PrefixedID()) + if err != nil { + return nil, nil, err + } + } + + parents := make(map[gidx.PrefixedID]RelationshipType, len(liveResource)) + + for _, relationship := range liveResource { + if relationship.Relation != string(RelateParent) { + continue + } + + parents[relationship.SubjectID] = RelationshipType(relationship.Relation) + } + + subject := make(map[gidx.PrefixedID]RelationshipType, len(liveSubject)) + + for _, relationship := range liveSubject { + if relationship.Relation != string(relation) { + continue + } + + subject[relationship.ResourceID] = RelationshipType(relationship.Relation) + } + + return parents, subject, nil +} diff --git a/internal/service/projects.go b/internal/service/projects.go index 14b1f4e..d47c428 100644 --- a/internal/service/projects.go +++ b/internal/service/projects.go @@ -3,9 +3,44 @@ package service import ( "context" + "go.infratographer.com/x/events" "go.infratographer.com/x/gidx" + + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" ) +const projectEvent = "metal_project" + +// buildProjectRelationships compiles all relations into a relationships object to be processed by the processors. +func (s *service) buildProjectRelationships(project *models.ProjectDetails) (Relationships, error) { + relations := Relationships{ + Resource: project, + // Relate project to organization. + Parent: Relation{ + Resource: project.Organization, + Relation: RelateParent, + }, + } + + for _, member := range project.Memberships { + for _, role := range member.Roles { + if _, ok := s.roles[role]; !ok { + s.logger.Warnf("unrecognized project role '%s' for %s on %s", role, member.User.PrefixedID(), project.PrefixedID()) + + continue + } + + relations.Memberships = append(relations.Memberships, ResourceMemberships{ + Role: role, + Member: member.User, + }) + } + } + + return relations, nil +} + +// IsProjectID checks if the provided id has the metal project prefix. func (s *service) IsProjectID(id gidx.PrefixedID) bool { if idType, ok := s.idPrefixMap[id.Prefix()]; ok { return idType == TypeProject @@ -14,10 +49,50 @@ func (s *service) IsProjectID(id gidx.PrefixedID) bool { return false } +// TouchProject initializes a sync for the provided project id for relationships and memberships. func (s *service) TouchProject(ctx context.Context, id gidx.PrefixedID) error { + logger := s.logger.With("project.id", id.String()) + + project, err := s.metal.GetProjectDetails(ctx, id) + if err != nil { + logger.Errorw("failed to get project", "error", err) + + return err + } + + relationships, err := s.buildProjectRelationships(project) + if err != nil { + logger.Errorw("failed to build project relationships", "error", err) + + return err + } + + relationshipChanges := s.processRelationships(ctx, projectEvent, relationships) + rolesChanged, assignmentsChanged := s.processMemberships(ctx, relationships, false) + + s.logger.Infow("project sync complete", + "resource.id", project.PrefixedID(), + "relationships.changed", relationshipChanges, + "membership.roles_changed", rolesChanged, + "membership.assignments_changed", assignmentsChanged, + ) + return nil } +// DeleteProject deletes the provided project id. func (s *service) DeleteProject(ctx context.Context, id gidx.PrefixedID) error { + err := s.publisher.PublishChange(ctx, projectEvent, events.ChangeMessage{ + SubjectID: id, + EventType: string(events.DeleteChangeType), + }) + if err != nil { + s.logger.Errorw("error publishing project delete", + "subject_type", projectEvent, + "resource.id", id, + "error", err, + ) + } + return nil } diff --git a/internal/service/projects_test.go b/internal/service/projects_test.go new file mode 100644 index 0000000..f10a6bf --- /dev/null +++ b/internal/service/projects_test.go @@ -0,0 +1,303 @@ +package service_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "go.infratographer.com/x/events" + "go.infratographer.com/x/gidx" + + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" + "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers" + "go.equinixmetal.net/infra9-metal-bridge/internal/permissions" + "go.equinixmetal.net/infra9-metal-bridge/internal/service" +) + +func TestTouchProjectEmpty(t *testing.T) { + rootTenantID := gidx.PrefixedID("tnntten-root1") + + roleMap := map[string][]string{ + "owner": { + "action1", + "action2", + }, + } + + userID := gidx.PrefixedID("idntusr-2aPXqbgfYSlld36XLfvNjEqUIIL9ekRZzgs2eBGOCBw") + user := &models.UserDetails{ + ID: "usr1", + } + + orgID := gidx.PrefixedID("metlorg-org1") + org := &models.OrganizationDetails{ + ID: "org1", + } + + projectID := gidx.PrefixedID("metlprj-prj1") + project := &models.ProjectDetails{ + ID: "prj1", + Organization: org, + Memberships: []*models.Membership[models.ProjectDetails]{ + { + User: user, + Roles: []string{ + "owner", + }, + }, + }, + } + + var ( + mMetal = new(providers.MockProvider) + mPerms = new(permissions.MockClient) + mPublisher = new(mockPublisher) + ) + + // Relationships + + mMetal.On("GetProjectDetails", projectID).Return(project, nil) + + mPerms.On("ListResourceRelationshipsFrom", projectID).Return([]permissions.ResourceRelationship{}, nil) + + relateParentChangeMessage := events.ChangeMessage{ + SubjectID: projectID, + EventType: string(events.CreateChangeType), + AdditionalSubjectIDs: []gidx.PrefixedID{ + orgID, + }, + } + + mPublisher.On("PublishChange", "metal_project", relateParentChangeMessage).Return(nil) + + // Memberships + + newRoleID := gidx.PrefixedID("permrol-role1") + + mPerms.On("ListResourceRoles", projectID).Return(permissions.ResourceRoles{}, nil) + mPerms.On("CreateRole", projectID, roleMap["owner"]).Return(newRoleID, nil) + + mPerms.On("AssignRole", newRoleID, userID).Return(nil) + + // Run scenario + + svc, err := service.New(mPublisher, mMetal, mPerms, + service.WithRootTenant(rootTenantID.String()), + service.WithRoles(roleMap), + ) + + require.NoError(t, err) + + err = svc.TouchProject(context.Background(), projectID) + + require.NoError(t, err) + + require.True(t, mMetal.AssertExpectations(t), "unexpected calls to metal provider") + require.True(t, mPerms.AssertExpectations(t), "unexpected calls to permissions client") + require.True(t, mPublisher.AssertExpectations(t), "unexpected calls to events publisher") +} + +func TestTouchProjectCleanup(t *testing.T) { + rootTenantID := gidx.PrefixedID("tnntten-root1") + + roleMap := map[string][]string{ + "owner": { + "action1", + "action2", + }, + "collaborator": { + "action1", + }, + } + + oldUserID := gidx.PrefixedID("idntusr-2aPXqbgfYSlld36XLfvNjEqUIIL9ekRZzgs2eBGOCBw") + oldOrgID := gidx.PrefixedID("metlorg-org1") + + userID := gidx.PrefixedID("idntusr-Ewj_PJUue9eDIDoyCoWG48GtwlysqTj2Y4qWPiJPN1s") + user := &models.UserDetails{ + ID: "usr2", + } + + orgID := gidx.PrefixedID("metlorg-org2") + org := &models.OrganizationDetails{ + ID: "org2", + } + + projectID := gidx.PrefixedID("metlprj-prj2") + project := &models.ProjectDetails{ + ID: "prj2", + Organization: org, + Memberships: []*models.Membership[models.ProjectDetails]{ + { + User: user, + Roles: []string{ + "owner", + }, + }, + }, + } + + var ( + mMetal = new(providers.MockProvider) + mPerms = new(permissions.MockClient) + mPublisher = new(mockPublisher) + ) + + // Relationships + + mMetal.On("GetProjectDetails", projectID).Return(project, nil) + + existingRelsFrom := []permissions.ResourceRelationship{ + { + Relation: string(service.RelateParent), + SubjectID: oldOrgID, + }, + } + + mPerms.On("ListResourceRelationshipsFrom", projectID).Return(existingRelsFrom, nil) + + mPerms.On("DeleteResourceRelationship", projectID, string(service.RelateParent), oldOrgID).Return(nil) + + relateParentChangeMessage := events.ChangeMessage{ + SubjectID: projectID, + EventType: string(events.CreateChangeType), + AdditionalSubjectIDs: []gidx.PrefixedID{ + orgID, + }, + } + + mPublisher.On("PublishChange", "metal_project", relateParentChangeMessage).Return(nil) + + // Memberships + + oldRoleID := gidx.PrefixedID("permrol-role1") + newRoleID := gidx.PrefixedID("permrol-role2") + + existingRoles := permissions.ResourceRoles{ + { + ID: oldRoleID, + Actions: roleMap["collaborator"], + }, + } + + mPerms.On("ListResourceRoles", projectID).Return(existingRoles, nil) + + existingRoleAssignments := []gidx.PrefixedID{ + oldUserID, + } + + mPerms.On("ListRoleAssignments", oldRoleID).Return(existingRoleAssignments, nil) + + mPerms.On("CreateRole", projectID, roleMap["owner"]).Return(newRoleID, nil) + mPerms.On("DeleteRole", oldRoleID).Return(nil) + + mPerms.On("AssignRole", newRoleID, userID).Return(nil) + mPerms.On("UnassignRole", oldRoleID, oldUserID).Return(nil) + + // Run scenario + + svc, err := service.New(mPublisher, mMetal, mPerms, + service.WithRootTenant(rootTenantID.String()), + service.WithRoles(roleMap), + ) + + require.NoError(t, err) + + err = svc.TouchProject(context.Background(), projectID) + + require.NoError(t, err) + + require.True(t, mMetal.AssertExpectations(t), "unexpected calls to metal provider") + require.True(t, mPerms.AssertExpectations(t), "unexpected calls to permissions client") + require.True(t, mPublisher.AssertExpectations(t), "unexpected calls to events publisher") +} + +func TestTouchProjectNoChange(t *testing.T) { + rootTenantID := gidx.PrefixedID("tnntten-root1") + + roleMap := map[string][]string{ + "owner": { + "action1", + "action2", + }, + } + + userID := gidx.PrefixedID("idntusr-2aPXqbgfYSlld36XLfvNjEqUIIL9ekRZzgs2eBGOCBw") + user := &models.UserDetails{ + ID: "usr1", + } + + orgID := gidx.PrefixedID("metlorg-org1") + org := &models.OrganizationDetails{ + ID: "org1", + } + + projectID := gidx.PrefixedID("metlprj-prj1") + project := &models.ProjectDetails{ + ID: "prj1", + Organization: org, + Memberships: []*models.Membership[models.ProjectDetails]{ + { + User: user, + Roles: []string{ + "owner", + }, + }, + }, + } + + var ( + mMetal = new(providers.MockProvider) + mPerms = new(permissions.MockClient) + mPublisher = new(mockPublisher) + ) + + // Relationships + + mMetal.On("GetProjectDetails", projectID).Return(project, nil) + + existingRelsFrom := []permissions.ResourceRelationship{ + { + Relation: string(service.RelateParent), + SubjectID: orgID, + }, + } + + mPerms.On("ListResourceRelationshipsFrom", projectID).Return(existingRelsFrom, nil) + + // Memberships + + roleID := gidx.PrefixedID("permrol-role1") + + existingRoles := permissions.ResourceRoles{ + { + ID: roleID, + Actions: roleMap["owner"], + }, + } + + mPerms.On("ListResourceRoles", projectID).Return(existingRoles, nil) + + existingRoleAssignments := []gidx.PrefixedID{ + userID, + } + + mPerms.On("ListRoleAssignments", roleID).Return(existingRoleAssignments, nil) + + // Run scenario + + svc, err := service.New(mPublisher, mMetal, mPerms, + service.WithRootTenant(rootTenantID.String()), + service.WithRoles(roleMap), + ) + + require.NoError(t, err) + + err = svc.TouchProject(context.Background(), projectID) + + require.NoError(t, err) + + require.True(t, mMetal.AssertExpectations(t), "unexpected calls to metal provider") + require.True(t, mPerms.AssertExpectations(t), "unexpected calls to permissions client") + require.True(t, mPublisher.AssertExpectations(t), "unexpected calls to events publisher") +} diff --git a/internal/service/relationships.go b/internal/service/relationships.go index ebcc4ef..bb665b6 100644 --- a/internal/service/relationships.go +++ b/internal/service/relationships.go @@ -2,63 +2,41 @@ package service import ( "go.infratographer.com/x/gidx" - - "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" ) const ( - RelateOwner RelationshipType = "owner" + // RelateOwner is the owner relationship type. + RelateOwner RelationshipType = "owner" + + // RelateParent is the parent relationship type. RelateParent RelationshipType = "parent" ) +// RelationshipType are relationship types. type RelationshipType string +// IDPrefixableResource ensures the the interface passed provides prefixed ids. type IDPrefixableResource interface { PrefixedID() gidx.PrefixedID } +// Relationships defines a resource and all possible relationships and memberships. type Relationships struct { - Relationships []Relationship - Memberships []ResourceMemberships + Resource IDPrefixableResource + Parent Relation + SubjectRelation RelationshipType + SubjectRelationships []Relation + Memberships []ResourceMemberships } -func (r Relationships) DeDupe() Relationships { - rels := make(map[string]bool) - mems := make(map[string]bool) - - var results Relationships - - for _, rel := range r.Relationships { - key := rel.Resource.PrefixedID().String() + "/" + string(rel.Relation) + "/" + rel.RelatedResource.PrefixedID().String() - - if _, ok := rels[key]; !ok { - rels[key] = true - - results.Relationships = append(results.Relationships, rel) - } - } - - for _, member := range r.Memberships { - key := member.Resource.PrefixedID().String() + "/" + member.Role + "/" + member.Member.PrefixedID().String() - - if _, ok := mems[key]; !ok { - mems[key] = true - - results.Memberships = append(results.Memberships, member) - } - } - - return results -} - -type Relationship struct { - Resource IDPrefixableResource - Relation RelationshipType - RelatedResource IDPrefixableResource -} - -type ResourceMemberships struct { +// Relation defines a relation to a resource. +type Relation struct { + Relation RelationshipType Resource IDPrefixableResource - Role string - Member *models.UserDetails +} + +// ResourceMemberships defines a member and role. +type ResourceMemberships struct { + Role string + Member IDPrefixableResource } diff --git a/internal/service/service.go b/internal/service/service.go index c344b1b..5ea1ab7 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -13,20 +13,42 @@ import ( const ( // TypeOrganization defines the organization type. - TypeOrganization = "organization" + TypeOrganization ObjectType = "organization" // TypeProject defines the project type. - TypeProject = "project" + TypeProject ObjectType = "project" // TypeUser defines the user type. - TypeUser = "user" + TypeUser ObjectType = "user" ) // DefaultPrefixMap is the default id prefix to type relationship. -var DefaultPrefixMap = map[string]string{ - "metlorg": TypeOrganization, - "metlprj": TypeProject, - "metlusr": TypeUser, +var DefaultPrefixMap = map[string]ObjectType{ + TypeOrganization.Prefix(): TypeOrganization, + TypeProject.Prefix(): TypeProject, + TypeUser.Prefix(): TypeUser, +} + +// ObjectType defines a type of object. +type ObjectType string + +// Prefix returns the objects id prefix. +func (t ObjectType) Prefix() string { + switch t { + case TypeOrganization: + return "metlorg" + case TypeProject: + return "metlprj" + case TypeUser: + return "metlusr" + default: + return "" + } +} + +// String returns a string fo the object type. +func (t ObjectType) String() string { + return string(t) } // Service defines a bridge service methods @@ -47,39 +69,48 @@ type Service interface { // IsUser checks if the provided id has an id prefix which is a user. IsUser(id gidx.PrefixedID) bool - // TouchUser triggers a sync of a user and their permissions. - TouchUser(ctx context.Context, id gidx.PrefixedID) error - // DeleteUser deletes the user and their permissions. - DeleteUser(ctx context.Context, id gidx.PrefixedID) error + // AssignUser assigns a user to the given resource. + AssignUser(ctx context.Context, userID gidx.PrefixedID, resourceIDs ...gidx.PrefixedID) error + // UnassignUser removes the users from the given resource. + UnassignUser(ctx context.Context, userID gidx.PrefixedID, resourceIDs ...gidx.PrefixedID) error + + // IsAssignableResource checks if the provided resource ID may have assigned users. + IsAssignableResource(id gidx.PrefixedID) bool +} + +// EventPublisher defines the required methods to publish events. +type EventPublisher interface { + PublishChange(ctx context.Context, subjectType string, change events.ChangeMessage) error } var _ Service = &service{} type service struct { logger *zap.SugaredLogger - publisher *events.Publisher - metal *metal.Client - perms *permissions.Client - idPrefixMap map[string]string + publisher EventPublisher + metal metal.Client + perms permissions.Client + idPrefixMap map[string]ObjectType - rootResource rootResource + rootResource prefixedID roles map[string][]string } -type rootResource struct { +type prefixedID struct { id gidx.PrefixedID } -func (r rootResource) PrefixedID() gidx.PrefixedID { +func (r prefixedID) PrefixedID() gidx.PrefixedID { return r.id } -func New(publisher *events.Publisher, metal *metal.Client, perms *permissions.Client, options ...Option) (Service, error) { +// New creates a new service. +func New(publisher EventPublisher, metal metal.Client, perms permissions.Client, options ...Option) (Service, error) { svc := &service{ publisher: publisher, metal: metal, perms: perms, - idPrefixMap: make(map[string]string), + idPrefixMap: make(map[string]ObjectType), } for _, opt := range options { diff --git a/internal/service/users.go b/internal/service/users.go index 0aa8183..a46e50b 100644 --- a/internal/service/users.go +++ b/internal/service/users.go @@ -6,6 +6,7 @@ import ( "go.infratographer.com/x/gidx" ) +// IsUser checks the provided id has the metal user prefix. func (s *service) IsUser(id gidx.PrefixedID) bool { if idType, ok := s.idPrefixMap[id.Prefix()]; ok { return idType == TypeUser @@ -14,10 +15,134 @@ func (s *service) IsUser(id gidx.PrefixedID) bool { return false } -func (s *service) TouchUser(ctx context.Context, id gidx.PrefixedID) error { +// IsAssignableResource checks that the provided id is an id which can have memberships assignments. +func (s *service) IsAssignableResource(id gidx.PrefixedID) bool { + if idType, ok := s.idPrefixMap[id.Prefix()]; ok { + switch idType { + case TypeOrganization, TypeProject: + return true + default: + return false + } + } + + return false +} + +// Assignuser assigns the provided users to the given resource ids. +func (s *service) AssignUser(ctx context.Context, userID gidx.PrefixedID, resourceIDs ...gidx.PrefixedID) error { + var totalResources, rolesChanged, assignmentsChanged int + + mlogger := s.logger.With("member.id", userID.String()) + + memberID := prefixedID{userID} + + for _, resourceID := range resourceIDs { + role, err := s.getUserResourceRole(ctx, userID, resourceID) + if err != nil { + mlogger.Warnw("failed to determine role for user resource", "error", err) + continue + } + + if role == "" { + continue + } + + roles, assignments := s.processMemberships(ctx, Relationships{ + Resource: prefixedID{resourceID}, + Memberships: []ResourceMemberships{ + { + Role: role, + Member: memberID, + }, + }, + }, true) + + totalResources++ + + rolesChanged += roles + assignmentsChanged += assignments + } + + mlogger.Infow("assignment sync complete", + "membership.roles_changed", rolesChanged, + "membership.assignments_changed", assignmentsChanged, + ) + return nil } -func (s *service) DeleteUser(ctx context.Context, id gidx.PrefixedID) error { +// UnassignUser removes the assignment for the provided user id to the given resources. +func (s *service) UnassignUser(ctx context.Context, userID gidx.PrefixedID, resourceIDs ...gidx.PrefixedID) error { + for _, resourceID := range resourceIDs { + rlogger := s.logger.With("user.id", userID, "resource.id", resourceID) + + role, err := s.getUserResourceRole(ctx, userID, resourceID) + if err != nil { + rlogger.Warnw("failed to determine role for user resource", "error", err) + + continue + } + + if role == "" { + continue + } + + actions := s.roles[role] + + rlogger = rlogger.With("role.name", role, "role.actions", actions) + + resourceRole, err := s.perms.FindResourceRoleByActions(ctx, resourceID, actions) + if err != nil { + rlogger.Warnw("failed to find role by actions for resource", "error", err) + + continue + } + + rlogger = rlogger.With("role.id", resourceRole.ID) + + assigned, err := s.perms.RoleHasAssignment(ctx, resourceRole.ID, userID) + if err != nil { + rlogger.Warnw("failed to check role assignment", "error", err) + + continue + } + + if !assigned { + rlogger.Warnw("unable to unassign member which is not assigned") + + continue + } + + if err = s.perms.UnassignRole(ctx, resourceRole.ID, userID); err != nil { + rlogger.Errorw("failed to unassign member from role", "error", err) + + continue + } + } + return nil } + +// getuserResourceRole fetches the appropriate object types user role for the given resource. +func (s *service) getUserResourceRole(ctx context.Context, userID, resourceID gidx.PrefixedID) (string, error) { + var ( + role string + err error + ) + + if idType, ok := s.idPrefixMap[resourceID.Prefix()]; ok { + switch idType { + case TypeOrganization: + role, err = s.metal.GetUserOrganizationRole(ctx, userID, resourceID) + case TypeProject: + role, err = s.metal.GetUserProjectRole(ctx, userID, resourceID) + } + } + + if err != nil { + return "", err + } + + return role, nil +}