From 351eb6a51f9740098d986e206a2b07c5e099ad42 Mon Sep 17 00:00:00 2001 From: Mike Mason Date: Wed, 22 Nov 2023 17:38:56 +0000 Subject: [PATCH 01/11] 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 --- .devcontainer/.env | 2 + .devcontainer/docker-compose.yml | 7 +- cmd/root.go | 13 + db/migrations.go | 11 + .../20231122000000_initial_schema.sql | 30 ++ go.mod | 13 + go.sum | 151 +++++++++ internal/config/config.go | 2 + internal/database/db.go | 38 +++ internal/database/errors.go | 34 ++ internal/database/roles.go | 250 +++++++++++++++ internal/database/roles_test.go | 300 ++++++++++++++++++ internal/database/testdb/testdb.go | 48 +++ 13 files changed, 898 insertions(+), 1 deletion(-) create mode 100644 db/migrations.go create mode 100644 db/migrations/20231122000000_initial_schema.sql create mode 100644 internal/database/db.go create mode 100644 internal/database/errors.go create mode 100644 internal/database/roles.go create mode 100644 internal/database/roles_test.go create mode 100644 internal/database/testdb/testdb.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/cmd/root.go b/cmd/root.go index 3fbcb702..be3b0024 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,11 +7,14 @@ 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" + dbm "go.infratographer.com/permissions-api/db" "go.infratographer.com/permissions-api/internal/config" ) @@ -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(dbm.Migrations) + goosex.SetLogger(logger) + goosex.SetDBURI(globalCfg.CRDB.GetURI()) + }) + // Add version command versionx.RegisterCobraCommand(rootCmd, func() { versionx.PrintVersion(logger) }) diff --git a/db/migrations.go b/db/migrations.go new file mode 100644 index 00000000..efffd7ca --- /dev/null +++ b/db/migrations.go @@ -0,0 +1,11 @@ +// Package db provides an embedded filesystem containing all the database migrations +package db + +import ( + "embed" +) + +// Migrations contain an embedded filesystem with all the sql migration files +// +//go:embed migrations/*.sql +var Migrations embed.FS diff --git a/db/migrations/20231122000000_initial_schema.sql b/db/migrations/20231122000000_initial_schema.sql new file mode 100644 index 00000000..a73352f9 --- /dev/null +++ b/db/migrations/20231122000000_initial_schema.sql @@ -0,0 +1,30 @@ +-- +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, + "creator_id" character varying NOT NULL, + "created_at" timestamptz NOT NULL, + "updated_at" timestamptz NOT NULL, + PRIMARY KEY ("id") +); +-- create index "roles_creator_id" to table: "roles" +CREATE INDEX "roles_creator_id" ON "roles" ("creator_id"); +-- 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_creator_id" to table: "roles" +DROP INDEX "roles_creator_id"; +-- reverse: create "roles" table +DROP TABLE "roles"; 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/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/database/db.go b/internal/database/db.go new file mode 100644 index 00000000..16ca50a7 --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,38 @@ +// Package database interacts with the permissions-api database handling the metadata updates for roles and resources. +package database + +import ( + "context" + "database/sql" + + "go.infratographer.com/x/gidx" +) + +// Database defines the interface the database exposes. +type Database 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 gidx.PrefixedID, roleID gidx.PrefixedID, name string, resourceID gidx.PrefixedID) (*Role, error) + DeleteRole(ctx context.Context, roleID gidx.PrefixedID) (*Role, 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) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row +} + +type database struct { + DB +} + +// NewDatabase creates a new Database using the provided underlying DB. +func NewDatabase(db DB) Database { + return &database{ + DB: db, + } +} diff --git a/internal/database/errors.go b/internal/database/errors.go new file mode 100644 index 00000000..23f99bfd --- /dev/null +++ b/internal/database/errors.go @@ -0,0 +1,34 @@ +package database + +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") +) + +func pqIsRoleAlreadyExistsError(err error) bool { + if pqErr, ok := err.(*pq.Error); ok { + return pqErr.Code.Name() == "unique_violation" && pqErr.Constraint == "roles_pkey" + } + + return false +} + +func pqIsRoleNameTakenError(err error) bool { + if pqErr, ok := err.(*pq.Error); ok { + return pqErr.Code.Name() == "unique_violation" && pqErr.Constraint == "roles_resource_id_name" + } + + return false +} diff --git a/internal/database/roles.go b/internal/database/roles.go new file mode 100644 index 00000000..d1e6d0ab --- /dev/null +++ b/internal/database/roles.go @@ -0,0 +1,250 @@ +package database + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "go.infratographer.com/x/gidx" +) + +// Role represents a role in the database. +type Role struct { + ID gidx.PrefixedID + Name string + ResourceID gidx.PrefixedID + CreatorID gidx.PrefixedID + CreatedAt time.Time + UpdatedAt time.Time + + Commit func() error + Rollback func() error +} + +// GetRoleByID retrieves a role from the database by the provided prefixed ID. +// If no role exists an ErrRoleNotFound error is returned. +func (db *database) GetRoleByID(ctx context.Context, id gidx.PrefixedID) (*Role, error) { + var role Role + + err := db.QueryRowContext(ctx, ` + SELECT + id, + name, + resource_id, + creator_id, + created_at, + updated_at + FROM roles + WHERE id = $1 + `, id.String(), + ).Scan( + &role.ID, + &role.Name, + &role.ResourceID, + &role.CreatorID, + &role.CreatedAt, + &role.UpdatedAt, + ) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: %s", ErrNoRoleFound, id.String()) + } + + return nil, fmt.Errorf("%w: %s", err, id.String()) + } + + return &role, 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 (db *database) GetResourceRoleByName(ctx context.Context, resourceID gidx.PrefixedID, name string) (*Role, error) { + var role Role + + err := db.QueryRowContext(ctx, ` + SELECT + id, + name, + resource_id, + creator_id, + created_at, + updated_at + FROM roles + WHERE + resource_id = $1 + AND name = $2 + `, + resourceID.String(), + name, + ).Scan( + &role.ID, + &role.Name, + &role.ResourceID, + &role.CreatorID, + &role.CreatedAt, + &role.UpdatedAt, + ) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: %s", ErrNoRoleFound, name) + } + + return nil, 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 (db *database) ListResourceRoles(ctx context.Context, resourceID gidx.PrefixedID) ([]*Role, error) { + rows, err := db.QueryContext(ctx, ` + SELECT + id, + name, + resource_id, + creator_id, + 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.CreatorID, &role.CreatedAt, &role.UpdatedAt); err != nil { + return nil, err + } + + roles = append(roles, &role) + } + + return roles, nil +} + +// CreateRole creates a role with the provided details returning the new Role entry. +// 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. +func (db *database) CreateRole(ctx context.Context, actorID, roleID gidx.PrefixedID, name string, resourceID gidx.PrefixedID) (*Role, error) { + var role Role + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + + err = tx.QueryRowContext(ctx, ` + INSERT + INTO roles (id, name, resource_id, creator_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, now(), now()) + RETURNING id, name, resource_id, creator_id, created_at, updated_at + `, roleID.String(), name, resourceID.String(), actorID.String(), + ).Scan( + &role.ID, + &role.Name, + &role.ResourceID, + &role.CreatorID, + &role.CreatedAt, + &role.UpdatedAt, + ) + + if err != nil { + if pqIsRoleAlreadyExistsError(err) { + return nil, fmt.Errorf("%w: %s", ErrRoleAlreadyExists, roleID.String()) + } + + if pqIsRoleNameTakenError(err) { + return nil, fmt.Errorf("%w: %s", ErrRoleNameTaken, name) + } + + return nil, err + } + + role.Commit = tx.Commit + role.Rollback = tx.Rollback + + return &role, nil +} + +// UpdateRole updates an existing role if one exists. +// If no role already exists, a new role is created in the same way as CreateRole. +// If changing the name and the new name results in a duplicate name error, an ErrRoleNameTaken error is returned. +func (db *database) UpdateRole(ctx context.Context, actorID, roleID gidx.PrefixedID, name string, resourceID gidx.PrefixedID) (*Role, error) { + var role Role + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + + err = tx.QueryRowContext(ctx, ` + INSERT INTO roles (id, name, resource_id, creator_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, now(), now()) + ON CONFLICT (id) DO UPDATE + SET (name, resource_id, updated_at) = (excluded.name, excluded.resource_id, excluded.updated_at) + RETURNING id, name, resource_id, creator_id, created_at, updated_at + `, roleID.String(), name, resourceID.String(), actorID.String(), + ).Scan( + &role.ID, + &role.Name, + &role.ResourceID, + &role.CreatorID, + &role.CreatedAt, + &role.UpdatedAt, + ) + + if err != nil { + if pqIsRoleNameTakenError(err) { + return nil, fmt.Errorf("%w: %s", ErrRoleNameTaken, name) + } + + return nil, err + } + + role.Commit = tx.Commit + role.Rollback = tx.Rollback + + return &role, nil +} + +// DeleteRole deletes the role id provided, if no rows are affected an ErrNoRoleFound error is returned. +func (db *database) DeleteRole(ctx context.Context, roleID gidx.PrefixedID) (*Role, error) { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + + result, err := tx.ExecContext(ctx, `DELETE FROM roles WHERE id = $1`, roleID.String()) + if err != nil { + return nil, err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return nil, err + } + + if rowsAffected != 1 { + return nil, ErrNoRoleFound + } + + return &Role{ + ID: roleID, + Commit: tx.Commit, + Rollback: tx.Rollback, + }, nil +} diff --git a/internal/database/roles_test.go b/internal/database/roles_test.go new file mode 100644 index 00000000..68459b7f --- /dev/null +++ b/internal/database/roles_test.go @@ -0,0 +1,300 @@ +package database_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/database" + "go.infratographer.com/permissions-api/internal/database/testdb" +) + +func TestGetRoleByID(t *testing.T) { + db, dbClose := testdb.NewTestDatabase(t) + defer dbClose() + + ctx := context.Background() + actorID := gidx.PrefixedID("idntusr-abc123") + roleID := gidx.PrefixedID("permrol-def456") + roleName := "admins" + resourceID := gidx.PrefixedID("testten-jkl789") + + // ensure expected empty results returned + role, err := db.GetRoleByID(ctx, roleID) + + require.Error(t, err, "error expected when no role is found") + assert.ErrorIs(t, err, database.ErrNoRoleFound) + require.Nil(t, role, "no role expected to be returned") + + createdRole, err := db.CreateRole(ctx, actorID, roleID, roleName, resourceID) + + require.NoError(t, err, "no error expected while seeding database role") + + err = createdRole.Commit() + + require.NoError(t, err, "no error expected while committing role creation") + + role, err = db.GetRoleByID(ctx, roleID) + + require.NoError(t, err, "no error expected while retrieving role") + + require.NotNil(t, role, "role expected to be returned") + + assert.Equal(t, roleID, role.ID) + assert.Equal(t, roleName, role.Name) + assert.Equal(t, resourceID, role.ResourceID) + assert.Equal(t, actorID, role.CreatorID) + assert.Equal(t, createdRole.CreatedAt, role.CreatedAt) + assert.Equal(t, createdRole.UpdatedAt, role.UpdatedAt) +} + +func TestGetResourceRoleByName(t *testing.T) { + db, dbClose := testdb.NewTestDatabase(t) + defer dbClose() + + ctx := context.Background() + actorID := gidx.PrefixedID("idntusr-abc123") + roleID := gidx.PrefixedID("permrol-def456") + roleName := "admins" + resourceID := gidx.PrefixedID("testten-jkl789") + + // ensure expected empty results returned + role, err := db.GetResourceRoleByName(ctx, resourceID, "admins") + + require.Error(t, err, "error expected when no role is found") + assert.ErrorIs(t, err, database.ErrNoRoleFound) + require.Nil(t, role, "role expected to be returned") + + createdRole, err := db.CreateRole(ctx, actorID, roleID, roleName, resourceID) + + require.NoError(t, err, "no error expected while seeding database role") + + err = createdRole.Commit() + + require.NoError(t, err, "no error expected while committing role creation") + + role, err = db.GetResourceRoleByName(ctx, resourceID, "admins") + + require.NoError(t, err, "no error expected while retrieving role") + + require.NotNil(t, role, "role expected to be returned") + + assert.Equal(t, roleID, role.ID) + assert.Equal(t, roleName, role.Name) + assert.Equal(t, resourceID, role.ResourceID) + assert.Equal(t, actorID, role.CreatorID) + assert.Equal(t, createdRole.CreatedAt, role.CreatedAt) + assert.Equal(t, createdRole.UpdatedAt, role.UpdatedAt) +} + +func TestListResourceRoles(t *testing.T) { + db, dbClose := testdb.NewTestDatabase(t) + defer dbClose() + + ctx := context.Background() + actorID := gidx.PrefixedID("idntusr-abc123") + + resourceID := gidx.PrefixedID("testten-jkl789") + + // ensure expected empty results returned + roles, err := db.ListResourceRoles(ctx, resourceID) + + require.NoError(t, err, "no error expected while retrieving resource roles") + require.Len(t, roles, 0, "no roles should be returned before they're created") + + groups := map[string]gidx.PrefixedID{ + "super-admins": "permrol-abc123", + "admins": "permrol-def456", + "users": "permrol-ghi789", + } + + for roleName, roleID := range groups { + role, err := db.CreateRole(ctx, actorID, roleID, roleName, resourceID) + + require.NoError(t, err, "no error expected creating role", roleName) + + err = role.Commit() + + require.NoError(t, err, "no error expected while committing role", roleName) + } + + roles, err = db.ListResourceRoles(ctx, resourceID) + + require.NoError(t, err, "no error expected while retrieving resource roles") + + assert.Len(t, roles, len(groups), "expected returned roles to match group count") + + for _, role := range roles { + require.NotNil(t, role, "role expected to be returned") + + assert.Equal(t, groups[role.Name], role.ID) + assert.NotEmpty(t, role.Name) + assert.Equal(t, resourceID, role.ResourceID) + assert.Equal(t, actorID, role.CreatorID) + assert.False(t, role.CreatedAt.IsZero()) + assert.False(t, role.UpdatedAt.IsZero()) + } +} + +func TestCreateRole(t *testing.T) { + db, dbClose := testdb.NewTestDatabase(t) + defer dbClose() + + ctx := context.Background() + actorID := gidx.PrefixedID("idntusr-abc123") + roleID := gidx.PrefixedID("permrol-def456") + roleID2 := gidx.PrefixedID("permrole-lmn789") + roleName := "admins" + resourceID := gidx.PrefixedID("testten-jkl789") + + role, err := db.CreateRole(ctx, actorID, roleID, roleName, resourceID) + + require.NoError(t, err, "no error expected while creating role") + + err = role.Commit() + + require.NoError(t, err, "no error expected while committing role creation") + + assert.Equal(t, roleID, role.ID) + assert.Equal(t, roleName, role.Name) + assert.Equal(t, resourceID, role.ResourceID) + assert.Equal(t, actorID, role.CreatorID) + assert.False(t, role.CreatedAt.IsZero()) + assert.False(t, role.UpdatedAt.IsZero()) + + dupeRole, err := db.CreateRole(ctx, actorID, roleID, roleName, resourceID) + + assert.Error(t, err, "expected error for duplicate index") + assert.ErrorIs(t, err, database.ErrRoleAlreadyExists, "expected error to be for role already exists") + require.Nil(t, dupeRole, "expected role to be nil") + + takenNameRole, err := db.CreateRole(ctx, actorID, roleID2, roleName, resourceID) + + assert.Error(t, err, "expected error for already taken name") + assert.ErrorIs(t, err, database.ErrRoleNameTaken, "expected error to be for already taken name") + require.Nil(t, takenNameRole, "expected role to be nil") +} + +func TestUpdateRole(t *testing.T) { + db, dbClose := testdb.NewTestDatabase(t) + defer dbClose() + + ctx := context.Background() + + createActorID := gidx.PrefixedID("idntusr-abc123") + roleID1 := gidx.PrefixedID("permrol-def456") + roleID2 := gidx.PrefixedID("permrol-mno753") + roleName := "admins" + roleName2 := "temps" + resourceID := gidx.PrefixedID("testten-jkl789") + + createdRole, err := db.CreateRole(ctx, createActorID, roleID1, roleName, resourceID) + require.NoError(t, err, "no error expected while seeding database role") + + err = createdRole.Commit() + + require.NoError(t, err, "no error expected while committing role creation") + + createdRole2, err := db.CreateRole(ctx, createActorID, roleID2, roleName2, resourceID) + require.NoError(t, err, "no error expected while seeding database role 2") + + err = createdRole2.Commit() + + require.NoError(t, err, "no error expected while committing role 2 creation") + + updateActorID := gidx.PrefixedID("idntusr-abc456") + + t.Run("update error", func(t *testing.T) { + role, err := db.UpdateRole(ctx, updateActorID, roleID2, roleName, resourceID) + + assert.Error(t, err, "expected error updating role name to an already taken role name") + assert.ErrorIs(t, err, database.ErrRoleNameTaken, "expected error to be role name taken error") + assert.Nil(t, role, "expected role to be nil") + }) + + updateRoleName := "new-admins" + updateResourceID := gidx.PrefixedID("testten-mno101") + + t.Run("existing role", func(t *testing.T) { + role, err := db.UpdateRole(ctx, updateActorID, roleID1, updateRoleName, updateResourceID) + + require.NoError(t, err, "no error expected while updating role") + + require.NotNil(t, role, "role expected to be returned") + + err = role.Commit() + + require.NoError(t, err, "no error expected while committing role update") + + assert.Equal(t, roleID1, role.ID) + assert.Equal(t, updateRoleName, role.Name) + assert.Equal(t, updateResourceID, role.ResourceID) + assert.Equal(t, createActorID, role.CreatorID) + assert.Equal(t, createdRole.CreatedAt, role.CreatedAt) + assert.NotEqual(t, createdRole.UpdatedAt, role.UpdatedAt) + }) + + t.Run("new role", func(t *testing.T) { + newRoleID := gidx.PrefixedID("permrol-xyz789") + newRoleName := "users" + newResourceID := gidx.PrefixedID("testten-lmn159") + + role, err := db.UpdateRole(ctx, updateActorID, newRoleID, newRoleName, newResourceID) + + require.NoError(t, err, "no error expected while updating role") + + require.NotNil(t, role, "role expected to be returned") + + err = role.Commit() + + require.NoError(t, err, "no error expected while committing new role from update") + + assert.Equal(t, newRoleID, role.ID) + assert.Equal(t, newRoleName, role.Name) + assert.Equal(t, newResourceID, role.ResourceID) + assert.Equal(t, updateActorID, role.CreatorID) + assert.False(t, createdRole.CreatedAt.IsZero()) + assert.False(t, createdRole.UpdatedAt.IsZero()) + }) +} + +func TestDeleteRole(t *testing.T) { + db, dbClose := testdb.NewTestDatabase(t) + defer dbClose() + + ctx := context.Background() + actorID := gidx.PrefixedID("idntusr-abc123") + roleID := gidx.PrefixedID("permrol-def456") + roleName := "admins" + resourceID := gidx.PrefixedID("testten-jkl789") + + _, err := db.DeleteRole(ctx, roleID) + + require.Error(t, err, "error expected while deleting role which doesn't exist") + require.ErrorIs(t, err, database.ErrNoRoleFound, "expected no role found error for missing role") + + role, err := db.CreateRole(ctx, actorID, roleID, roleName, resourceID) + + require.NoError(t, err, "no error expected while seeding database role") + + err = role.Commit() + + require.NoError(t, err, "no error expected while committing role creation") + + role, err = db.DeleteRole(ctx, roleID) + + require.NoError(t, err, "no error expected while deleting role") + + err = role.Commit() + + require.NoError(t, err, "no error expected while committing role deletion") + + role, err = db.GetRoleByID(ctx, roleID) + + require.Error(t, err, "expected error retrieving role") + assert.ErrorIs(t, err, database.ErrNoRoleFound, "expected no rows error") + assert.Nil(t, role, "role expected to nil") +} diff --git a/internal/database/testdb/testdb.go b/internal/database/testdb/testdb.go new file mode 100644 index 00000000..fd8bb5f0 --- /dev/null +++ b/internal/database/testdb/testdb.go @@ -0,0 +1,48 @@ +// Package testdb is a testing helper package which initializes a new crdb database and runs migrations +// returning a new database which may be used during testing. +package testdb + +import ( + "testing" + + "github.com/cockroachdb/cockroach-go/v2/testserver" + "github.com/pressly/goose/v3" + + dbm "go.infratographer.com/permissions-api/db" + "go.infratographer.com/permissions-api/internal/database" +) + +// NewTestDatabase creates a new permissions database instance for testing. +func NewTestDatabase(t *testing.T) (database.Database, func()) { + t.Helper() + + server, err := testserver.NewTestServer() + if err != nil { + t.Error(err) + t.FailNow() + + return nil, func() {} + } + + goose.SetBaseFS(dbm.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 database.NewDatabase(db), func() { db.Close() } +} From aeedd0d1c0c45902d866e25d43bb606ae102bd6f Mon Sep 17 00:00:00 2001 From: Mike Mason Date: Tue, 28 Nov 2023 21:21:44 +0000 Subject: [PATCH 02/11] 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 --- cmd/createrole.go | 19 ++++- cmd/server.go | 11 ++- cmd/worker.go | 31 ++++++--- internal/api/roles.go | 29 ++++++-- internal/api/types.go | 7 ++ internal/query/mock/mock.go | 11 ++- internal/query/relations.go | 116 ++++++++++++++++++++++++++++++- internal/query/relations_test.go | 30 ++++++-- internal/query/roles.go | 3 +- internal/query/service.go | 7 +- internal/types/types.go | 8 +++ 11 files changed, 237 insertions(+), 35 deletions(-) diff --git a/cmd/createrole.go b/cmd/createrole.go index c1fa2d56..b49ce108 100644 --- a/cmd/createrole.go +++ b/cmd/createrole.go @@ -5,11 +5,13 @@ 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" "go.infratographer.com/permissions-api/internal/config" + "go.infratographer.com/permissions-api/internal/database" "go.infratographer.com/permissions-api/internal/iapl" "go.infratographer.com/permissions-api/internal/query" "go.infratographer.com/permissions-api/internal/spicedbx" @@ -19,6 +21,7 @@ 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) + } + + permDB := database.NewDatabase(db) + 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, permDB, 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/server.go b/cmd/server.go index 2d8f5925..02049153 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" @@ -14,6 +15,7 @@ import ( "go.infratographer.com/permissions-api/internal/api" "go.infratographer.com/permissions-api/internal/config" + "go.infratographer.com/permissions-api/internal/database" "go.infratographer.com/permissions-api/internal/iapl" "go.infratographer.com/permissions-api/internal/query" "go.infratographer.com/permissions-api/internal/spicedbx" @@ -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) + } + + permDB := database.NewDatabase(db) + 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, permDB, query.WithPolicy(policy)) if err != nil { logger.Fatalw("error creating engine", "error", err) } diff --git a/cmd/worker.go b/cmd/worker.go index 89e014f0..29aabcd2 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" @@ -16,6 +17,7 @@ import ( "go.uber.org/zap" "go.infratographer.com/permissions-api/internal/config" + "go.infratographer.com/permissions-api/internal/database" "go.infratographer.com/permissions-api/internal/iapl" "go.infratographer.com/permissions-api/internal/pubsub" "go.infratographer.com/permissions-api/internal/query" @@ -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) + } + + permDB := database.NewDatabase(db) + 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, permDB, query.WithPolicy(policy)) if err != nil { logger.Fatalw("error creating engine", "error", err) } diff --git a/internal/api/roles.go b/internal/api/roles.go index 1f841b30..e0516446 100644 --- a/internal/api/roles.go +++ b/internal/api/roles.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "time" "github.com/labstack/echo/v4" "go.infratographer.com/x/gidx" @@ -49,14 +50,19 @@ 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, + Creator: role.Creator, + CreatedAt: role.CreatedAt.Format(time.RFC3339), + UpdatedAt: role.UpdatedAt.Format(time.RFC3339), } return c.JSON(http.StatusCreated, resp) @@ -102,8 +108,13 @@ 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, + Creator: role.Creator, + CreatedAt: role.CreatedAt.Format(time.RFC3339), + UpdatedAt: role.UpdatedAt.Format(time.RFC3339), } return c.JSON(http.StatusOK, resp) @@ -145,8 +156,12 @@ 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, + Creator: role.Creator, + CreatedAt: role.CreatedAt.Format(time.RFC3339), + UpdatedAt: role.UpdatedAt.Format(time.RFC3339), } resp.Data = append(resp.Data, roleResp) diff --git a/internal/api/types.go b/internal/api/types.go index 018ea06d..7dd93b36 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -5,12 +5,19 @@ import ( ) type createRoleRequest struct { + Name string `json:"name" binding:"required"` Actions []string `json:"actions" binding:"required"` } type roleResponse struct { ID gidx.PrefixedID `json:"id"` + Name string `json:"name"` Actions []string `json:"actions"` + + ResourceID gidx.PrefixedID `json:"resource_id,omitempty"` + Creator gidx.PrefixedID `json:"creator"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type resourceResponse struct { diff --git a/internal/query/mock/mock.go b/internal/query/mock/mock.go index c6e2289a..d3585ac6 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,15 +44,19 @@ 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, + Creator: actor.ID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } return role, nil diff --git a/internal/query/relations.go b/internal/query/relations.go index c7e136ab..a4cd2a21 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/database" "go.infratographer.com/permissions-api/internal/types" ) @@ -274,16 +276,42 @@ 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() + + role := newRole(roleName, actions) roleRels := e.roleRelationships(role, res) + dbRole, err := e.db.CreateRole(ctx, actor.ID, role.ID, roleName, res.ID) + if err != nil { + return types.Role{}, err + } + + defer dbRole.Rollback() + request := &pb.WriteRelationshipsRequest{Updates: roleRels} if _, err := e.client.WriteRelationships(ctx, request); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return types.Role{}, err } + if err = dbRole.Commit(); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return types.Role{}, err + } + + role.Creator = dbRole.CreatorID + role.ResourceID = dbRole.ResourceID + role.CreatedAt = dbRole.CreatedAt + role.UpdatedAt = dbRole.UpdatedAt + return role, nil } @@ -638,6 +666,19 @@ func (e *engine) ListRoles(ctx context.Context, resource types.Resource) ([]type }, } + dbDone := make(chan struct{}, 1) + + var ( + dbRoles []*database.Role + dbErr error + ) + + go func() { + defer close(dbDone) + + dbRoles, dbErr = e.db.ListResourceRoles(ctx, resource.ID) + }() + relationships, err := e.readRelationships(ctx, filter) if err != nil { return nil, err @@ -645,6 +686,30 @@ func (e *engine) ListRoles(ctx context.Context, resource types.Resource) ([]type out := relationshipsToRoles(relationships) + <-dbDone + + if dbErr != nil { + if !errors.Is(dbErr, database.ErrNoRoleFound) { + return nil, dbErr + } + } else { + rolesByID := make(map[string]*database.Role, len(dbRoles)) + for _, role := range dbRoles { + rolesByID[role.ID.String()] = role + } + + for i, role := range out { + if dbRole, ok := rolesByID[role.ID.String()]; ok { + role.Name = dbRole.Name + role.Creator = dbRole.CreatorID + role.CreatedAt = dbRole.CreatedAt + role.UpdatedAt = dbRole.UpdatedAt + + out[i] = role + } + } + } + return out, nil } @@ -724,9 +789,24 @@ func (e *engine) GetRole(ctx context.Context, roleResource types.Resource) (type actions[i] = relationToAction(action) } + dbRole, err := e.db.GetRoleByID(ctx, roleResource.ID) + if err != nil && !errors.Is(err, database.ErrNoRoleFound) { + e.logger.Error("error while getting role", zap.Error(err)) + } + + if dbRole == nil { + dbRole = new(database.Role) + } + return types.Role{ ID: roleResource.ID, + Name: dbRole.Name, Actions: actions, + + ResourceID: dbRole.ResourceID, + Creator: dbRole.CreatorID, + CreatedAt: dbRole.CreatedAt, + UpdatedAt: dbRole.UpdatedAt, }, nil } @@ -766,6 +846,10 @@ 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 { + ctx, span := e.tracer.Start(ctx, "engine.DeleteRole") + + defer span.End() + var ( resActions map[types.Resource][]string err error @@ -810,9 +894,35 @@ func (e *engine) DeleteRole(ctx context.Context, roleResource types.Resource) er } } + dbRole, err := e.db.DeleteRole(ctx, roleResource.ID) + if err != nil { + // If the role doesn't exist, simply ignore. + if !errors.Is(err, database.ErrNoRoleFound) { + return err + } + } else { + // Setup rollback in case an error occurs before we commit. + defer dbRole.Rollback() + } + 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()) + + return err + } + } + + // If the role was not found, dbRole will be nil. + if dbRole != nil { + if err = dbRole.Commit(); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return err } } diff --git a/internal/query/relations_test.go b/internal/query/relations_test.go index b0f6cbdd..b570f679 100644 --- a/internal/query/relations_test.go +++ b/internal/query/relations_test.go @@ -13,6 +13,7 @@ import ( "go.infratographer.com/x/gidx" "go.infratographer.com/x/testing/eventtools" + "go.infratographer.com/permissions-api/internal/database/testdb" "go.infratographer.com/permissions-api/internal/iapl" "go.infratographer.com/permissions-api/internal/spicedbx" "go.infratographer.com/permissions-api/internal/testingx" @@ -29,6 +30,8 @@ func testEngine(ctx context.Context, t *testing.T, namespace string) *engine { client, err := spicedbx.NewClient(config, false) require.NoError(t, err) + db, cleanPDB := testdb.NewTestDatabase(t) + policy := testPolicy() schema, err := spicedbx.GenerateSchema(namespace, policy.Schema()) @@ -50,11 +53,12 @@ func testEngine(ctx context.Context, t *testing.T, namespace string) *engine { t.Cleanup(func() { cleanDB(ctx, t, client, namespace) + cleanPDB() }) // 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, db, WithPolicy(policy)) require.NoError(t, err) return out.(*engine) @@ -138,8 +142,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 +171,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) @@ -219,8 +227,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 +293,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 +353,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 +606,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..135f87ce 100644 --- a/internal/query/service.go +++ b/internal/query/service.go @@ -10,6 +10,7 @@ import ( "go.opentelemetry.io/otel/trace" "go.uber.org/zap" + "go.infratographer.com/permissions-api/internal/database" "go.infratographer.com/permissions-api/internal/iapl" "go.infratographer.com/permissions-api/internal/types" ) @@ -24,7 +25,7 @@ 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) 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 +46,7 @@ type engine struct { namespace string client *authzed.Client kv nats.KeyValue + db database.Database schema []types.ResourceType schemaPrefixMap map[string]types.ResourceType schemaTypeMap map[string]types.ResourceType @@ -91,7 +93,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, db database.Database, options ...Option) (Engine, error) { tracer := otel.GetTracerProvider().Tracer("go.infratographer.com/permissions-api/internal/query") e := &engine{ @@ -99,6 +101,7 @@ func NewEngine(namespace string, client *authzed.Client, kv nats.KeyValue, optio namespace: namespace, client: client, kv: kv, + db: db, tracer: tracer, } diff --git a/internal/types/types.go b/internal/types/types.go index 443f55c9..21045887 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -2,13 +2,21 @@ 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 + Creator gidx.PrefixedID + CreatedAt time.Time + UpdatedAt time.Time } // ResourceTypeRelationship is a relationship for a resource type. From 80a9f63fad8f4415d358e258c3b319ef9bfc3781 Mon Sep 17 00:00:00 2001 From: Mike Mason Date: Wed, 29 Nov 2023 15:23:21 +0000 Subject: [PATCH 03/11] add logging and health check Signed-off-by: Mike Mason --- cmd/createrole.go | 2 +- cmd/server.go | 3 ++- cmd/worker.go | 3 ++- internal/database/db.go | 22 ++++++++++++++--- internal/database/errors.go | 4 +++ internal/database/options.go | 13 ++++++++++ internal/database/roles.go | 48 ++++++++++++++++++++++++++++++------ internal/query/relations.go | 4 +-- 8 files changed, 83 insertions(+), 16 deletions(-) create mode 100644 internal/database/options.go diff --git a/cmd/createrole.go b/cmd/createrole.go index b49ce108..47c0374e 100644 --- a/cmd/createrole.go +++ b/cmd/createrole.go @@ -81,7 +81,7 @@ func createRole(ctx context.Context, cfg *config.AppConfig) { logger.Fatalw("unable to initialize permissions-api database", "error", err) } - permDB := database.NewDatabase(db) + permDB := database.NewDatabase(db, database.WithLogger(logger)) var policy iapl.Policy diff --git a/cmd/server.go b/cmd/server.go index 02049153..1666a99d 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -70,7 +70,7 @@ func serve(ctx context.Context, cfg *config.AppConfig) { logger.Fatalw("unable to initialize permissions-api database", "error", err) } - permDB := database.NewDatabase(db) + permDB := database.NewDatabase(db, database.WithLogger(logger)) var policy iapl.Policy @@ -110,6 +110,7 @@ func serve(ctx context.Context, cfg *config.AppConfig) { srv.AddHandler(r) srv.AddReadinessCheck("spicedb", spicedbx.Healthcheck(spiceClient)) + srv.AddReadinessCheck("database", permDB.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 29aabcd2..bf0ffb06 100644 --- a/cmd/worker.go +++ b/cmd/worker.go @@ -69,7 +69,7 @@ func worker(ctx context.Context, cfg *config.AppConfig) { logger.Fatalw("unable to initialize permissions-api database", "error", err) } - permDB := database.NewDatabase(db) + permDB := database.NewDatabase(db, database.WithLogger(logger)) var policy iapl.Policy @@ -123,6 +123,7 @@ func worker(ctx context.Context, cfg *config.AppConfig) { } srv.AddReadinessCheck("spicedb", spicedbx.Healthcheck(spiceClient)) + srv.AddReadinessCheck("database", permDB.HealthCheck) quit := make(chan os.Signal, 1) diff --git a/internal/database/db.go b/internal/database/db.go index 16ca50a7..9dde50d8 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -6,6 +6,7 @@ import ( "database/sql" "go.infratographer.com/x/gidx" + "go.uber.org/zap" ) // Database defines the interface the database exposes. @@ -16,6 +17,7 @@ type Database interface { CreateRole(ctx context.Context, actorID gidx.PrefixedID, roleID gidx.PrefixedID, name string, resourceID gidx.PrefixedID) (*Role, error) UpdateRole(ctx context.Context, actorID gidx.PrefixedID, roleID gidx.PrefixedID, name string, resourceID gidx.PrefixedID) (*Role, error) DeleteRole(ctx context.Context, roleID gidx.PrefixedID) (*Role, error) + HealthCheck(ctx context.Context) error } // DB is the interface the database package requires from a database engine to run. @@ -24,15 +26,29 @@ type DB interface { BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row + PingContext(ctx context.Context) error } type database struct { DB + logger *zap.SugaredLogger +} + +// HealthCheck calls the underlying databases PingContext to check that the database is alive and accepting connections. +func (db *database) HealthCheck(ctx context.Context) error { + return db.PingContext(ctx) } // NewDatabase creates a new Database using the provided underlying DB. -func NewDatabase(db DB) Database { - return &database{ - DB: db, +func NewDatabase(db DB, options ...Option) Database { + d := &database{ + DB: db, + logger: zap.NewNop().Sugar(), } + + for _, opt := range options { + opt(d) + } + + return d } diff --git a/internal/database/errors.go b/internal/database/errors.go index 23f99bfd..8f0599e1 100644 --- a/internal/database/errors.go +++ b/internal/database/errors.go @@ -15,6 +15,10 @@ var ( // 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") ) func pqIsRoleAlreadyExistsError(err error) bool { diff --git a/internal/database/options.go b/internal/database/options.go new file mode 100644 index 00000000..63510d67 --- /dev/null +++ b/internal/database/options.go @@ -0,0 +1,13 @@ +package database + +import "go.uber.org/zap" + +// Option defines a database configuration option. +type Option func(d *database) + +// WithLogger sets the logger for the database. +func WithLogger(logger *zap.SugaredLogger) Option { + return func(d *database) { + d.logger = logger.Named("database") + } +} diff --git a/internal/database/roles.go b/internal/database/roles.go index d1e6d0ab..1abd90a2 100644 --- a/internal/database/roles.go +++ b/internal/database/roles.go @@ -8,6 +8,7 @@ import ( "time" "go.infratographer.com/x/gidx" + "go.uber.org/zap" ) // Role represents a role in the database. @@ -19,8 +20,36 @@ type Role struct { CreatedAt time.Time UpdatedAt time.Time - Commit func() error - Rollback func() error + logger *zap.SugaredLogger + commit func() error + rollback func() error +} + +// Commit calls commit on the transaction if the role has been created within a transaction. +// If not the method returns an ErrMethodUnavailable error. +func (r *Role) Commit() error { + if r.commit == nil { + return ErrMethodUnavailable + } + + return r.commit() +} + +// Rollback calls rollback on the transaction if the role has been created within a transaction. +// If not the method returns an ErrMethodUnavailable error. +// +// To simplify rollbacks, logging has automatically been setup to log any errors produced if a rollback fails. +func (r *Role) Rollback() error { + if r.rollback == nil { + return ErrMethodUnavailable + } + + err := r.rollback() + if err != nil { + r.logger.Errorw("failed to rollback role", "role_id", r.ID, zap.Error(err)) + } + + return err } // GetRoleByID retrieves a role from the database by the provided prefixed ID. @@ -174,8 +203,9 @@ func (db *database) CreateRole(ctx context.Context, actorID, roleID gidx.Prefixe return nil, err } - role.Commit = tx.Commit - role.Rollback = tx.Rollback + role.logger = db.logger.Named("role") + role.commit = tx.Commit + role.rollback = tx.Rollback return &role, nil } @@ -215,8 +245,9 @@ func (db *database) UpdateRole(ctx context.Context, actorID, roleID gidx.Prefixe return nil, err } - role.Commit = tx.Commit - role.Rollback = tx.Rollback + role.logger = db.logger.Named("role") + role.commit = tx.Commit + role.rollback = tx.Rollback return &role, nil } @@ -244,7 +275,8 @@ func (db *database) DeleteRole(ctx context.Context, roleID gidx.PrefixedID) (*Ro return &Role{ ID: roleID, - Commit: tx.Commit, - Rollback: tx.Rollback, + logger: db.logger.Named("role"), + commit: tx.Commit, + rollback: tx.Rollback, }, nil } diff --git a/internal/query/relations.go b/internal/query/relations.go index a4cd2a21..5af14672 100644 --- a/internal/query/relations.go +++ b/internal/query/relations.go @@ -289,7 +289,7 @@ func (e *engine) CreateRole(ctx context.Context, actor, res types.Resource, role return types.Role{}, err } - defer dbRole.Rollback() + defer dbRole.Rollback() //nolint:errcheck // error is logged in function request := &pb.WriteRelationshipsRequest{Updates: roleRels} @@ -902,7 +902,7 @@ func (e *engine) DeleteRole(ctx context.Context, roleResource types.Resource) er } } else { // Setup rollback in case an error occurs before we commit. - defer dbRole.Rollback() + defer dbRole.Rollback() //nolint:errcheck // error is logged in function } for _, filter := range filters { From 84d2e1ef3d9654c65870499ae0088be2c8592256 Mon Sep 17 00:00:00 2001 From: Mike Mason Date: Wed, 29 Nov 2023 18:45:09 +0000 Subject: [PATCH 04/11] add support for updating of roles Signed-off-by: Mike Mason --- internal/api/roles.go | 57 +++++++++++ internal/api/router.go | 1 + internal/api/types.go | 5 + internal/database/roles.go | 2 +- internal/query/mock/mock.go | 5 + internal/query/relations.go | 161 +++++++++++++++++++++++++++++++ internal/query/relations_test.go | 62 ++++++++++++ internal/query/service.go | 1 + 8 files changed, 293 insertions(+), 1 deletion(-) diff --git a/internal/api/roles.go b/internal/api/roles.go index e0516446..4338c675 100644 --- a/internal/api/roles.go +++ b/internal/api/roles.go @@ -68,6 +68,63 @@ func (r *Router) roleCreate(c echo.Context) error { 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 { + return echo.NewHTTPError(http.StatusBadRequest, "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, + Creator: role.Creator, + 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") 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 7dd93b36..71825d06 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -9,6 +9,11 @@ type createRoleRequest struct { 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"` diff --git a/internal/database/roles.go b/internal/database/roles.go index 1abd90a2..d9e83710 100644 --- a/internal/database/roles.go +++ b/internal/database/roles.go @@ -45,7 +45,7 @@ func (r *Role) Rollback() error { } err := r.rollback() - if err != nil { + if err != nil && !errors.Is(err, sql.ErrTxDone) { r.logger.Errorw("failed to rollback role", "role_id", r.ID, zap.Error(err)) } diff --git a/internal/query/mock/mock.go b/internal/query/mock/mock.go index d3585ac6..253d28f3 100644 --- a/internal/query/mock/mock.go +++ b/internal/query/mock/mock.go @@ -62,6 +62,11 @@ func (e *Engine) CreateRole(ctx context.Context, actor, res types.Resource, name return role, nil } +// UpdateRole returns nothing but satisfies the Engine interface. +func (e *Engine) UpdateRole(ctx context.Context, actor, roleResource types.Resource, newName string, newActions []string) (types.Role, error) { + return types.Role{}, nil +} + // 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 5af14672..9d7bb8fc 100644 --- a/internal/query/relations.go +++ b/internal/query/relations.go @@ -315,6 +315,130 @@ func (e *engine) CreateRole(ctx context.Context, actor, res types.Resource, role 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]bool, len(oldActions)) + new := make(map[string]bool, len(newActions)) + + var add, rem []string + + for _, action := range oldActions { + old[action] = true + } + + for _, action := range newActions { + new[action] = true + + // If the new action is not in the old actions, then we need to add the action. + if !old[action] { + 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 !new[action] { + 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() + + role, err := e.GetRole(ctx, roleResource) + if err != nil { + return types.Role{}, err + } + + newName = strings.TrimSpace(newName) + + addActions, remActions := actionsDiff(role.Actions, newActions) + + // If no changes, return existing role with no changes. + if role.Name == newName && len(addActions) == 0 && len(remActions) == 0 { + return role, nil + } + + resourceID := role.ResourceID + + // If the resource id is not found in the permissions database, then we must locate it in the spicedb database. + if resourceID == gidx.NullPrefixedID { + resource, err := e.GetRoleResource(ctx, roleResource) + if err != nil { + return types.Role{}, fmt.Errorf("failed to locate roles associated resource: %s: %w", roleResource.ID.String(), err) + } + + resourceID = resource.ID + } + + resource, err := e.NewResourceFromID(resourceID) + if err != nil { + return types.Role{}, err + } + + var ( + dbRole *database.Role + dbErr error + ) + + // If new name has changed, commit change to permissions database. + if newName != "" && role.Name != newName { + dbRole, dbErr = e.db.UpdateRole(ctx, actor.ID, role.ID, newName, resourceID) + if dbErr != nil { + return types.Role{}, dbErr + } + + defer dbRole.Rollback() //nolint:errcheck // error is logged in function + } + + // 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()) + + return types.Role{}, err + } + + role.Actions = newActions + } + + // Only commit if dbRole is defined meaning the name was also updated. + if dbRole != nil { + if err = dbRole.Commit(); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return types.Role{}, err + } + + role.Name = dbRole.Name + role.Creator = dbRole.CreatorID + role.ResourceID = dbRole.ResourceID + role.CreatedAt = dbRole.CreatedAt + role.UpdatedAt = dbRole.UpdatedAt + } + + return role, nil +} + func actionToRelation(action string) string { return action + "_rel" } @@ -357,6 +481,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)) diff --git a/internal/query/relations_test.go b/internal/query/relations_test.go index b570f679..2c9bf950 100644 --- a/internal/query/relations_test.go +++ b/internal/query/relations_test.go @@ -218,6 +218,68 @@ 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) + + 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]) { + assert.Error(t, res.Err) + }, + }, + { + Name: "UpdateSuccess", + Input: role.ID, + CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.Role]) { + assert.NoError(t, res.Err) + require.Equal(t, "test2", res.Success.Name) + }, + }, + } + + 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, actorRes, 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 TestRoleDelete(t *testing.T) { namespace := "testroles" ctx := context.Background() diff --git a/internal/query/service.go b/internal/query/service.go index 135f87ce..6bd29e11 100644 --- a/internal/query/service.go +++ b/internal/query/service.go @@ -26,6 +26,7 @@ type Engine interface { UnassignSubjectRole(ctx context.Context, subject types.Resource, role types.Role) error CreateRelationships(ctx context.Context, rels []types.Relationship) 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) From 7da3e831f5f3d58ec644d43b3482de6d0653feee Mon Sep 17 00:00:00 2001 From: Mike Mason Date: Thu, 30 Nov 2023 15:02:51 +0000 Subject: [PATCH 05/11] 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 --- internal/database/db.go | 6 +- internal/database/roles.go | 82 ++++++++--------------- internal/database/roles_test.go | 106 +++++++++++++++--------------- internal/database/transactions.go | 43 ++++++++++++ internal/query/relations.go | 48 +++++++------- 5 files changed, 151 insertions(+), 134 deletions(-) create mode 100644 internal/database/transactions.go diff --git a/internal/database/db.go b/internal/database/db.go index 9dde50d8..106cf977 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -14,9 +14,9 @@ type Database 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 gidx.PrefixedID, roleID gidx.PrefixedID, name string, resourceID gidx.PrefixedID) (*Role, error) - DeleteRole(ctx context.Context, roleID gidx.PrefixedID) (*Role, error) + CreateRoleTransaction(ctx context.Context, actorID gidx.PrefixedID, roleID gidx.PrefixedID, name string, resourceID gidx.PrefixedID) (*Transaction[*Role], error) + UpdateRoleTransaction(ctx context.Context, actorID gidx.PrefixedID, roleID gidx.PrefixedID, name string, resourceID gidx.PrefixedID) (*Transaction[*Role], error) + DeleteRoleTransaction(ctx context.Context, roleID gidx.PrefixedID) (*Transaction[*Role], error) HealthCheck(ctx context.Context) error } diff --git a/internal/database/roles.go b/internal/database/roles.go index d9e83710..93e69e6f 100644 --- a/internal/database/roles.go +++ b/internal/database/roles.go @@ -8,9 +8,11 @@ import ( "time" "go.infratographer.com/x/gidx" - "go.uber.org/zap" ) +// TxRole defines a Role Transaction. +type TxRole = *Transaction[*Role] + // Role represents a role in the database. type Role struct { ID gidx.PrefixedID @@ -19,37 +21,6 @@ type Role struct { CreatorID gidx.PrefixedID CreatedAt time.Time UpdatedAt time.Time - - logger *zap.SugaredLogger - commit func() error - rollback func() error -} - -// Commit calls commit on the transaction if the role has been created within a transaction. -// If not the method returns an ErrMethodUnavailable error. -func (r *Role) Commit() error { - if r.commit == nil { - return ErrMethodUnavailable - } - - return r.commit() -} - -// Rollback calls rollback on the transaction if the role has been created within a transaction. -// If not the method returns an ErrMethodUnavailable error. -// -// To simplify rollbacks, logging has automatically been setup to log any errors produced if a rollback fails. -func (r *Role) Rollback() error { - if r.rollback == nil { - return ErrMethodUnavailable - } - - err := r.rollback() - if err != nil && !errors.Is(err, sql.ErrTxDone) { - r.logger.Errorw("failed to rollback role", "role_id", r.ID, zap.Error(err)) - } - - return err } // GetRoleByID retrieves a role from the database by the provided prefixed ID. @@ -165,10 +136,13 @@ func (db *database) ListResourceRoles(ctx context.Context, resourceID gidx.Prefi return roles, nil } -// CreateRole creates a role with the provided details returning the new Role entry. +// CreateRoleTransaction creates a role with the provided details in a new transaction which must be committed. // 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. -func (db *database) CreateRole(ctx context.Context, actorID, roleID gidx.PrefixedID, name string, resourceID gidx.PrefixedID) (*Role, error) { +// +// Transaction.Commit() or Transaction.Rollback() should be called if error is nil otherwise the database will hold +// the indexes waiting for the transaction to complete. +func (db *database) CreateRoleTransaction(ctx context.Context, actorID, roleID gidx.PrefixedID, name string, resourceID gidx.PrefixedID) (TxRole, error) { var role Role tx, err := db.BeginTx(ctx, nil) @@ -203,17 +177,16 @@ func (db *database) CreateRole(ctx context.Context, actorID, roleID gidx.Prefixe return nil, err } - role.logger = db.logger.Named("role") - role.commit = tx.Commit - role.rollback = tx.Rollback - - return &role, nil + return newTransaction(db.logger.With("role_id", role.ID), tx, &role), nil } -// UpdateRole updates an existing role if one exists. -// If no role already exists, a new role is created in the same way as CreateRole. +// UpdateRoleTransaction starts a new transaction to update an existing role if one exists. +// If no role already exists, a new role is created in the same way as CreateRoleTransaction. // If changing the name and the new name results in a duplicate name error, an ErrRoleNameTaken error is returned. -func (db *database) UpdateRole(ctx context.Context, actorID, roleID gidx.PrefixedID, name string, resourceID gidx.PrefixedID) (*Role, error) { +// +// Transaction.Commit() or Transaction.Rollback() should be called if error is nil otherwise the database will hold +// the indexes waiting for the transaction to complete. +func (db *database) UpdateRoleTransaction(ctx context.Context, actorID, roleID gidx.PrefixedID, name string, resourceID gidx.PrefixedID) (TxRole, error) { var role Role tx, err := db.BeginTx(ctx, nil) @@ -245,15 +218,15 @@ func (db *database) UpdateRole(ctx context.Context, actorID, roleID gidx.Prefixe return nil, err } - role.logger = db.logger.Named("role") - role.commit = tx.Commit - role.rollback = tx.Rollback - - return &role, nil + return newTransaction(db.logger.With("role_id", role.ID), tx, &role), nil } -// DeleteRole deletes the role id provided, if no rows are affected an ErrNoRoleFound error is returned. -func (db *database) DeleteRole(ctx context.Context, roleID gidx.PrefixedID) (*Role, error) { +// DeleteRoleTransaction starts a new transaction to delete the role for the id provided. +// If no rows are affected an ErrNoRoleFound error is returned. +// +// Transaction.Commit() or Transaction.Rollback() should be called if error is nil otherwise the database will hold +// the indexes waiting for the transaction to complete. +func (db *database) DeleteRoleTransaction(ctx context.Context, roleID gidx.PrefixedID) (TxRole, error) { tx, err := db.BeginTx(ctx, nil) if err != nil { return nil, err @@ -273,10 +246,9 @@ func (db *database) DeleteRole(ctx context.Context, roleID gidx.PrefixedID) (*Ro return nil, ErrNoRoleFound } - return &Role{ - ID: roleID, - logger: db.logger.Named("role"), - commit: tx.Commit, - rollback: tx.Rollback, - }, nil + role := Role{ + ID: roleID, + } + + return newTransaction(db.logger.With("role_id", role.ID), tx, &role), nil } diff --git a/internal/database/roles_test.go b/internal/database/roles_test.go index 68459b7f..16b87cda 100644 --- a/internal/database/roles_test.go +++ b/internal/database/roles_test.go @@ -29,11 +29,11 @@ func TestGetRoleByID(t *testing.T) { assert.ErrorIs(t, err, database.ErrNoRoleFound) require.Nil(t, role, "no role expected to be returned") - createdRole, err := db.CreateRole(ctx, actorID, roleID, roleName, resourceID) + tx, err := db.CreateRoleTransaction(ctx, actorID, roleID, roleName, resourceID) require.NoError(t, err, "no error expected while seeding database role") - err = createdRole.Commit() + err = tx.Commit() require.NoError(t, err, "no error expected while committing role creation") @@ -47,8 +47,8 @@ func TestGetRoleByID(t *testing.T) { assert.Equal(t, roleName, role.Name) assert.Equal(t, resourceID, role.ResourceID) assert.Equal(t, actorID, role.CreatorID) - assert.Equal(t, createdRole.CreatedAt, role.CreatedAt) - assert.Equal(t, createdRole.UpdatedAt, role.UpdatedAt) + assert.Equal(t, tx.Record.CreatedAt, role.CreatedAt) + assert.Equal(t, tx.Record.UpdatedAt, role.UpdatedAt) } func TestGetResourceRoleByName(t *testing.T) { @@ -68,11 +68,11 @@ func TestGetResourceRoleByName(t *testing.T) { assert.ErrorIs(t, err, database.ErrNoRoleFound) require.Nil(t, role, "role expected to be returned") - createdRole, err := db.CreateRole(ctx, actorID, roleID, roleName, resourceID) + roleTx, err := db.CreateRoleTransaction(ctx, actorID, roleID, roleName, resourceID) require.NoError(t, err, "no error expected while seeding database role") - err = createdRole.Commit() + err = roleTx.Commit() require.NoError(t, err, "no error expected while committing role creation") @@ -86,8 +86,8 @@ func TestGetResourceRoleByName(t *testing.T) { assert.Equal(t, roleName, role.Name) assert.Equal(t, resourceID, role.ResourceID) assert.Equal(t, actorID, role.CreatorID) - assert.Equal(t, createdRole.CreatedAt, role.CreatedAt) - assert.Equal(t, createdRole.UpdatedAt, role.UpdatedAt) + assert.Equal(t, roleTx.Record.CreatedAt, role.CreatedAt) + assert.Equal(t, roleTx.Record.UpdatedAt, role.UpdatedAt) } func TestListResourceRoles(t *testing.T) { @@ -112,11 +112,11 @@ func TestListResourceRoles(t *testing.T) { } for roleName, roleID := range groups { - role, err := db.CreateRole(ctx, actorID, roleID, roleName, resourceID) + roleTx, err := db.CreateRoleTransaction(ctx, actorID, roleID, roleName, resourceID) - require.NoError(t, err, "no error expected creating role", roleName) + require.NoError(t, err, "no error expected creating role transaction", roleName) - err = role.Commit() + err = roleTx.Commit() require.NoError(t, err, "no error expected while committing role", roleName) } @@ -139,7 +139,7 @@ func TestListResourceRoles(t *testing.T) { } } -func TestCreateRole(t *testing.T) { +func TestCreateRoleTransaction(t *testing.T) { db, dbClose := testdb.NewTestDatabase(t) defer dbClose() @@ -150,28 +150,28 @@ func TestCreateRole(t *testing.T) { roleName := "admins" resourceID := gidx.PrefixedID("testten-jkl789") - role, err := db.CreateRole(ctx, actorID, roleID, roleName, resourceID) + roleTx, err := db.CreateRoleTransaction(ctx, actorID, roleID, roleName, resourceID) require.NoError(t, err, "no error expected while creating role") - err = role.Commit() + err = roleTx.Commit() require.NoError(t, err, "no error expected while committing role creation") - assert.Equal(t, roleID, role.ID) - assert.Equal(t, roleName, role.Name) - assert.Equal(t, resourceID, role.ResourceID) - assert.Equal(t, actorID, role.CreatorID) - assert.False(t, role.CreatedAt.IsZero()) - assert.False(t, role.UpdatedAt.IsZero()) + assert.Equal(t, roleID, roleTx.Record.ID) + assert.Equal(t, roleName, roleTx.Record.Name) + assert.Equal(t, resourceID, roleTx.Record.ResourceID) + assert.Equal(t, actorID, roleTx.Record.CreatorID) + assert.False(t, roleTx.Record.CreatedAt.IsZero()) + assert.False(t, roleTx.Record.UpdatedAt.IsZero()) - dupeRole, err := db.CreateRole(ctx, actorID, roleID, roleName, resourceID) + dupeRole, err := db.CreateRoleTransaction(ctx, actorID, roleID, roleName, resourceID) assert.Error(t, err, "expected error for duplicate index") assert.ErrorIs(t, err, database.ErrRoleAlreadyExists, "expected error to be for role already exists") require.Nil(t, dupeRole, "expected role to be nil") - takenNameRole, err := db.CreateRole(ctx, actorID, roleID2, roleName, resourceID) + takenNameRole, err := db.CreateRoleTransaction(ctx, actorID, roleID2, roleName, resourceID) assert.Error(t, err, "expected error for already taken name") assert.ErrorIs(t, err, database.ErrRoleNameTaken, "expected error to be for already taken name") @@ -191,50 +191,51 @@ func TestUpdateRole(t *testing.T) { roleName2 := "temps" resourceID := gidx.PrefixedID("testten-jkl789") - createdRole, err := db.CreateRole(ctx, createActorID, roleID1, roleName, resourceID) + createdRoleTx, err := db.CreateRoleTransaction(ctx, createActorID, roleID1, roleName, resourceID) require.NoError(t, err, "no error expected while seeding database role") - err = createdRole.Commit() + err = createdRoleTx.Commit() require.NoError(t, err, "no error expected while committing role creation") - createdRole2, err := db.CreateRole(ctx, createActorID, roleID2, roleName2, resourceID) + createdRole2Tx, err := db.CreateRoleTransaction(ctx, createActorID, roleID2, roleName2, resourceID) require.NoError(t, err, "no error expected while seeding database role 2") - err = createdRole2.Commit() + err = createdRole2Tx.Commit() require.NoError(t, err, "no error expected while committing role 2 creation") updateActorID := gidx.PrefixedID("idntusr-abc456") t.Run("update error", func(t *testing.T) { - role, err := db.UpdateRole(ctx, updateActorID, roleID2, roleName, resourceID) + roleTx, err := db.UpdateRoleTransaction(ctx, updateActorID, roleID2, roleName, resourceID) assert.Error(t, err, "expected error updating role name to an already taken role name") assert.ErrorIs(t, err, database.ErrRoleNameTaken, "expected error to be role name taken error") - assert.Nil(t, role, "expected role to be nil") + assert.Nil(t, roleTx, "expected role to be nil") }) updateRoleName := "new-admins" updateResourceID := gidx.PrefixedID("testten-mno101") t.Run("existing role", func(t *testing.T) { - role, err := db.UpdateRole(ctx, updateActorID, roleID1, updateRoleName, updateResourceID) + updateTx, err := db.UpdateRoleTransaction(ctx, updateActorID, roleID1, updateRoleName, updateResourceID) require.NoError(t, err, "no error expected while updating role") - require.NotNil(t, role, "role expected to be returned") + require.NotNil(t, updateTx, "transaction expected to be returned") + require.NotNil(t, updateTx.Record, "role expected to be returned") - err = role.Commit() + err = updateTx.Commit() require.NoError(t, err, "no error expected while committing role update") - assert.Equal(t, roleID1, role.ID) - assert.Equal(t, updateRoleName, role.Name) - assert.Equal(t, updateResourceID, role.ResourceID) - assert.Equal(t, createActorID, role.CreatorID) - assert.Equal(t, createdRole.CreatedAt, role.CreatedAt) - assert.NotEqual(t, createdRole.UpdatedAt, role.UpdatedAt) + assert.Equal(t, roleID1, updateTx.Record.ID) + assert.Equal(t, updateRoleName, updateTx.Record.Name) + assert.Equal(t, updateResourceID, updateTx.Record.ResourceID) + assert.Equal(t, createActorID, updateTx.Record.CreatorID) + assert.Equal(t, createdRoleTx.Record.CreatedAt, updateTx.Record.CreatedAt) + assert.NotEqual(t, createdRoleTx.Record.UpdatedAt, updateTx.Record.UpdatedAt) }) t.Run("new role", func(t *testing.T) { @@ -242,22 +243,23 @@ func TestUpdateRole(t *testing.T) { newRoleName := "users" newResourceID := gidx.PrefixedID("testten-lmn159") - role, err := db.UpdateRole(ctx, updateActorID, newRoleID, newRoleName, newResourceID) + updateTx, err := db.UpdateRoleTransaction(ctx, updateActorID, newRoleID, newRoleName, newResourceID) require.NoError(t, err, "no error expected while updating role") - require.NotNil(t, role, "role expected to be returned") + require.NotNil(t, updateTx, "transaction expected to be returned") + require.NotNil(t, updateTx.Record, "role expected to be returned") - err = role.Commit() + err = updateTx.Commit() require.NoError(t, err, "no error expected while committing new role from update") - assert.Equal(t, newRoleID, role.ID) - assert.Equal(t, newRoleName, role.Name) - assert.Equal(t, newResourceID, role.ResourceID) - assert.Equal(t, updateActorID, role.CreatorID) - assert.False(t, createdRole.CreatedAt.IsZero()) - assert.False(t, createdRole.UpdatedAt.IsZero()) + assert.Equal(t, newRoleID, updateTx.Record.ID) + assert.Equal(t, newRoleName, updateTx.Record.Name) + assert.Equal(t, newResourceID, updateTx.Record.ResourceID) + assert.Equal(t, updateActorID, updateTx.Record.CreatorID) + assert.False(t, createdRoleTx.Record.CreatedAt.IsZero()) + assert.False(t, createdRoleTx.Record.UpdatedAt.IsZero()) }) } @@ -271,28 +273,28 @@ func TestDeleteRole(t *testing.T) { roleName := "admins" resourceID := gidx.PrefixedID("testten-jkl789") - _, err := db.DeleteRole(ctx, roleID) + _, err := db.DeleteRoleTransaction(ctx, roleID) require.Error(t, err, "error expected while deleting role which doesn't exist") require.ErrorIs(t, err, database.ErrNoRoleFound, "expected no role found error for missing role") - role, err := db.CreateRole(ctx, actorID, roleID, roleName, resourceID) + createTx, err := db.CreateRoleTransaction(ctx, actorID, roleID, roleName, resourceID) require.NoError(t, err, "no error expected while seeding database role") - err = role.Commit() + err = createTx.Commit() require.NoError(t, err, "no error expected while committing role creation") - role, err = db.DeleteRole(ctx, roleID) + deleteTx, err := db.DeleteRoleTransaction(ctx, roleID) require.NoError(t, err, "no error expected while deleting role") - err = role.Commit() + err = deleteTx.Commit() require.NoError(t, err, "no error expected while committing role deletion") - role, err = db.GetRoleByID(ctx, roleID) + role, err := db.GetRoleByID(ctx, roleID) require.Error(t, err, "expected error retrieving role") assert.ErrorIs(t, err, database.ErrNoRoleFound, "expected no rows error") diff --git a/internal/database/transactions.go b/internal/database/transactions.go new file mode 100644 index 00000000..a3ec2ac7 --- /dev/null +++ b/internal/database/transactions.go @@ -0,0 +1,43 @@ +package database + +import ( + "database/sql" + "errors" + + "go.uber.org/zap" +) + +// Transaction represents an in flight change being made to the database that must be committed or rolled back. +type Transaction[T any] struct { + logger *zap.SugaredLogger + tx *sql.Tx + + Record T +} + +// Commit completes the transaction and writes the changes to the database. +func (t *Transaction[T]) Commit() error { + return t.tx.Commit() +} + +// Rollback reverts the transaction and discards the changes from the database. +// +// To simplify rollbacks, logging has automatically been setup to log any errors produced if a rollback fails. +func (t *Transaction[T]) Rollback() error { + err := t.tx.Rollback() + if err != nil && !errors.Is(err, sql.ErrTxDone) { + t.logger.Errorw("failed to rollback transaction", zap.Error(err)) + } + + return err +} + +// newTransaction creates a new Transaction with the required fields. +func newTransaction[T any](logger *zap.SugaredLogger, tx *sql.Tx, record T) *Transaction[T] { + return &Transaction[T]{ + logger: logger, + tx: tx, + + Record: record, + } +} diff --git a/internal/query/relations.go b/internal/query/relations.go index 9d7bb8fc..23ebbed4 100644 --- a/internal/query/relations.go +++ b/internal/query/relations.go @@ -284,12 +284,12 @@ func (e *engine) CreateRole(ctx context.Context, actor, res types.Resource, role role := newRole(roleName, actions) roleRels := e.roleRelationships(role, res) - dbRole, err := e.db.CreateRole(ctx, actor.ID, role.ID, roleName, res.ID) + dbTx, err := e.db.CreateRoleTransaction(ctx, actor.ID, role.ID, roleName, res.ID) if err != nil { return types.Role{}, err } - defer dbRole.Rollback() //nolint:errcheck // error is logged in function + defer dbTx.Rollback() //nolint:errcheck // error is logged in function request := &pb.WriteRelationshipsRequest{Updates: roleRels} @@ -300,17 +300,17 @@ func (e *engine) CreateRole(ctx context.Context, actor, res types.Resource, role return types.Role{}, err } - if err = dbRole.Commit(); err != nil { + if err = dbTx.Commit(); err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) return types.Role{}, err } - role.Creator = dbRole.CreatorID - role.ResourceID = dbRole.ResourceID - role.CreatedAt = dbRole.CreatedAt - role.UpdatedAt = dbRole.UpdatedAt + role.Creator = dbTx.Record.CreatorID + role.ResourceID = dbTx.Record.ResourceID + role.CreatedAt = dbTx.Record.CreatedAt + role.UpdatedAt = dbTx.Record.UpdatedAt return role, nil } @@ -390,18 +390,18 @@ func (e *engine) UpdateRole(ctx context.Context, actor, roleResource types.Resou } var ( - dbRole *database.Role - dbErr error + dbTx database.TxRole + dbErr error ) // If new name has changed, commit change to permissions database. if newName != "" && role.Name != newName { - dbRole, dbErr = e.db.UpdateRole(ctx, actor.ID, role.ID, newName, resourceID) + dbTx, dbErr = e.db.UpdateRoleTransaction(ctx, actor.ID, role.ID, newName, resourceID) if dbErr != nil { return types.Role{}, dbErr } - defer dbRole.Rollback() //nolint:errcheck // error is logged in function + defer dbTx.Rollback() //nolint:errcheck // error is logged in function } // If a change in actions, apply changes to spicedb. @@ -420,20 +420,20 @@ func (e *engine) UpdateRole(ctx context.Context, actor, roleResource types.Resou role.Actions = newActions } - // Only commit if dbRole is defined meaning the name was also updated. - if dbRole != nil { - if err = dbRole.Commit(); err != nil { + // Only commit if dbTx is defined meaning the name was also updated. + if dbTx != nil { + if err = dbTx.Commit(); err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) return types.Role{}, err } - role.Name = dbRole.Name - role.Creator = dbRole.CreatorID - role.ResourceID = dbRole.ResourceID - role.CreatedAt = dbRole.CreatedAt - role.UpdatedAt = dbRole.UpdatedAt + role.Name = dbTx.Record.Name + role.Creator = dbTx.Record.CreatorID + role.ResourceID = dbTx.Record.ResourceID + role.CreatedAt = dbTx.Record.CreatedAt + role.UpdatedAt = dbTx.Record.UpdatedAt } return role, nil @@ -1055,7 +1055,7 @@ func (e *engine) DeleteRole(ctx context.Context, roleResource types.Resource) er } } - dbRole, err := e.db.DeleteRole(ctx, roleResource.ID) + dbTx, err := e.db.DeleteRoleTransaction(ctx, roleResource.ID) if err != nil { // If the role doesn't exist, simply ignore. if !errors.Is(err, database.ErrNoRoleFound) { @@ -1063,7 +1063,7 @@ func (e *engine) DeleteRole(ctx context.Context, roleResource types.Resource) er } } else { // Setup rollback in case an error occurs before we commit. - defer dbRole.Rollback() //nolint:errcheck // error is logged in function + defer dbTx.Rollback() //nolint:errcheck // error is logged in function } for _, filter := range filters { @@ -1077,9 +1077,9 @@ func (e *engine) DeleteRole(ctx context.Context, roleResource types.Resource) er } } - // If the role was not found, dbRole will be nil. - if dbRole != nil { - if err = dbRole.Commit(); err != nil { + // If the role was not found, dbTx will be nil. + if dbTx != nil { + if err = dbTx.Commit(); err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) From 28f33d7846aecc9a4cfe2214fafe7ca7c31d300a Mon Sep 17 00:00:00 2001 From: Mike Mason Date: Fri, 1 Dec 2023 20:35:52 +0000 Subject: [PATCH 06/11] add database changes to chart and support migrations Signed-off-by: Mike Mason --- 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 +++++++++ 7 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 chart/permissions-api/templates/job-migrate-database.yaml 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: "" From cadcf2f3faea9e6cdb765886259460a4fe38db59 Mon Sep 17 00:00:00 2001 From: Mike Mason Date: Thu, 7 Dec 2023 15:46:42 +0000 Subject: [PATCH 07/11] implement review suggestions Signed-off-by: Mike Mason --- cmd/createrole.go | 6 +- cmd/root.go | 4 +- cmd/server.go | 8 +- cmd/worker.go | 8 +- db/migrations.go | 11 - internal/api/roles.go | 8 +- internal/database/db.go | 54 --- internal/database/options.go | 13 - internal/database/roles.go | 254 ------------ internal/database/roles_test.go | 302 -------------- internal/database/transactions.go | 43 -- internal/query/mock/mock.go | 2 +- internal/query/relations.go | 172 ++++---- internal/query/relations_test.go | 8 +- internal/query/service.go | 8 +- internal/storage/context.go | 73 ++++ internal/{database => storage}/errors.go | 8 +- internal/storage/migrations.go | 10 + .../20231122000000_initial_schema.sql | 0 internal/storage/options.go | 13 + internal/storage/roles.go | 274 ++++++++++++ internal/storage/roles_test.go | 392 ++++++++++++++++++ internal/storage/storage.go | 50 +++ .../teststore/teststore.go} | 17 +- internal/testingx/testing.go | 5 +- internal/types/types.go | 2 +- 26 files changed, 944 insertions(+), 801 deletions(-) delete mode 100644 db/migrations.go delete mode 100644 internal/database/db.go delete mode 100644 internal/database/options.go delete mode 100644 internal/database/roles.go delete mode 100644 internal/database/roles_test.go delete mode 100644 internal/database/transactions.go create mode 100644 internal/storage/context.go rename internal/{database => storage}/errors.go (77%) create mode 100644 internal/storage/migrations.go rename {db => internal/storage}/migrations/20231122000000_initial_schema.sql (100%) 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 rename internal/{database/testdb/testdb.go => storage/teststore/teststore.go} (50%) diff --git a/cmd/createrole.go b/cmd/createrole.go index 47c0374e..22ad554d 100644 --- a/cmd/createrole.go +++ b/cmd/createrole.go @@ -11,10 +11,10 @@ import ( "go.infratographer.com/x/viperx" "go.infratographer.com/permissions-api/internal/config" - "go.infratographer.com/permissions-api/internal/database" "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 ( @@ -81,7 +81,7 @@ func createRole(ctx context.Context, cfg *config.AppConfig) { logger.Fatalw("unable to initialize permissions-api database", "error", err) } - permDB := database.NewDatabase(db, database.WithLogger(logger)) + store := storage.New(db, storage.WithLogger(logger)) var policy iapl.Policy @@ -110,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, permDB, 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) } diff --git a/cmd/root.go b/cmd/root.go index be3b0024..8b0263d2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,8 +14,8 @@ import ( "go.infratographer.com/x/viperx" "go.uber.org/zap" - dbm "go.infratographer.com/permissions-api/db" "go.infratographer.com/permissions-api/internal/config" + "go.infratographer.com/permissions-api/internal/storage" ) var ( @@ -50,7 +50,7 @@ func init() { // Add migrate command goosex.RegisterCobraCommand(rootCmd, func() { - goosex.SetBaseFS(dbm.Migrations) + goosex.SetBaseFS(storage.Migrations) goosex.SetLogger(logger) goosex.SetDBURI(globalCfg.CRDB.GetURI()) }) diff --git a/cmd/server.go b/cmd/server.go index 1666a99d..41170326 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -15,10 +15,10 @@ import ( "go.infratographer.com/permissions-api/internal/api" "go.infratographer.com/permissions-api/internal/config" - "go.infratographer.com/permissions-api/internal/database" "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 ( @@ -70,7 +70,7 @@ func serve(ctx context.Context, cfg *config.AppConfig) { logger.Fatalw("unable to initialize permissions-api database", "error", err) } - permDB := database.NewDatabase(db, database.WithLogger(logger)) + store := storage.New(db, storage.WithLogger(logger)) var policy iapl.Policy @@ -89,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, permDB, query.WithPolicy(policy)) + engine, err := query.NewEngine("infratographer", spiceClient, kv, store, query.WithPolicy(policy)) if err != nil { logger.Fatalw("error creating engine", "error", err) } @@ -110,7 +110,7 @@ func serve(ctx context.Context, cfg *config.AppConfig) { srv.AddHandler(r) srv.AddReadinessCheck("spicedb", spicedbx.Healthcheck(spiceClient)) - srv.AddReadinessCheck("database", permDB.HealthCheck) + 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 bf0ffb06..469976ac 100644 --- a/cmd/worker.go +++ b/cmd/worker.go @@ -17,11 +17,11 @@ import ( "go.uber.org/zap" "go.infratographer.com/permissions-api/internal/config" - "go.infratographer.com/permissions-api/internal/database" "go.infratographer.com/permissions-api/internal/iapl" "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 @@ -69,7 +69,7 @@ func worker(ctx context.Context, cfg *config.AppConfig) { logger.Fatalw("unable to initialize permissions-api database", "error", err) } - permDB := database.NewDatabase(db, database.WithLogger(logger)) + store := storage.New(db, storage.WithLogger(logger)) var policy iapl.Policy @@ -88,7 +88,7 @@ func worker(ctx context.Context, cfg *config.AppConfig) { logger.Fatalw("invalid spicedb policy", "error", err) } - engine, err := query.NewEngine("infratographer", spiceClient, kv, permDB, query.WithPolicy(policy)) + engine, err := query.NewEngine("infratographer", spiceClient, kv, store, query.WithPolicy(policy)) if err != nil { logger.Fatalw("error creating engine", "error", err) } @@ -123,7 +123,7 @@ func worker(ctx context.Context, cfg *config.AppConfig) { } srv.AddReadinessCheck("spicedb", spicedbx.Healthcheck(spiceClient)) - srv.AddReadinessCheck("database", permDB.HealthCheck) + srv.AddReadinessCheck("storage", store.HealthCheck) quit := make(chan os.Signal, 1) diff --git a/db/migrations.go b/db/migrations.go deleted file mode 100644 index efffd7ca..00000000 --- a/db/migrations.go +++ /dev/null @@ -1,11 +0,0 @@ -// Package db provides an embedded filesystem containing all the database migrations -package db - -import ( - "embed" -) - -// Migrations contain an embedded filesystem with all the sql migration files -// -//go:embed migrations/*.sql -var Migrations embed.FS diff --git a/internal/api/roles.go b/internal/api/roles.go index 4338c675..f0c93755 100644 --- a/internal/api/roles.go +++ b/internal/api/roles.go @@ -60,7 +60,7 @@ func (r *Router) roleCreate(c echo.Context) error { Name: role.Name, Actions: role.Actions, ResourceID: role.ResourceID, - Creator: role.Creator, + Creator: role.CreatorID, CreatedAt: role.CreatedAt.Format(time.RFC3339), UpdatedAt: role.UpdatedAt.Format(time.RFC3339), } @@ -117,7 +117,7 @@ func (r *Router) roleUpdate(c echo.Context) error { Name: role.Name, Actions: role.Actions, ResourceID: role.ResourceID, - Creator: role.Creator, + Creator: role.CreatorID, CreatedAt: role.CreatedAt.Format(time.RFC3339), UpdatedAt: role.UpdatedAt.Format(time.RFC3339), } @@ -169,7 +169,7 @@ func (r *Router) roleGet(c echo.Context) error { Name: role.Name, Actions: role.Actions, ResourceID: role.ResourceID, - Creator: role.Creator, + Creator: role.CreatorID, CreatedAt: role.CreatedAt.Format(time.RFC3339), UpdatedAt: role.UpdatedAt.Format(time.RFC3339), } @@ -216,7 +216,7 @@ func (r *Router) rolesList(c echo.Context) error { ID: role.ID, Name: role.Name, Actions: role.Actions, - Creator: role.Creator, + Creator: role.CreatorID, CreatedAt: role.CreatedAt.Format(time.RFC3339), UpdatedAt: role.UpdatedAt.Format(time.RFC3339), } diff --git a/internal/database/db.go b/internal/database/db.go deleted file mode 100644 index 106cf977..00000000 --- a/internal/database/db.go +++ /dev/null @@ -1,54 +0,0 @@ -// Package database interacts with the permissions-api database handling the metadata updates for roles and resources. -package database - -import ( - "context" - "database/sql" - - "go.infratographer.com/x/gidx" - "go.uber.org/zap" -) - -// Database defines the interface the database exposes. -type Database 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) - CreateRoleTransaction(ctx context.Context, actorID gidx.PrefixedID, roleID gidx.PrefixedID, name string, resourceID gidx.PrefixedID) (*Transaction[*Role], error) - UpdateRoleTransaction(ctx context.Context, actorID gidx.PrefixedID, roleID gidx.PrefixedID, name string, resourceID gidx.PrefixedID) (*Transaction[*Role], error) - DeleteRoleTransaction(ctx context.Context, roleID gidx.PrefixedID) (*Transaction[*Role], error) - 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) - QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) - QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row - PingContext(ctx context.Context) error -} - -type database struct { - DB - logger *zap.SugaredLogger -} - -// HealthCheck calls the underlying databases PingContext to check that the database is alive and accepting connections. -func (db *database) HealthCheck(ctx context.Context) error { - return db.PingContext(ctx) -} - -// NewDatabase creates a new Database using the provided underlying DB. -func NewDatabase(db DB, options ...Option) Database { - d := &database{ - DB: db, - logger: zap.NewNop().Sugar(), - } - - for _, opt := range options { - opt(d) - } - - return d -} diff --git a/internal/database/options.go b/internal/database/options.go deleted file mode 100644 index 63510d67..00000000 --- a/internal/database/options.go +++ /dev/null @@ -1,13 +0,0 @@ -package database - -import "go.uber.org/zap" - -// Option defines a database configuration option. -type Option func(d *database) - -// WithLogger sets the logger for the database. -func WithLogger(logger *zap.SugaredLogger) Option { - return func(d *database) { - d.logger = logger.Named("database") - } -} diff --git a/internal/database/roles.go b/internal/database/roles.go deleted file mode 100644 index 93e69e6f..00000000 --- a/internal/database/roles.go +++ /dev/null @@ -1,254 +0,0 @@ -package database - -import ( - "context" - "database/sql" - "errors" - "fmt" - "time" - - "go.infratographer.com/x/gidx" -) - -// TxRole defines a Role Transaction. -type TxRole = *Transaction[*Role] - -// Role represents a role in the database. -type Role struct { - ID gidx.PrefixedID - Name string - ResourceID gidx.PrefixedID - CreatorID 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 (db *database) GetRoleByID(ctx context.Context, id gidx.PrefixedID) (*Role, error) { - var role Role - - err := db.QueryRowContext(ctx, ` - SELECT - id, - name, - resource_id, - creator_id, - created_at, - updated_at - FROM roles - WHERE id = $1 - `, id.String(), - ).Scan( - &role.ID, - &role.Name, - &role.ResourceID, - &role.CreatorID, - &role.CreatedAt, - &role.UpdatedAt, - ) - - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("%w: %s", ErrNoRoleFound, id.String()) - } - - return nil, fmt.Errorf("%w: %s", err, id.String()) - } - - return &role, 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 (db *database) GetResourceRoleByName(ctx context.Context, resourceID gidx.PrefixedID, name string) (*Role, error) { - var role Role - - err := db.QueryRowContext(ctx, ` - SELECT - id, - name, - resource_id, - creator_id, - created_at, - updated_at - FROM roles - WHERE - resource_id = $1 - AND name = $2 - `, - resourceID.String(), - name, - ).Scan( - &role.ID, - &role.Name, - &role.ResourceID, - &role.CreatorID, - &role.CreatedAt, - &role.UpdatedAt, - ) - - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("%w: %s", ErrNoRoleFound, name) - } - - return nil, 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 (db *database) ListResourceRoles(ctx context.Context, resourceID gidx.PrefixedID) ([]*Role, error) { - rows, err := db.QueryContext(ctx, ` - SELECT - id, - name, - resource_id, - creator_id, - 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.CreatorID, &role.CreatedAt, &role.UpdatedAt); err != nil { - return nil, err - } - - roles = append(roles, &role) - } - - return roles, nil -} - -// CreateRoleTransaction creates a role with the provided details in a new transaction which must be committed. -// 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. -// -// Transaction.Commit() or Transaction.Rollback() should be called if error is nil otherwise the database will hold -// the indexes waiting for the transaction to complete. -func (db *database) CreateRoleTransaction(ctx context.Context, actorID, roleID gidx.PrefixedID, name string, resourceID gidx.PrefixedID) (TxRole, error) { - var role Role - - tx, err := db.BeginTx(ctx, nil) - if err != nil { - return nil, err - } - - err = tx.QueryRowContext(ctx, ` - INSERT - INTO roles (id, name, resource_id, creator_id, created_at, updated_at) - VALUES ($1, $2, $3, $4, now(), now()) - RETURNING id, name, resource_id, creator_id, created_at, updated_at - `, roleID.String(), name, resourceID.String(), actorID.String(), - ).Scan( - &role.ID, - &role.Name, - &role.ResourceID, - &role.CreatorID, - &role.CreatedAt, - &role.UpdatedAt, - ) - - if err != nil { - if pqIsRoleAlreadyExistsError(err) { - return nil, fmt.Errorf("%w: %s", ErrRoleAlreadyExists, roleID.String()) - } - - if pqIsRoleNameTakenError(err) { - return nil, fmt.Errorf("%w: %s", ErrRoleNameTaken, name) - } - - return nil, err - } - - return newTransaction(db.logger.With("role_id", role.ID), tx, &role), nil -} - -// UpdateRoleTransaction starts a new transaction to update an existing role if one exists. -// If no role already exists, a new role is created in the same way as CreateRoleTransaction. -// If changing the name and the new name results in a duplicate name error, an ErrRoleNameTaken error is returned. -// -// Transaction.Commit() or Transaction.Rollback() should be called if error is nil otherwise the database will hold -// the indexes waiting for the transaction to complete. -func (db *database) UpdateRoleTransaction(ctx context.Context, actorID, roleID gidx.PrefixedID, name string, resourceID gidx.PrefixedID) (TxRole, error) { - var role Role - - tx, err := db.BeginTx(ctx, nil) - if err != nil { - return nil, err - } - - err = tx.QueryRowContext(ctx, ` - INSERT INTO roles (id, name, resource_id, creator_id, created_at, updated_at) - VALUES ($1, $2, $3, $4, now(), now()) - ON CONFLICT (id) DO UPDATE - SET (name, resource_id, updated_at) = (excluded.name, excluded.resource_id, excluded.updated_at) - RETURNING id, name, resource_id, creator_id, created_at, updated_at - `, roleID.String(), name, resourceID.String(), actorID.String(), - ).Scan( - &role.ID, - &role.Name, - &role.ResourceID, - &role.CreatorID, - &role.CreatedAt, - &role.UpdatedAt, - ) - - if err != nil { - if pqIsRoleNameTakenError(err) { - return nil, fmt.Errorf("%w: %s", ErrRoleNameTaken, name) - } - - return nil, err - } - - return newTransaction(db.logger.With("role_id", role.ID), tx, &role), nil -} - -// DeleteRoleTransaction starts a new transaction to delete the role for the id provided. -// If no rows are affected an ErrNoRoleFound error is returned. -// -// Transaction.Commit() or Transaction.Rollback() should be called if error is nil otherwise the database will hold -// the indexes waiting for the transaction to complete. -func (db *database) DeleteRoleTransaction(ctx context.Context, roleID gidx.PrefixedID) (TxRole, error) { - tx, err := db.BeginTx(ctx, nil) - if err != nil { - return nil, err - } - - result, err := tx.ExecContext(ctx, `DELETE FROM roles WHERE id = $1`, roleID.String()) - if err != nil { - return nil, err - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return nil, err - } - - if rowsAffected != 1 { - return nil, ErrNoRoleFound - } - - role := Role{ - ID: roleID, - } - - return newTransaction(db.logger.With("role_id", role.ID), tx, &role), nil -} diff --git a/internal/database/roles_test.go b/internal/database/roles_test.go deleted file mode 100644 index 16b87cda..00000000 --- a/internal/database/roles_test.go +++ /dev/null @@ -1,302 +0,0 @@ -package database_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/database" - "go.infratographer.com/permissions-api/internal/database/testdb" -) - -func TestGetRoleByID(t *testing.T) { - db, dbClose := testdb.NewTestDatabase(t) - defer dbClose() - - ctx := context.Background() - actorID := gidx.PrefixedID("idntusr-abc123") - roleID := gidx.PrefixedID("permrol-def456") - roleName := "admins" - resourceID := gidx.PrefixedID("testten-jkl789") - - // ensure expected empty results returned - role, err := db.GetRoleByID(ctx, roleID) - - require.Error(t, err, "error expected when no role is found") - assert.ErrorIs(t, err, database.ErrNoRoleFound) - require.Nil(t, role, "no role expected to be returned") - - tx, err := db.CreateRoleTransaction(ctx, actorID, roleID, roleName, resourceID) - - require.NoError(t, err, "no error expected while seeding database role") - - err = tx.Commit() - - require.NoError(t, err, "no error expected while committing role creation") - - role, err = db.GetRoleByID(ctx, roleID) - - require.NoError(t, err, "no error expected while retrieving role") - - require.NotNil(t, role, "role expected to be returned") - - assert.Equal(t, roleID, role.ID) - assert.Equal(t, roleName, role.Name) - assert.Equal(t, resourceID, role.ResourceID) - assert.Equal(t, actorID, role.CreatorID) - assert.Equal(t, tx.Record.CreatedAt, role.CreatedAt) - assert.Equal(t, tx.Record.UpdatedAt, role.UpdatedAt) -} - -func TestGetResourceRoleByName(t *testing.T) { - db, dbClose := testdb.NewTestDatabase(t) - defer dbClose() - - ctx := context.Background() - actorID := gidx.PrefixedID("idntusr-abc123") - roleID := gidx.PrefixedID("permrol-def456") - roleName := "admins" - resourceID := gidx.PrefixedID("testten-jkl789") - - // ensure expected empty results returned - role, err := db.GetResourceRoleByName(ctx, resourceID, "admins") - - require.Error(t, err, "error expected when no role is found") - assert.ErrorIs(t, err, database.ErrNoRoleFound) - require.Nil(t, role, "role expected to be returned") - - roleTx, err := db.CreateRoleTransaction(ctx, actorID, roleID, roleName, resourceID) - - require.NoError(t, err, "no error expected while seeding database role") - - err = roleTx.Commit() - - require.NoError(t, err, "no error expected while committing role creation") - - role, err = db.GetResourceRoleByName(ctx, resourceID, "admins") - - require.NoError(t, err, "no error expected while retrieving role") - - require.NotNil(t, role, "role expected to be returned") - - assert.Equal(t, roleID, role.ID) - assert.Equal(t, roleName, role.Name) - assert.Equal(t, resourceID, role.ResourceID) - assert.Equal(t, actorID, role.CreatorID) - assert.Equal(t, roleTx.Record.CreatedAt, role.CreatedAt) - assert.Equal(t, roleTx.Record.UpdatedAt, role.UpdatedAt) -} - -func TestListResourceRoles(t *testing.T) { - db, dbClose := testdb.NewTestDatabase(t) - defer dbClose() - - ctx := context.Background() - actorID := gidx.PrefixedID("idntusr-abc123") - - resourceID := gidx.PrefixedID("testten-jkl789") - - // ensure expected empty results returned - roles, err := db.ListResourceRoles(ctx, resourceID) - - require.NoError(t, err, "no error expected while retrieving resource roles") - require.Len(t, roles, 0, "no roles should be returned before they're created") - - groups := map[string]gidx.PrefixedID{ - "super-admins": "permrol-abc123", - "admins": "permrol-def456", - "users": "permrol-ghi789", - } - - for roleName, roleID := range groups { - roleTx, err := db.CreateRoleTransaction(ctx, actorID, roleID, roleName, resourceID) - - require.NoError(t, err, "no error expected creating role transaction", roleName) - - err = roleTx.Commit() - - require.NoError(t, err, "no error expected while committing role", roleName) - } - - roles, err = db.ListResourceRoles(ctx, resourceID) - - require.NoError(t, err, "no error expected while retrieving resource roles") - - assert.Len(t, roles, len(groups), "expected returned roles to match group count") - - for _, role := range roles { - require.NotNil(t, role, "role expected to be returned") - - assert.Equal(t, groups[role.Name], role.ID) - assert.NotEmpty(t, role.Name) - assert.Equal(t, resourceID, role.ResourceID) - assert.Equal(t, actorID, role.CreatorID) - assert.False(t, role.CreatedAt.IsZero()) - assert.False(t, role.UpdatedAt.IsZero()) - } -} - -func TestCreateRoleTransaction(t *testing.T) { - db, dbClose := testdb.NewTestDatabase(t) - defer dbClose() - - ctx := context.Background() - actorID := gidx.PrefixedID("idntusr-abc123") - roleID := gidx.PrefixedID("permrol-def456") - roleID2 := gidx.PrefixedID("permrole-lmn789") - roleName := "admins" - resourceID := gidx.PrefixedID("testten-jkl789") - - roleTx, err := db.CreateRoleTransaction(ctx, actorID, roleID, roleName, resourceID) - - require.NoError(t, err, "no error expected while creating role") - - err = roleTx.Commit() - - require.NoError(t, err, "no error expected while committing role creation") - - assert.Equal(t, roleID, roleTx.Record.ID) - assert.Equal(t, roleName, roleTx.Record.Name) - assert.Equal(t, resourceID, roleTx.Record.ResourceID) - assert.Equal(t, actorID, roleTx.Record.CreatorID) - assert.False(t, roleTx.Record.CreatedAt.IsZero()) - assert.False(t, roleTx.Record.UpdatedAt.IsZero()) - - dupeRole, err := db.CreateRoleTransaction(ctx, actorID, roleID, roleName, resourceID) - - assert.Error(t, err, "expected error for duplicate index") - assert.ErrorIs(t, err, database.ErrRoleAlreadyExists, "expected error to be for role already exists") - require.Nil(t, dupeRole, "expected role to be nil") - - takenNameRole, err := db.CreateRoleTransaction(ctx, actorID, roleID2, roleName, resourceID) - - assert.Error(t, err, "expected error for already taken name") - assert.ErrorIs(t, err, database.ErrRoleNameTaken, "expected error to be for already taken name") - require.Nil(t, takenNameRole, "expected role to be nil") -} - -func TestUpdateRole(t *testing.T) { - db, dbClose := testdb.NewTestDatabase(t) - defer dbClose() - - ctx := context.Background() - - createActorID := gidx.PrefixedID("idntusr-abc123") - roleID1 := gidx.PrefixedID("permrol-def456") - roleID2 := gidx.PrefixedID("permrol-mno753") - roleName := "admins" - roleName2 := "temps" - resourceID := gidx.PrefixedID("testten-jkl789") - - createdRoleTx, err := db.CreateRoleTransaction(ctx, createActorID, roleID1, roleName, resourceID) - require.NoError(t, err, "no error expected while seeding database role") - - err = createdRoleTx.Commit() - - require.NoError(t, err, "no error expected while committing role creation") - - createdRole2Tx, err := db.CreateRoleTransaction(ctx, createActorID, roleID2, roleName2, resourceID) - require.NoError(t, err, "no error expected while seeding database role 2") - - err = createdRole2Tx.Commit() - - require.NoError(t, err, "no error expected while committing role 2 creation") - - updateActorID := gidx.PrefixedID("idntusr-abc456") - - t.Run("update error", func(t *testing.T) { - roleTx, err := db.UpdateRoleTransaction(ctx, updateActorID, roleID2, roleName, resourceID) - - assert.Error(t, err, "expected error updating role name to an already taken role name") - assert.ErrorIs(t, err, database.ErrRoleNameTaken, "expected error to be role name taken error") - assert.Nil(t, roleTx, "expected role to be nil") - }) - - updateRoleName := "new-admins" - updateResourceID := gidx.PrefixedID("testten-mno101") - - t.Run("existing role", func(t *testing.T) { - updateTx, err := db.UpdateRoleTransaction(ctx, updateActorID, roleID1, updateRoleName, updateResourceID) - - require.NoError(t, err, "no error expected while updating role") - - require.NotNil(t, updateTx, "transaction expected to be returned") - require.NotNil(t, updateTx.Record, "role expected to be returned") - - err = updateTx.Commit() - - require.NoError(t, err, "no error expected while committing role update") - - assert.Equal(t, roleID1, updateTx.Record.ID) - assert.Equal(t, updateRoleName, updateTx.Record.Name) - assert.Equal(t, updateResourceID, updateTx.Record.ResourceID) - assert.Equal(t, createActorID, updateTx.Record.CreatorID) - assert.Equal(t, createdRoleTx.Record.CreatedAt, updateTx.Record.CreatedAt) - assert.NotEqual(t, createdRoleTx.Record.UpdatedAt, updateTx.Record.UpdatedAt) - }) - - t.Run("new role", func(t *testing.T) { - newRoleID := gidx.PrefixedID("permrol-xyz789") - newRoleName := "users" - newResourceID := gidx.PrefixedID("testten-lmn159") - - updateTx, err := db.UpdateRoleTransaction(ctx, updateActorID, newRoleID, newRoleName, newResourceID) - - require.NoError(t, err, "no error expected while updating role") - - require.NotNil(t, updateTx, "transaction expected to be returned") - require.NotNil(t, updateTx.Record, "role expected to be returned") - - err = updateTx.Commit() - - require.NoError(t, err, "no error expected while committing new role from update") - - assert.Equal(t, newRoleID, updateTx.Record.ID) - assert.Equal(t, newRoleName, updateTx.Record.Name) - assert.Equal(t, newResourceID, updateTx.Record.ResourceID) - assert.Equal(t, updateActorID, updateTx.Record.CreatorID) - assert.False(t, createdRoleTx.Record.CreatedAt.IsZero()) - assert.False(t, createdRoleTx.Record.UpdatedAt.IsZero()) - }) -} - -func TestDeleteRole(t *testing.T) { - db, dbClose := testdb.NewTestDatabase(t) - defer dbClose() - - ctx := context.Background() - actorID := gidx.PrefixedID("idntusr-abc123") - roleID := gidx.PrefixedID("permrol-def456") - roleName := "admins" - resourceID := gidx.PrefixedID("testten-jkl789") - - _, err := db.DeleteRoleTransaction(ctx, roleID) - - require.Error(t, err, "error expected while deleting role which doesn't exist") - require.ErrorIs(t, err, database.ErrNoRoleFound, "expected no role found error for missing role") - - createTx, err := db.CreateRoleTransaction(ctx, actorID, roleID, roleName, resourceID) - - require.NoError(t, err, "no error expected while seeding database role") - - err = createTx.Commit() - - require.NoError(t, err, "no error expected while committing role creation") - - deleteTx, err := db.DeleteRoleTransaction(ctx, roleID) - - require.NoError(t, err, "no error expected while deleting role") - - err = deleteTx.Commit() - - require.NoError(t, err, "no error expected while committing role deletion") - - role, err := db.GetRoleByID(ctx, roleID) - - require.Error(t, err, "expected error retrieving role") - assert.ErrorIs(t, err, database.ErrNoRoleFound, "expected no rows error") - assert.Nil(t, role, "role expected to nil") -} diff --git a/internal/database/transactions.go b/internal/database/transactions.go deleted file mode 100644 index a3ec2ac7..00000000 --- a/internal/database/transactions.go +++ /dev/null @@ -1,43 +0,0 @@ -package database - -import ( - "database/sql" - "errors" - - "go.uber.org/zap" -) - -// Transaction represents an in flight change being made to the database that must be committed or rolled back. -type Transaction[T any] struct { - logger *zap.SugaredLogger - tx *sql.Tx - - Record T -} - -// Commit completes the transaction and writes the changes to the database. -func (t *Transaction[T]) Commit() error { - return t.tx.Commit() -} - -// Rollback reverts the transaction and discards the changes from the database. -// -// To simplify rollbacks, logging has automatically been setup to log any errors produced if a rollback fails. -func (t *Transaction[T]) Rollback() error { - err := t.tx.Rollback() - if err != nil && !errors.Is(err, sql.ErrTxDone) { - t.logger.Errorw("failed to rollback transaction", zap.Error(err)) - } - - return err -} - -// newTransaction creates a new Transaction with the required fields. -func newTransaction[T any](logger *zap.SugaredLogger, tx *sql.Tx, record T) *Transaction[T] { - return &Transaction[T]{ - logger: logger, - tx: tx, - - Record: record, - } -} diff --git a/internal/query/mock/mock.go b/internal/query/mock/mock.go index 253d28f3..d7c5d643 100644 --- a/internal/query/mock/mock.go +++ b/internal/query/mock/mock.go @@ -54,7 +54,7 @@ func (e *Engine) CreateRole(ctx context.Context, actor, res types.Resource, name ID: gidx.MustNewID(query.ApplicationPrefix), Name: name, Actions: outActions, - Creator: actor.ID, + CreatorID: actor.ID, CreatedAt: time.Now(), UpdatedAt: time.Now(), } diff --git a/internal/query/relations.go b/internal/query/relations.go index 23ebbed4..3ad8c66c 100644 --- a/internal/query/relations.go +++ b/internal/query/relations.go @@ -15,7 +15,7 @@ import ( "go.uber.org/multierr" "go.uber.org/zap" - "go.infratographer.com/permissions-api/internal/database" + "go.infratographer.com/permissions-api/internal/storage" "go.infratographer.com/permissions-api/internal/types" ) @@ -281,15 +281,20 @@ func (e *engine) CreateRole(ctx context.Context, actor, res types.Resource, role defer span.End() + roleName = strings.TrimSpace(roleName) + role := newRole(roleName, actions) roleRels := e.roleRelationships(role, res) - dbTx, err := e.db.CreateRoleTransaction(ctx, actor.ID, role.ID, roleName, res.ID) + dbCtx, err := e.store.BeginContext(ctx) if err != nil { - return types.Role{}, err + return types.Role{}, nil } - defer dbTx.Rollback() //nolint:errcheck // error is logged in function + 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} @@ -297,20 +302,24 @@ func (e *engine) CreateRole(ctx context.Context, actor, res types.Resource, role span.RecordError(err) span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + return types.Role{}, err } - if err = dbTx.Commit(); err != nil { + if err = e.store.CommitContext(dbCtx); err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + return types.Role{}, err } - role.Creator = dbTx.Record.CreatorID - role.ResourceID = dbTx.Record.ResourceID - role.CreatedAt = dbTx.Record.CreatedAt - role.UpdatedAt = dbTx.Record.UpdatedAt + role.CreatorID = dbRole.CreatorID + role.ResourceID = dbRole.ResourceID + role.CreatedAt = dbRole.CreatedAt + role.UpdatedAt = dbRole.UpdatedAt return role, nil } @@ -365,10 +374,15 @@ func (e *engine) UpdateRole(ctx context.Context, actor, roleResource types.Resou newName = strings.TrimSpace(newName) + // If the name is the same, then clear the changed name. + if role.Name == newName { + newName = "" + } + addActions, remActions := actionsDiff(role.Actions, newActions) // If no changes, return existing role with no changes. - if role.Name == newName && len(addActions) == 0 && len(remActions) == 0 { + if newName == "" && len(addActions) == 0 && len(remActions) == 0 { return role, nil } @@ -389,19 +403,16 @@ func (e *engine) UpdateRole(ctx context.Context, actor, roleResource types.Resou return types.Role{}, err } - var ( - dbTx database.TxRole - dbErr error - ) + dbCtx, err := e.store.BeginContext(ctx) + if err != nil { + return types.Role{}, err + } - // If new name has changed, commit change to permissions database. - if newName != "" && role.Name != newName { - dbTx, dbErr = e.db.UpdateRoleTransaction(ctx, actor.ID, role.ID, newName, resourceID) - if dbErr != nil { - return types.Role{}, dbErr - } + dbRole, err := e.store.UpdateRole(dbCtx, role.ID, newName) + if err != nil { + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) - defer dbTx.Rollback() //nolint:errcheck // error is logged in function + return types.Role{}, err } // If a change in actions, apply changes to spicedb. @@ -414,31 +425,38 @@ func (e *engine) UpdateRole(ctx context.Context, actor, roleResource types.Resou span.RecordError(err) span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + return types.Role{}, err } role.Actions = newActions } - // Only commit if dbTx is defined meaning the name was also updated. - if dbTx != nil { - if err = dbTx.Commit(); err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, err.Error()) + if err := e.store.CommitContext(dbCtx); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) - return types.Role{}, err - } + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) - role.Name = dbTx.Record.Name - role.Creator = dbTx.Record.CreatorID - role.ResourceID = dbTx.Record.ResourceID - role.CreatedAt = dbTx.Record.CreatedAt - role.UpdatedAt = dbTx.Record.UpdatedAt + return types.Role{}, err } + role.Name = dbRole.Name + role.CreatorID = dbRole.CreatorID + 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" } @@ -827,18 +845,10 @@ func (e *engine) ListRoles(ctx context.Context, resource types.Resource) ([]type }, } - dbDone := make(chan struct{}, 1) - - var ( - dbRoles []*database.Role - dbErr error - ) - - go func() { - defer close(dbDone) - - dbRoles, dbErr = e.db.ListResourceRoles(ctx, resource.ID) - }() + dbRoles, err := e.store.ListResourceRoles(ctx, resource.ID) + if err != nil { + return nil, err + } relationships, err := e.readRelationships(ctx, filter) if err != nil { @@ -847,27 +857,19 @@ func (e *engine) ListRoles(ctx context.Context, resource types.Resource) ([]type out := relationshipsToRoles(relationships) - <-dbDone - - if dbErr != nil { - if !errors.Is(dbErr, database.ErrNoRoleFound) { - return nil, dbErr - } - } else { - rolesByID := make(map[string]*database.Role, len(dbRoles)) - for _, role := range dbRoles { - rolesByID[role.ID.String()] = role - } + rolesByID := make(map[string]storage.Role, len(dbRoles)) + for _, role := range dbRoles { + rolesByID[role.ID.String()] = role + } - for i, role := range out { - if dbRole, ok := rolesByID[role.ID.String()]; ok { - role.Name = dbRole.Name - role.Creator = dbRole.CreatorID - role.CreatedAt = dbRole.CreatedAt - role.UpdatedAt = dbRole.UpdatedAt + for i, role := range out { + if dbRole, ok := rolesByID[role.ID.String()]; ok { + role.Name = dbRole.Name + role.CreatorID = dbRole.CreatorID + role.CreatedAt = dbRole.CreatedAt + role.UpdatedAt = dbRole.UpdatedAt - out[i] = role - } + out[i] = role } } @@ -950,22 +952,18 @@ func (e *engine) GetRole(ctx context.Context, roleResource types.Resource) (type actions[i] = relationToAction(action) } - dbRole, err := e.db.GetRoleByID(ctx, roleResource.ID) - if err != nil && !errors.Is(err, database.ErrNoRoleFound) { + 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)) } - if dbRole == nil { - dbRole = new(database.Role) - } - return types.Role{ ID: roleResource.ID, Name: dbRole.Name, Actions: actions, ResourceID: dbRole.ResourceID, - Creator: dbRole.CreatorID, + CreatorID: dbRole.CreatorID, CreatedAt: dbRole.CreatedAt, UpdatedAt: dbRole.UpdatedAt, }, nil @@ -1055,15 +1053,16 @@ func (e *engine) DeleteRole(ctx context.Context, roleResource types.Resource) er } } - dbTx, err := e.db.DeleteRoleTransaction(ctx, roleResource.ID) + dbCtx, err := e.store.BeginContext(ctx) if err != nil { - // If the role doesn't exist, simply ignore. - if !errors.Is(err, database.ErrNoRoleFound) { - return err - } - } else { - // Setup rollback in case an error occurs before we commit. - defer dbTx.Rollback() //nolint:errcheck // error is logged in function + return err + } + + _, err = e.store.DeleteRole(dbCtx, roleResource.ID) + if err != nil { + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return err } for _, filter := range filters { @@ -1073,18 +1072,19 @@ func (e *engine) DeleteRole(ctx context.Context, roleResource types.Resource) er span.RecordError(err) span.SetStatus(codes.Error, err.Error()) + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + return err } } - // If the role was not found, dbTx will be nil. - if dbTx != nil { - if err = dbTx.Commit(); err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, err.Error()) + if err = e.store.CommitContext(dbCtx); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) - return err - } + logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + + return err } return nil diff --git a/internal/query/relations_test.go b/internal/query/relations_test.go index 2c9bf950..71991df7 100644 --- a/internal/query/relations_test.go +++ b/internal/query/relations_test.go @@ -13,9 +13,9 @@ import ( "go.infratographer.com/x/gidx" "go.infratographer.com/x/testing/eventtools" - "go.infratographer.com/permissions-api/internal/database/testdb" "go.infratographer.com/permissions-api/internal/iapl" "go.infratographer.com/permissions-api/internal/spicedbx" + "go.infratographer.com/permissions-api/internal/storage/teststore" "go.infratographer.com/permissions-api/internal/testingx" "go.infratographer.com/permissions-api/internal/types" ) @@ -30,7 +30,7 @@ func testEngine(ctx context.Context, t *testing.T, namespace string) *engine { client, err := spicedbx.NewClient(config, false) require.NoError(t, err) - db, cleanPDB := testdb.NewTestDatabase(t) + store, cleanStore := teststore.NewTestStorage(t) policy := testPolicy() @@ -53,12 +53,12 @@ func testEngine(ctx context.Context, t *testing.T, namespace string) *engine { t.Cleanup(func() { cleanDB(ctx, t, client, namespace) - cleanPDB() + 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, db, WithPolicy(policy)) + out, err := NewEngine(namespace, client, kv, store, WithPolicy(policy)) require.NoError(t, err) return out.(*engine) diff --git a/internal/query/service.go b/internal/query/service.go index 6bd29e11..6eacbad0 100644 --- a/internal/query/service.go +++ b/internal/query/service.go @@ -10,8 +10,8 @@ import ( "go.opentelemetry.io/otel/trace" "go.uber.org/zap" - "go.infratographer.com/permissions-api/internal/database" "go.infratographer.com/permissions-api/internal/iapl" + "go.infratographer.com/permissions-api/internal/storage" "go.infratographer.com/permissions-api/internal/types" ) @@ -47,7 +47,7 @@ type engine struct { namespace string client *authzed.Client kv nats.KeyValue - db database.Database + store storage.Storage schema []types.ResourceType schemaPrefixMap map[string]types.ResourceType schemaTypeMap map[string]types.ResourceType @@ -94,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, db database.Database, 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{ @@ -102,7 +102,7 @@ func NewEngine(namespace string, client *authzed.Client, kv nats.KeyValue, db da namespace: namespace, client: client, kv: kv, - db: db, + store: store, tracer: tracer, } diff --git a/internal/storage/context.go b/internal/storage/context.go new file mode 100644 index 00000000..a7437dd3 --- /dev/null +++ b/internal/storage/context.go @@ -0,0 +1,73 @@ +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 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/database/errors.go b/internal/storage/errors.go similarity index 77% rename from internal/database/errors.go rename to internal/storage/errors.go index 8f0599e1..a04d8e44 100644 --- a/internal/database/errors.go +++ b/internal/storage/errors.go @@ -1,4 +1,4 @@ -package database +package storage import ( "errors" @@ -19,6 +19,12 @@ var ( // 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") ) func pqIsRoleAlreadyExistsError(err error) bool { 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/db/migrations/20231122000000_initial_schema.sql b/internal/storage/migrations/20231122000000_initial_schema.sql similarity index 100% rename from db/migrations/20231122000000_initial_schema.sql rename to internal/storage/migrations/20231122000000_initial_schema.sql 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..51858d34 --- /dev/null +++ b/internal/storage/roles.go @@ -0,0 +1,274 @@ +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, roleID gidx.PrefixedID, name string) (Role, error) + DeleteRole(ctx context.Context, roleID gidx.PrefixedID) (Role, error) +} + +// Role represents a role in the database. +type Role struct { + ID gidx.PrefixedID + Name string + ResourceID gidx.PrefixedID + CreatorID 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) { + var role Role + + err := e.QueryRowContext(ctx, ` + SELECT + id, + name, + resource_id, + creator_id, + created_at, + updated_at + FROM roles + WHERE id = $1 + `, id.String(), + ).Scan( + &role.ID, + &role.Name, + &role.ResourceID, + &role.CreatorID, + &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 +} + +// 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) { + var role Role + + err := e.QueryRowContext(ctx, ` + SELECT + id, + name, + resource_id, + creator_id, + created_at, + updated_at + FROM roles + WHERE + resource_id = $1 + AND name = $2 + `, + resourceID.String(), + name, + ).Scan( + &role.ID, + &role.Name, + &role.ResourceID, + &role.CreatorID, + &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) { + rows, err := e.QueryContext(ctx, ` + SELECT + id, + name, + resource_id, + creator_id, + 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.CreatorID, &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, creator_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, now(), now()) + RETURNING id, name, resource_id, creator_id, created_at, updated_at + `, roleID.String(), name, resourceID.String(), actorID.String(), + ).Scan( + &role.ID, + &role.Name, + &role.ResourceID, + &role.CreatorID, + &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 one exists. +// If no role already exists, a new role is created in the same way as CreateRole. +// 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, roleID gidx.PrefixedID, name string) (Role, error) { + tx, err := getContextTx(ctx) + if err != nil { + return Role{}, err + } + + var row *sql.Row + + // If no name is provided, only update the updated_at timestamp. + if name == "" { + row = tx.QueryRowContext(ctx, ` + UPDATE roles SET updated_at = now() WHERE id = $1 + RETURNING id, name, resource_id, creator_id, created_at, updated_at + `, roleID.String()) + } else { + row = tx.QueryRowContext(ctx, ` + UPDATE roles SET name = $1, updated_at = now() WHERE id = $2 + RETURNING id, name, resource_id, creator_id, created_at, updated_at + `, name, roleID.String(), + ) + } + + var role Role + + err = row.Scan( + &role.ID, + &role.Name, + &role.ResourceID, + &role.CreatorID, + &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..14771e64 --- /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.CreatorID) + 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.CreatorID) + 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.CreatorID) + 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.CreatorID) + 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, 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..ba050002 --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,50 @@ +// 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) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row + PingContext(ctx context.Context) 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/database/testdb/testdb.go b/internal/storage/teststore/teststore.go similarity index 50% rename from internal/database/testdb/testdb.go rename to internal/storage/teststore/teststore.go index fd8bb5f0..1a5bf42b 100644 --- a/internal/database/testdb/testdb.go +++ b/internal/storage/teststore/teststore.go @@ -1,6 +1,6 @@ -// Package testdb is a testing helper package which initializes a new crdb database and runs migrations -// returning a new database which may be used during testing. -package testdb +// 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" @@ -8,12 +8,11 @@ import ( "github.com/cockroachdb/cockroach-go/v2/testserver" "github.com/pressly/goose/v3" - dbm "go.infratographer.com/permissions-api/db" - "go.infratographer.com/permissions-api/internal/database" + "go.infratographer.com/permissions-api/internal/storage" ) -// NewTestDatabase creates a new permissions database instance for testing. -func NewTestDatabase(t *testing.T) (database.Database, func()) { +// NewTestStorage creates a new permissions database instance for testing. +func NewTestStorage(t *testing.T) (storage.Storage, func()) { t.Helper() server, err := testserver.NewTestServer() @@ -24,7 +23,7 @@ func NewTestDatabase(t *testing.T) (database.Database, func()) { return nil, func() {} } - goose.SetBaseFS(dbm.Migrations) + goose.SetBaseFS(storage.Migrations) db, err := goose.OpenDBWithDriver("postgres", server.PGURL().String()) if err != nil { @@ -44,5 +43,5 @@ func NewTestDatabase(t *testing.T) (database.Database, func()) { return nil, func() {} } - return database.NewDatabase(db), func() { db.Close() } + 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 21045887..99548b68 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -14,7 +14,7 @@ type Role struct { Actions []string ResourceID gidx.PrefixedID - Creator gidx.PrefixedID + CreatorID gidx.PrefixedID CreatedAt time.Time UpdatedAt time.Time } From 15c1247287b6cef1702e14c496f35faded491375 Mon Sep 17 00:00:00 2001 From: Mike Mason Date: Tue, 12 Dec 2023 22:39:12 +0000 Subject: [PATCH 08/11] implement second round of review suggestions Signed-off-by: Mike Mason --- internal/api/roles.go | 21 +++-- internal/api/types.go | 3 +- internal/query/mock/mock.go | 11 ++- internal/query/relations.go | 54 ++++++------ internal/storage/context.go | 13 +++ internal/storage/errors.go | 17 +++- .../20231122000000_initial_schema.sql | 15 ++-- internal/storage/roles.go | 82 +++++++++++-------- internal/storage/roles_test.go | 10 +-- internal/storage/storage.go | 9 +- internal/types/types.go | 3 +- 11 files changed, 151 insertions(+), 87 deletions(-) diff --git a/internal/api/roles.go b/internal/api/roles.go index f0c93755..50166100 100644 --- a/internal/api/roles.go +++ b/internal/api/roles.go @@ -1,6 +1,7 @@ package api import ( + "errors" "net/http" "time" @@ -8,6 +9,8 @@ import ( "go.infratographer.com/x/gidx" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + + "go.infratographer.com/permissions-api/internal/query" ) const ( @@ -60,7 +63,8 @@ func (r *Router) roleCreate(c echo.Context) error { Name: role.Name, Actions: role.Actions, ResourceID: role.ResourceID, - Creator: role.CreatorID, + CreatedBy: role.CreatedBy, + UpdatedBy: role.UpdatedBy, CreatedAt: role.CreatedAt.Format(time.RFC3339), UpdatedAt: role.UpdatedAt.Format(time.RFC3339), } @@ -100,7 +104,11 @@ func (r *Router) roleUpdate(c echo.Context) error { // check on the role resource. resource, err := r.engine.GetRoleResource(ctx, roleResource) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "error getting resource").SetInternal(err) + 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 { @@ -117,7 +125,8 @@ func (r *Router) roleUpdate(c echo.Context) error { Name: role.Name, Actions: role.Actions, ResourceID: role.ResourceID, - Creator: role.CreatorID, + CreatedBy: role.CreatedBy, + UpdatedBy: role.UpdatedBy, CreatedAt: role.CreatedAt.Format(time.RFC3339), UpdatedAt: role.UpdatedAt.Format(time.RFC3339), } @@ -169,7 +178,8 @@ func (r *Router) roleGet(c echo.Context) error { Name: role.Name, Actions: role.Actions, ResourceID: role.ResourceID, - Creator: role.CreatorID, + CreatedBy: role.CreatedBy, + UpdatedBy: role.UpdatedBy, CreatedAt: role.CreatedAt.Format(time.RFC3339), UpdatedAt: role.UpdatedAt.Format(time.RFC3339), } @@ -216,7 +226,8 @@ func (r *Router) rolesList(c echo.Context) error { ID: role.ID, Name: role.Name, Actions: role.Actions, - Creator: role.CreatorID, + CreatedBy: role.CreatedBy, + UpdatedBy: role.UpdatedBy, CreatedAt: role.CreatedAt.Format(time.RFC3339), UpdatedAt: role.UpdatedAt.Format(time.RFC3339), } diff --git a/internal/api/types.go b/internal/api/types.go index 71825d06..e02e96c7 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -20,7 +20,8 @@ type roleResponse struct { Actions []string `json:"actions"` ResourceID gidx.PrefixedID `json:"resource_id,omitempty"` - Creator gidx.PrefixedID `json:"creator"` + CreatedBy gidx.PrefixedID `json:"created_by"` + UpdatedBy gidx.PrefixedID `json:"updated_by"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } diff --git a/internal/query/mock/mock.go b/internal/query/mock/mock.go index d7c5d643..c9e9a589 100644 --- a/internal/query/mock/mock.go +++ b/internal/query/mock/mock.go @@ -54,7 +54,8 @@ func (e *Engine) CreateRole(ctx context.Context, actor, res types.Resource, name ID: gidx.MustNewID(query.ApplicationPrefix), Name: name, Actions: outActions, - CreatorID: actor.ID, + CreatedBy: actor.ID, + UpdatedBy: actor.ID, CreatedAt: time.Now(), UpdatedAt: time.Now(), } @@ -62,9 +63,13 @@ func (e *Engine) CreateRole(ctx context.Context, actor, res types.Resource, name return role, nil } -// UpdateRole returns nothing but satisfies the Engine interface. +// UpdateRole returns the provided mock results. func (e *Engine) UpdateRole(ctx context.Context, actor, roleResource types.Resource, newName string, newActions []string) (types.Role, error) { - return types.Role{}, nil + 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. diff --git a/internal/query/relations.go b/internal/query/relations.go index 3ad8c66c..2230c413 100644 --- a/internal/query/relations.go +++ b/internal/query/relations.go @@ -313,10 +313,16 @@ func (e *engine) CreateRole(ctx context.Context, actor, res types.Resource, role 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.CreatorID = dbRole.CreatorID + role.CreatedBy = dbRole.CreatedBy + role.UpdatedBy = dbRole.UpdatedBy role.ResourceID = dbRole.ResourceID role.CreatedAt = dbRole.CreatedAt role.UpdatedAt = dbRole.UpdatedAt @@ -367,48 +373,35 @@ func (e *engine) UpdateRole(ctx context.Context, actor, roleResource types.Resou defer span.End() - role, err := e.GetRole(ctx, roleResource) + dbCtx, err := e.store.BeginContext(ctx) + if err != nil { + return types.Role{}, err + } + + role, err := e.GetRole(dbCtx, roleResource) if err != nil { return types.Role{}, err } newName = strings.TrimSpace(newName) - // If the name is the same, then clear the changed name. - if role.Name == newName { - newName = "" + if newName == "" { + newName = role.Name } addActions, remActions := actionsDiff(role.Actions, newActions) // If no changes, return existing role with no changes. - if newName == "" && len(addActions) == 0 && len(remActions) == 0 { + if newName == role.Name && len(addActions) == 0 && len(remActions) == 0 { return role, nil } - resourceID := role.ResourceID - - // If the resource id is not found in the permissions database, then we must locate it in the spicedb database. - if resourceID == gidx.NullPrefixedID { - resource, err := e.GetRoleResource(ctx, roleResource) - if err != nil { - return types.Role{}, fmt.Errorf("failed to locate roles associated resource: %s: %w", roleResource.ID.String(), err) - } - - resourceID = resource.ID - } - - resource, err := e.NewResourceFromID(resourceID) + resource, err := e.NewResourceFromID(role.ResourceID) if err != nil { return types.Role{}, err } - dbCtx, err := e.store.BeginContext(ctx) - if err != nil { - return types.Role{}, err - } - - dbRole, err := e.store.UpdateRole(dbCtx, role.ID, newName) + dbRole, err := e.store.UpdateRole(dbCtx, actor.ID, role.ID, newName) if err != nil { logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) @@ -439,11 +432,14 @@ func (e *engine) UpdateRole(ctx context.Context, actor, roleResource types.Resou logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) + // TODO: add spicedb rollback logic. + return types.Role{}, err } role.Name = dbRole.Name - role.CreatorID = dbRole.CreatorID + role.CreatedBy = dbRole.CreatedBy + role.UpdatedBy = dbRole.UpdatedBy role.ResourceID = dbRole.ResourceID role.CreatedAt = dbRole.CreatedAt role.UpdatedAt = dbRole.UpdatedAt @@ -865,7 +861,8 @@ func (e *engine) ListRoles(ctx context.Context, resource types.Resource) ([]type for i, role := range out { if dbRole, ok := rolesByID[role.ID.String()]; ok { role.Name = dbRole.Name - role.CreatorID = dbRole.CreatorID + role.CreatedBy = dbRole.CreatedBy + role.UpdatedBy = dbRole.UpdatedBy role.CreatedAt = dbRole.CreatedAt role.UpdatedAt = dbRole.UpdatedAt @@ -963,7 +960,8 @@ func (e *engine) GetRole(ctx context.Context, roleResource types.Resource) (type Actions: actions, ResourceID: dbRole.ResourceID, - CreatorID: dbRole.CreatorID, + CreatedBy: dbRole.CreatedBy, + UpdatedBy: dbRole.UpdatedBy, CreatedAt: dbRole.CreatedAt, UpdatedAt: dbRole.UpdatedAt, }, nil diff --git a/internal/storage/context.go b/internal/storage/context.go index a7437dd3..48fc67ef 100644 --- a/internal/storage/context.go +++ b/internal/storage/context.go @@ -39,6 +39,19 @@ func getContextTx(ctx context.Context) (*sql.Tx, error) { } } +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 { diff --git a/internal/storage/errors.go b/internal/storage/errors.go index a04d8e44..c264eb90 100644 --- a/internal/storage/errors.go +++ b/internal/storage/errors.go @@ -27,17 +27,30 @@ var ( 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 == "roles_pkey" + 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 == "roles_resource_id_name" + return pqErr.Code.Name() == "unique_violation" && pqErr.Constraint == pqIndexRolesResourceIDName } return false diff --git a/internal/storage/migrations/20231122000000_initial_schema.sql b/internal/storage/migrations/20231122000000_initial_schema.sql index a73352f9..058fcdd2 100644 --- a/internal/storage/migrations/20231122000000_initial_schema.sql +++ b/internal/storage/migrations/20231122000000_initial_schema.sql @@ -4,13 +4,16 @@ CREATE TABLE "roles" ( "id" character varying NOT NULL, "name" character varying(64) NOT NULL, "resource_id" character varying NOT NULL, - "creator_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_creator_id" to table: "roles" -CREATE INDEX "roles_creator_id" ON "roles" ("creator_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" @@ -24,7 +27,9 @@ DROP INDEX "roles_resource_id_name"; DROP INDEX "roles_updated_at"; -- reverse: create index "roles_created_at" to table: "roles" DROP INDEX "roles_created_at"; --- reverse: create index "roles_creator_id" to table: "roles" -DROP INDEX "roles_creator_id"; +-- 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/roles.go b/internal/storage/roles.go index 51858d34..d3cd24fc 100644 --- a/internal/storage/roles.go +++ b/internal/storage/roles.go @@ -16,7 +16,7 @@ type RoleService interface { 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, roleID gidx.PrefixedID, name string) (Role, error) + UpdateRole(ctx context.Context, actorID, roleID gidx.PrefixedID, name string) (Role, error) DeleteRole(ctx context.Context, roleID gidx.PrefixedID) (Role, error) } @@ -25,7 +25,8 @@ type Role struct { ID gidx.PrefixedID Name string ResourceID gidx.PrefixedID - CreatorID gidx.PrefixedID + CreatedBy gidx.PrefixedID + UpdatedBy gidx.PrefixedID CreatedAt time.Time UpdatedAt time.Time } @@ -33,14 +34,20 @@ type Role struct { // 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 := e.QueryRowContext(ctx, ` + err = db.QueryRowContext(ctx, ` SELECT id, name, resource_id, - creator_id, + created_by, + updated_by, created_at, updated_at FROM roles @@ -50,7 +57,8 @@ func (e *engine) GetRoleByID(ctx context.Context, id gidx.PrefixedID) (Role, err &role.ID, &role.Name, &role.ResourceID, - &role.CreatorID, + &role.CreatedBy, + &role.UpdatedBy, &role.CreatedAt, &role.UpdatedAt, ) @@ -69,14 +77,20 @@ func (e *engine) GetRoleByID(ctx context.Context, id gidx.PrefixedID) (Role, err // 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 := e.QueryRowContext(ctx, ` + err = db.QueryRowContext(ctx, ` SELECT id, name, resource_id, - creator_id, + created_by, + updated_by, created_at, updated_at FROM roles @@ -90,7 +104,8 @@ func (e *engine) GetResourceRoleByName(ctx context.Context, resourceID gidx.Pref &role.ID, &role.Name, &role.ResourceID, - &role.CreatorID, + &role.CreatedBy, + &role.UpdatedBy, &role.CreatedAt, &role.UpdatedAt, ) @@ -109,12 +124,18 @@ func (e *engine) GetResourceRoleByName(ctx context.Context, resourceID gidx.Pref // 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) { - rows, err := e.QueryContext(ctx, ` + db, err := getContextDBQuery(ctx, e) + if err != nil { + return nil, err + } + + rows, err := db.QueryContext(ctx, ` SELECT id, name, resource_id, - creator_id, + created_by, + updated_by, created_at, updated_at FROM roles @@ -133,7 +154,7 @@ func (e *engine) ListResourceRoles(ctx context.Context, resourceID gidx.Prefixed for rows.Next() { var role Role - if err := rows.Scan(&role.ID, &role.Name, &role.ResourceID, &role.CreatorID, &role.CreatedAt, &role.UpdatedAt); err != nil { + if err := rows.Scan(&role.ID, &role.Name, &role.ResourceID, &role.CreatedBy, &role.UpdatedBy, &role.CreatedAt, &role.UpdatedAt); err != nil { return nil, err } @@ -159,15 +180,16 @@ func (e *engine) CreateRole(ctx context.Context, actorID, roleID gidx.PrefixedID err = tx.QueryRowContext(ctx, ` INSERT - INTO roles (id, name, resource_id, creator_id, created_at, updated_at) - VALUES ($1, $2, $3, $4, now(), now()) - RETURNING id, name, resource_id, creator_id, created_at, updated_at + 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.CreatorID, + &role.CreatedBy, + &role.UpdatedBy, &role.CreatedAt, &role.UpdatedAt, ) @@ -187,41 +209,29 @@ func (e *engine) CreateRole(ctx context.Context, actorID, roleID gidx.PrefixedID return role, nil } -// UpdateRole updates an existing role if one exists. -// If no role already exists, a new role is created in the same way as CreateRole. +// 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, roleID gidx.PrefixedID, name string) (Role, 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 row *sql.Row - - // If no name is provided, only update the updated_at timestamp. - if name == "" { - row = tx.QueryRowContext(ctx, ` - UPDATE roles SET updated_at = now() WHERE id = $1 - RETURNING id, name, resource_id, creator_id, created_at, updated_at - `, roleID.String()) - } else { - row = tx.QueryRowContext(ctx, ` - UPDATE roles SET name = $1, updated_at = now() WHERE id = $2 - RETURNING id, name, resource_id, creator_id, created_at, updated_at - `, name, roleID.String(), - ) - } - var role Role - err = row.Scan( + 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.CreatorID, + &role.CreatedBy, + &role.UpdatedBy, &role.CreatedAt, &role.UpdatedAt, ) diff --git a/internal/storage/roles_test.go b/internal/storage/roles_test.go index 14771e64..1e62a9fb 100644 --- a/internal/storage/roles_test.go +++ b/internal/storage/roles_test.go @@ -54,7 +54,7 @@ func TestGetRoleByID(t *testing.T) { 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.CreatorID) + assert.Equal(t, actorID, res.Success.CreatedBy) assert.Equal(t, createdRole.CreatedAt, res.Success.CreatedAt) assert.Equal(t, createdRole.UpdatedAt, res.Success.UpdatedAt) }, @@ -124,7 +124,7 @@ func TestListResourceRoles(t *testing.T) { require.NotEmpty(t, role.Name) assert.Equal(t, groups[role.Name], role.ID) assert.Equal(t, resourceID, role.ResourceID) - assert.Equal(t, actorID, role.CreatorID) + assert.Equal(t, actorID, role.CreatedBy) assert.False(t, role.CreatedAt.IsZero()) assert.False(t, role.UpdatedAt.IsZero()) } @@ -172,7 +172,7 @@ func TestCreateRole(t *testing.T) { 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.CreatorID) + assert.Equal(t, actorID, res.Success.CreatedBy) assert.False(t, res.Success.CreatedAt.IsZero()) assert.False(t, res.Success.UpdatedAt.IsZero()) }, @@ -290,7 +290,7 @@ func TestUpdateRole(t *testing.T) { assert.Equal(t, role1ID, res.Success.ID) assert.Equal(t, "root-admins", res.Success.Name) - assert.Equal(t, actorID, res.Success.CreatorID) + assert.Equal(t, actorID, res.Success.CreatedBy) assert.Equal(t, createdDBRole1.CreatedAt, res.Success.CreatedAt) assert.NotEqual(t, createdDBRole1.UpdatedAt, res.Success.UpdatedAt) }, @@ -320,7 +320,7 @@ func TestUpdateRole(t *testing.T) { return result } - result.Success, result.Err = store.UpdateRole(dbCtx, input.id, input.name) + 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 diff --git a/internal/storage/storage.go b/internal/storage/storage.go index ba050002..ea1d64dc 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -20,9 +20,16 @@ type Storage interface { // *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 - PingContext(ctx context.Context) error + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) } type engine struct { diff --git a/internal/types/types.go b/internal/types/types.go index 99548b68..34704350 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -14,7 +14,8 @@ type Role struct { Actions []string ResourceID gidx.PrefixedID - CreatorID gidx.PrefixedID + CreatedBy gidx.PrefixedID + UpdatedBy gidx.PrefixedID CreatedAt time.Time UpdatedAt time.Time } From 97ab79940e90638906b06141b2583e450fa34a74 Mon Sep 17 00:00:00 2001 From: Mike Mason Date: Thu, 14 Dec 2023 00:22:28 +0000 Subject: [PATCH 09/11] add missing rollback comments and ensure tests are checking results properly Signed-off-by: Mike Mason --- internal/query/relations.go | 33 ++++++++++++++++++++++---------- internal/query/relations_test.go | 16 ++++++++++++---- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/internal/query/relations.go b/internal/query/relations.go index 2230c413..b8733288 100644 --- a/internal/query/relations.go +++ b/internal/query/relations.go @@ -337,27 +337,27 @@ func actionsDiff(oldActions, newActions []string) ([]string, []string) { return nil, nil } - old := make(map[string]bool, len(oldActions)) - new := make(map[string]bool, len(newActions)) + old := make(map[string]struct{}, len(oldActions)) + new := make(map[string]struct{}, len(newActions)) var add, rem []string for _, action := range oldActions { - old[action] = true + old[action] = struct{}{} } for _, action := range newActions { - new[action] = true + new[action] = struct{}{} // If the new action is not in the old actions, then we need to add the action. - if !old[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 !new[action] { + if _, ok := new[action]; !ok { rem = append(rem, action) } } @@ -432,7 +432,10 @@ func (e *engine) UpdateRole(ctx context.Context, actor, roleResource types.Resou logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) - // TODO: add spicedb rollback logic. + // 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 } @@ -853,13 +856,13 @@ func (e *engine) ListRoles(ctx context.Context, resource types.Resource) ([]type out := relationshipsToRoles(relationships) - rolesByID := make(map[string]storage.Role, len(dbRoles)) + rolesByID := make(map[gidx.PrefixedID]storage.Role, len(dbRoles)) for _, role := range dbRoles { - rolesByID[role.ID.String()] = role + rolesByID[role.ID] = role } for i, role := range out { - if dbRole, ok := rolesByID[role.ID.String()]; ok { + if dbRole, ok := rolesByID[role.ID]; ok { role.Name = dbRole.Name role.CreatedBy = dbRole.CreatedBy role.UpdatedBy = dbRole.UpdatedBy @@ -1072,6 +1075,11 @@ func (e *engine) DeleteRole(ctx context.Context, roleResource types.Resource) er 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 } } @@ -1082,6 +1090,11 @@ func (e *engine) DeleteRole(ctx context.Context, roleResource types.Resource) er 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 } diff --git a/internal/query/relations_test.go b/internal/query/relations_test.go index 71991df7..73a3a1f3 100644 --- a/internal/query/relations_test.go +++ b/internal/query/relations_test.go @@ -229,6 +229,8 @@ func TestRoleUpdate(t *testing.T) { 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) @@ -241,15 +243,21 @@ func TestRoleUpdate(t *testing.T) { Name: "UpdateMissingRole", Input: gidx.MustNewID(RolePrefix), CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.Role]) { - assert.Error(t, res.Err) + require.Error(t, res.Err) + assert.ErrorIs(t, res.Err, ErrRoleNotFound) }, }, { Name: "UpdateSuccess", Input: role.ID, CheckFn: func(ctx context.Context, t *testing.T, res testingx.TestResult[types.Role]) { - assert.NoError(t, res.Err) - require.Equal(t, "test2", res.Success.Name) + 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) }, }, } @@ -262,7 +270,7 @@ func TestRoleUpdate(t *testing.T) { } } - _, err = e.UpdateRole(ctx, actorRes, roleResource, "test2", nil) + _, err = e.UpdateRole(ctx, actorUpdateRes, roleResource, "test2", nil) if err != nil { return testingx.TestResult[types.Role]{ Err: err, From 1c568839269207bb16abe8c5e8337ed9ac00fd16 Mon Sep 17 00:00:00 2001 From: Mike Mason Date: Thu, 11 Jan 2024 21:33:05 +0000 Subject: [PATCH 10/11] 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 --- internal/query/relations.go | 47 +++++++++++++++++++++++--------- internal/query/relations_test.go | 3 +- internal/storage/roles.go | 26 ++++++++++++++++++ 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/internal/query/relations.go b/internal/query/relations.go index b8733288..0af4ffd7 100644 --- a/internal/query/relations.go +++ b/internal/query/relations.go @@ -378,8 +378,22 @@ func (e *engine) UpdateRole(ctx context.Context, actor, roleResource types.Resou 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 } @@ -1010,14 +1024,30 @@ func (e *engine) DeleteRole(ctx context.Context, roleResource types.Resource) er defer span.End() - var ( - resActions map[types.Resource][]string - err error - ) + 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 } @@ -1027,10 +1057,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 @@ -1054,11 +1080,6 @@ func (e *engine) DeleteRole(ctx context.Context, roleResource types.Resource) er } } - dbCtx, err := e.store.BeginContext(ctx) - if err != nil { - return err - } - _, err = e.store.DeleteRole(dbCtx, roleResource.ID) if err != nil { logRollbackErr(e.logger, e.store.RollbackContext(dbCtx)) diff --git a/internal/query/relations_test.go b/internal/query/relations_test.go index 73a3a1f3..366221bf 100644 --- a/internal/query/relations_test.go +++ b/internal/query/relations_test.go @@ -15,6 +15,7 @@ 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" @@ -244,7 +245,7 @@ func TestRoleUpdate(t *testing.T) { 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, ErrRoleNotFound) + assert.ErrorIs(t, res.Err, storage.ErrNoRoleFound) }, }, { diff --git a/internal/storage/roles.go b/internal/storage/roles.go index d3cd24fc..870445ae 100644 --- a/internal/storage/roles.go +++ b/internal/storage/roles.go @@ -18,6 +18,7 @@ type RoleService interface { 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. @@ -74,6 +75,31 @@ func (e *engine) GetRoleByID(ctx context.Context, id gidx.PrefixedID) (Role, err 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) { From f2d105f4019ab270c6770a6f311e09b8cca8beb1 Mon Sep 17 00:00:00 2001 From: Mike Mason Date: Thu, 18 Jan 2024 15:18:09 +0000 Subject: [PATCH 11/11] correct ListRoles to ensure it always lists roles from the database Signed-off-by: Mike Mason --- internal/query/relations.go | 38 ++++++++------ internal/query/relations_test.go | 85 ++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 16 deletions(-) diff --git a/internal/query/relations.go b/internal/query/relations.go index 0af4ffd7..4c3b8ab6 100644 --- a/internal/query/relations.go +++ b/internal/query/relations.go @@ -844,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" @@ -858,32 +863,33 @@ func (e *engine) ListRoles(ctx context.Context, resource types.Resource) ([]type }, } - dbRoles, err := e.store.ListResourceRoles(ctx, resource.ID) - if err != nil { - return nil, err - } - relationships, err := e.readRelationships(ctx, filter) if err != nil { return nil, err } - out := relationshipsToRoles(relationships) + spicedbRoles := relationshipsToRoles(relationships) + + rolesByID := make(map[gidx.PrefixedID]types.Role, len(spicedbRoles)) - rolesByID := make(map[gidx.PrefixedID]storage.Role, len(dbRoles)) - for _, role := range dbRoles { + for _, role := range spicedbRoles { rolesByID[role.ID] = role } - for i, role := range out { - if dbRole, ok := rolesByID[role.ID]; ok { - role.Name = dbRole.Name - role.CreatedBy = dbRole.CreatedBy - role.UpdatedBy = dbRole.UpdatedBy - role.CreatedAt = dbRole.CreatedAt - role.UpdatedAt = dbRole.UpdatedAt + out := make([]types.Role, len(dbRoles)) - out[i] = role + 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, } } diff --git a/internal/query/relations_test.go b/internal/query/relations_test.go index 366221bf..2235c612 100644 --- a/internal/query/relations_test.go +++ b/internal/query/relations_test.go @@ -289,6 +289,91 @@ func TestRoleUpdate(t *testing.T) { 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()