From 28ffea56cb1811ec02655e4f5ea020806b605415 Mon Sep 17 00:00:00 2001 From: Mike Mason Date: Fri, 19 Jan 2024 11:39:56 -0600 Subject: [PATCH] add named role support (#202) * add permissions-api database, migrations Database package along with migrations and migrate command giving permissions-api it's own database to store details into. Initial support is for Roles Get, Create, Update and Delete. Signed-off-by: Mike Mason * implement role metadata database into query engine This integrates the new database which contains role metadata into the query engine as well as updates the http api to expose this new information. Signed-off-by: Mike Mason * add logging and health check Signed-off-by: Mike Mason * add support for updating of roles Signed-off-by: Mike Mason * update method names to be more descriptive Updated CreateRole, UpdateRole and DeleteRole to CreateRoleTransaction, UpdateRoleTransaction and DeleteRoleTransaction to make it more clear that a transaction is being started. Additionally, the comments on these methods have been updated to include statements that Commit or Rollback must be called to ensure the database lifts all locks on rows which are affected. Previously, the method names made it appear as though the action was taken and completed. However this could lead to hung connections and rows. The new names make it clear that a new transaction is being started. The returning struct only has two methods Commit and Rollback in addition to the Record attribute resulting in a simple structure. Signed-off-by: Mike Mason * add database changes to chart and support migrations Signed-off-by: Mike Mason * implement review suggestions Signed-off-by: Mike Mason * implement second round of review suggestions Signed-off-by: Mike Mason * add missing rollback comments and ensure tests are checking results properly Signed-off-by: Mike Mason * lock role record before updating or deleting Since we're working with multiple backends, this allows us to place a lock early and ensure a separate request doesn't conflict with an in-flight change. Signed-off-by: Mike Mason * correct ListRoles to ensure it always lists roles from the database Signed-off-by: Mike Mason --------- Signed-off-by: Mike Mason --- .devcontainer/.env | 2 + .devcontainer/docker-compose.yml | 7 +- chart/permissions-api/templates/_helpers.tpl | 18 + .../templates/config-server.yaml | 2 +- .../templates/config-worker.yaml | 2 +- .../templates/deployment-server.yaml | 32 ++ .../templates/deployment-worker.yaml | 8 + .../templates/job-migrate-database.yaml | 70 ++++ chart/permissions-api/values.yaml | 33 ++ cmd/createrole.go | 19 +- cmd/root.go | 13 + cmd/server.go | 12 +- cmd/worker.go | 32 +- go.mod | 13 + go.sum | 151 +++++++ internal/api/roles.go | 97 ++++- internal/api/router.go | 1 + internal/api/types.go | 13 + internal/config/config.go | 2 + internal/query/mock/mock.go | 21 +- internal/query/relations.go | 333 ++++++++++++++- internal/query/relations_test.go | 186 ++++++++- internal/query/roles.go | 3 +- internal/query/service.go | 8 +- internal/storage/context.go | 86 ++++ internal/storage/errors.go | 57 +++ internal/storage/migrations.go | 10 + .../20231122000000_initial_schema.sql | 35 ++ internal/storage/options.go | 13 + internal/storage/roles.go | 310 ++++++++++++++ internal/storage/roles_test.go | 392 ++++++++++++++++++ internal/storage/storage.go | 57 +++ internal/storage/teststore/teststore.go | 47 +++ internal/testingx/testing.go | 5 +- internal/types/types.go | 9 + 35 files changed, 2051 insertions(+), 48 deletions(-) create mode 100644 chart/permissions-api/templates/job-migrate-database.yaml create mode 100644 internal/storage/context.go create mode 100644 internal/storage/errors.go create mode 100644 internal/storage/migrations.go create mode 100644 internal/storage/migrations/20231122000000_initial_schema.sql create mode 100644 internal/storage/options.go create mode 100644 internal/storage/roles.go create mode 100644 internal/storage/roles_test.go create mode 100644 internal/storage/storage.go create mode 100644 internal/storage/teststore/teststore.go diff --git a/.devcontainer/.env b/.devcontainer/.env index 5f8461e1..24d9fee7 100644 --- a/.devcontainer/.env +++ b/.devcontainer/.env @@ -19,6 +19,8 @@ IDENTITYAPI_TRACING_PROVIDER=jaeger IDENTITYAPI_TRACING_JAEGER_ENDPOINT=http://localhost:14268/api/traces IDENTITYAPI_CRDB_URI="postgresql://root@crdb:26257/identityapi_dev?sslmode=disable" +PERMISSIONSAPI_CRDB_URI="postgresql://root@crdb:26257/permissionsapi?sslmode=disable" + PERMISSIONSAPI_TRACING_ENABLED=true PERMISSIONSAPI_TRACING_PROVIDER=otlpgrpc PERMISSIONSAPI_TRACING_OTLP_ENDPOINT=jaeger:4317 diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 7ab07a01..7bbfbca7 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -3,6 +3,7 @@ version: '3.8' networks: infradev: + volumes: crdb: null @@ -39,7 +40,11 @@ services: create_databases: image: cockroachdb/cockroach:v23.1.12 restart: on-failure:5 - command: "sql --insecure -e 'CREATE DATABASE IF NOT EXISTS spicedb;'" + command: | + sql --insecure -e ' + CREATE DATABASE IF NOT EXISTS permissionsapi; + CREATE DATABASE IF NOT EXISTS spicedb; + ' env_file: - .env depends_on: diff --git a/chart/permissions-api/templates/_helpers.tpl b/chart/permissions-api/templates/_helpers.tpl index 08527833..930a7657 100644 --- a/chart/permissions-api/templates/_helpers.tpl +++ b/chart/permissions-api/templates/_helpers.tpl @@ -18,6 +18,11 @@ secret: secretName: {{ . }} {{- end }} +{{- with .Values.config.crdb.caSecretName }} +- name: crdb-ca + secret: + secretName: {{ . }} +{{- end }} {{- with .Values.config.spicedb.policyConfigMapName }} - name: policy-file configMap: @@ -36,6 +41,10 @@ - name: nats-creds mountPath: /nats {{- end }} +{{- if .Values.config.crdb.caSecretName }} +- name: crdb-ca + mountPath: {{ .Values.config.crdb.caMountPath }} +{{- end }} {{- if .Values.config.spicedb.policyConfigMapName }} - name: policy-file mountPath: /policy @@ -51,6 +60,11 @@ secret: secretName: {{ . }} {{- end }} +{{- with .Values.config.crdb.caSecretName }} +- name: crdb-ca + secret: + secretName: {{ . }} +{{- end }} {{- with .Values.config.events.nats.credsSecretName }} - name: nats-creds secret: @@ -70,6 +84,10 @@ - name: spicedb-ca mountPath: /etc/ssl/spicedb/ {{- end }} +{{- if .Values.config.crdb.caSecretName }} +- name: crdb-ca + mountPath: {{ .Values.config.crdb.caMountPath }} +{{- end }} {{- if .Values.config.events.nats.credsSecretName }} - name: nats-creds mountPath: /nats diff --git a/chart/permissions-api/templates/config-server.yaml b/chart/permissions-api/templates/config-server.yaml index c9f099b1..9991cc96 100644 --- a/chart/permissions-api/templates/config-server.yaml +++ b/chart/permissions-api/templates/config-server.yaml @@ -10,4 +10,4 @@ metadata: service: server data: config.yaml: | - {{- pick .Values.config "server" "oidc" "spicedb" "tracing" "events" | toYaml | nindent 4 }} + {{- pick .Values.config "server" "oidc" "crdb" "spicedb" "tracing" "events" | toYaml | nindent 4 }} diff --git a/chart/permissions-api/templates/config-worker.yaml b/chart/permissions-api/templates/config-worker.yaml index 71c1b6f6..23bdc222 100644 --- a/chart/permissions-api/templates/config-worker.yaml +++ b/chart/permissions-api/templates/config-worker.yaml @@ -10,4 +10,4 @@ metadata: service: worker data: config.yaml: | - {{- pick .Values.config "server" "events" "oidc" "spicedb" "tracing" | toYaml | nindent 4 }} + {{- pick .Values.config "server" "events" "oidc" "crdb" "spicedb" "tracing" | toYaml | nindent 4 }} diff --git a/chart/permissions-api/templates/deployment-server.yaml b/chart/permissions-api/templates/deployment-server.yaml index c97ce9c2..999e8e2f 100644 --- a/chart/permissions-api/templates/deployment-server.yaml +++ b/chart/permissions-api/templates/deployment-server.yaml @@ -11,6 +11,7 @@ metadata: {{- end }} {{- with .Values.deployment.annotations }} annotations: + checksum/config: {{ include (print $.Template.BasePath "/config-server.yaml") . | sha256sum }} {{ toYaml . | nindent 4 }} {{- end }} spec: @@ -43,6 +44,30 @@ spec: securityContext: {{- toYaml .Values.deployment.podSecurityContext | nindent 8 }} {{- end }} + {{- if eq .Values.config.crdb.migrateHook "init" }} + initContainers: + - name: {{ include "common.names.name" . }}-migrate-database-init + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - migrate + - up + - --config + - /config/config.yaml + {{- with .Values.config.crdb.uriSecretName }} + env: + - name: PERMISSIONSAPI_CRDB_URI + valueFrom: + secretKeyRef: + name: {{ . }} + key: uri + {{- end }} + {{- with .Values.deployment.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: {{ include "permapi.server.volumeMounts" . | nindent 12 }} + {{- end }} containers: - name: {{ include "common.names.name" . }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" @@ -54,6 +79,13 @@ spec: env: - name: PERMISSIONSAPI_SERVER_LISTEN value: ":{{ include "permapi.listenPort" . }}" + {{- with .Values.config.crdb.uriSecretName }} + - name: PERMISSIONSAPI_CRDB_URI + valueFrom: + secretKeyRef: + name: {{ . }} + key: uri + {{- end }} {{- if .Values.config.spicedb.policyConfigMapName }} - name: PERMISSIONSAPI_SPICEDB_POLICYFILE value: /policy/policy.yaml diff --git a/chart/permissions-api/templates/deployment-worker.yaml b/chart/permissions-api/templates/deployment-worker.yaml index 4b89e0eb..dbb451fd 100644 --- a/chart/permissions-api/templates/deployment-worker.yaml +++ b/chart/permissions-api/templates/deployment-worker.yaml @@ -11,6 +11,7 @@ metadata: {{- end }} {{- with .Values.deployment.annotations }} annotations: + checksum/config: {{ include (print $.Template.BasePath "/config-worker.yaml") . | sha256sum }} {{ toYaml . | nindent 4 }} {{- end }} spec: @@ -54,6 +55,13 @@ spec: env: - name: PERMISSIONSAPI_SERVER_LISTEN value: ":{{ include "permapi.listenPort" . }}" + {{- with .Values.config.crdb.uriSecretName }} + - name: PERMISSIONSAPI_CRDB_URI + valueFrom: + secretKeyRef: + name: {{ . }} + key: uri + {{- end }} {{- if .Values.config.events.nats.tokenSecretName }} - name: PERMISSIONSAPI_EVENTS_NATS_TOKEN valueFrom: diff --git a/chart/permissions-api/templates/job-migrate-database.yaml b/chart/permissions-api/templates/job-migrate-database.yaml new file mode 100644 index 00000000..955cf827 --- /dev/null +++ b/chart/permissions-api/templates/job-migrate-database.yaml @@ -0,0 +1,70 @@ +{{- if has .Values.config.crdb.migrateHook (list "pre-sync" "manual") }} +--- +apiVersion: batch/v1 +kind: Job +metadata: + {{- if eq .Values.config.crdb.migrateHook "manual" }} + name: {{ include "common.names.name" . }}-migrate-database + {{- else }} + generateName: migrate-database- + annotations: + argocd.argoproj.io/hook: PreSync + {{- end }} +spec: + revisionHistoryLimit: 3 + selector: + matchLabels: + service: migrate-database + {{- include "common.labels.matchLabels" . | nindent 6 }} + template: + metadata: + labels: + service: migrate-database + {{- include "common.labels.standard" . | nindent 8 }} + spec: + restartPolicy: OnFailure + terminationGracePeriodSeconds: 30 + {{- with .Values.deployment.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if .Values.deployment.podSecurityContext }} + securityContext: + {{- toYaml .Values.deployment.podSecurityContext | nindent 8 }} + {{- end }} + containers: + - name: {{ include "common.names.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - migrate + - up + - --config + - /config/config.yaml + {{- with .Values.config.crdb.uriSecretName }} + env: + - name: PERMISSIONSAPI_CRDB_URI + valueFrom: + secretKeyRef: + name: {{ . }} + key: uri + {{- end }} + {{- with .Values.deployment.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: {{ include "permapi.server.volumeMounts" . | nindent 12 }} + {{- with .Values.deployment.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.deployment.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.deployment.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: {{ include "permapi.server.volumes" . | nindent 8 }} +{{- end }} diff --git a/chart/permissions-api/values.yaml b/chart/permissions-api/values.yaml index a06ba59d..84ef4408 100644 --- a/chart/permissions-api/values.yaml +++ b/chart/permissions-api/values.yaml @@ -46,6 +46,39 @@ config: # policyConfigMapName is the name of the Config Map containing the policy file configuration policyConfigMapName: "" + crdb: + # migrateHook sets when to run database migrations. one of: pre-sync, init, manual + # - pre-sync: hook runs as a job before any other changes are synced. + # - init: is run as an init container to the server deployment and may run multiple times if replica count is high. + # - manual: a migrate-database job will be available to triggered manually + migrateHook: "init" + # name is the database name + name: "" + # host is the database host + host: "" + # user is the auth username to the database + user: "" + # password is the auth password to the database + password: "" + # params is the connection parameters to the database + params: "" + # uri is the raw uri connection string + uri: "" + # uriSecretName if set retrieves the `uri` from the provided secret name + uriSecretName: "" + # caSecretName if defined mounts database certificates from the provided secret + # secrets are mounted at `caMountPath` + caSecretName: "" + # caMountPath is the path the caSecretName is mounted at + caMountPath: /etc/ssl/crdb/ + connections: + # max_open is the maximum number of open connections to the database + max_open: 0 + # max_idle is the maximum number of connections in the idle connection + max_idle: 0 + # max_lifetime is the maximum amount of time a connection may be idle + max_lifetime: 0 + events: # zedTokenBucket is the NATS bucket to use for caching ZedTokens zedTokenBucket: "" diff --git a/cmd/createrole.go b/cmd/createrole.go index c1fa2d56..22ad554d 100644 --- a/cmd/createrole.go +++ b/cmd/createrole.go @@ -5,6 +5,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" + "go.infratographer.com/x/crdbx" "go.infratographer.com/x/events" "go.infratographer.com/x/gidx" "go.infratographer.com/x/viperx" @@ -13,12 +14,14 @@ import ( "go.infratographer.com/permissions-api/internal/iapl" "go.infratographer.com/permissions-api/internal/query" "go.infratographer.com/permissions-api/internal/spicedbx" + "go.infratographer.com/permissions-api/internal/storage" ) const ( createRoleFlagSubject = "subject" createRoleFlagResource = "resource" createRoleFlagActions = "actions" + createRoleFlagName = "name" ) var ( @@ -38,20 +41,23 @@ func init() { flags.String(createRoleFlagSubject, "", "subject to assign to created role") flags.StringSlice(createRoleFlagActions, []string{}, "actions to assign to created role") flags.String(createRoleFlagResource, "", "resource to bind to created role") + flags.String(createRoleFlagName, "", "name of role to create") v := viper.GetViper() viperx.MustBindFlag(v, createRoleFlagSubject, flags.Lookup(createRoleFlagSubject)) viperx.MustBindFlag(v, createRoleFlagActions, flags.Lookup(createRoleFlagActions)) viperx.MustBindFlag(v, createRoleFlagResource, flags.Lookup(createRoleFlagResource)) + viperx.MustBindFlag(v, createRoleFlagName, flags.Lookup(createRoleFlagName)) } func createRole(ctx context.Context, cfg *config.AppConfig) { subjectIDStr := viper.GetString(createRoleFlagSubject) actions := viper.GetStringSlice(createRoleFlagActions) resourceIDStr := viper.GetString(createRoleFlagResource) + name := viper.GetString(createRoleFlagName) - if subjectIDStr == "" || len(actions) == 0 || resourceIDStr == "" { + if subjectIDStr == "" || len(actions) == 0 || resourceIDStr == "" || name == "" { logger.Fatal("invalid config") } @@ -70,6 +76,13 @@ func createRole(ctx context.Context, cfg *config.AppConfig) { logger.Fatalw("failed to initialize KV", "error", err) } + db, err := crdbx.NewDB(cfg.CRDB, cfg.Tracing.Enabled) + if err != nil { + logger.Fatalw("unable to initialize permissions-api database", "error", err) + } + + store := storage.New(db, storage.WithLogger(logger)) + var policy iapl.Policy if cfg.SpiceDB.PolicyFile != "" { @@ -97,7 +110,7 @@ func createRole(ctx context.Context, cfg *config.AppConfig) { logger.Fatalw("error parsing subject ID", "error", err) } - engine, err := query.NewEngine("infratographer", spiceClient, kv, query.WithPolicy(policy), query.WithLogger(logger)) + engine, err := query.NewEngine("infratographer", spiceClient, kv, store, query.WithPolicy(policy), query.WithLogger(logger)) if err != nil { logger.Fatalw("error creating engine", "error", err) } @@ -112,7 +125,7 @@ func createRole(ctx context.Context, cfg *config.AppConfig) { logger.Fatalw("error creating subject resource", "error", err) } - role, err := engine.CreateRole(ctx, resource, actions) + role, err := engine.CreateRole(ctx, subjectResource, resource, name, actions) if err != nil { logger.Fatalw("error creating role", "error", err) } diff --git a/cmd/root.go b/cmd/root.go index 3fbcb702..8b0263d2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,12 +7,15 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" + "go.infratographer.com/x/crdbx" + "go.infratographer.com/x/goosex" "go.infratographer.com/x/loggingx" "go.infratographer.com/x/versionx" "go.infratographer.com/x/viperx" "go.uber.org/zap" "go.infratographer.com/permissions-api/internal/config" + "go.infratographer.com/permissions-api/internal/storage" ) var ( @@ -42,6 +45,16 @@ func init() { rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is /etc/infratographer/permissions-api.yaml)") loggingx.MustViperFlags(viper.GetViper(), rootCmd.PersistentFlags()) + // Database Flags + crdbx.MustViperFlags(viper.GetViper(), rootCmd.Flags()) + + // Add migrate command + goosex.RegisterCobraCommand(rootCmd, func() { + goosex.SetBaseFS(storage.Migrations) + goosex.SetLogger(logger) + goosex.SetDBURI(globalCfg.CRDB.GetURI()) + }) + // Add version command versionx.RegisterCobraCommand(rootCmd, func() { versionx.PrintVersion(logger) }) diff --git a/cmd/server.go b/cmd/server.go index 2d8f5925..41170326 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -5,6 +5,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" + "go.infratographer.com/x/crdbx" "go.infratographer.com/x/echojwtx" "go.infratographer.com/x/echox" "go.infratographer.com/x/events" @@ -17,6 +18,7 @@ import ( "go.infratographer.com/permissions-api/internal/iapl" "go.infratographer.com/permissions-api/internal/query" "go.infratographer.com/permissions-api/internal/spicedbx" + "go.infratographer.com/permissions-api/internal/storage" ) var ( @@ -63,6 +65,13 @@ func serve(ctx context.Context, cfg *config.AppConfig) { logger.Fatalw("failed to initialize KV", "error", err) } + db, err := crdbx.NewDB(cfg.CRDB, cfg.Tracing.Enabled) + if err != nil { + logger.Fatalw("unable to initialize permissions-api database", "error", err) + } + + store := storage.New(db, storage.WithLogger(logger)) + var policy iapl.Policy if cfg.SpiceDB.PolicyFile != "" { @@ -80,7 +89,7 @@ func serve(ctx context.Context, cfg *config.AppConfig) { logger.Fatalw("invalid spicedb policy", "error", err) } - engine, err := query.NewEngine("infratographer", spiceClient, kv, query.WithPolicy(policy)) + engine, err := query.NewEngine("infratographer", spiceClient, kv, store, query.WithPolicy(policy)) if err != nil { logger.Fatalw("error creating engine", "error", err) } @@ -101,6 +110,7 @@ func serve(ctx context.Context, cfg *config.AppConfig) { srv.AddHandler(r) srv.AddReadinessCheck("spicedb", spicedbx.Healthcheck(spiceClient)) + srv.AddReadinessCheck("storage", store.HealthCheck) if err := srv.Run(); err != nil { logger.Fatal("failed to run server", zap.Error(err)) diff --git a/cmd/worker.go b/cmd/worker.go index 89e014f0..469976ac 100644 --- a/cmd/worker.go +++ b/cmd/worker.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" + "go.infratographer.com/x/crdbx" "go.infratographer.com/x/echox" "go.infratographer.com/x/events" "go.infratographer.com/x/otelx" @@ -20,6 +21,7 @@ import ( "go.infratographer.com/permissions-api/internal/pubsub" "go.infratographer.com/permissions-api/internal/query" "go.infratographer.com/permissions-api/internal/spicedbx" + "go.infratographer.com/permissions-api/internal/storage" ) const shutdownTimeout = 10 * time.Second @@ -52,6 +54,23 @@ func worker(ctx context.Context, cfg *config.AppConfig) { logger.Fatalw("unable to initialize spicedb client", "error", err) } + eventsConn, err := events.NewConnection(cfg.Events.Config, events.WithLogger(logger)) + if err != nil { + logger.Fatalw("failed to initialize events", "error", err) + } + + kv, err := initializeKV(cfg.Events, eventsConn) + if err != nil { + logger.Fatalw("failed to initialize KV", "error", err) + } + + db, err := crdbx.NewDB(cfg.CRDB, cfg.Tracing.Enabled) + if err != nil { + logger.Fatalw("unable to initialize permissions-api database", "error", err) + } + + store := storage.New(db, storage.WithLogger(logger)) + var policy iapl.Policy if cfg.SpiceDB.PolicyFile != "" { @@ -69,17 +88,7 @@ func worker(ctx context.Context, cfg *config.AppConfig) { logger.Fatalw("invalid spicedb policy", "error", err) } - eventsConn, err := events.NewConnection(cfg.Events.Config, events.WithLogger(logger)) - if err != nil { - logger.Fatalw("failed to initialize events", "error", err) - } - - kv, err := initializeKV(cfg.Events, eventsConn) - if err != nil { - logger.Fatalw("failed to initialize KV", "error", err) - } - - engine, err := query.NewEngine("infratographer", spiceClient, kv, query.WithPolicy(policy), query.WithLogger(logger)) + engine, err := query.NewEngine("infratographer", spiceClient, kv, store, query.WithPolicy(policy)) if err != nil { logger.Fatalw("error creating engine", "error", err) } @@ -114,6 +123,7 @@ func worker(ctx context.Context, cfg *config.AppConfig) { } srv.AddReadinessCheck("spicedb", spicedbx.Healthcheck(spiceClient)) + srv.AddReadinessCheck("storage", store.HealthCheck) quit := make(chan os.Signal, 1) diff --git a/go.mod b/go.mod index b866420a..2b30f2f7 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,12 @@ go 1.20 require ( github.com/authzed/authzed-go v0.10.1 github.com/authzed/grpcutil v0.0.0-20230908193239-4286bb1d6403 + github.com/cockroachdb/cockroach-go/v2 v2.3.5 github.com/labstack/echo/v4 v4.11.3 + github.com/lib/pq v1.10.9 github.com/nats-io/nats.go v1.31.0 github.com/pkg/errors v0.9.1 + github.com/pressly/goose/v3 v3.15.0 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.17.0 @@ -25,6 +28,7 @@ require ( require ( github.com/MicahParks/keyfunc/v2 v2.1.0 // indirect + github.com/XSAM/otelsql v0.23.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d // indirect @@ -35,6 +39,7 @@ require ( github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/go-logr/logr v1.2.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/gofrs/flock v0.8.1 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang-jwt/jwt/v5 v5.0.0 // indirect github.com/golang/protobuf v1.5.3 // indirect @@ -42,6 +47,14 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/chunkreader/v2 v2.0.1 // indirect + github.com/jackc/pgconn v1.14.0 // indirect + github.com/jackc/pgio v1.0.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgproto3/v2 v2.3.2 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgtype v1.14.0 // indirect + github.com/jackc/pgx/v4 v4.18.1 // indirect github.com/jaevor/go-nanoid v1.3.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/jzelinskie/stringz v0.0.2 // indirect diff --git a/go.sum b/go.sum index 550b2a2b..fd5f89dc 100644 --- a/go.sum +++ b/go.sum @@ -41,9 +41,12 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k= github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/XSAM/otelsql v0.23.0 h1:NsJQS9YhI1+RDsFqE9mW5XIQmPmdF/qa8qQOLZN8XEA= +github.com/XSAM/otelsql v0.23.0/go.mod h1:oX4LXMsb+9lAZhvHjUS61oQP/hbcJRadWHnBKNL+LuM= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/authzed/authzed-go v0.10.1 h1:0aX2Ox9PPPknID92kLs/FnmhCmfl6Ni16v3ZTLsds5M= github.com/authzed/authzed-go v0.10.1/go.mod h1:ZsaFPCiMjwT0jLW0gCyYzh3elHqhKDDGGRySyykXwqc= @@ -75,11 +78,19 @@ github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4 h1:/inchEIKaYC1Akx+H+gqO04wryn5h75LSazbRlnya1k= +github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= +github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/cockroachdb/cockroach-go/v2 v2.3.5 h1:Khtm8K6fTTz/ZCWPzU9Ne3aOW9VyAnj4qIPCJgKtwK0= +github.com/cockroachdb/cockroach-go/v2 v2.3.5/go.mod h1:1wNJ45eSXW9AnOc3skntW9ZUZz6gxrQK3cOj3rK+BC8= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -107,6 +118,10 @@ github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbV github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= +github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= @@ -191,6 +206,55 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= +github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= +github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= +github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= +github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= +github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= +github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= +github.com/jackc/pgconn v1.14.0 h1:vrbA9Ud87g6JdFWkHTJXppVce58qPIdP7N8y0Ml/A7Q= +github.com/jackc/pgconn v1.14.0/go.mod h1:9mBNlny0UvkgJdCDvdVHYSjI+8tD2rnKK69Wz8ti++E= +github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= +github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= +github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc= +github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= +github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.3.2 h1:7eY55bdBeCz1F2fTzSz69QC+pG46jYq9/jtSPiJ5nn0= +github.com/jackc/pgproto3/v2 v2.3.2/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= +github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= +github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= +github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= +github.com/jackc/pgtype v1.14.0 h1:y+xUdabmyMkJLyApYuPj38mW+aAIqCe5uuBB51rH3Vw= +github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= +github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= +github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= +github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= +github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= +github.com/jackc/pgx/v4 v4.18.1 h1:YP7G1KABtKpB5IHrO9vYwSrCOhs7p3uqhvhhQBptya0= +github.com/jackc/pgx/v4 v4.18.1/go.mod h1:FydWkUyadDmdNH/mHnGob881GawxeEm7TcMCzkb+qQE= +github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.3.0 h1:eHK/5clGOatcjX3oWGBO/MpxpbHzSwud5EWTSCI+MX0= +github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jaevor/go-nanoid v1.3.0 h1:nD+iepesZS6pr3uOVf20vR9GdGgJW1HPaR46gtrxzkg= github.com/jaevor/go-nanoid v1.3.0/go.mod h1:SI+jFaPuddYkqkVQoNGHs81navCtH388TcrH0RqFKgY= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -199,15 +263,18 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jzelinskie/stringz v0.0.2 h1:OSjMEYvz8tjhovgZ/6cGcPID736ubeukr35mu6RYAmg= github.com/jzelinskie/stringz v0.0.2/go.mod h1:hHYbgxJuNLRw91CmpuFsYEOyQqpDVFg8pvEh23vy4P0= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 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= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/labstack/echo-contrib v0.15.0 h1:9K+oRU265y4Mu9zpRDv3X+DGTqUALY6oRHCSZZKCRVU= @@ -218,11 +285,22 @@ github.com/labstack/echo/v4 v4.11.3 h1:Upyu3olaqSHkCjs1EJJwQ3WId8b8b1hxbogyommKk github.com/labstack/echo/v4 v4.11.3/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws= github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 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.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= @@ -258,6 +336,8 @@ github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qR github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.15.0 h1:6tY5aDqFknY6VZkorFGgZtWygodZQxfmmEF4rqyJW9k= +github.com/pressly/goose/v3 v3.15.0/go.mod h1:LlIo3zGccjb/YUgG+Svdb9Er14vefRdlDI7URCDrwYo= github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw= github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -267,14 +347,23 @@ github.com/prometheus/common v0.40.0 h1:Afz7EVRqGg2Mqqf4JuF9vdvp1pi220m55Pi9T2Jn github.com/prometheus/common v0.40.0/go.mod h1:L65ZJPSmfn/UBWLQIHV7dBrKFidB/wPlF1y5TlSt9OE= 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 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.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= +github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ= github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= @@ -291,6 +380,7 @@ github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI= github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -301,6 +391,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -314,6 +405,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= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= go.infratographer.com/x v0.3.9 h1:fsfF/w5zHgiNAHvYmvsWlICNha2X53WNLVSKOkyPnWo= go.infratographer.com/x v0.3.9/go.mod h1:n/61MZRKFbGlS8xUwAhTyDhqcL2Wk6uPsXADC2n5t1I= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= @@ -347,27 +440,46 @@ go.opentelemetry.io/otel/metric v1.16.0 h1:RbrpwVG1Hfv85LgnZ7+txXioPDoh6EdbZHo26 go.opentelemetry.io/otel/metric v1.16.0/go.mod h1:QE47cpOmkwipPiefDwo2wDzwJrlfxxNYodqc4xnGCo4= go.opentelemetry.io/otel/sdk v1.16.0 h1:Z1Ok1YsijYL0CSJpHt4cS3wDDh7p572grzNrBMiMWgE= go.opentelemetry.io/otel/sdk v1.16.0/go.mod h1:tMsIuKXuuIWPBAOrH+eHtvhTL+SntFtXF9QD68aP6p4= +go.opentelemetry.io/otel/sdk/metric v0.39.0 h1:Kun8i1eYf48kHH83RucG93ffz0zGV1sh46FAScOTuDI= go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs= go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.opentelemetry.io/proto/otlp v0.19.0 h1:IVN6GR+mhC4s5yfcTbmzHYODqvWAp3ZedA2SJPI1Nnw= go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -405,6 +517,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -417,6 +531,7 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -438,6 +553,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.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -461,10 +578,14 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -472,10 +593,13 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -504,12 +628,18 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 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= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -519,6 +649,7 @@ 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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -533,14 +664,18 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3 golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -549,6 +684,7 @@ golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= @@ -576,6 +712,10 @@ golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= +golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -693,6 +833,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 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= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= @@ -710,6 +851,16 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo= +modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q= +modernc.org/ccgo/v3 v3.16.14 h1:af6KNtFgsVmnDYrWk3PQCS9XT6BXe7o3ZFJKkIKvXNQ= +modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/memory v1.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/sqlite v1.25.0 h1:AFweiwPNd/b3BoKnBOfFm+Y260guGMF+0UFk0savqeA= +modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/api/roles.go b/internal/api/roles.go index 1f841b30..50166100 100644 --- a/internal/api/roles.go +++ b/internal/api/roles.go @@ -1,12 +1,16 @@ package api import ( + "errors" "net/http" + "time" "github.com/labstack/echo/v4" "go.infratographer.com/x/gidx" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + + "go.infratographer.com/permissions-api/internal/query" ) const ( @@ -49,19 +53,87 @@ func (r *Router) roleCreate(c echo.Context) error { return err } - role, err := r.engine.CreateRole(ctx, resource, reqBody.Actions) + role, err := r.engine.CreateRole(ctx, subjectResource, resource, reqBody.Name, reqBody.Actions) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "error creating resource").SetInternal(err) } resp := roleResponse{ - ID: role.ID, - Actions: role.Actions, + ID: role.ID, + Name: role.Name, + Actions: role.Actions, + ResourceID: role.ResourceID, + CreatedBy: role.CreatedBy, + UpdatedBy: role.UpdatedBy, + CreatedAt: role.CreatedAt.Format(time.RFC3339), + UpdatedAt: role.UpdatedAt.Format(time.RFC3339), } return c.JSON(http.StatusCreated, resp) } +func (r *Router) roleUpdate(c echo.Context) error { + roleIDStr := c.Param("role_id") + + ctx, span := tracer.Start(c.Request().Context(), "api.roleUpdate", trace.WithAttributes(attribute.String("id", roleIDStr))) + defer span.End() + + roleID, err := gidx.Parse(roleIDStr) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error parsing role ID").SetInternal(err) + } + + var reqBody updateRoleRequest + + err = c.Bind(&reqBody) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error parsing request body").SetInternal(err) + } + + subjectResource, err := r.currentSubject(c) + if err != nil { + return err + } + + roleResource, err := r.engine.NewResourceFromID(roleID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "error updating role").SetInternal(err) + } + + // Roles belong to resources by way of the actions they can perform; do the permissions + // check on the role resource. + resource, err := r.engine.GetRoleResource(ctx, roleResource) + if err != nil { + if errors.Is(err, query.ErrRoleNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "resource not found").SetInternal(err) + } + + return echo.NewHTTPError(http.StatusInternalServerError, "error getting resource").SetInternal(err) + } + + if err := r.checkActionWithResponse(ctx, subjectResource, actionRoleUpdate, resource); err != nil { + return err + } + + role, err := r.engine.UpdateRole(ctx, subjectResource, roleResource, reqBody.Name, reqBody.Actions) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "error updating resource").SetInternal(err) + } + + resp := roleResponse{ + ID: role.ID, + Name: role.Name, + Actions: role.Actions, + ResourceID: role.ResourceID, + CreatedBy: role.CreatedBy, + UpdatedBy: role.UpdatedBy, + CreatedAt: role.CreatedAt.Format(time.RFC3339), + UpdatedAt: role.UpdatedAt.Format(time.RFC3339), + } + + return c.JSON(http.StatusOK, resp) +} + func (r *Router) roleGet(c echo.Context) error { roleIDStr := c.Param("role_id") @@ -102,8 +174,14 @@ func (r *Router) roleGet(c echo.Context) error { } resp := roleResponse{ - ID: role.ID, - Actions: role.Actions, + ID: role.ID, + Name: role.Name, + Actions: role.Actions, + ResourceID: role.ResourceID, + CreatedBy: role.CreatedBy, + UpdatedBy: role.UpdatedBy, + CreatedAt: role.CreatedAt.Format(time.RFC3339), + UpdatedAt: role.UpdatedAt.Format(time.RFC3339), } return c.JSON(http.StatusOK, resp) @@ -145,8 +223,13 @@ func (r *Router) rolesList(c echo.Context) error { for _, role := range roles { roleResp := roleResponse{ - ID: role.ID, - Actions: role.Actions, + ID: role.ID, + Name: role.Name, + Actions: role.Actions, + CreatedBy: role.CreatedBy, + UpdatedBy: role.UpdatedBy, + CreatedAt: role.CreatedAt.Format(time.RFC3339), + UpdatedAt: role.UpdatedAt.Format(time.RFC3339), } resp.Data = append(resp.Data, roleResp) diff --git a/internal/api/router.go b/internal/api/router.go index 2594c2b9..bf53a6ff 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -61,6 +61,7 @@ func (r *Router) Routes(rg *echo.Group) { v1.GET("/relationships/from/:id", r.relationshipListFrom) v1.GET("/relationships/to/:id", r.relationshipListTo) v1.GET("/roles/:role_id", r.roleGet) + v1.PATCH("/roles/:role_id", r.roleUpdate) v1.DELETE("/roles/:id", r.roleDelete) v1.GET("/roles/:role_id/resource", r.roleGetResource) v1.POST("/roles/:role_id/assignments", r.assignmentCreate) diff --git a/internal/api/types.go b/internal/api/types.go index 018ea06d..e02e96c7 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -5,12 +5,25 @@ import ( ) type createRoleRequest struct { + Name string `json:"name" binding:"required"` Actions []string `json:"actions" binding:"required"` } +type updateRoleRequest struct { + Name string `json:"name"` + Actions []string `json:"actions"` +} + type roleResponse struct { ID gidx.PrefixedID `json:"id"` + Name string `json:"name"` Actions []string `json:"actions"` + + ResourceID gidx.PrefixedID `json:"resource_id,omitempty"` + CreatedBy gidx.PrefixedID `json:"created_by"` + UpdatedBy gidx.PrefixedID `json:"updated_by"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type resourceResponse struct { diff --git a/internal/config/config.go b/internal/config/config.go index aeb3b517..f49b7801 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ package config import ( "github.com/spf13/pflag" "github.com/spf13/viper" + "go.infratographer.com/x/crdbx" "go.infratographer.com/x/echojwtx" "go.infratographer.com/x/echox" "go.infratographer.com/x/events" @@ -23,6 +24,7 @@ type EventsConfig struct { // AppConfig is the struct used for configuring the app type AppConfig struct { + CRDB crdbx.Config OIDC echojwtx.AuthConfig Logging loggingx.Config Server echox.Config diff --git a/internal/query/mock/mock.go b/internal/query/mock/mock.go index c6e2289a..c9e9a589 100644 --- a/internal/query/mock/mock.go +++ b/internal/query/mock/mock.go @@ -3,6 +3,7 @@ package mock import ( "context" "errors" + "time" "go.infratographer.com/permissions-api/internal/iapl" "go.infratographer.com/permissions-api/internal/query" @@ -43,20 +44,34 @@ func (e *Engine) CreateRelationships(ctx context.Context, rels []types.Relations } // CreateRole creates a Role object and does not persist it anywhere. -func (e *Engine) CreateRole(ctx context.Context, res types.Resource, actions []string) (types.Role, error) { +func (e *Engine) CreateRole(ctx context.Context, actor, res types.Resource, name string, actions []string) (types.Role, error) { // Copy actions instead of using the given slice outActions := make([]string, len(actions)) copy(outActions, actions) role := types.Role{ - ID: gidx.MustNewID(query.ApplicationPrefix), - Actions: outActions, + ID: gidx.MustNewID(query.ApplicationPrefix), + Name: name, + Actions: outActions, + CreatedBy: actor.ID, + UpdatedBy: actor.ID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } return role, nil } +// UpdateRole returns the provided mock results. +func (e *Engine) UpdateRole(ctx context.Context, actor, roleResource types.Resource, newName string, newActions []string) (types.Role, error) { + args := e.Called(actor, roleResource, newName, newActions) + + retRole := args.Get(0).(types.Role) + + return retRole, args.Error(1) +} + // GetRole returns nothing but satisfies the Engine interface. func (e *Engine) GetRole(ctx context.Context, roleResource types.Resource) (types.Role, error) { return types.Role{}, nil diff --git a/internal/query/relations.go b/internal/query/relations.go index c7e136ab..4c3b8ab6 100644 --- a/internal/query/relations.go +++ b/internal/query/relations.go @@ -13,7 +13,9 @@ import ( "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" "go.uber.org/multierr" + "go.uber.org/zap" + "go.infratographer.com/permissions-api/internal/storage" "go.infratographer.com/permissions-api/internal/types" ) @@ -274,19 +276,200 @@ func (e *engine) CreateRelationships(ctx context.Context, rels []types.Relations } // CreateRole creates a role scoped to the given resource with the given actions. -func (e *engine) CreateRole(ctx context.Context, res types.Resource, actions []string) (types.Role, error) { - role := newRole(actions) +func (e *engine) CreateRole(ctx context.Context, actor, res types.Resource, roleName string, actions []string) (types.Role, error) { + ctx, span := e.tracer.Start(ctx, "engine.CreateRole") + + defer span.End() + + roleName = strings.TrimSpace(roleName) + + role := newRole(roleName, actions) roleRels := e.roleRelationships(role, res) + dbCtx, err := e.store.BeginContext(ctx) + if err != nil { + return types.Role{}, nil + } + + dbRole, err := e.store.CreateRole(dbCtx, actor.ID, role.ID, roleName, res.ID) + if err != nil { + return types.Role{}, err + } + request := &pb.WriteRelationshipsRequest{Updates: roleRels} if _, err := e.client.WriteRelationships(ctx, request); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return types.Role{}, err + } + + if err = e.store.CommitContext(dbCtx); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + // No rollback of spicedb relations are done here. + // This does result in dangling unused entries in spicedb, + // however there are no assignments to these newly created + // and now discarded roles and so they won't be used. + return types.Role{}, err } + role.CreatedBy = dbRole.CreatedBy + role.UpdatedBy = dbRole.UpdatedBy + role.ResourceID = dbRole.ResourceID + role.CreatedAt = dbRole.CreatedAt + role.UpdatedAt = dbRole.UpdatedAt + return role, nil } +// actionsDiff determines which actions needs to be added and removed. +// If no new actions are provided it is assumed no changes are requested. +func actionsDiff(oldActions, newActions []string) ([]string, []string) { + if len(newActions) == 0 { + return nil, nil + } + + old := make(map[string]struct{}, len(oldActions)) + new := make(map[string]struct{}, len(newActions)) + + var add, rem []string + + for _, action := range oldActions { + old[action] = struct{}{} + } + + for _, action := range newActions { + new[action] = struct{}{} + + // If the new action is not in the old actions, then we need to add the action. + if _, ok := old[action]; !ok { + add = append(add, action) + } + } + + for _, action := range oldActions { + // If the old action is not in the new actions, then we need to remove it. + if _, ok := new[action]; !ok { + rem = append(rem, action) + } + } + + return add, rem +} + +// UpdateRole allows for updating an existing role with a new name and new actions. +// If new name is empty, no change is made. +// If new actions is an empty slice, no change is made. +func (e *engine) UpdateRole(ctx context.Context, actor, roleResource types.Resource, newName string, newActions []string) (types.Role, error) { + ctx, span := e.tracer.Start(ctx, "engine.UpdateRole") + + defer span.End() + + dbCtx, err := e.store.BeginContext(ctx) + if err != nil { + return types.Role{}, err + } + + err = e.store.LockRoleForUpdate(dbCtx, roleResource.ID) + if err != nil { + sErr := fmt.Errorf("failed to lock role: %s: %w", roleResource.ID, err) + + span.RecordError(sErr) + span.SetStatus(codes.Error, sErr.Error()) + + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return types.Role{}, err + } + + role, err := e.GetRole(dbCtx, roleResource) + if err != nil { + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return types.Role{}, err + } + + newName = strings.TrimSpace(newName) + + if newName == "" { + newName = role.Name + } + + addActions, remActions := actionsDiff(role.Actions, newActions) + + // If no changes, return existing role with no changes. + if newName == role.Name && len(addActions) == 0 && len(remActions) == 0 { + return role, nil + } + + resource, err := e.NewResourceFromID(role.ResourceID) + if err != nil { + return types.Role{}, err + } + + dbRole, err := e.store.UpdateRole(dbCtx, actor.ID, role.ID, newName) + if err != nil { + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return types.Role{}, err + } + + // If a change in actions, apply changes to spicedb. + if len(addActions) != 0 || len(remActions) != 0 { + roleRels := e.roleResourceRelationshipsTouchDelete(roleResource, resource, addActions, remActions) + + request := &pb.WriteRelationshipsRequest{Updates: roleRels} + + if _, err := e.client.WriteRelationships(ctx, request); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return types.Role{}, err + } + + role.Actions = newActions + } + + if err := e.store.CommitContext(dbCtx); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + // At this point, spicedb changes have already been applied. + // Attempting to rollback could result in failures that could result in the same situation. + // + // TODO: add spicedb rollback logic along with rollback failure scenarios. + + return types.Role{}, err + } + + role.Name = dbRole.Name + role.CreatedBy = dbRole.CreatedBy + role.UpdatedBy = dbRole.UpdatedBy + role.ResourceID = dbRole.ResourceID + role.CreatedAt = dbRole.CreatedAt + role.UpdatedAt = dbRole.UpdatedAt + + return role, nil +} + +func logRollbackErr(logger *zap.SugaredLogger, err error, args ...interface{}) { + if err != nil { + logger.With(args...).Error("error while rolling back", zap.Error(err)) + } +} + func actionToRelation(action string) string { return action + "_rel" } @@ -329,6 +512,43 @@ func (e *engine) roleRelationships(role types.Role, resource types.Resource) []* return rels } +func (e *engine) roleResourceRelationshipsTouchDelete(roleResource, resource types.Resource, touchActions, deleteActions []string) []*pb.RelationshipUpdate { + var rels []*pb.RelationshipUpdate + + resourceRef := resourceToSpiceDBRef(e.namespace, resource) + roleRef := resourceToSpiceDBRef(e.namespace, roleResource) + + for _, action := range touchActions { + rels = append(rels, &pb.RelationshipUpdate{ + Operation: pb.RelationshipUpdate_OPERATION_TOUCH, + Relationship: &pb.Relationship{ + Resource: resourceRef, + Relation: actionToRelation(action), + Subject: &pb.SubjectReference{ + Object: roleRef, + OptionalRelation: roleSubjectRelation, + }, + }, + }) + } + + for _, action := range deleteActions { + rels = append(rels, &pb.RelationshipUpdate{ + Operation: pb.RelationshipUpdate_OPERATION_DELETE, + Relationship: &pb.Relationship{ + Resource: resourceRef, + Relation: actionToRelation(action), + Subject: &pb.SubjectReference{ + Object: roleRef, + OptionalRelation: roleSubjectRelation, + }, + }, + }) + } + + return rels +} + func (e *engine) relationshipsToUpdates(rels []types.Relationship) []*pb.RelationshipUpdate { relUpdates := make([]*pb.RelationshipUpdate, len(rels)) @@ -624,6 +844,11 @@ func (e *engine) ListRelationshipsTo(ctx context.Context, resource types.Resourc // ListRoles returns all roles bound to a given resource. func (e *engine) ListRoles(ctx context.Context, resource types.Resource) ([]types.Role, error) { + dbRoles, err := e.store.ListResourceRoles(ctx, resource.ID) + if err != nil { + return nil, err + } + resType := e.namespace + "/" + resource.Type roleType := e.namespace + "/role" @@ -643,7 +868,30 @@ func (e *engine) ListRoles(ctx context.Context, resource types.Resource) ([]type return nil, err } - out := relationshipsToRoles(relationships) + spicedbRoles := relationshipsToRoles(relationships) + + rolesByID := make(map[gidx.PrefixedID]types.Role, len(spicedbRoles)) + + for _, role := range spicedbRoles { + rolesByID[role.ID] = role + } + + out := make([]types.Role, len(dbRoles)) + + for i, dbRole := range dbRoles { + spicedbRole := rolesByID[dbRole.ID] + + out[i] = types.Role{ + ID: dbRole.ID, + Name: dbRole.Name, + Actions: spicedbRole.Actions, + ResourceID: dbRole.ResourceID, + CreatedBy: dbRole.CreatedBy, + UpdatedBy: dbRole.UpdatedBy, + CreatedAt: dbRole.CreatedAt, + UpdatedAt: dbRole.UpdatedAt, + } + } return out, nil } @@ -724,9 +972,21 @@ func (e *engine) GetRole(ctx context.Context, roleResource types.Resource) (type actions[i] = relationToAction(action) } + dbRole, err := e.store.GetRoleByID(ctx, roleResource.ID) + if err != nil && !errors.Is(err, storage.ErrNoRoleFound) { + e.logger.Error("error while getting role", zap.Error(err)) + } + return types.Role{ ID: roleResource.ID, + Name: dbRole.Name, Actions: actions, + + ResourceID: dbRole.ResourceID, + CreatedBy: dbRole.CreatedBy, + UpdatedBy: dbRole.UpdatedBy, + CreatedAt: dbRole.CreatedAt, + UpdatedAt: dbRole.UpdatedAt, }, nil } @@ -766,14 +1026,34 @@ func (e *engine) GetRoleResource(ctx context.Context, roleResource types.Resourc // DeleteRole removes all role actions from the assigned resource. func (e *engine) DeleteRole(ctx context.Context, roleResource types.Resource) error { - var ( - resActions map[types.Resource][]string - err error - ) + ctx, span := e.tracer.Start(ctx, "engine.DeleteRole") + + defer span.End() + + dbCtx, err := e.store.BeginContext(ctx) + if err != nil { + return err + } + + err = e.store.LockRoleForUpdate(dbCtx, roleResource.ID) + if err != nil { + sErr := fmt.Errorf("failed to lock role: %s: %w", roleResource.ID, err) + + span.RecordError(sErr) + span.SetStatus(codes.Error, sErr.Error()) + + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return err + } + + var resActions map[types.Resource][]string for _, resType := range e.schemaRoleables { resActions, err = e.listRoleResourceActions(ctx, roleResource, resType.Name) if err != nil { + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + return err } @@ -783,10 +1063,6 @@ func (e *engine) DeleteRole(ctx context.Context, roleResource types.Resource) er } } - if len(resActions) == 0 { - return ErrRoleNotFound - } - roleType := e.namespace + "/role" var filters []*pb.RelationshipFilter @@ -810,12 +1086,45 @@ func (e *engine) DeleteRole(ctx context.Context, roleResource types.Resource) er } } + _, err = e.store.DeleteRole(dbCtx, roleResource.ID) + if err != nil { + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return err + } + for _, filter := range filters { if err = e.deleteRelationships(ctx, filter); err != nil { - return fmt.Errorf("failed to delete role action %s: %w", filter.OptionalResourceId, err) + err = fmt.Errorf("failed to delete role action %s: %w", filter.OptionalResourceId, err) + + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + // At this point, some spicedb changes may have already been applied. + // Attempting to rollback could result in failures that could result in the same situation. + // + // TODO: add spicedb rollback logic along with rollback failure scenarios. + + return err } } + if err = e.store.CommitContext(dbCtx); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + // At this point, spicedb changes have already been applied. + // Attempting to rollback could result in failures that could result in the same situation. + // + // TODO: add spicedb rollback logic along with rollback failure scenarios. + + return err + } + return nil } diff --git a/internal/query/relations_test.go b/internal/query/relations_test.go index b0f6cbdd..2235c612 100644 --- a/internal/query/relations_test.go +++ b/internal/query/relations_test.go @@ -15,6 +15,8 @@ import ( "go.infratographer.com/permissions-api/internal/iapl" "go.infratographer.com/permissions-api/internal/spicedbx" + "go.infratographer.com/permissions-api/internal/storage" + "go.infratographer.com/permissions-api/internal/storage/teststore" "go.infratographer.com/permissions-api/internal/testingx" "go.infratographer.com/permissions-api/internal/types" ) @@ -29,6 +31,8 @@ func testEngine(ctx context.Context, t *testing.T, namespace string) *engine { client, err := spicedbx.NewClient(config, false) require.NoError(t, err) + store, cleanStore := teststore.NewTestStorage(t) + policy := testPolicy() schema, err := spicedbx.GenerateSchema(namespace, policy.Schema()) @@ -50,11 +54,12 @@ func testEngine(ctx context.Context, t *testing.T, namespace string) *engine { t.Cleanup(func() { cleanDB(ctx, t, client, namespace) + cleanStore() }) // We call the constructor here to ensure the engine is created appropriately, but // then return the underlying type so we can do testing with it. - out, err := NewEngine(namespace, client, kv, WithPolicy(policy)) + out, err := NewEngine(namespace, client, kv, store, WithPolicy(policy)) require.NoError(t, err) return out.(*engine) @@ -138,8 +143,10 @@ func TestCreateRoles(t *testing.T) { require.NoError(t, err) tenRes, err := e.NewResourceFromID(tenID) require.NoError(t, err) + actorRes, err := e.NewResourceFromID(gidx.MustNewID("idntusr")) + require.NoError(t, err) - _, err = e.CreateRole(ctx, tenRes, actions) + _, err = e.CreateRole(ctx, actorRes, tenRes, "test", actions) if err != nil { return testingx.TestResult[[]types.Role]{ Err: err, @@ -165,8 +172,10 @@ func TestGetRoles(t *testing.T) { require.NoError(t, err) tenRes, err := e.NewResourceFromID(tenID) require.NoError(t, err) + actorRes, err := e.NewResourceFromID(gidx.MustNewID("idntusr")) + require.NoError(t, err) - role, err := e.CreateRole(ctx, tenRes, []string{"loadbalancer_get"}) + role, err := e.CreateRole(ctx, actorRes, tenRes, "test", []string{"loadbalancer_get"}) require.NoError(t, err) roleRes, err := e.NewResourceFromID(role.ID) require.NoError(t, err) @@ -210,6 +219,161 @@ func TestGetRoles(t *testing.T) { testingx.RunTests(ctx, t, testCases, testFn) } +func TestRoleUpdate(t *testing.T) { + namespace := "testroles" + ctx := context.Background() + e := testEngine(ctx, t, namespace) + + tenID, err := gidx.NewID("tnntten") + require.NoError(t, err) + tenRes, err := e.NewResourceFromID(tenID) + require.NoError(t, err) + actorRes, err := e.NewResourceFromID(gidx.MustNewID("idntusr")) + require.NoError(t, err) + actorUpdateRes, err := e.NewResourceFromID(gidx.MustNewID("idntusr")) + require.NoError(t, err) + + role, err := e.CreateRole(ctx, actorRes, tenRes, "test", []string{"loadbalancer_get"}) + require.NoError(t, err) + roles, err := e.ListRoles(ctx, tenRes) + require.NoError(t, err) + require.NotEmpty(t, roles) + + testCases := []testingx.TestCase[gidx.PrefixedID, types.Role]{ + { + Name: "UpdateMissingRole", + Input: gidx.MustNewID(RolePrefix), + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.Role]) { + require.Error(t, res.Err) + assert.ErrorIs(t, res.Err, storage.ErrNoRoleFound) + }, + }, + { + Name: "UpdateSuccess", + Input: role.ID, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.Role]) { + require.NoError(t, res.Err) + assert.Equal(t, "test2", res.Success.Name) + assert.Equal(t, role.Actions, res.Success.Actions) + assert.Equal(t, role.CreatedBy, res.Success.CreatedBy) + assert.Equal(t, actorUpdateRes.ID, res.Success.UpdatedBy) + assert.Equal(t, role.CreatedAt, res.Success.CreatedAt) + assert.NotEqual(t, role.UpdatedAt, res.Success.UpdatedAt) + }, + }, + } + + testFn := func(ctx context.Context, roleID gidx.PrefixedID) testingx.TestResult[types.Role] { + roleResource, err := e.NewResourceFromID(roleID) + if err != nil { + return testingx.TestResult[types.Role]{ + Err: err, + } + } + + _, err = e.UpdateRole(ctx, actorUpdateRes, roleResource, "test2", nil) + if err != nil { + return testingx.TestResult[types.Role]{ + Err: err, + } + } + + updatedRole, err := e.GetRole(ctx, roleResource) + + return testingx.TestResult[types.Role]{ + Success: updatedRole, + Err: err, + } + } + + testingx.RunTests(ctx, t, testCases, testFn) +} + +func TestListRoles(t *testing.T) { + namespace := "testroles" + ctx := context.Background() + e := testEngine(ctx, t, namespace) + + actorRes, err := e.NewResourceFromID(gidx.MustNewID("idntusr")) + require.NoError(t, err) + + type ( + tenCtxKey struct{} + roleCtxKey struct{} + ) + + var ( + tenCtx tenCtxKey + roleCtx roleCtxKey + ) + + testCases := []testingx.TestCase[any, []types.Role]{ + { + Name: "RoleFoundWithActions", + SetupFn: func(ctx context.Context, t *testing.T) context.Context { + tenID, err := gidx.NewID("tnntten") + require.NoError(t, err) + + tenRes, err := e.NewResourceFromID(tenID) + require.NoError(t, err) + + role, err := e.CreateRole(ctx, actorRes, tenRes, t.Name(), []string{"loadbalancer_get"}) + require.NoError(t, err) + require.NotEmpty(t, role.ID) + + ctx = context.WithValue(ctx, tenCtx, tenRes) + ctx = context.WithValue(ctx, roleCtx, role) + + return ctx + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[[]types.Role]) { + assert.NoError(t, res.Err) + require.NotEmpty(t, res.Success) + assert.Equal(t, ctx.Value(roleCtx), res.Success[0]) + assert.NotEmpty(t, res.Success[0].Actions) + }, + }, + { + Name: "RoleFoundWithoutActions", + SetupFn: func(ctx context.Context, t *testing.T) context.Context { + tenID, err := gidx.NewID("tnntten") + require.NoError(t, err) + + tenRes, err := e.NewResourceFromID(tenID) + require.NoError(t, err) + + role, err := e.CreateRole(ctx, actorRes, tenRes, t.Name(), nil) + require.NoError(t, err) + require.NotEmpty(t, role.ID) + + ctx = context.WithValue(ctx, tenCtx, tenRes) + ctx = context.WithValue(ctx, roleCtx, role) + + return ctx + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[[]types.Role]) { + assert.NoError(t, res.Err) + require.NotEmpty(t, res.Success) + assert.Equal(t, ctx.Value(roleCtx), res.Success[0]) + assert.Empty(t, res.Success[0].Actions) + }, + }, + } + + testFn := func(ctx context.Context, _ any) testingx.TestResult[[]types.Role] { + tenRes := ctx.Value(tenCtx).(types.Resource) + + roles, err := e.ListRoles(ctx, tenRes) + + return testingx.TestResult[[]types.Role]{ + Success: roles, + Err: err, + } + } + + testingx.RunTests(ctx, t, testCases, testFn) +} + func TestRoleDelete(t *testing.T) { namespace := "testroles" ctx := context.Background() @@ -219,8 +383,10 @@ func TestRoleDelete(t *testing.T) { require.NoError(t, err) tenRes, err := e.NewResourceFromID(tenID) require.NoError(t, err) + actorRes, err := e.NewResourceFromID(gidx.MustNewID("idntusr")) + require.NoError(t, err) - role, err := e.CreateRole(ctx, tenRes, []string{"loadbalancer_get"}) + role, err := e.CreateRole(ctx, actorRes, tenRes, "test", []string{"loadbalancer_get"}) require.NoError(t, err) roles, err := e.ListRoles(ctx, tenRes) require.NoError(t, err) @@ -283,9 +449,13 @@ func TestAssignments(t *testing.T) { require.NoError(t, err) subjRes, err := e.NewResourceFromID(subjID) require.NoError(t, err) + actorRes, err := e.NewResourceFromID(gidx.MustNewID("idntusr")) + require.NoError(t, err) role, err := e.CreateRole( ctx, + actorRes, tenRes, + "test", []string{ "loadbalancer_update", }, @@ -339,9 +509,13 @@ func TestUnassignments(t *testing.T) { require.NoError(t, err) subjRes, err := e.NewResourceFromID(subjID) require.NoError(t, err) + actorRes, err := e.NewResourceFromID(gidx.MustNewID("idntusr")) + require.NoError(t, err) role, err := e.CreateRole( ctx, + actorRes, tenRes, + "test", []string{ "loadbalancer_update", }, @@ -588,9 +762,13 @@ func TestSubjectActions(t *testing.T) { require.NoError(t, err) subjRes, err := e.NewResourceFromID(subjID) require.NoError(t, err) + actorRes, err := e.NewResourceFromID(gidx.MustNewID("idntusr")) + require.NoError(t, err) role, err := e.CreateRole( ctx, + actorRes, tenRes, + "test", []string{ "loadbalancer_update", }, diff --git a/internal/query/roles.go b/internal/query/roles.go index 6d19254d..5cddb45c 100644 --- a/internal/query/roles.go +++ b/internal/query/roles.go @@ -13,9 +13,10 @@ const ( RolePrefix string = ApplicationPrefix + "rol" ) -func newRole(actions []string) types.Role { +func newRole(name string, actions []string) types.Role { return types.Role{ ID: gidx.MustNewID(RolePrefix), + Name: name, Actions: actions, } } diff --git a/internal/query/service.go b/internal/query/service.go index 8c1afcfb..6eacbad0 100644 --- a/internal/query/service.go +++ b/internal/query/service.go @@ -11,6 +11,7 @@ import ( "go.uber.org/zap" "go.infratographer.com/permissions-api/internal/iapl" + "go.infratographer.com/permissions-api/internal/storage" "go.infratographer.com/permissions-api/internal/types" ) @@ -24,7 +25,8 @@ type Engine interface { AssignSubjectRole(ctx context.Context, subject types.Resource, role types.Role) error UnassignSubjectRole(ctx context.Context, subject types.Resource, role types.Role) error CreateRelationships(ctx context.Context, rels []types.Relationship) error - CreateRole(ctx context.Context, res types.Resource, actions []string) (types.Role, error) + CreateRole(ctx context.Context, actor, res types.Resource, roleName string, actions []string) (types.Role, error) + UpdateRole(ctx context.Context, actor, roleResource types.Resource, newName string, newActions []string) (types.Role, error) GetRole(ctx context.Context, roleResource types.Resource) (types.Role, error) GetRoleResource(ctx context.Context, roleResource types.Resource) (types.Resource, error) ListAssignments(ctx context.Context, role types.Role) ([]types.Resource, error) @@ -45,6 +47,7 @@ type engine struct { namespace string client *authzed.Client kv nats.KeyValue + store storage.Storage schema []types.ResourceType schemaPrefixMap map[string]types.ResourceType schemaTypeMap map[string]types.ResourceType @@ -91,7 +94,7 @@ func resourceHasRoleBindings(resType types.ResourceType) bool { } // NewEngine returns a new client for making permissions queries. -func NewEngine(namespace string, client *authzed.Client, kv nats.KeyValue, options ...Option) (Engine, error) { +func NewEngine(namespace string, client *authzed.Client, kv nats.KeyValue, store storage.Storage, options ...Option) (Engine, error) { tracer := otel.GetTracerProvider().Tracer("go.infratographer.com/permissions-api/internal/query") e := &engine{ @@ -99,6 +102,7 @@ func NewEngine(namespace string, client *authzed.Client, kv nats.KeyValue, optio namespace: namespace, client: client, kv: kv, + store: store, tracer: tracer, } diff --git a/internal/storage/context.go b/internal/storage/context.go new file mode 100644 index 00000000..48fc67ef --- /dev/null +++ b/internal/storage/context.go @@ -0,0 +1,86 @@ +package storage + +import ( + "context" + "database/sql" +) + +// TransactionManager manages the state of sql transactions within a context +type TransactionManager interface { + BeginContext(context.Context) (context.Context, error) + CommitContext(context.Context) error + RollbackContext(context.Context) error +} + +type contextKey struct{} + +var txKey contextKey + +func beginTxContext(ctx context.Context, db DB) (context.Context, error) { + tx, err := db.BeginTx(ctx, nil) + + if err != nil { + return nil, err + } + + out := context.WithValue(ctx, txKey, tx) + + return out, nil +} + +func getContextTx(ctx context.Context) (*sql.Tx, error) { + switch v := ctx.Value(txKey).(type) { + case *sql.Tx: + return v, nil + case nil: + return nil, ErrorMissingContextTx + default: + panic("unknown type for context transaction") + } +} + +func getContextDBQuery(ctx context.Context, def DBQuery) (DBQuery, error) { + tx, err := getContextTx(ctx) + + switch err { + case nil: + return tx, nil + case ErrorMissingContextTx: + return def, nil + default: + return nil, err + } +} + +func commitContextTx(ctx context.Context) error { + tx, err := getContextTx(ctx) + if err != nil { + return err + } + + return tx.Commit() +} + +func rollbackContextTx(ctx context.Context) error { + tx, err := getContextTx(ctx) + if err != nil { + return err + } + + return tx.Rollback() +} + +// BeginContext starts a new transaction. +func (e *engine) BeginContext(ctx context.Context) (context.Context, error) { + return beginTxContext(ctx, e.DB) +} + +// CommitContext commits the transaction in the provided context. +func (e *engine) CommitContext(ctx context.Context) error { + return commitContextTx(ctx) +} + +// RollbackContext rollsback the transaction in the provided context. +func (e *engine) RollbackContext(ctx context.Context) error { + return rollbackContextTx(ctx) +} diff --git a/internal/storage/errors.go b/internal/storage/errors.go new file mode 100644 index 00000000..c264eb90 --- /dev/null +++ b/internal/storage/errors.go @@ -0,0 +1,57 @@ +package storage + +import ( + "errors" + + "github.com/lib/pq" +) + +var ( + // ErrNoRoleFound is returned when no role is found when retrieving or deleting a role. + ErrNoRoleFound = errors.New("role not found") + + // ErrRoleAlreadyExists is returned when creating a role which already has an existing record. + ErrRoleAlreadyExists = errors.New("role already exists") + + // ErrRoleNameTaken is returned when the role name provided already exists under the same resource id. + ErrRoleNameTaken = errors.New("role name already taken") + + // ErrMethodUnavailable is returned when the provided method is called is unavailable in the current environment. + // For example there is nothing to commit after getting a role so calling Commit on a Role after retrieving it will return this error. + ErrMethodUnavailable = errors.New("method unavailable") + + // ErrorMissingContextTx represents an error where no context transaction was provided. + ErrorMissingContextTx = errors.New("no transaction provided in context") + + // ErrorInvalidContextTx represents an error where the given context transaction is of the wrong type. + ErrorInvalidContextTx = errors.New("invalid type for transaction context") +) + +const ( + pqIndexRolesPrimaryKey = "roles_pkey" + pqIndexRolesResourceIDName = "roles_resource_id_name" +) + +// pqIsRoleAlreadyExistsError checks that the provided error is a postgres error. +// If so, checks if postgres threw a unique_violation error on the roles primary key index. +// If postgres has raised a unique violation error on this index it means a record already exists +// with a matching primary key (role id). +func pqIsRoleAlreadyExistsError(err error) bool { + if pqErr, ok := err.(*pq.Error); ok { + return pqErr.Code.Name() == "unique_violation" && pqErr.Constraint == pqIndexRolesPrimaryKey + } + + return false +} + +// pqIsRoleNameTakenError checks that the provided error is a postgres error. +// If so, checks if postgres threw a unique_violation error on the roles resource id name index. +// If postgres has raised a unique violation error on this index it means a record already exists +// with the same resource id and role name combination. +func pqIsRoleNameTakenError(err error) bool { + if pqErr, ok := err.(*pq.Error); ok { + return pqErr.Code.Name() == "unique_violation" && pqErr.Constraint == pqIndexRolesResourceIDName + } + + return false +} diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go new file mode 100644 index 00000000..4a2fb14c --- /dev/null +++ b/internal/storage/migrations.go @@ -0,0 +1,10 @@ +package storage + +import ( + "embed" +) + +// Migrations contains an embedded filesystem with all the sql migration files +// +//go:embed migrations/*.sql +var Migrations embed.FS diff --git a/internal/storage/migrations/20231122000000_initial_schema.sql b/internal/storage/migrations/20231122000000_initial_schema.sql new file mode 100644 index 00000000..058fcdd2 --- /dev/null +++ b/internal/storage/migrations/20231122000000_initial_schema.sql @@ -0,0 +1,35 @@ +-- +goose Up +-- create "roles" table +CREATE TABLE "roles" ( + "id" character varying NOT NULL, + "name" character varying(64) NOT NULL, + "resource_id" character varying NOT NULL, + "created_by" character varying NOT NULL, + "updated_by" character varying NOT NULL, + "created_at" timestamptz NOT NULL, + "updated_at" timestamptz NOT NULL, + PRIMARY KEY ("id") +); +-- create index "roles_created_by" to table: "roles" +CREATE INDEX "roles_created_by" ON "roles" ("created_by"); +-- create index "roles_created_by" to table: "roles" +CREATE INDEX "roles_updated_by" ON "roles" ("updated_by"); +-- create index "roles_created_at" to table: "roles" +CREATE INDEX "roles_created_at" ON "roles" ("created_at"); +-- create index "roles_updated_at" to table: "roles" +CREATE INDEX "roles_updated_at" ON "roles" ("updated_at"); +-- create index "roles_resource_id_name" to table: "roles" +CREATE UNIQUE INDEX "roles_resource_id_name" ON "roles" ("resource_id", "name"); +-- +goose Down +-- reverse: create index "roles_resource_id_name" to table: "roles" +DROP INDEX "roles_resource_id_name"; +-- reverse: create index "roles_updated_at" to table: "roles" +DROP INDEX "roles_updated_at"; +-- reverse: create index "roles_created_at" to table: "roles" +DROP INDEX "roles_created_at"; +-- reverse: create index "roles_updated_by" to table: "roles" +DROP INDEX "roles_updated_by"; +-- reverse: create index "roles_created_by" to table: "roles" +DROP INDEX "roles_created_by"; +-- reverse: create "roles" table +DROP TABLE "roles"; diff --git a/internal/storage/options.go b/internal/storage/options.go new file mode 100644 index 00000000..a2a10df3 --- /dev/null +++ b/internal/storage/options.go @@ -0,0 +1,13 @@ +package storage + +import "go.uber.org/zap" + +// Option defines a storage engine configuration option. +type Option func(e *engine) + +// WithLogger sets the logger for the storage engine. +func WithLogger(logger *zap.SugaredLogger) Option { + return func(e *engine) { + e.logger = logger.Named("storage") + } +} diff --git a/internal/storage/roles.go b/internal/storage/roles.go new file mode 100644 index 00000000..870445ae --- /dev/null +++ b/internal/storage/roles.go @@ -0,0 +1,310 @@ +package storage + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "go.infratographer.com/x/gidx" +) + +// RoleService represents a service for managing roles. +type RoleService interface { + GetRoleByID(ctx context.Context, id gidx.PrefixedID) (Role, error) + GetResourceRoleByName(ctx context.Context, resourceID gidx.PrefixedID, name string) (Role, error) + ListResourceRoles(ctx context.Context, resourceID gidx.PrefixedID) ([]Role, error) + CreateRole(ctx context.Context, actorID gidx.PrefixedID, roleID gidx.PrefixedID, name string, resourceID gidx.PrefixedID) (Role, error) + UpdateRole(ctx context.Context, actorID, roleID gidx.PrefixedID, name string) (Role, error) + DeleteRole(ctx context.Context, roleID gidx.PrefixedID) (Role, error) + LockRoleForUpdate(ctx context.Context, roleID gidx.PrefixedID) error +} + +// Role represents a role in the database. +type Role struct { + ID gidx.PrefixedID + Name string + ResourceID gidx.PrefixedID + CreatedBy gidx.PrefixedID + UpdatedBy gidx.PrefixedID + CreatedAt time.Time + UpdatedAt time.Time +} + +// GetRoleByID retrieves a role from the database by the provided prefixed ID. +// If no role exists an ErrRoleNotFound error is returned. +func (e *engine) GetRoleByID(ctx context.Context, id gidx.PrefixedID) (Role, error) { + db, err := getContextDBQuery(ctx, e) + if err != nil { + return Role{}, err + } + + var role Role + + err = db.QueryRowContext(ctx, ` + SELECT + id, + name, + resource_id, + created_by, + updated_by, + created_at, + updated_at + FROM roles + WHERE id = $1 + `, id.String(), + ).Scan( + &role.ID, + &role.Name, + &role.ResourceID, + &role.CreatedBy, + &role.UpdatedBy, + &role.CreatedAt, + &role.UpdatedAt, + ) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Role{}, fmt.Errorf("%w: %s", ErrNoRoleFound, id.String()) + } + + return Role{}, fmt.Errorf("%w: %s", err, id.String()) + } + + return role, nil +} + +// LockRoleForUpdate locks the provided role's record to be updated to ensure consistency. +// If no role exists an ErrNoRoleFound error is returned. +func (e *engine) LockRoleForUpdate(ctx context.Context, id gidx.PrefixedID) error { + db, err := getContextDBQuery(ctx, e) + if err != nil { + return err + } + + result, err := db.ExecContext(ctx, `SELECT 1 FROM roles WHERE id = $1 FOR UPDATE`, id.String()) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + + if rowsAffected == 0 { + return ErrNoRoleFound + } + + return nil +} + +// GetResourceRoleByName retrieves a role from the database by the provided resource ID and role name. +// If no role exists an ErrRoleNotFound error is returned. +func (e *engine) GetResourceRoleByName(ctx context.Context, resourceID gidx.PrefixedID, name string) (Role, error) { + db, err := getContextDBQuery(ctx, e) + if err != nil { + return Role{}, err + } + + var role Role + + err = db.QueryRowContext(ctx, ` + SELECT + id, + name, + resource_id, + created_by, + updated_by, + created_at, + updated_at + FROM roles + WHERE + resource_id = $1 + AND name = $2 + `, + resourceID.String(), + name, + ).Scan( + &role.ID, + &role.Name, + &role.ResourceID, + &role.CreatedBy, + &role.UpdatedBy, + &role.CreatedAt, + &role.UpdatedAt, + ) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Role{}, fmt.Errorf("%w: %s", ErrNoRoleFound, name) + } + + return Role{}, err + } + + return role, nil +} + +// ListResourceRoles retrieves all roles associated with the provided resource ID. +// If no roles are found an empty slice is returned. +func (e *engine) ListResourceRoles(ctx context.Context, resourceID gidx.PrefixedID) ([]Role, error) { + db, err := getContextDBQuery(ctx, e) + if err != nil { + return nil, err + } + + rows, err := db.QueryContext(ctx, ` + SELECT + id, + name, + resource_id, + created_by, + updated_by, + created_at, + updated_at + FROM roles + WHERE + resource_id = $1 + `, + resourceID.String(), + ) + + if err != nil { + return nil, err + } + + var roles []Role + + for rows.Next() { + var role Role + + if err := rows.Scan(&role.ID, &role.Name, &role.ResourceID, &role.CreatedBy, &role.UpdatedBy, &role.CreatedAt, &role.UpdatedAt); err != nil { + return nil, err + } + + roles = append(roles, role) + } + + return roles, nil +} + +// CreateRole creates a role with the provided details. +// If a role already exists with the given roleID an ErrRoleAlreadyExists error is returned. +// If a role already exists with the same name under the given resource ID then an ErrRoleNameTaken error is returned. +// +// This method must be called with a context returned from BeginContext. +// CommitContext or RollbackContext must be called afterwards if this method returns no error. +func (e *engine) CreateRole(ctx context.Context, actorID, roleID gidx.PrefixedID, name string, resourceID gidx.PrefixedID) (Role, error) { + tx, err := getContextTx(ctx) + if err != nil { + return Role{}, err + } + + var role Role + + err = tx.QueryRowContext(ctx, ` + INSERT + INTO roles (id, name, resource_id, created_by, updated_by, created_at, updated_at) + VALUES ($1, $2, $3, $4, $4, now(), now()) + RETURNING id, name, resource_id, created_by, updated_by, created_at, updated_at + `, roleID.String(), name, resourceID.String(), actorID.String(), + ).Scan( + &role.ID, + &role.Name, + &role.ResourceID, + &role.CreatedBy, + &role.UpdatedBy, + &role.CreatedAt, + &role.UpdatedAt, + ) + + if err != nil { + if pqIsRoleAlreadyExistsError(err) { + return Role{}, fmt.Errorf("%w: %s", ErrRoleAlreadyExists, roleID.String()) + } + + if pqIsRoleNameTakenError(err) { + return Role{}, fmt.Errorf("%w: %s", ErrRoleNameTaken, name) + } + + return Role{}, err + } + + return role, nil +} + +// UpdateRole updates an existing role. +// If changing the name and the new name results in a duplicate name error, an ErrRoleNameTaken error is returned. +// +// This method must be called with a context returned from BeginContext. +// CommitContext or RollbackContext must be called afterwards if this method returns no error. +func (e *engine) UpdateRole(ctx context.Context, actorID, roleID gidx.PrefixedID, name string) (Role, error) { + tx, err := getContextTx(ctx) + if err != nil { + return Role{}, err + } + + var role Role + + err = tx.QueryRowContext(ctx, ` + UPDATE roles SET name = $1, updated_by = $2, updated_at = now() WHERE id = $3 + RETURNING id, name, resource_id, created_by, updated_by, created_at, updated_at + `, name, actorID.String(), roleID.String(), + ).Scan( + &role.ID, + &role.Name, + &role.ResourceID, + &role.CreatedBy, + &role.UpdatedBy, + &role.CreatedAt, + &role.UpdatedAt, + ) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Role{}, fmt.Errorf("%w: %s", ErrNoRoleFound, roleID.String()) + } + + if pqIsRoleNameTakenError(err) { + return Role{}, fmt.Errorf("%w: %s", ErrRoleNameTaken, name) + } + + return Role{}, err + } + + return role, nil +} + +// DeleteRole deletes the role for the id provided. +// If no rows are affected an ErrNoRoleFound error is returned. +// +// This method must be called with a context returned from BeginContext. +// CommitContext or RollbackContext must be called afterwards if this method returns no error. +func (e *engine) DeleteRole(ctx context.Context, roleID gidx.PrefixedID) (Role, error) { + tx, err := getContextTx(ctx) + if err != nil { + return Role{}, err + } + + result, err := tx.ExecContext(ctx, `DELETE FROM roles WHERE id = $1`, roleID.String()) + if err != nil { + return Role{}, err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return Role{}, err + } + + if rowsAffected == 0 { + return Role{}, ErrNoRoleFound + } + + role := Role{ + ID: roleID, + } + + return role, nil +} diff --git a/internal/storage/roles_test.go b/internal/storage/roles_test.go new file mode 100644 index 00000000..1e62a9fb --- /dev/null +++ b/internal/storage/roles_test.go @@ -0,0 +1,392 @@ +package storage_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.infratographer.com/x/gidx" + + "go.infratographer.com/permissions-api/internal/storage" + "go.infratographer.com/permissions-api/internal/storage/teststore" + "go.infratographer.com/permissions-api/internal/testingx" +) + +func TestGetRoleByID(t *testing.T) { + store, closeStore := teststore.NewTestStorage(t) + + t.Cleanup(closeStore) + + ctx := context.Background() + + actorID := gidx.PrefixedID("idntusr-abc123") + resourceID := gidx.PrefixedID("testten-jkl789") + roleID := gidx.MustNewID("permrol") + roleName := "users" + + dbCtx, err := store.BeginContext(ctx) + require.NoError(t, err, "no error expected beginning transaction context") + + createdRole, err := store.CreateRole(dbCtx, actorID, roleID, roleName, resourceID) + require.NoError(t, err, "no error expected while seeding database role") + + err = store.CommitContext(dbCtx) + require.NoError(t, err, "no error expected while committing role creation") + + testCases := []testingx.TestCase[gidx.PrefixedID, storage.Role]{ + { + Name: "NotFound", + Input: "permrol-notfound123", + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[storage.Role]) { + require.Error(t, res.Err, "error expected when no role is found") + assert.ErrorIs(t, res.Err, storage.ErrNoRoleFound) + require.Empty(t, res.Success.ID, "no role expected to be returned") + }, + }, + { + Name: "Found", + Input: roleID, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[storage.Role]) { + require.NoError(t, err, "no error expected while retrieving role") + + assert.Equal(t, roleID, res.Success.ID) + assert.Equal(t, roleName, res.Success.Name) + assert.Equal(t, resourceID, res.Success.ResourceID) + assert.Equal(t, actorID, res.Success.CreatedBy) + assert.Equal(t, createdRole.CreatedAt, res.Success.CreatedAt) + assert.Equal(t, createdRole.UpdatedAt, res.Success.UpdatedAt) + }, + }, + } + + testFn := func(ctx context.Context, input gidx.PrefixedID) testingx.TestResult[storage.Role] { + role, err := store.GetRoleByID(ctx, input) + + return testingx.TestResult[storage.Role]{ + Success: role, + Err: err, + } + } + + testingx.RunTests(ctx, t, testCases, testFn) +} + +func TestListResourceRoles(t *testing.T) { + store, closeStore := teststore.NewTestStorage(t) + + t.Cleanup(closeStore) + + ctx := context.Background() + + actorID := gidx.PrefixedID("idntusr-abc123") + resourceID := gidx.PrefixedID("testten-jkl789") + + groups := map[string]gidx.PrefixedID{ + "super-admins": "permrol-abc123", + "admins": "permrol-def456", + "users": "permrol-ghi789", + } + + dbCtx, err := store.BeginContext(ctx) + require.NoError(t, err, "no error expected beginning transaction context") + + for roleName, roleID := range groups { + _, err := store.CreateRole(dbCtx, actorID, roleID, roleName, resourceID) + + require.NoError(t, err, "no error expected creating role", roleName) + } + + err = store.CommitContext(dbCtx) + require.NoError(t, err, "no error expected while committing roles") + + testCases := []testingx.TestCase[gidx.PrefixedID, []storage.Role]{ + { + Name: "NoRoles", + Input: "testten-noroles123", + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[[]storage.Role]) { + require.NoError(t, res.Err, "no error expected while retrieving resource roles") + require.Len(t, res.Success, 0, "no roles should be returned before they're created") + }, + }, + { + Name: "FoundRoles", + Input: resourceID, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[[]storage.Role]) { + require.NoError(t, res.Err, "no error expected while retrieving role") + + assert.Len(t, res.Success, len(groups), "expected returned roles to match group count") + + for _, role := range res.Success { + require.NotEmpty(t, role.ID, "role expected to be returned") + + require.NotEmpty(t, role.Name) + assert.Equal(t, groups[role.Name], role.ID) + assert.Equal(t, resourceID, role.ResourceID) + assert.Equal(t, actorID, role.CreatedBy) + assert.False(t, role.CreatedAt.IsZero()) + assert.False(t, role.UpdatedAt.IsZero()) + } + }, + }, + } + + testFn := func(ctx context.Context, input gidx.PrefixedID) testingx.TestResult[[]storage.Role] { + roles, err := store.ListResourceRoles(ctx, input) + + return testingx.TestResult[[]storage.Role]{ + Success: roles, + Err: err, + } + } + + testingx.RunTests(ctx, t, testCases, testFn) +} + +func TestCreateRole(t *testing.T) { + store, closeStore := teststore.NewTestStorage(t) + + t.Cleanup(closeStore) + + ctx := context.Background() + + actorID := gidx.PrefixedID("idntusr-abc123") + resourceID := gidx.PrefixedID("testten-jkl789") + + type testInput struct { + id gidx.PrefixedID + name string + } + + testCases := []testingx.TestCase[testInput, storage.Role]{ + { + Name: "Success", + Input: testInput{ + id: "permrol-abc123", + name: "admins", + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[storage.Role]) { + require.NoError(t, res.Err, "no error expected creating role") + + assert.Equal(t, "permrol-abc123", res.Success.ID.String()) + assert.Equal(t, "admins", res.Success.Name) + assert.Equal(t, resourceID, res.Success.ResourceID) + assert.Equal(t, actorID, res.Success.CreatedBy) + assert.False(t, res.Success.CreatedAt.IsZero()) + assert.False(t, res.Success.UpdatedAt.IsZero()) + }, + Sync: true, + }, + { + Name: "DuplicateIndex", + Input: testInput{ + id: "permrol-abc123", + name: "users", + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[storage.Role]) { + require.Error(t, res.Err, "expected error for duplicate index") + assert.ErrorIs(t, res.Err, storage.ErrRoleAlreadyExists, "expected error to be for role already exists") + require.Empty(t, res.Success.ID, "expected role to be empty") + }, + Sync: true, + }, + { + Name: "NameTaken", + Input: testInput{ + id: "permrol-def456", + name: "admins", + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[storage.Role]) { + assert.Error(t, res.Err, "expected error for already taken name") + assert.ErrorIs(t, res.Err, storage.ErrRoleNameTaken, "expected error to be for already taken name") + require.Empty(t, res.Success.ID, "expected role to be empty") + }, + Sync: true, + }, + } + + testFn := func(ctx context.Context, input testInput) testingx.TestResult[storage.Role] { + var result testingx.TestResult[storage.Role] + + dbCtx, err := store.BeginContext(ctx) + if err != nil { + result.Err = err + + return result + } + + result.Success, result.Err = store.CreateRole(dbCtx, actorID, input.id, input.name, resourceID) + if result.Err != nil { + store.RollbackContext(dbCtx) //nolint:errcheck // skip check in test + + return result + } + + result.Err = store.CommitContext(dbCtx) + + return result + } + + testingx.RunTests(ctx, t, testCases, testFn) +} + +func TestUpdateRole(t *testing.T) { + store, closeStore := teststore.NewTestStorage(t) + + t.Cleanup(closeStore) + + ctx := context.Background() + + actorID := gidx.PrefixedID("idntusr-abc123") + resourceID := gidx.PrefixedID("testten-jkl789") + + role1ID := gidx.PrefixedID("permrol-abc123") + role1Name := "admins" + + role2ID := gidx.PrefixedID("permrol-def456") + role2Name := "users" + + dbCtx, err := store.BeginContext(ctx) + + require.NoError(t, err, "no error expected beginning transaction context") + + createdDBRole1, err := store.CreateRole(dbCtx, actorID, role1ID, role1Name, resourceID) + require.NoError(t, err, "no error expected while seeding database role") + + _, err = store.CreateRole(dbCtx, actorID, role2ID, role2Name, resourceID) + require.NoError(t, err, "no error expected while seeding database role 2") + + err = store.CommitContext(dbCtx) + require.NoError(t, err, "no error expected while committing role creations") + + type testInput struct { + id gidx.PrefixedID + name string + } + + testCases := []testingx.TestCase[testInput, storage.Role]{ + { + Name: "NameTaken", + Input: testInput{ + id: role1ID, + name: role2Name, + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[storage.Role]) { + assert.Error(t, res.Err, "expected error updating role name to an already taken role name") + assert.ErrorIs(t, res.Err, storage.ErrRoleNameTaken, "expected error to be role name taken error") + assert.Empty(t, res.Success.ID, "expected role to be empty") + }, + Sync: true, + }, + { + Name: "Success", + Input: testInput{ + id: role1ID, + name: "root-admins", + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[storage.Role]) { + require.NoError(t, res.Err, "no error expected while updating role") + + assert.Equal(t, role1ID, res.Success.ID) + assert.Equal(t, "root-admins", res.Success.Name) + assert.Equal(t, actorID, res.Success.CreatedBy) + assert.Equal(t, createdDBRole1.CreatedAt, res.Success.CreatedAt) + assert.NotEqual(t, createdDBRole1.UpdatedAt, res.Success.UpdatedAt) + }, + Sync: true, + }, + { + Name: "NotFound", + Input: testInput{ + id: "permrol-notfound789", + name: "not-found", + }, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[storage.Role]) { + require.Error(t, res.Err, "an error expected to be returned for an unknown role") + assert.ErrorIs(t, res.Err, storage.ErrNoRoleFound, "unexpected error returned") + assert.Empty(t, res.Success.ID, "expected role to be empty") + }, + }, + } + + testFn := func(ctx context.Context, input testInput) testingx.TestResult[storage.Role] { + var result testingx.TestResult[storage.Role] + + dbCtx, err := store.BeginContext(ctx) + if err != nil { + result.Err = err + + return result + } + + result.Success, result.Err = store.UpdateRole(dbCtx, actorID, input.id, input.name) + if result.Err != nil { + store.RollbackContext(dbCtx) //nolint:errcheck // skip check in test + + return result + } + + result.Err = store.CommitContext(dbCtx) + + return result + } + + testingx.RunTests(ctx, t, testCases, testFn) +} + +func TestDeleteRole(t *testing.T) { + store, closeStore := teststore.NewTestStorage(t) + + t.Cleanup(closeStore) + + ctx := context.Background() + + actorID := gidx.PrefixedID("idntusr-abc123") + roleID := gidx.PrefixedID("permrol-def456") + roleName := "admins" + resourceID := gidx.PrefixedID("testten-jkl789") + + dbCtx, err := store.BeginContext(ctx) + + require.NoError(t, err, "no error expected beginning transaction context") + + dbRole, err := store.DeleteRole(dbCtx, roleID) + + require.Error(t, err, "error expected while deleting role which doesn't exist") + require.ErrorIs(t, err, storage.ErrNoRoleFound, "expected no role found error for missing role") + assert.Empty(t, dbRole.ID, "expected role to be empty") + + dbCtx, err = store.BeginContext(ctx) + + require.NoError(t, err, "no error expected beginning transaction context") + + createdDBRole, err := store.CreateRole(dbCtx, actorID, roleID, roleName, resourceID) + + require.NoError(t, err, "no error expected while seeding database role") + + err = store.CommitContext(dbCtx) + + require.NoError(t, err, "no error expected while committing role creation") + + dbCtx, err = store.BeginContext(ctx) + + require.NoError(t, err, "no error expected beginning transaction context") + + deletedDBRole, err := store.DeleteRole(dbCtx, roleID) + + require.NoError(t, err, "no error expected while deleting role") + + err = store.CommitContext(dbCtx) + + require.NoError(t, err, "no error expected while committing role deletion") + + role, err := store.GetRoleByID(ctx, roleID) + + require.Error(t, err, "expected error retrieving role") + assert.ErrorIs(t, err, storage.ErrNoRoleFound, "expected no rows error") + assert.Empty(t, role.ID, "role id expected to be empty") + + assert.Equal(t, roleID, createdDBRole.ID, "unexpected created role id") + assert.Equal(t, roleID, deletedDBRole.ID, "unexpected deleted role id") +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 00000000..ea1d64dc --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,57 @@ +// Package storage interacts with the permissions-api database handling the metadata updates for roles and resources. +package storage + +import ( + "context" + "database/sql" + + "go.uber.org/zap" +) + +// Storage defines the interface the engine exposes. +type Storage interface { + RoleService + TransactionManager + + HealthCheck(ctx context.Context) error +} + +// DB is the interface the database package requires from a database engine to run. +// *sql.DB implements these methods. +type DB interface { + BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) + PingContext(ctx context.Context) error + + DBQuery +} + +// DBQuery are required methods for querying the database. +type DBQuery interface { + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +type engine struct { + DB + logger *zap.SugaredLogger +} + +// HealthCheck calls the underlying databases PingContext to check that the database is alive and accepting connections. +func (e *engine) HealthCheck(ctx context.Context) error { + return e.PingContext(ctx) +} + +// New creates a new storage engine using the provided underlying DB. +func New(db DB, options ...Option) Storage { + s := &engine{ + DB: db, + logger: zap.NewNop().Sugar(), + } + + for _, opt := range options { + opt(s) + } + + return s +} diff --git a/internal/storage/teststore/teststore.go b/internal/storage/teststore/teststore.go new file mode 100644 index 00000000..1a5bf42b --- /dev/null +++ b/internal/storage/teststore/teststore.go @@ -0,0 +1,47 @@ +// Package teststore is a testing helper package which initializes a new crdb database and runs migrations +// returning a new store which may be used during testing. +package teststore + +import ( + "testing" + + "github.com/cockroachdb/cockroach-go/v2/testserver" + "github.com/pressly/goose/v3" + + "go.infratographer.com/permissions-api/internal/storage" +) + +// NewTestStorage creates a new permissions database instance for testing. +func NewTestStorage(t *testing.T) (storage.Storage, func()) { + t.Helper() + + server, err := testserver.NewTestServer() + if err != nil { + t.Error(err) + t.FailNow() + + return nil, func() {} + } + + goose.SetBaseFS(storage.Migrations) + + db, err := goose.OpenDBWithDriver("postgres", server.PGURL().String()) + if err != nil { + t.Error(err) + t.FailNow() + + return nil, func() {} + } + + if err = goose.Run("up", db, "migrations"); err != nil { + t.Error(err) + + db.Close() + + t.FailNow() + + return nil, func() {} + } + + return storage.New(db), func() { db.Close() } +} diff --git a/internal/testingx/testing.go b/internal/testingx/testing.go index 013dfe44..4efde0e9 100644 --- a/internal/testingx/testing.go +++ b/internal/testingx/testing.go @@ -22,6 +22,7 @@ type TestCase[T, U any] struct { SetupFn func(context.Context, *testing.T) context.Context CheckFn func(context.Context, *testing.T, TestResult[U]) CleanupFn func(context.Context) + Sync bool } // RunTests runs all provided test cases using the given test function. @@ -30,7 +31,9 @@ func RunTests[T, U any](ctx context.Context, t *testing.T, cases []TestCase[T, U testCase := testCase t.Run(testCase.Name, func(t *testing.T) { - t.Parallel() + if !testCase.Sync { + t.Parallel() + } // Ensure we're closed over ctx ctx := ctx diff --git a/internal/types/types.go b/internal/types/types.go index 443f55c9..34704350 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -2,13 +2,22 @@ package types import ( + "time" + "go.infratographer.com/x/gidx" ) // Role is a collection of permissions. type Role struct { ID gidx.PrefixedID + Name string Actions []string + + ResourceID gidx.PrefixedID + CreatedBy gidx.PrefixedID + UpdatedBy gidx.PrefixedID + CreatedAt time.Time + UpdatedAt time.Time } // ResourceTypeRelationship is a relationship for a resource type.