From 7b25f7a1c651ee2ad4086439df6c1ac91f44786d Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 6 Feb 2024 16:56:38 -0800 Subject: [PATCH 01/62] Refactoring and adding shoreline models to platform/user for auth. --- go.mod | 4 + go.sum | 21 + user/hasher.go | 57 + user/internal_user.go | 837 ++++ user/kcclient.go | 623 +++ user/profile.go | 125 + user/user.go | 4 - .../Nerzal/gocloak/v13/.codebeatsettings | 6 + .../github.com/Nerzal/gocloak/v13/.gitignore | 19 + .../Nerzal/gocloak/v13/.golangci.yml | 44 + .../Nerzal/gocloak/v13/.nancy-ignore | 1 + .../github.com/Nerzal/gocloak/v13/Dockerfile | 11 + vendor/github.com/Nerzal/gocloak/v13/LICENSE | 201 + vendor/github.com/Nerzal/gocloak/v13/Makefile | 8 + .../gocloak/v13/PULL_REQUEST_TEMPLATE.md | 2 + .../github.com/Nerzal/gocloak/v13/README.md | 496 ++ .../github.com/Nerzal/gocloak/v13/client.go | 4306 +++++++++++++++++ .../Nerzal/gocloak/v13/docker-compose.yml | 23 + .../github.com/Nerzal/gocloak/v13/errors.go | 38 + .../Nerzal/gocloak/v13/gocloak-gopher.png | Bin 0 -> 308991 bytes .../github.com/Nerzal/gocloak/v13/models.go | 1518 ++++++ .../Nerzal/gocloak/v13/pkg/jwx/jwx.go | 162 + .../Nerzal/gocloak/v13/pkg/jwx/models.go | 59 + .../Nerzal/gocloak/v13/run-tests.sh | 26 + .../github.com/Nerzal/gocloak/v13/test.json | 0 vendor/github.com/Nerzal/gocloak/v13/token.go | 14 + vendor/github.com/Nerzal/gocloak/v13/utils.go | 151 + .../github.com/go-resty/resty/v2/.gitignore | 30 + .../github.com/go-resty/resty/v2/BUILD.bazel | 51 + vendor/github.com/go-resty/resty/v2/LICENSE | 21 + vendor/github.com/go-resty/resty/v2/README.md | 925 ++++ vendor/github.com/go-resty/resty/v2/WORKSPACE | 31 + vendor/github.com/go-resty/resty/v2/client.go | 1391 ++++++ vendor/github.com/go-resty/resty/v2/digest.go | 295 ++ .../go-resty/resty/v2/middleware.go | 589 +++ .../github.com/go-resty/resty/v2/redirect.go | 109 + .../github.com/go-resty/resty/v2/request.go | 1093 +++++ .../github.com/go-resty/resty/v2/response.go | 189 + vendor/github.com/go-resty/resty/v2/resty.go | 40 + vendor/github.com/go-resty/resty/v2/retry.go | 252 + vendor/github.com/go-resty/resty/v2/trace.go | 130 + .../github.com/go-resty/resty/v2/transport.go | 36 + .../go-resty/resty/v2/transport112.go | 35 + .../go-resty/resty/v2/transport_js.go | 17 + .../go-resty/resty/v2/transport_other.go | 17 + vendor/github.com/go-resty/resty/v2/util.go | 384 ++ .../opentracing/opentracing-go/.gitignore | 1 + .../opentracing/opentracing-go/.travis.yml | 20 + .../opentracing/opentracing-go/CHANGELOG.md | 63 + .../opentracing/opentracing-go/LICENSE | 201 + .../opentracing/opentracing-go/Makefile | 20 + .../opentracing/opentracing-go/README.md | 171 + .../opentracing/opentracing-go/ext.go | 24 + .../opentracing-go/globaltracer.go | 42 + .../opentracing/opentracing-go/gocontext.go | 65 + .../opentracing/opentracing-go/log/field.go | 282 ++ .../opentracing/opentracing-go/log/util.go | 61 + .../opentracing/opentracing-go/noop.go | 64 + .../opentracing/opentracing-go/propagation.go | 176 + .../opentracing/opentracing-go/span.go | 189 + .../opentracing/opentracing-go/tracer.go | 304 ++ vendor/github.com/segmentio/ksuid/.gitignore | 31 + vendor/github.com/segmentio/ksuid/LICENSE.md | 21 + vendor/github.com/segmentio/ksuid/README.md | 234 + vendor/github.com/segmentio/ksuid/base62.go | 202 + vendor/github.com/segmentio/ksuid/ksuid.go | 352 ++ vendor/github.com/segmentio/ksuid/rand.go | 55 + vendor/github.com/segmentio/ksuid/sequence.go | 55 + vendor/github.com/segmentio/ksuid/set.go | 343 ++ vendor/github.com/segmentio/ksuid/uint128.go | 141 + vendor/modules.txt | 14 + 71 files changed, 17518 insertions(+), 4 deletions(-) create mode 100644 user/hasher.go create mode 100644 user/internal_user.go create mode 100644 user/kcclient.go create mode 100644 user/profile.go create mode 100644 vendor/github.com/Nerzal/gocloak/v13/.codebeatsettings create mode 100644 vendor/github.com/Nerzal/gocloak/v13/.gitignore create mode 100644 vendor/github.com/Nerzal/gocloak/v13/.golangci.yml create mode 100644 vendor/github.com/Nerzal/gocloak/v13/.nancy-ignore create mode 100644 vendor/github.com/Nerzal/gocloak/v13/Dockerfile create mode 100644 vendor/github.com/Nerzal/gocloak/v13/LICENSE create mode 100644 vendor/github.com/Nerzal/gocloak/v13/Makefile create mode 100644 vendor/github.com/Nerzal/gocloak/v13/PULL_REQUEST_TEMPLATE.md create mode 100644 vendor/github.com/Nerzal/gocloak/v13/README.md create mode 100644 vendor/github.com/Nerzal/gocloak/v13/client.go create mode 100644 vendor/github.com/Nerzal/gocloak/v13/docker-compose.yml create mode 100644 vendor/github.com/Nerzal/gocloak/v13/errors.go create mode 100644 vendor/github.com/Nerzal/gocloak/v13/gocloak-gopher.png create mode 100644 vendor/github.com/Nerzal/gocloak/v13/models.go create mode 100644 vendor/github.com/Nerzal/gocloak/v13/pkg/jwx/jwx.go create mode 100644 vendor/github.com/Nerzal/gocloak/v13/pkg/jwx/models.go create mode 100644 vendor/github.com/Nerzal/gocloak/v13/run-tests.sh create mode 100644 vendor/github.com/Nerzal/gocloak/v13/test.json create mode 100644 vendor/github.com/Nerzal/gocloak/v13/token.go create mode 100644 vendor/github.com/Nerzal/gocloak/v13/utils.go create mode 100644 vendor/github.com/go-resty/resty/v2/.gitignore create mode 100644 vendor/github.com/go-resty/resty/v2/BUILD.bazel create mode 100644 vendor/github.com/go-resty/resty/v2/LICENSE create mode 100644 vendor/github.com/go-resty/resty/v2/README.md create mode 100644 vendor/github.com/go-resty/resty/v2/WORKSPACE create mode 100644 vendor/github.com/go-resty/resty/v2/client.go create mode 100644 vendor/github.com/go-resty/resty/v2/digest.go create mode 100644 vendor/github.com/go-resty/resty/v2/middleware.go create mode 100644 vendor/github.com/go-resty/resty/v2/redirect.go create mode 100644 vendor/github.com/go-resty/resty/v2/request.go create mode 100644 vendor/github.com/go-resty/resty/v2/response.go create mode 100644 vendor/github.com/go-resty/resty/v2/resty.go create mode 100644 vendor/github.com/go-resty/resty/v2/retry.go create mode 100644 vendor/github.com/go-resty/resty/v2/trace.go create mode 100644 vendor/github.com/go-resty/resty/v2/transport.go create mode 100644 vendor/github.com/go-resty/resty/v2/transport112.go create mode 100644 vendor/github.com/go-resty/resty/v2/transport_js.go create mode 100644 vendor/github.com/go-resty/resty/v2/transport_other.go create mode 100644 vendor/github.com/go-resty/resty/v2/util.go create mode 100644 vendor/github.com/opentracing/opentracing-go/.gitignore create mode 100644 vendor/github.com/opentracing/opentracing-go/.travis.yml create mode 100644 vendor/github.com/opentracing/opentracing-go/CHANGELOG.md create mode 100644 vendor/github.com/opentracing/opentracing-go/LICENSE create mode 100644 vendor/github.com/opentracing/opentracing-go/Makefile create mode 100644 vendor/github.com/opentracing/opentracing-go/README.md create mode 100644 vendor/github.com/opentracing/opentracing-go/ext.go create mode 100644 vendor/github.com/opentracing/opentracing-go/globaltracer.go create mode 100644 vendor/github.com/opentracing/opentracing-go/gocontext.go create mode 100644 vendor/github.com/opentracing/opentracing-go/log/field.go create mode 100644 vendor/github.com/opentracing/opentracing-go/log/util.go create mode 100644 vendor/github.com/opentracing/opentracing-go/noop.go create mode 100644 vendor/github.com/opentracing/opentracing-go/propagation.go create mode 100644 vendor/github.com/opentracing/opentracing-go/span.go create mode 100644 vendor/github.com/opentracing/opentracing-go/tracer.go create mode 100644 vendor/github.com/segmentio/ksuid/.gitignore create mode 100644 vendor/github.com/segmentio/ksuid/LICENSE.md create mode 100644 vendor/github.com/segmentio/ksuid/README.md create mode 100644 vendor/github.com/segmentio/ksuid/base62.go create mode 100644 vendor/github.com/segmentio/ksuid/ksuid.go create mode 100644 vendor/github.com/segmentio/ksuid/rand.go create mode 100644 vendor/github.com/segmentio/ksuid/sequence.go create mode 100644 vendor/github.com/segmentio/ksuid/set.go create mode 100644 vendor/github.com/segmentio/ksuid/uint128.go diff --git a/go.mod b/go.mod index 474c59826..0778d2f87 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ toolchain go1.21.0 require ( github.com/IBM/sarama v1.42.1 + github.com/Nerzal/gocloak/v13 v13.8.0 github.com/ant0ine/go-json-rest v3.3.2+incompatible github.com/aws/aws-sdk-go v1.47.4 github.com/blang/semver v3.5.1+incompatible @@ -75,6 +76,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.16.0 // indirect + github.com/go-resty/resty/v2 v2.11.0 // indirect github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/golang/protobuf v1.5.3 // indirect @@ -117,6 +119,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/montanaflynn/stats v0.7.1 // indirect github.com/oapi-codegen/runtime v1.0.0 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/pierrec/lz4/v4 v4.1.18 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -127,6 +130,7 @@ require ( github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/schollz/closestmatch v2.1.0+incompatible // indirect + github.com/segmentio/ksuid v1.0.4 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/tdewolff/minify/v2 v2.20.6 // indirect github.com/tdewolff/parse/v2 v2.7.4 // indirect diff --git a/go.sum b/go.sum index 0da3073d2..bc739f6df 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/Joker/hpp v1.0.0 h1:65+iuJYdRXv/XyN62C1uEmmOx3432rNG/rKlX6V7Kkc= github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY= github.com/Joker/jade v1.1.3 h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk= github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM= +github.com/Nerzal/gocloak/v13 v13.8.0 h1:7s9cK8X3vy8OIic+pG4POE9vGy02tSHkMhvWXv0P2m8= +github.com/Nerzal/gocloak/v13 v13.8.0/go.mod h1:rRBtEdh5N0+JlZZEsrfZcB2sRMZWbgSxI2EIv9jpJp4= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06 h1:KkH3I3sJuOLP3TjA/dfr4NAY8bghDwnXiU7cTKxQqo0= github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06/go.mod h1:7erjKLwalezA0k99cWs5L11HWOAPNjdUZ6RxH1BXbbM= @@ -103,6 +105,10 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE= github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY= +github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I= +github.com/go-resty/resty/v2 v2.11.0 h1:i7jMfNOJYMp69lq7qozJP+bjgzfAzeOhuGlyDrqxT/8= +github.com/go-resty/resty/v2 v2.11.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= @@ -251,6 +257,8 @@ github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4 github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= @@ -281,6 +289,8 @@ github.com/sanity-io/litter v1.5.5 h1:iE+sBxPBzoK6uaEP5Lt3fHNgpKcHXc/A2HGETy0uJQ github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= github.com/schollz/closestmatch v2.1.0+incompatible h1:Uel2GXEpJqOWBrlyI+oY9LTiyyjYS17cCYRqP13/SHk= github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= +github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= +github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -393,6 +403,7 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -402,10 +413,12 @@ golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLL 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-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 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.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 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.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= @@ -413,6 +426,7 @@ golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -435,11 +449,14 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -448,8 +465,11 @@ 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.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.4.0 h1:Z81tqI5ddIoXDPvVQ7/7CC9TnLM7ubaFG2qXYd5BbYY= golang.org/x/time v0.4.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -458,6 +478,7 @@ golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/user/hasher.go b/user/hasher.go new file mode 100644 index 000000000..1c2b81e3f --- /dev/null +++ b/user/hasher.go @@ -0,0 +1,57 @@ +package user + +import ( + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "errors" + "math/big" + "strconv" + "time" +) + +func generateUniqueHash(strings []string, length int) (string, error) { + + if len(strings) > 0 && length > 0 { + + hash := sha256.New() + + for i := range strings { + hash.Write([]byte(strings[i])) + } + + max := big.NewInt(9999999999) + //add some randomness + n, err := rand.Int(rand.Reader, max) + + if err != nil { + return "", err + } + hash.Write([]byte(n.String())) + //and use unix nano + hash.Write([]byte(strconv.FormatInt(time.Now().UnixNano(), 10))) + hashString := hex.EncodeToString(hash.Sum(nil)) + return string([]rune(hashString)[0:length]), nil + } + + return "", errors.New("both strings and length are required") + +} + +func GeneratePasswordHash(id, pw, salt string) (string, error) { + + if salt == "" || id == "" { + return "", errors.New("id and salt are required") + } + + hash := sha1.New() + if pw != "" { + hash.Write([]byte(pw)) + } + hash.Write([]byte(salt)) + hash.Write([]byte(id)) + pwHash := hex.EncodeToString(hash.Sum(nil)) + + return pwHash, nil +} diff --git a/user/internal_user.go b/user/internal_user.go new file mode 100644 index 000000000..2823d9e4b --- /dev/null +++ b/user/internal_user.go @@ -0,0 +1,837 @@ +package user + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "math/rand" + "regexp" + "strconv" + "strings" + "time" + + "github.com/Nerzal/gocloak/v13" + + "github.com/tidepool-org/platform/pointer" +) + +const ( + custodialEmailFormat = "unclaimed-custodial-automation+%020d@tidepool.org" + RoleClinic = "clinic" + RoleClinician = "clinician" + RoleCustodialAccount = "custodial_account" + RoleMigratedClinic = "migrated_clinic" + RolePatient = "patient" + RoleBrokered = "brokered" +) + +var custodialAccountRegexp = regexp.MustCompile("unclaimed-custodial-automation\\+\\d+@tidepool\\.org") + +var validRoles = map[string]struct{}{ + RoleBrokered: {}, + RoleClinic: {}, + RoleClinician: {}, + RoleCustodialAccount: {}, + RoleMigratedClinic: {}, + RolePatient: {}, +} + +var custodialAccountRoles = []string{RoleCustodialAccount, RolePatient} + +type InternalUser struct { + Id string `json:"userid,omitempty" bson:"userid,omitempty"` // map userid to id + Username string `json:"username,omitempty" bson:"username,omitempty"` + Emails []string `json:"emails,omitempty" bson:"emails,omitempty"` + Roles []string `json:"roles,omitempty" bson:"roles,omitempty"` + TermsAccepted string `json:"termsAccepted,omitempty" bson:"termsAccepted,omitempty"` + EmailVerified bool `json:"emailVerified" bson:"authenticated"` //tag is name `authenticated` for historical reasons + PwHash string `json:"-" bson:"pwhash,omitempty"` + Hash string `json:"-" bson:"userhash,omitempty"` + // Private map[string]*IdHashPair `json:"-" bson:"private"` + IsMigrated bool `json:"-" bson:"-"` + IsUnclaimedCustodial bool `json:"-" bson:"-"` + Enabled bool `json:"-" bson:"-"` + CreatedTime string `json:"createdTime,omitempty" bson:"createdTime,omitempty"` + CreatedUserID string `json:"createdUserId,omitempty" bson:"createdUserId,omitempty"` + ModifiedTime string `json:"modifiedTime,omitempty" bson:"modifiedTime,omitempty"` + ModifiedUserID string `json:"modifiedUserId,omitempty" bson:"modifiedUserId,omitempty"` + DeletedTime string `json:"deletedTime,omitempty" bson:"deletedTime,omitempty"` + DeletedUserID string `json:"deletedUserId,omitempty" bson:"deletedUserId,omitempty"` + Attributes map[string][]string `json:"-"` + Profile *UserProfile `json:"-"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` +} + +// ExternalUser is the user returned to services. +type ExternalUser struct { + // same attributes as original shoreline Api.asSerializableUser + ID *string `json:"userid,omitempty"` + Username *string `json:"username,omitempty"` + Emails *[]string `json:"emails,omitempty"` + EmailVerified *bool `json:"emailVerified,omitempty"` + Roles *[]string `json:"roles,omitempty"` + TermsAccepted *bool `json:"terms_accepted"` +} + +// MigrationUser is a User that conforms to the structure +// expected by the keycloak-user-migration keycloak plugin +type MigrationUser struct { + ID string `json:"id"` + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` + Enabled bool `json:"enabled,omitempty"` + EmailVerified bool `json:"emailVerified,omitempty"` + Roles []string `json:"roles,omitempty"` + Attributes struct { + TermsAcceptedDate []string `json:"terms_and_conditions,omitempty"` + } `json:"attributes"` +} + +/* + * Incoming user details used to create or update a `User` + */ +type NewUserDetails struct { + Username *string + Emails []string + Password *string + Roles []string + EmailVerified bool +} + +type NewCustodialUserDetails struct { + Username *string + Emails []string +} + +type UpdateUserDetails struct { + Username *string + Emails []string + Password *string + HashedPassword *string + Roles []string + TermsAccepted *string + EmailVerified *bool +} + +type Profile struct { + FullName string `json:"fullName"` +} + +var ( + User_error_details_missing = errors.New("User details are missing") + User_error_username_missing = errors.New("Username is missing") + User_error_username_invalid = errors.New("Username is invalid") + User_error_emails_missing = errors.New("Emails are missing") + User_error_emails_invalid = errors.New("Emails are invalid") + User_error_password_missing = errors.New("Password is missing") + User_error_password_invalid = errors.New("Password is invalid") + User_error_roles_invalid = errors.New("Roles are invalid") + User_error_terms_accepted_invalid = errors.New("Terms accepted is invalid") + User_error_email_verified_invalid = errors.New("Email verified is invalid") +) + +func ExtractBool(data map[string]interface{}, key string) (*bool, bool) { + if raw, ok := data[key]; !ok { + return nil, true + } else if extractedBool, ok := raw.(bool); !ok { + return nil, false + } else { + return &extractedBool, true + } +} + +func ExtractString(data map[string]interface{}, key string) (*string, bool) { + if raw, ok := data[key]; !ok { + return nil, true + } else if extractedString, ok := raw.(string); !ok { + return nil, false + } else { + return &extractedString, true + } +} + +func ExtractArray(data map[string]interface{}, key string) ([]interface{}, bool) { + if raw, ok := data[key]; !ok { + return nil, true + } else if extractedArray, ok := raw.([]interface{}); !ok { + return nil, false + } else if len(extractedArray) == 0 { + return []interface{}{}, true + } else { + return extractedArray, true + } +} + +func ExtractStringArray(data map[string]interface{}, key string) ([]string, bool) { + if rawArray, ok := ExtractArray(data, key); !ok { + return nil, false + } else if rawArray == nil { + return nil, true + } else { + extractedStringArray := make([]string, 0) + for _, raw := range rawArray { + if extractedString, ok := raw.(string); !ok { + return nil, false + } else { + extractedStringArray = append(extractedStringArray, extractedString) + } + } + return extractedStringArray, true + } +} + +func ExtractStringMap(data map[string]interface{}, key string) (map[string]interface{}, bool) { + if raw, ok := data[key]; !ok { + return nil, true + } else if extractedMap, ok := raw.(map[string]interface{}); !ok { + return nil, false + } else if len(extractedMap) == 0 { + return map[string]interface{}{}, true + } else { + return extractedMap, true + } +} + +func IsValidEmail(email string) bool { + ok, _ := regexp.MatchString(`\A(?i)([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z`, email) + return ok +} + +func IsValidPassword(password string) bool { + ok, _ := regexp.MatchString(`\A\S{8,72}\z`, password) + return ok +} + +func IsValidRole(role string) bool { + _, ok := validRoles[role] + return ok +} + +func IsValidDate(date string) bool { + _, err := time.Parse("2006-01-02", date) + return err == nil +} + +func ParseAndValidateDateParam(date string) (time.Time, error) { + if date == "" { + return time.Time{}, nil + } + + return time.Parse("2006-01-02", date) +} + +func IsValidTimestamp(timestamp string) bool { + _, err := ParseTimestamp(timestamp) + return err == nil +} + +func ParseTimestamp(timestamp string) (time.Time, error) { + return time.Parse(TimestampFormat, timestamp) +} + +func TimestampToUnixString(timestamp string) (unix string, err error) { + parsed, err := ParseTimestamp(timestamp) + if err != nil { + return + } + unix = fmt.Sprintf("%v", parsed.Unix()) + return +} + +func UnixStringToTimestamp(unixString string) (timestamp string, err error) { + i, err := strconv.ParseInt(unixString, 10, 64) + if err != nil { + return + } + t := time.Unix(i, 0) + timestamp = t.Format(TimestampFormat) + return +} + +func IsValidUserID(id string) bool { + ok, _ := regexp.MatchString(`^([a-fA-F0-9]{10})$|^([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})$`, id) + return ok +} + +func (details *NewUserDetails) ExtractFromJSON(reader io.Reader) error { + if reader == nil { + return User_error_details_missing + } + + var decoded map[string]interface{} + if err := json.NewDecoder(reader).Decode(&decoded); err != nil { + return err + } + + var ( + username *string + emails []string + password *string + roles []string + ok bool + ) + + if username, ok = ExtractString(decoded, "username"); !ok { + return User_error_username_invalid + } + if emails, ok = ExtractStringArray(decoded, "emails"); !ok { + return User_error_emails_invalid + } + if password, ok = ExtractString(decoded, "password"); !ok { + return User_error_password_invalid + } + if roles, ok = ExtractStringArray(decoded, "roles"); !ok { + return User_error_roles_invalid + } + + details.Username = username + details.Emails = emails + details.Password = password + details.Roles = roles + return nil +} + +func (details *NewUserDetails) Validate() error { + if details.Username == nil { + return User_error_username_missing + } else if !IsValidEmail(*details.Username) { + return User_error_username_invalid + } + + if len(details.Emails) == 0 { + return User_error_emails_missing + } else { + for _, email := range details.Emails { + if !IsValidEmail(email) { + return User_error_emails_invalid + } + } + } + + if details.Password == nil { + return User_error_password_missing + } else if !IsValidPassword(*details.Password) { + return User_error_password_invalid + } + + if details.Roles != nil { + for _, role := range details.Roles { + if !IsValidRole(role) { + return User_error_roles_invalid + } + } + } + + return nil +} + +func ParseNewUserDetails(reader io.Reader) (*NewUserDetails, error) { + details := &NewUserDetails{} + if err := details.ExtractFromJSON(reader); err != nil { + return nil, err + } else { + return details, nil + } +} + +func NewUser(details *NewUserDetails, salt string) (user *InternalUser, err error) { + if details == nil { + return nil, errors.New("New user details is nil") + } else if err := details.Validate(); err != nil { + return nil, err + } + + user = &InternalUser{Username: *details.Username, Emails: details.Emails, Roles: details.Roles} + + if user.Id, err = generateUniqueHash([]string{*details.Username, *details.Password}, 10); err != nil { + return nil, errors.New("User: error generating id") + } + if user.Hash, err = generateUniqueHash([]string{*details.Username, *details.Password, user.Id}, 24); err != nil { + return nil, errors.New("User: error generating hash") + } + + if err = user.HashPassword(*details.Password, salt); err != nil { + return nil, errors.New("User: error generating password hash") + } + + return user, nil +} + +func (details *NewCustodialUserDetails) ExtractFromJSON(reader io.Reader) error { + if reader == nil { + return User_error_details_missing + } + + var decoded map[string]interface{} + if err := json.NewDecoder(reader).Decode(&decoded); err != nil { + return err + } + + var ( + username *string + emails []string + ok bool + ) + + if username, ok = ExtractString(decoded, "username"); !ok { + return User_error_username_invalid + } + if emails, ok = ExtractStringArray(decoded, "emails"); !ok { + return User_error_emails_invalid + } + + details.Username = username + details.Emails = emails + return nil +} + +func (details *NewCustodialUserDetails) Validate() error { + if details.Username != nil { + if !IsValidEmail(*details.Username) { + return User_error_username_invalid + } + } + + if details.Emails != nil { + for _, email := range details.Emails { + if !IsValidEmail(email) { + return User_error_emails_invalid + } + } + } + + return nil +} + +func ParseNewCustodialUserDetails(reader io.Reader) (*NewCustodialUserDetails, error) { + details := &NewCustodialUserDetails{} + if err := details.ExtractFromJSON(reader); err != nil { + return nil, err + } else { + return details, nil + } +} + +func NewCustodialUser(details *NewCustodialUserDetails, salt string) (user *InternalUser, err error) { + if details == nil { + return nil, errors.New("New custodial user details is nil") + } else if err := details.Validate(); err != nil { + return nil, err + } + + var username string + if details.Username != nil { + username = *details.Username + } + + user = &InternalUser{ + Username: username, + Emails: details.Emails, + } + + id, err := generateUniqueHash([]string{username}, 10) + if err != nil { + return nil, errors.New("User: error generating id") + } + if user.Hash, err = generateUniqueHash([]string{username, id}, 24); err != nil { + return nil, errors.New("User: error generating hash") + } + + return user, nil +} + +func NewUserDetailsFromCustodialUserDetails(details *NewCustodialUserDetails) (*NewUserDetails, error) { + var email string + if len(details.Emails) > 0 { + email = details.Emails[0] + } else if details.Username != nil { + email = *details.Username + } else { + email = GenerateTemporaryCustodialEmail() + } + + return &NewUserDetails{ + Username: &email, + Emails: []string{email}, + Roles: custodialAccountRoles, + }, nil +} + +func GenerateTemporaryCustodialEmail() string { + random := rand.Uint64() + return fmt.Sprintf(custodialEmailFormat, random) +} + +func IsTemporaryCustodialEmail(email string) bool { + return custodialAccountRegexp.MatchString(email) +} + +func (details *UpdateUserDetails) ExtractFromJSON(reader io.Reader) error { + if reader == nil { + return User_error_details_missing + } + + var decoded map[string]interface{} + if err := json.NewDecoder(reader).Decode(&decoded); err != nil { + return err + } + + var ( + username *string + emails []string + password *string + roles []string + termsAccepted *string + emailVerified *bool + ok bool + ) + + decoded, ok = ExtractStringMap(decoded, "updates") + if !ok || decoded == nil { + return User_error_details_missing + } + + if username, ok = ExtractString(decoded, "username"); !ok { + return User_error_username_invalid + } + if emails, ok = ExtractStringArray(decoded, "emails"); !ok { + return User_error_emails_invalid + } + if password, ok = ExtractString(decoded, "password"); !ok { + return User_error_password_invalid + } + if roles, ok = ExtractStringArray(decoded, "roles"); !ok { + return User_error_roles_invalid + } + if termsAccepted, ok = ExtractString(decoded, "termsAccepted"); !ok { + return User_error_terms_accepted_invalid + } + if emailVerified, ok = ExtractBool(decoded, "emailVerified"); !ok { + return User_error_email_verified_invalid + } + + details.Username = username + details.Emails = emails + details.Password = password + details.Roles = roles + details.TermsAccepted = termsAccepted + details.EmailVerified = emailVerified + return nil +} + +func (details *UpdateUserDetails) Validate() error { + if details.Username != nil { + if !IsValidEmail(*details.Username) { + return User_error_username_invalid + } + } + + if details.Emails != nil { + for _, email := range details.Emails { + if !IsValidEmail(email) { + return User_error_emails_invalid + } + } + } + + if details.Password != nil { + if !IsValidPassword(*details.Password) { + return User_error_password_invalid + } + } + + if details.Roles != nil { + for _, role := range details.Roles { + if !IsValidRole(role) { + return User_error_roles_invalid + } + } + } + + if details.TermsAccepted != nil { + if !IsValidTimestamp(*details.TermsAccepted) { + return User_error_terms_accepted_invalid + } + } + + return nil +} + +func ParseUpdateUserDetails(reader io.Reader) (*UpdateUserDetails, error) { + details := &UpdateUserDetails{} + if err := details.ExtractFromJSON(reader); err != nil { + return nil, err + } else { + return details, nil + } +} + +func NewUserFromKeycloakUser(keycloakUser *gocloak.User) *InternalUser { + attributes := map[string][]string{} + if keycloakUser.Attributes != nil { + attributes = *keycloakUser.Attributes + } + termsAcceptedDate := "" + if len(attributes[termsAcceptedAttribute]) > 0 { + if ts, err := UnixStringToTimestamp(attributes[termsAcceptedAttribute][0]); err == nil { + termsAcceptedDate = ts + } + } + + user := &InternalUser{ + Id: pointer.ToString(keycloakUser.ID), + Username: pointer.ToString(keycloakUser.Username), + Roles: pointer.ToStringArray(keycloakUser.RealmRoles), + TermsAccepted: termsAcceptedDate, + EmailVerified: pointer.ToBool(keycloakUser.EmailVerified), + IsMigrated: true, + Enabled: pointer.ToBool(keycloakUser.Enabled), + } + + if keycloakUser.Email != nil { + user.Emails = []string{*keycloakUser.Email} + } + // All non-custodial users have a password and it's important to set the hash to a non-empty value. + // When users are serialized by this service, the payload contains a flag `passwordExists` that + // is computed based on the presence of a password hash in the user struct. This flag is used by + // other services (e.g. hydrophone) to determine whether the user is custodial or not. + if !user.IsCustodialAccount() { + user.PwHash = "true" + } + + return user +} + +func NewKeycloakUser(gocloakUser *gocloak.User) *InternalUser { + if gocloakUser == nil { + return nil + } + var emails []string + if gocloakUser.Email != nil { + emails = append(emails, pointer.ToString(gocloakUser.Email)) + } + user := &InternalUser{ + Id: pointer.ToString(gocloakUser.ID), + Username: pointer.ToString(gocloakUser.Username), + FirstName: pointer.ToString(gocloakUser.FirstName), + LastName: pointer.ToString(gocloakUser.LastName), + Emails: emails, + EmailVerified: pointer.ToBool(gocloakUser.EmailVerified), + Enabled: pointer.ToBool(gocloakUser.Enabled), + } + if gocloakUser.Attributes != nil { + attrs := maps.Clone(*gocloakUser.Attributes) + if ts, ok := attrs[termsAcceptedAttribute]; ok && len(ts) > 0 { + user.TermsAccepted = ts[0] + } + if prof, ok := profileFromAttributes(attrs); ok { + user.Profile = prof + } + } + + if gocloakUser.RealmRoles != nil { + user.Roles = *gocloakUser.RealmRoles + } + + return user +} + +func (u *InternalUser) Email() string { + return strings.ToLower(u.Username) +} + +func (u *InternalUser) DeepClone() *InternalUser { + panic("todo - needed? only used in mongostore") + return nil +} + +func (u *InternalUser) HasRole(role string) bool { + for _, userRole := range u.Roles { + if userRole == role { + return true + } + } + return false +} + +// IsClinic returns true if the user is legacy clinic Account +func (u *InternalUser) IsClinic() bool { + return u.HasRole(RoleClinic) +} + +func (u *InternalUser) IsCustodialAccount() bool { + return u.HasRole(RoleCustodialAccount) +} + +// IsClinician returns true if the user is a clinician +func (u *InternalUser) IsClinician() bool { + return u.HasRole(RoleClinician) +} + +func (u *InternalUser) AreTermsAccepted() bool { + _, err := TimestampToUnixString(u.TermsAccepted) + return err == nil +} + +func (u *InternalUser) IsEnabled() bool { + if u.IsMigrated { + return u.Enabled + } + return u.PwHash != "" && !u.IsDeleted() +} + +func (u *InternalUser) IsDeleted() bool { + // mdb only? + return u.DeletedTime != "" +} + +func (u *InternalUser) HashPassword(pw, salt string) error { + if passwordHash, err := GeneratePasswordHash(u.Id, pw, salt); err != nil { + return err + } else { + u.PwHash = passwordHash + return nil + } +} + +func (u *InternalUser) PasswordsMatch(pw, salt string) bool { + if u.PwHash == "" || pw == "" { + return false + } else if pwMatch, err := GeneratePasswordHash(u.Id, pw, salt); err != nil { + return false + } else { + return u.PwHash == pwMatch + } +} + +func (u *InternalUser) IsEmailVerified(secret string) bool { + if secret != "" { + if strings.Contains(u.Username, secret) { + return true + } + for i := range u.Emails { + if strings.Contains(u.Emails[i], secret) { + return true + } + } + } + return u.EmailVerified +} + +func ToMigrationUser(u *InternalUser) *MigrationUser { + migratedUser := &MigrationUser{ + ID: u.Id, + Username: strings.ToLower(u.Username), + Email: strings.ToLower(u.Email()), + Roles: u.Roles, + } + if len(migratedUser.Roles) == 0 { + migratedUser.Roles = []string{RolePatient} + } + if !u.IsMigrated && u.PwHash == "" && !u.HasRole(RoleCustodialAccount) { + migratedUser.Roles = append(migratedUser.Roles, RoleCustodialAccount) + } + if termsAccepted, err := TimestampToUnixString(u.TermsAccepted); err == nil { + migratedUser.Attributes.TermsAcceptedDate = []string{termsAccepted} + } + + return migratedUser +} + +func ToExternalUser(user *InternalUser) *ExternalUser { + var id *string + if len(user.Id) > 0 { + id = &user.Id + } + var emails *[]string + if len(user.Emails) == 1 && !IsTemporaryCustodialEmail(user.Emails[0]) { + emails = &user.Emails + } + var username *string + if len(user.Username) > 0 && !IsTemporaryCustodialEmail(user.Username) { + username = &user.Username + } + var roles *[]string + if len(user.Roles) > 0 { + roles = &user.Roles + } + var emailVerified *bool + if len(user.Username) > 0 || len(user.Emails) > 0 { + emailVerified = &user.EmailVerified + } + var termsAccepted *bool + if user.AreTermsAccepted() { + termsAccepted = pointer.FromBool(true) + } + return &ExternalUser{ + ID: id, + Emails: emails, + Username: username, + Roles: roles, + TermsAccepted: termsAccepted, + EmailVerified: emailVerified, + } +} + +// func (u *User) DeepClone() *User { +// clonedUser := &User{ +// Id: u.Id, +// Username: u.Username, +// TermsAccepted: u.TermsAccepted, +// EmailVerified: u.EmailVerified, +// PwHash: u.PwHash, +// Hash: u.Hash, +// IsMigrated: u.IsMigrated, +// } +// if u.Emails != nil { +// clonedUser.Emails = make([]string, len(u.Emails)) +// copy(clonedUser.Emails, u.Emails) +// } +// if u.Roles != nil { +// clonedUser.Roles = make([]string, len(u.Roles)) +// copy(clonedUser.Roles, u.Roles) +// } +// // if u.Private != nil { +// // clonedUser.Private = make(map[string]*IdHashPair) +// // for k, v := range u.Private { +// // clonedUser.Private[k] = &IdHashPair{Id: v.Id, Hash: v.Hash} +// // } +// // } +// return clonedUser +// } + +// func (u *User) ToKeycloakUser() *KeycloakUser { +// keycloakUser := &KeycloakUser{ +// ID: u.Id, +// Username: strings.ToLower(u.Username), +// Email: strings.ToLower(u.Email()), +// Enabled: u.IsEnabled(), +// EmailVerified: u.EmailVerified, +// Roles: u.Roles, +// Attributes: KeycloakUserAttributes{}, +// } +// if len(keycloakUser.Roles) == 0 { +// keycloakUser.Roles = []string{RolePatient} +// } +// if !u.IsMigrated && u.PwHash == "" && !u.HasRole(RoleCustodialAccount) { +// keycloakUser.Roles = append(keycloakUser.Roles, RoleCustodialAccount) +// } +// if termsAccepted, err := TimestampToUnixString(u.TermsAccepted); err == nil { +// keycloakUser.Attributes.TermsAcceptedDate = []string{termsAccepted} +// } + +// return keycloakUser +// } + +// func (u *User) IsEnabled() bool { +// if u.IsMigrated { +// return u.Enabled +// } else { +// return u.PwHash != "" && !u.IsDeleted() +// } +// } diff --git a/user/kcclient.go b/user/kcclient.go new file mode 100644 index 000000000..efff1434c --- /dev/null +++ b/user/kcclient.go @@ -0,0 +1,623 @@ +package user + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/Nerzal/gocloak/v13" + "github.com/Nerzal/gocloak/v13/pkg/jwx" + "github.com/go-resty/resty/v2" + "github.com/kelseyhightower/envconfig" + "golang.org/x/oauth2" + + "github.com/tidepool-org/platform/pointer" +) + +const ( + tokenPrefix = "kc" + tokenPartsSeparator = ":" + masterRealm = "master" + serverRole = "backend_service" + termsAcceptedAttribute = "terms_and_conditions" + + TimestampFormat = "2006-01-02T15:04:05-07:00" +) + +var shorelineManagedRoles = map[string]struct{}{"patient": {}, "clinic": {}, "clinician": {}, "custodial_account": {}} + +var ErrUserNotFound = errors.New("user not found") +var ErrUserConflict = errors.New("user already exists") + +type TokenIntrospectionResult struct { + Active bool `json:"active"` + Subject string `json:"sub"` + EmailVerified bool `json:"email_verified"` + ExpiresAt int64 `json:"eat"` + RealmAccess RealmAccess `json:"realm_access"` + IdentityProvider string `json:"identityProvider"` +} + +type AccessTokenCustomClaims struct { + jwx.Claims + IdentityProvider string `json:"identity_provider,omitempty"` +} + +type RealmAccess struct { + Roles []string `json:"roles"` +} + +func (t *TokenIntrospectionResult) IsServerToken() bool { + if len(t.RealmAccess.Roles) > 0 { + for _, role := range t.RealmAccess.Roles { + if role == serverRole { + return true + } + } + } + + return false +} + +type Config struct { + ClientID string `envconfig:"TIDEPOOL_KEYCLOAK_CLIENT_ID" required:"true"` + ClientSecret string `envconfig:"TIDEPOOL_KEYCLOAK_CLIENT_SECRET" required:"true"` + LongLivedClientID string `envconfig:"TIDEPOOL_KEYCLOAK_LONG_LIVED_CLIENT_ID" required:"true"` + LongLivedClientSecret string `envconfig:"TIDEPOOL_KEYCLOAK_LONG_LIVED_CLIENT_SECRET" required:"true"` + BackendClientID string `envconfig:"TIDEPOOL_KEYCLOAK_BACKEND_CLIENT_ID" required:"true"` + BackendClientSecret string `envconfig:"TIDEPOOL_KEYCLOAK_BACKEND_CLIENT_SECRET" required:"true"` + BaseUrl string `envconfig:"TIDEPOOL_KEYCLOAK_BASE_URL" required:"true"` + Realm string `envconfig:"TIDEPOOL_KEYCLOAK_REALM" required:"true"` + AdminUsername string `envconfig:"TIDEPOOL_KEYCLOAK_ADMIN_USERNAME" required:"true"` + AdminPassword string `envconfig:"TIDEPOOL_KEYCLOAK_ADMIN_PASSWORD" required:"true"` +} + +func (c *Config) FromEnv() error { + return envconfig.Process("", c) +} + +type KeycloakClient struct { + cfg *Config + adminToken *oauth2.Token + adminTokenRefreshExpires time.Time + keycloak *gocloak.GoCloak +} + +func NewKeycloakClient(config *Config) *KeycloakClient { + return &KeycloakClient{ + cfg: config, + keycloak: gocloak.NewClient(config.BaseUrl), + } +} + +func (c *KeycloakClient) Login(ctx context.Context, username, password string) (*oauth2.Token, error) { + return c.doLogin(ctx, c.cfg.ClientID, c.cfg.ClientSecret, username, password) +} + +func (c *KeycloakClient) LoginLongLived(ctx context.Context, username, password string) (*oauth2.Token, error) { + return c.doLogin(ctx, c.cfg.LongLivedClientID, c.cfg.LongLivedClientSecret, username, password) +} + +func (c *KeycloakClient) doLogin(ctx context.Context, clientId, clientSecret, username, password string) (*oauth2.Token, error) { + jwt, err := c.keycloak.Login( + ctx, + clientId, + clientSecret, + c.cfg.Realm, + username, + password, + ) + if err != nil { + return nil, err + } + return c.jwtToAccessToken(jwt), nil +} + +func (c *KeycloakClient) GetBackendServiceToken(ctx context.Context) (*oauth2.Token, error) { + jwt, err := c.keycloak.LoginClient(ctx, c.cfg.BackendClientID, c.cfg.BackendClientSecret, c.cfg.Realm) + if err != nil { + return nil, err + } + return c.jwtToAccessToken(jwt), nil +} + +func (c *KeycloakClient) jwtToAccessToken(jwt *gocloak.JWT) *oauth2.Token { + if jwt == nil { + return nil + } + return (&oauth2.Token{ + AccessToken: jwt.AccessToken, + TokenType: jwt.TokenType, + RefreshToken: jwt.RefreshToken, + Expiry: time.Now().Add(time.Duration(jwt.ExpiresIn) * time.Second), + }).WithExtra(map[string]interface{}{ + "refresh_expires_in": jwt.RefreshExpiresIn, + }) +} + +func (c *KeycloakClient) RevokeToken(ctx context.Context, token oauth2.Token) error { + clientId, clientSecret := c.getClientAndSecretFromToken(ctx, token) + return c.keycloak.Logout( + ctx, + clientId, + clientSecret, + c.cfg.Realm, + token.RefreshToken, + ) +} + +func (c *KeycloakClient) RefreshToken(ctx context.Context, token oauth2.Token) (*oauth2.Token, error) { + clientId, clientSecret := c.getClientAndSecretFromToken(ctx, token) + + jwt, err := c.keycloak.RefreshToken( + ctx, + token.RefreshToken, + clientId, + clientSecret, + c.cfg.Realm, + ) + if err != nil { + return nil, err + } + return c.jwtToAccessToken(jwt), nil +} + +func (c *KeycloakClient) GetUserById(ctx context.Context, id string) (*gocloak.User, error) { + if id == "" { + return nil, nil + } + + users, err := c.FindUsersWithIds(ctx, []string{id}) + if err != nil || len(users) == 0 { + return nil, err + } + + return users[0], nil +} + +func (c *KeycloakClient) GetUserByEmail(ctx context.Context, email string) (*gocloak.User, error) { + if email == "" { + return nil, nil + } + token, err := c.getAdminToken(ctx) + if err != nil { + return nil, err + } + + users, err := c.keycloak.GetUsers(ctx, token.AccessToken, c.cfg.Realm, gocloak.GetUsersParams{ + Email: &email, + Exact: gocloak.BoolP(true), + }) + if err != nil || len(users) == 0 { + return nil, err + } + + return c.GetUserById(ctx, *users[0].ID) +} +func (c *KeycloakClient) UpdateUser(ctx context.Context, user *gocloak.User) error { + token, err := c.getAdminToken(ctx) + if err != nil { + return err + } + + // gocloakUser := gocloak.User{ + // ID: &user.ID, + // Username: &user.Username, + // Enabled: &user.Enabled, + // EmailVerified: &user.EmailVerified, + // FirstName: &user.FirstName, + // LastName: &user.LastName, + // Email: &user.Email, + // } + + // attrs := map[string][]string{} + // if len(user.Attributes.TermsAcceptedDate) > 0 { + // attrs["terms_and_conditions"] = user.Attributes.TermsAcceptedDate + // } + // if user.Attributes.Profile != nil { + // maps.Copy(attrs, user.Attributes.Profile.ToAttributes()) + // } + + // if len(attrs) > 0 { + // gocloakUser.Attributes = &attrs + // } + + // if err := c.keycloak.UpdateUser(ctx, token.AccessToken, c.cfg.Realm, gocloakUser); err != nil { + if err := c.keycloak.UpdateUser(ctx, token.AccessToken, c.cfg.Realm, *user); err != nil { + return err + } + if err := c.updateRolesForUser(ctx, user); err != nil { + return err + } + return nil +} + +// func (c *KeycloakClient) UpdateUser(ctx context.Context, user *KeycloakUser) error { +// token, err := c.getAdminToken(ctx) +// if err != nil { +// return err +// } + +// gocloakUser := gocloak.User{ +// ID: &user.ID, +// Username: &user.Username, +// Enabled: &user.Enabled, +// EmailVerified: &user.EmailVerified, +// FirstName: &user.FirstName, +// LastName: &user.LastName, +// Email: &user.Email, +// } + +// attrs := map[string][]string{} +// if len(user.Attributes.TermsAcceptedDate) > 0 { +// attrs["terms_and_conditions"] = user.Attributes.TermsAcceptedDate +// } +// if user.Attributes.Profile != nil { +// maps.Copy(attrs, user.Attributes.Profile.ToAttributes()) +// } + +// if len(attrs) > 0 { +// gocloakUser.Attributes = &attrs +// } + +// if err := c.keycloak.UpdateUser(ctx, token.AccessToken, c.cfg.Realm, gocloakUser); err != nil { +// return err +// } +// if err := c.updateRolesForUser(ctx, user); err != nil { +// return err +// } +// return nil +// } + +func (c *KeycloakClient) UpdateUserPassword(ctx context.Context, id, password string) error { + token, err := c.getAdminToken(ctx) + if err != nil { + return err + } + + return c.keycloak.SetPassword( + ctx, + token.AccessToken, + id, + c.cfg.Realm, + password, + false, + ) +} + +func (c *KeycloakClient) CreateUser(ctx context.Context, user *gocloak.User) (*gocloak.User, error) { + token, err := c.getAdminToken(ctx) + if err != nil { + return nil, err + } + + // model := gocloak.User{ + // Username: &user.Username, + // Email: &user.Email, + // EmailVerified: &user.EmailVerified, + // Enabled: &user.Enabled, + // RealmRoles: &user.Roles, + // } + + // attrs := map[string][]string{} + // if len(user.Attributes.TermsAcceptedDate) > 0 { + // attrs["terms_and_conditions"] = user.Attributes.TermsAcceptedDate + // } + // if user.Attributes.Profile != nil { + // maps.Copy(attrs, user.Attributes.Profile.ToAttributes()) + // } + // if len(attrs) > 0 { + // model.Attributes = &attrs + // } + + userID, err := c.keycloak.CreateUser(ctx, token.AccessToken, c.cfg.Realm, *user) + if err != nil { + var gerr *gocloak.APIError + if errors.As(err, &gerr) && gerr.Code == http.StatusConflict { + err = ErrUserConflict + } + return nil, err + } + + if err := c.updateRolesForUser(ctx, user); err != nil { + return nil, err + } + + return c.GetUserById(ctx, userID) +} + +// func (c *KeycloakClient) CreateUser(ctx context.Context, user *KeycloakUser) (*KeycloakUser, error) { +// token, err := c.getAdminToken(ctx) +// if err != nil { +// return nil, err +// } + +// model := gocloak.User{ +// Username: &user.Username, +// Email: &user.Email, +// EmailVerified: &user.EmailVerified, +// Enabled: &user.Enabled, +// RealmRoles: &user.Roles, +// } + +// attrs := map[string][]string{} +// if len(user.Attributes.TermsAcceptedDate) > 0 { +// attrs["terms_and_conditions"] = user.Attributes.TermsAcceptedDate +// } +// if user.Attributes.Profile != nil { +// maps.Copy(attrs, user.Attributes.Profile.ToAttributes()) +// } +// if len(attrs) > 0 { +// model.Attributes = &attrs +// } + +// user.ID, err = c.keycloak.CreateUser(ctx, token.AccessToken, c.cfg.Realm, model) +// if err != nil { +// if e, ok := err.(*gocloak.APIError); ok && e.Code == http.StatusConflict { +// err = ErrUserConflict +// } +// return nil, err +// } + +// if err := c.updateRolesForUser(ctx, user); err != nil { +// return nil, err +// } + +// return c.GetUserById(ctx, user.ID) +// } + +func (c *KeycloakClient) FindUsersWithIds(ctx context.Context, ids []string) (users []*gocloak.User, err error) { + const errMessage = "could not retrieve users by ids" + + token, err := c.getAdminToken(ctx) + if err != nil { + return + } + + var res []*gocloak.User + var errorResponse gocloak.HTTPErrorResponse + response, err := c.keycloak.RestyClient().R(). + SetContext(ctx). + SetError(&errorResponse). + SetAuthToken(token.AccessToken). + SetResult(&res). + SetQueryParam("ids", strings.Join(ids, ",")). + Get(c.getRealmURL(c.cfg.Realm, "tidepool-admin", "users")) + + err = checkForError(response, err, errMessage) + if err != nil { + return + } + + return res, nil +} + +func (c *KeycloakClient) IntrospectToken(ctx context.Context, token oauth2.Token) (*TokenIntrospectionResult, error) { + clientId, clientSecret := c.getClientAndSecretFromToken(ctx, token) + + rtr, err := c.keycloak.RetrospectToken( + ctx, + token.AccessToken, + clientId, + clientSecret, + c.cfg.Realm, + ) + if err != nil { + return nil, err + } + + result := &TokenIntrospectionResult{ + Active: pointer.ToBool(rtr.Active), + } + if result.Active { + customClaims := &AccessTokenCustomClaims{} + _, err := c.keycloak.DecodeAccessTokenCustomClaims( + ctx, + token.AccessToken, + c.cfg.Realm, + customClaims, + ) + if err != nil { + return nil, err + } + result.Subject = customClaims.Subject + result.EmailVerified = customClaims.EmailVerified + result.ExpiresAt = customClaims.ExpiresAt + result.RealmAccess = RealmAccess{ + Roles: customClaims.RealmAccess.Roles, + } + result.IdentityProvider = customClaims.IdentityProvider + } + + return result, nil +} + +func (c *KeycloakClient) DeleteUser(ctx context.Context, id string) error { + token, err := c.getAdminToken(ctx) + if err != nil { + return err + } + + if err := c.keycloak.DeleteUser(ctx, token.AccessToken, c.cfg.Realm, id); err != nil { + if aErr, ok := err.(*gocloak.APIError); ok && aErr.Code == http.StatusNotFound { + return nil + } + } + return err +} + +func (c *KeycloakClient) DeleteUserSessions(ctx context.Context, id string) error { + token, err := c.getAdminToken(ctx) + if err != nil { + return err + } + + if err := c.keycloak.LogoutAllSessions(ctx, token.AccessToken, c.cfg.Realm, id); err != nil { + if aErr, ok := err.(*gocloak.APIError); ok && aErr.Code == http.StatusNotFound { + return nil + } + } + + return err +} + +func (c *KeycloakClient) getRealmURL(realm string, path ...string) string { + path = append([]string{c.cfg.BaseUrl, "realms", realm}, path...) + return strings.Join(path, "/") +} + +func (c *KeycloakClient) getAdminToken(ctx context.Context) (*oauth2.Token, error) { + var err error + if c.adminTokenIsExpired() { + err = c.loginAsAdmin(ctx) + } + + return c.adminToken, err +} + +func (c *KeycloakClient) loginAsAdmin(ctx context.Context) error { + jwt, err := c.keycloak.LoginAdmin( + ctx, + c.cfg.AdminUsername, + c.cfg.AdminPassword, + masterRealm, + ) + if err != nil { + return err + } + + c.adminToken = c.jwtToAccessToken(jwt) + c.adminTokenRefreshExpires = time.Now().Add(time.Duration(jwt.ExpiresIn) * time.Second) + return nil +} + +func (c *KeycloakClient) adminTokenIsExpired() bool { + return c.adminToken == nil || time.Now().After(c.adminTokenRefreshExpires) +} + +func (c *KeycloakClient) updateRolesForUser(ctx context.Context, user *gocloak.User) error { + token, err := c.getAdminToken(ctx) + if err != nil { + return err + } + + realmRoles, err := c.keycloak.GetRealmRoles(ctx, token.AccessToken, c.cfg.Realm, gocloak.GetRoleParams{ + Max: gocloak.IntP(1000), + }) + if err != nil { + return err + } + currentUserRoles, err := c.keycloak.GetRealmRolesByUserID(ctx, token.AccessToken, c.cfg.Realm, *user.ID) + if err != nil { + return err + } + + var rolesToAdd []gocloak.Role + var rolesToDelete []gocloak.Role + + targetRoles := make(map[string]struct{}) + if user.RealmRoles != nil { + for _, targetRoleName := range *user.RealmRoles { + targetRoles[targetRoleName] = struct{}{} + } + } + + for targetRoleName, _ := range targetRoles { + realmRole := getRealmRoleByName(realmRoles, targetRoleName) + if realmRole != nil { + rolesToAdd = append(rolesToAdd, *realmRole) + } + } + + for _, currentRole := range currentUserRoles { + if currentRole == nil || currentRole.Name == nil || *currentRole.Name == "" { + continue + } + + if _, ok := targetRoles[*currentRole.Name]; !ok { + // Only remove roles managed by shoreline + if _, ok := shorelineManagedRoles[*currentRole.Name]; ok { + rolesToDelete = append(rolesToDelete, *currentRole) + } + } + } + + if len(rolesToAdd) > 0 { + if err = c.keycloak.AddRealmRoleToUser(ctx, token.AccessToken, c.cfg.Realm, *user.ID, rolesToAdd); err != nil { + return err + } + } + if len(rolesToDelete) > 0 { + if err = c.keycloak.DeleteRealmRoleFromUser(ctx, token.AccessToken, c.cfg.Realm, *user.ID, rolesToDelete); err != nil { + return err + } + } + + return nil +} + +func (c *KeycloakClient) getClientAndSecretFromToken(ctx context.Context, token oauth2.Token) (string, string) { + clientId := c.cfg.ClientID + clientSecret := c.cfg.ClientSecret + + customClaims := &jwx.Claims{} + _, err := c.keycloak.DecodeAccessTokenCustomClaims( + ctx, + token.AccessToken, + c.cfg.Realm, + customClaims, + ) + + if err == nil && customClaims.Azp == c.cfg.LongLivedClientID { + clientId = c.cfg.LongLivedClientID + clientSecret = c.cfg.LongLivedClientSecret + } + + return clientId, clientSecret +} + +func getRealmRoleByName(realmRoles []*gocloak.Role, name string) *gocloak.Role { + for _, realmRole := range realmRoles { + if realmRole.Name != nil && *realmRole.Name == name { + return realmRole + } + } + + return nil +} + +// checkForError Copied from gocloak - used for sending requests to custom endpoints +func checkForError(resp *resty.Response, err error, errMessage string) error { + if err != nil { + return &gocloak.APIError{ + Code: 0, + Message: fmt.Errorf("%w: %s", err, errMessage).Error(), + } + } + + if resp == nil { + return &gocloak.APIError{ + Message: "empty response", + } + } + + if resp.IsError() { + var msg string + + if e, ok := resp.Error().(*gocloak.HTTPErrorResponse); ok && e.NotEmpty() { + msg = fmt.Sprintf("%s: %s", resp.Status(), e) + } else { + msg = resp.Status() + } + + return &gocloak.APIError{ + Code: resp.StatusCode(), + Message: msg, + } + } + + return nil +} diff --git a/user/profile.go b/user/profile.go new file mode 100644 index 000000000..e338a99d8 --- /dev/null +++ b/user/profile.go @@ -0,0 +1,125 @@ +package user + +import ( + "slices" +) + +type UserProfile struct { + FullName string `json:"fullName"` + Patient *PatientProfile `json:"patient,omitempty"` + Clinic *ClinicProfile `json:"clinic,omitempty"` +} + +type PatientProfile struct { + Birthday string `json:"birthday"` + DiagnosisDate string `json:"diagnosisDate"` + DiagnosisType string `json:"diagnosisType"` + TargetDevices []string `json:"targetDevices"` + TargetTimezone string `json:"targetTimezone"` + About string `json:"about"` +} + +type ClinicProfile struct { + Name string `json:"diagnosisDate"` + Role []string `json:"role"` + Telephone string `json:"telephone"` +} + +func (u *UserProfile) ToAttributes() map[string][]string { + attributes := map[string][]string{} + + addAttribute(attributes, "profile.fullName", u.FullName) + if u.Patient != nil { + patient := u.Patient + addAttribute(attributes, "profile.patient.birthday", patient.Birthday) + addAttribute(attributes, "profile.patient.diagnosisDate", patient.DiagnosisDate) + addAttribute(attributes, "profile.patient.diagnosisType", patient.DiagnosisType) + addAttributes(attributes, "profile.patient.targetDevices", patient.TargetDevices...) + addAttribute(attributes, "profile.patient.targetTimezone", patient.TargetTimezone) + addAttribute(attributes, "profile.patient.about", patient.About) + } + + if u.Clinic != nil { + clinic := u.Clinic + addAttribute(attributes, "profile.clinic.name", clinic.Name) + addAttributes(attributes, "profile.clinic.role", clinic.Role...) + addAttribute(attributes, "profile.clinic.telephone", clinic.Telephone) + } + + return attributes +} + +func profileFromAttributes(attributes map[string][]string) (profile *UserProfile, ok bool) { + u := &UserProfile{} + u.FullName = getAttribute(attributes, "profile.fullName") + + if containsAnyAttributeKeys(attributes, "profile.patient.birthday", "profile.patient.diagnosisDate", "profile.patient.diagnosisType", "profile.patient.targetDevices", "profile.patient.targetTimezone", "profile.patient.about") { + patient := &PatientProfile{} + patient.Birthday = getAttribute(attributes, "profile.patient.birthday") + patient.DiagnosisDate = getAttribute(attributes, "profile.patient.diagnosisDate") + patient.DiagnosisType = getAttribute(attributes, "profile.patient.diagnosisType") + patient.TargetDevices = getAttributes(attributes, "profile.patient.targetDevices") + patient.TargetTimezone = getAttribute(attributes, "profile.patient.targetTimezone") + patient.About = getAttribute(attributes, "profile.patient.about") + u.Patient = patient + } + + if containsAnyAttributeKeys(attributes, "profile.clinic.name", "profile.clinic.role", "profile.clinic.telephone") { + clinic := &ClinicProfile{} + clinic.Name = getAttribute(attributes, "profile.clinic.name") + clinic.Role = getAttributes(attributes, "profile.clinic.role") + clinic.Telephone = getAttribute(attributes, "profile.clinic.telephone") + u.Clinic = clinic + } + + if u.Clinic == nil && u.Patient == nil { + return nil, false + } + return u, true +} + +func addAttribute(attributes map[string][]string, attribute, value string) (ok bool) { + if !containsAttribute(attributes, attribute, value) { + attributes[attribute] = append(attributes[attribute], value) + return true + } + return false +} + +func getAttribute(attributes map[string][]string, attribute string) string { + if len(attributes[attribute]) > 0 { + return attributes[attribute][0] + } + return "" +} + +func getAttributes(attributes map[string][]string, attribute string) []string { + return attributes[attribute] +} + +func addAttributes(attributes map[string][]string, attribute string, values ...string) (ok bool) { + for _, value := range values { + if addAttribute(attributes, attribute, value) { + ok = true + } + } + return true +} + +func containsAttribute(attributes map[string][]string, attribute, value string) bool { + for key, vals := range attributes { + if key == attribute && slices.Contains(vals, value) { + return true + } + } + return false +} + +func containsAnyAttributeKeys(attributes map[string][]string, keys ...string) bool { + for key, vals := range attributes { + if len(vals) > 0 && slices.Contains(keys, key) { + return true + } + } + return false +} diff --git a/user/user.go b/user/user.go index bd5ea2e92..4171257d3 100644 --- a/user/user.go +++ b/user/user.go @@ -11,10 +11,6 @@ import ( structureValidator "github.com/tidepool-org/platform/structure/validator" ) -const ( - RoleClinic = "clinic" -) - func Roles() []string { return []string{ RoleClinic, diff --git a/vendor/github.com/Nerzal/gocloak/v13/.codebeatsettings b/vendor/github.com/Nerzal/gocloak/v13/.codebeatsettings new file mode 100644 index 000000000..bc34a0522 --- /dev/null +++ b/vendor/github.com/Nerzal/gocloak/v13/.codebeatsettings @@ -0,0 +1,6 @@ +{ + "GOLANG": { + "TOO_MANY_IVARS": [1000, 2000, 3000, 4000], + "ARITY": [1000, 2000, 3000, 4000] + } +} diff --git a/vendor/github.com/Nerzal/gocloak/v13/.gitignore b/vendor/github.com/Nerzal/gocloak/v13/.gitignore new file mode 100644 index 000000000..9dfe1c356 --- /dev/null +++ b/vendor/github.com/Nerzal/gocloak/v13/.gitignore @@ -0,0 +1,19 @@ +*.orig + +# Other stuff aslong as i'm to lazy to use environment variables +*.secret.go + +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +\.idea/ diff --git a/vendor/github.com/Nerzal/gocloak/v13/.golangci.yml b/vendor/github.com/Nerzal/gocloak/v13/.golangci.yml new file mode 100644 index 000000000..d59a6c32e --- /dev/null +++ b/vendor/github.com/Nerzal/gocloak/v13/.golangci.yml @@ -0,0 +1,44 @@ +run: + skip-dirs: + - (^|/)testdata($|/) + skip-dirs-use-default: false + +linters: + enable: + - goimports + - gofmt + - misspell + - gosec + - unconvert + - revive + - gocognit + - gocyclo + fast: true + +linters-settings: + misspell: + locale: US + golint: + min-confidence: 0 + govet: + check-shadowing: false + goimports: + local-prefixes: github.com/Nerzal/gocloak + gocognit: + min-complexity: 15 + gocyclo: + min-complexity: 15 + gofmt: + simplify: true + +issues: + exclude-use-default: false + exclude-rules: + - path: _test\.go + linters: + - gocyclo + - dupl + - gosec + - gocognit + exclude: + - should have a package comment diff --git a/vendor/github.com/Nerzal/gocloak/v13/.nancy-ignore b/vendor/github.com/Nerzal/gocloak/v13/.nancy-ignore new file mode 100644 index 000000000..d23fc3720 --- /dev/null +++ b/vendor/github.com/Nerzal/gocloak/v13/.nancy-ignore @@ -0,0 +1 @@ +CVE-2022-32149 diff --git a/vendor/github.com/Nerzal/gocloak/v13/Dockerfile b/vendor/github.com/Nerzal/gocloak/v13/Dockerfile new file mode 100644 index 000000000..1590f824c --- /dev/null +++ b/vendor/github.com/Nerzal/gocloak/v13/Dockerfile @@ -0,0 +1,11 @@ +FROM quay.io/keycloak/keycloak:19.0 +COPY testdata data/import +WORKDIR /opt/keycloak +ENV KC_HOSTNAME=localhost +ENV KEYCLOAK_USER=admin +ENV KEYCLOAK_PASSWORD=secret +ENV KEYCLOAK_ADMIN=admin +ENV KEYCLOAK_ADMIN_PASSWORD=secret +ENV KC_FEATURES=account-api,account2,authorization,client-policies,impersonation,docker,scripts,upload_scripts,admin-fine-grained-authz +RUN /opt/keycloak/bin/kc.sh import --file /data/import/gocloak-realm.json +ENTRYPOINT ["/opt/keycloak/bin/kc.sh"] diff --git a/vendor/github.com/Nerzal/gocloak/v13/LICENSE b/vendor/github.com/Nerzal/gocloak/v13/LICENSE new file mode 100644 index 000000000..12c5c6e80 --- /dev/null +++ b/vendor/github.com/Nerzal/gocloak/v13/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [2021] [Tobias Theel] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/github.com/Nerzal/gocloak/v13/Makefile b/vendor/github.com/Nerzal/gocloak/v13/Makefile new file mode 100644 index 000000000..0409e817d --- /dev/null +++ b/vendor/github.com/Nerzal/gocloak/v13/Makefile @@ -0,0 +1,8 @@ +test: + ./run-tests.sh + +start-keycloak: stop-keycloak + docker-compose up -d + +stop-keycloak: + docker-compose down diff --git a/vendor/github.com/Nerzal/gocloak/v13/PULL_REQUEST_TEMPLATE.md b/vendor/github.com/Nerzal/gocloak/v13/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..e28c7c6f3 --- /dev/null +++ b/vendor/github.com/Nerzal/gocloak/v13/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,2 @@ +Thanks for your contribution! +Hi, if there is an issue, that your PR adresses, please link it! diff --git a/vendor/github.com/Nerzal/gocloak/v13/README.md b/vendor/github.com/Nerzal/gocloak/v13/README.md new file mode 100644 index 000000000..6f4d7c09b --- /dev/null +++ b/vendor/github.com/Nerzal/gocloak/v13/README.md @@ -0,0 +1,496 @@ +# gocloak + +[![codebeat badge](https://codebeat.co/badges/18a37f35-6a95-4e40-9e78-272233892332)](https://codebeat.co/projects/github-com-nerzal-gocloak-main) +[![Go Report Card](https://goreportcard.com/badge/github.com/Nerzal/gocloak)](https://goreportcard.com/report/github.com/Nerzal/gocloak) +[![Go Doc](https://godoc.org/github.com/Nerzal/gocloak?status.svg)](https://godoc.org/github.com/Nerzal/gocloak) +[![Build Status](https://github.com/Nerzal/gocloak/workflows/Tests/badge.svg)](https://github.com/Nerzal/gocloak/actions?query=branch%3Amain+event%3Apush) +[![GitHub release](https://img.shields.io/github/tag/Nerzal/gocloak.svg)](https://GitHub.com/Nerzal/gocloak/releases/) +[![codecov](https://codecov.io/gh/Nerzal/gocloak/branch/master/graph/badge.svg)](https://codecov.io/gh/Nerzal/gocloak) +[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FNerzal%2Fgocloak.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2FNerzal%2Fgocloak?ref=badge_shield) + +Golang Keycloak API Package + +This client is based on: [go-keycloak](https://github.com/PhilippHeuer/go-keycloak) + +For Questions either raise an issue, or come to the [gopher-slack](https://invite.slack.golangbridge.org/) into the channel [#gocloak](https://gophers.slack.com/app_redirect?channel=gocloak) + +If u are using the echo framework have a look at [gocloak-echo](https://github.com/Nerzal/gocloak-echo) + +Benchmarks can be found [here](https://nerzal.github.io/gocloak/dev/bench/) + +## Contribution + +(WIP) + +## Changelog + +For release notes please consult the specific releases [here](https://github.com/Nerzal/gocloak/releases) + + +## Usage + +### Installation + +```shell +go get github.com/Nerzal/gocloak/v13 +``` + +### Importing + +```go + import "github.com/Nerzal/gocloak/v13" +``` + +### Create New User + +```go + client := gocloak.NewClient("https://mycool.keycloak.instance") + ctx := context.Background() + token, err := client.LoginAdmin(ctx, "user", "password", "realmName") + if err != nil { + panic("Something wrong with the credentials or url") + } + + user := gocloak.User{ + FirstName: gocloak.StringP("Bob"), + LastName: gocloak.StringP("Uncle"), + Email: gocloak.StringP("something@really.wrong"), + Enabled: gocloak.BoolP(true), + Username: gocloak.StringP("CoolGuy"), + } + + _, err = client.CreateUser(ctx, token.AccessToken, "realm", user) + if err != nil { + panic("Oh no!, failed to create user :(") + } +``` + +### Introspect Token + +```go + client := gocloak.NewClient(hostname) + ctx := context.Background() + token, err := client.LoginClient(ctx, clientID, clientSecret, realm) + if err != nil { + panic("Login failed:"+ err.Error()) + } + + rptResult, err := client.RetrospectToken(ctx, token.AccessToken, clientID, clientSecret, realm) + if err != nil { + panic("Inspection failed:"+ err.Error()) + } + + if !*rptResult.Active { + panic("Token is not active") + } + + permissions := rptResult.Permissions + // Do something with the permissions ;) +``` + +### Get Client id + +Client has 2 identity fields- `id` and `clientId` and both are unique in one realm. + +- `id` is generated automatically by Keycloak. +- `clientId` is configured by users in `Add client` page. + +To get the `clientId` from `id`, use `GetClients` method with `GetClientsParams{ClientID: &clientName}`. + +```go + clients, err := c.Client.GetClients( + c.Ctx, + c.JWT.AccessToken, + c.Realm, + gocloak.GetClientsParams{ + ClientID: &clientName, + }, + ) + if err != nil { + panic("List clients failed:"+ err.Error()) + } + for _, client := range clients { + return *client.ID, nil + } +``` + +## Features + +```go +// GoCloak holds all methods a client should fulfill +type GoCloak interface { + + RestyClient() *resty.Client + SetRestyClient(restyClient *resty.Client) + + GetToken(ctx context.Context, realm string, options TokenOptions) (*JWT, error) + GetRequestingPartyToken(ctx context.Context, token, realm string, options RequestingPartyTokenOptions) (*JWT, error) + GetRequestingPartyPermissions(ctx context.Context, token, realm string, options RequestingPartyTokenOptions) (*[]RequestingPartyPermission, error) + GetRequestingPartyPermissionDecision(ctx context.Context, token, realm string, options RequestingPartyTokenOptions) (*RequestingPartyPermissionDecision, error) + + Login(ctx context.Context, clientID, clientSecret, realm, username, password string) (*JWT, error) + LoginOtp(ctx context.Context, clientID, clientSecret, realm, username, password, totp string) (*JWT, error) + Logout(ctx context.Context, clientID, clientSecret, realm, refreshToken string) error + LogoutPublicClient(ctx context.Context, clientID, realm, accessToken, refreshToken string) error + LogoutAllSessions(ctx context.Context, accessToken, realm, userID string) error + RevokeUserConsents(ctx context.Context, accessToken, realm, userID, clientID string) error + LogoutUserSession(ctx context.Context, accessToken, realm, session string) error + LoginClient(ctx context.Context, clientID, clientSecret, realm string) (*JWT, error) + LoginClientSignedJWT(ctx context.Context, clientID, realm string, key interface{}, signedMethod jwt.SigningMethod, expiresAt *jwt.Time) (*JWT, error) + LoginAdmin(ctx context.Context, username, password, realm string) (*JWT, error) + RefreshToken(ctx context.Context, refreshToken, clientID, clientSecret, realm string) (*JWT, error) + DecodeAccessToken(ctx context.Context, accessToken, realm, expectedAudience string) (*jwt.Token, *jwt.MapClaims, error) + DecodeAccessTokenCustomClaims(ctx context.Context, accessToken, realm, expectedAudience string, claims jwt.Claims) (*jwt.Token, error) + RetrospectToken(ctx context.Context, accessToken, clientID, clientSecret, realm string) (*RetrospecTokenResult, error) + GetIssuer(ctx context.Context, realm string) (*IssuerResponse, error) + GetCerts(ctx context.Context, realm string) (*CertResponse, error) + GetServerInfo(ctx context.Context, accessToken string) (*ServerInfoRepesentation, error) + GetUserInfo(ctx context.Context, accessToken, realm string) (*UserInfo, error) + GetRawUserInfo(ctx context.Context, accessToken, realm string) (map[string]interface{}, error) + SetPassword(ctx context.Context, token, userID, realm, password string, temporary bool) error + ExecuteActionsEmail(ctx context.Context, token, realm string, params ExecuteActionsEmail) error + + CreateUser(ctx context.Context, token, realm string, user User) (string, error) + CreateGroup(ctx context.Context, accessToken, realm string, group Group) (string, error) + CreateChildGroup(ctx context.Context, token, realm, groupID string, group Group) (string, error) + CreateClientRole(ctx context.Context, accessToken, realm, idOfClient string, role Role) (string, error) + CreateClient(ctx context.Context, accessToken, realm string, newClient Client) (string, error) + CreateClientScope(ctx context.Context, accessToken, realm string, scope ClientScope) (string, error) + CreateComponent(ctx context.Context, accessToken, realm string, component Component) (string, error) + CreateClientScopeMappingsRealmRoles(ctx context.Context, token, realm, idOfClient string, roles []Role) error + CreateClientScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClient, idOfSelectedClient string, roles []Role) error + CreateClientScopesScopeMappingsRealmRoles(ctx context.Context, token, realm, idOfCLientScope string, roles []Role) error + CreateClientScopesScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClientScope, idOfClient string, roles []Role) error + + UpdateUser(ctx context.Context, accessToken, realm string, user User) error + UpdateGroup(ctx context.Context, accessToken, realm string, updatedGroup Group) error + UpdateRole(ctx context.Context, accessToken, realm, idOfClient string, role Role) error + UpdateClient(ctx context.Context, accessToken, realm string, updatedClient Client) error + UpdateClientScope(ctx context.Context, accessToken, realm string, scope ClientScope) error + + DeleteUser(ctx context.Context, accessToken, realm, userID string) error + DeleteComponent(ctx context.Context, accessToken, realm, componentID string) error + DeleteGroup(ctx context.Context, accessToken, realm, groupID string) error + DeleteClientRole(ctx context.Context, accessToken, realm, idOfClient, roleName string) error + DeleteClientRoleFromUser(ctx context.Context, token, realm, idOfClient, userID string, roles []Role) error + DeleteClient(ctx context.Context, accessToken, realm, idOfClient string) error + DeleteClientScope(ctx context.Context, accessToken, realm, scopeID string) error + DeleteClientScopeMappingsRealmRoles(ctx context.Context, token, realm, idOfClient string, roles []Role) error + DeleteClientScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClient, idOfSelectedClient string, roles []Role) error + DeleteClientScopesScopeMappingsRealmRoles(ctx context.Context, token, realm, idOfCLientScope string, roles []Role) error + DeleteClientScopesScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClientScope, ifOfClient string, roles []Role) error + + GetClient(ctx context.Context, accessToken, realm, idOfClient string) (*Client, error) + GetClientsDefaultScopes(ctx context.Context, token, realm, idOfClient string) ([]*ClientScope, error) + AddDefaultScopeToClient(ctx context.Context, token, realm, idOfClient, scopeID string) error + RemoveDefaultScopeFromClient(ctx context.Context, token, realm, idOfClient, scopeID string) error + GetClientsOptionalScopes(ctx context.Context, token, realm, idOfClient string) ([]*ClientScope, error) + AddOptionalScopeToClient(ctx context.Context, token, realm, idOfClient, scopeID string) error + RemoveOptionalScopeFromClient(ctx context.Context, token, realm, idOfClient, scopeID string) error + GetDefaultOptionalClientScopes(ctx context.Context, token, realm string) ([]*ClientScope, error) + GetDefaultDefaultClientScopes(ctx context.Context, token, realm string) ([]*ClientScope, error) + GetClientScope(ctx context.Context, token, realm, scopeID string) (*ClientScope, error) + GetClientScopes(ctx context.Context, token, realm string) ([]*ClientScope, error) + GetClientScopeMappings(ctx context.Context, token, realm, idOfClient string) (*MappingsRepresentation, error) + GetClientScopeMappingsRealmRoles(ctx context.Context, token, realm, idOfClient string) ([]*Role, error) + GetClientScopeMappingsRealmRolesAvailable(ctx context.Context, token, realm, idOfClient string) ([]*Role, error) + GetClientScopesScopeMappingsRealmRolesAvailable(ctx context.Context, token, realm, idOfClientScope string) ([]*Role, error) + GetClientScopesScopeMappingsClientRolesAvailable(ctx context.Context, token, realm, idOfClientScope, idOfClient string) ([]*Role, error) + GetClientScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClient, idOfSelectedClient string) ([]*Role, error) + GetClientScopesScopeMappingsRealmRoles(ctx context.Context, token, realm, idOfClientScope string) ([]*Role, error) + GetClientScopesScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClientScope, idOfClient string) ([]*Role, error) + GetClientScopeMappingsClientRolesAvailable(ctx context.Context, token, realm, idOfClient, idOfSelectedClient string) ([]*Role, error) + GetClientSecret(ctx context.Context, token, realm, idOfClient string) (*CredentialRepresentation, error) + GetClientServiceAccount(ctx context.Context, token, realm, idOfClient string) (*User, error) + RegenerateClientSecret(ctx context.Context, token, realm, idOfClient string) (*CredentialRepresentation, error) + GetKeyStoreConfig(ctx context.Context, accessToken, realm string) (*KeyStoreConfig, error) + GetUserByID(ctx context.Context, accessToken, realm, userID string) (*User, error) + GetUserCount(ctx context.Context, accessToken, realm string, params GetUsersParams) (int, error) + GetUsers(ctx context.Context, accessToken, realm string, params GetUsersParams) ([]*User, error) + GetUserGroups(ctx context.Context, accessToken, realm, userID string, params GetGroupsParams) ([]*UserGroup, error) + AddUserToGroup(ctx context.Context, token, realm, userID, groupID string) error + DeleteUserFromGroup(ctx context.Context, token, realm, userID, groupID string) error + GetComponents(ctx context.Context, accessToken, realm string) ([]*Component, error) + GetGroups(ctx context.Context, accessToken, realm string, params GetGroupsParams) ([]*Group, error) + GetGroupsCount(ctx context.Context, token, realm string, params GetGroupsParams) (int, error) + GetGroup(ctx context.Context, accessToken, realm, groupID string) (*Group, error) + GetDefaultGroups(ctx context.Context, accessToken, realm string) ([]*Group, error) + AddDefaultGroup(ctx context.Context, accessToken, realm, groupID string) error + RemoveDefaultGroup(ctx context.Context, accessToken, realm, groupID string) error + GetGroupMembers(ctx context.Context, accessToken, realm, groupID string, params GetGroupsParams) ([]*User, error) + GetRoleMappingByGroupID(ctx context.Context, accessToken, realm, groupID string) (*MappingsRepresentation, error) + GetRoleMappingByUserID(ctx context.Context, accessToken, realm, userID string) (*MappingsRepresentation, error) + GetClientRoles(ctx context.Context, accessToken, realm, idOfClient string, params GetRoleParams) ([]*Role, error) + GetClientRole(ctx context.Context, token, realm, idOfClient, roleName string) (*Role, error) + GetClientRoleByID(ctx context.Context, accessToken, realm, roleID string) (*Role, error) + GetClients(ctx context.Context, accessToken, realm string, params GetClientsParams) ([]*Client, error) + AddClientRoleComposite(ctx context.Context, token, realm, roleID string, roles []Role) error + DeleteClientRoleComposite(ctx context.Context, token, realm, roleID string, roles []Role) error + GetUsersByRoleName(ctx context.Context, token, realm, roleName string) ([]*User, error) + GetUsersByClientRoleName(ctx context.Context, token, realm, idOfClient, roleName string, params GetUsersByRoleParams) ([]*User, error) + CreateClientProtocolMapper(ctx context.Context, token, realm, idOfClient string, mapper ProtocolMapperRepresentation) (string, error) + UpdateClientProtocolMapper(ctx context.Context, token, realm, idOfClient, mapperID string, mapper ProtocolMapperRepresentation) error + DeleteClientProtocolMapper(ctx context.Context, token, realm, idOfClient, mapperID string) error + + // *** Realm Roles *** + + CreateRealmRole(ctx context.Context, token, realm string, role Role) (string, error) + GetRealmRole(ctx context.Context, token, realm, roleName string) (*Role, error) + GetRealmRoles(ctx context.Context, accessToken, realm string, params GetRoleParams) ([]*Role, error) + GetRealmRoleByID(ctx context.Context, token, realm, roleID string) (*Role, error) + GetRealmRolesByUserID(ctx context.Context, accessToken, realm, userID string) ([]*Role, error) + GetRealmRolesByGroupID(ctx context.Context, accessToken, realm, groupID string) ([]*Role, error) + UpdateRealmRole(ctx context.Context, token, realm, roleName string, role Role) error + UpdateRealmRoleByID(ctx context.Context, token, realm, roleID string, role Role) error + DeleteRealmRole(ctx context.Context, token, realm, roleName string) error + AddRealmRoleToUser(ctx context.Context, token, realm, userID string, roles []Role) error + DeleteRealmRoleFromUser(ctx context.Context, token, realm, userID string, roles []Role) error + AddRealmRoleToGroup(ctx context.Context, token, realm, groupID string, roles []Role) error + DeleteRealmRoleFromGroup(ctx context.Context, token, realm, groupID string, roles []Role) error + AddRealmRoleComposite(ctx context.Context, token, realm, roleName string, roles []Role) error + DeleteRealmRoleComposite(ctx context.Context, token, realm, roleName string, roles []Role) error + GetCompositeRealmRoles(ctx context.Context, token, realm, roleName string) ([]*Role, error) + GetCompositeRealmRolesByRoleID(ctx context.Context, token, realm, roleID string) ([]*Role, error) + GetCompositeRealmRolesByUserID(ctx context.Context, token, realm, userID string) ([]*Role, error) + GetCompositeRealmRolesByGroupID(ctx context.Context, token, realm, groupID string) ([]*Role, error) + GetAvailableRealmRolesByUserID(ctx context.Context, token, realm, userID string) ([]*Role, error) + GetAvailableRealmRolesByGroupID(ctx context.Context, token, realm, groupID string) ([]*Role, error) + + // *** Client Roles *** + + AddClientRoleToUser(ctx context.Context, token, realm, idOfClient, userID string, roles []Role) error + AddClientRoleToGroup(ctx context.Context, token, realm, idOfClient, groupID string, roles []Role) error + DeleteClientRoleFromGroup(ctx context.Context, token, realm, idOfClient, groupID string, roles []Role) error + GetCompositeClientRolesByRoleID(ctx context.Context, token, realm, idOfClient, roleID string) ([]*Role, error) + GetClientRolesByUserID(ctx context.Context, token, realm, idOfClient, userID string) ([]*Role, error) + GetClientRolesByGroupID(ctx context.Context, token, realm, idOfClient, groupID string) ([]*Role, error) + GetCompositeClientRolesByUserID(ctx context.Context, token, realm, idOfClient, userID string) ([]*Role, error) + GetCompositeClientRolesByGroupID(ctx context.Context, token, realm, idOfClient, groupID string) ([]*Role, error) + GetAvailableClientRolesByUserID(ctx context.Context, token, realm, idOfClient, userID string) ([]*Role, error) + GetAvailableClientRolesByGroupID(ctx context.Context, token, realm, idOfClient, groupID string) ([]*Role, error) + + // *** Realm *** + + GetRealm(ctx context.Context, token, realm string) (*RealmRepresentation, error) + GetRealms(ctx context.Context, token string) ([]*RealmRepresentation, error) + CreateRealm(ctx context.Context, token string, realm RealmRepresentation) (string, error) + UpdateRealm(ctx context.Context, token string, realm RealmRepresentation) error + DeleteRealm(ctx context.Context, token, realm string) error + ClearRealmCache(ctx context.Context, token, realm string) error + ClearUserCache(ctx context.Context, token, realm string) error + ClearKeysCache(ctx context.Context, token, realm string) error + +GetClientUserSessions(ctx context.Context, token, realm, idOfClient string, params ...GetClientUserSessionsParams) ([]*UserSessionRepresentation, error) +GetClientOfflineSessions(ctx context.Context, token, realm, idOfClient string, params ...GetClientUserSessionsParams) ([]*UserSessionRepresentation, error) + GetUserSessions(ctx context.Context, token, realm, userID string) ([]*UserSessionRepresentation, error) + GetUserOfflineSessionsForClient(ctx context.Context, token, realm, userID, idOfClient string) ([]*UserSessionRepresentation, error) + + // *** Protection API *** + GetResource(ctx context.Context, token, realm, idOfClient, resourceID string) (*ResourceRepresentation, error) + GetResources(ctx context.Context, token, realm, idOfClient string, params GetResourceParams) ([]*ResourceRepresentation, error) + CreateResource(ctx context.Context, token, realm, idOfClient string, resource ResourceRepresentation) (*ResourceRepresentation, error) + UpdateResource(ctx context.Context, token, realm, idOfClient string, resource ResourceRepresentation) error + DeleteResource(ctx context.Context, token, realm, idOfClient, resourceID string) error + + GetResourceClient(ctx context.Context, token, realm, resourceID string) (*ResourceRepresentation, error) + GetResourcesClient(ctx context.Context, token, realm string, params GetResourceParams) ([]*ResourceRepresentation, error) + CreateResourceClient(ctx context.Context, token, realm string, resource ResourceRepresentation) (*ResourceRepresentation, error) + UpdateResourceClient(ctx context.Context, token, realm string, resource ResourceRepresentation) error + DeleteResourceClient(ctx context.Context, token, realm, resourceID string) error + + GetScope(ctx context.Context, token, realm, idOfClient, scopeID string) (*ScopeRepresentation, error) + GetScopes(ctx context.Context, token, realm, idOfClient string, params GetScopeParams) ([]*ScopeRepresentation, error) + CreateScope(ctx context.Context, token, realm, idOfClient string, scope ScopeRepresentation) (*ScopeRepresentation, error) + UpdateScope(ctx context.Context, token, realm, idOfClient string, resource ScopeRepresentation) error + DeleteScope(ctx context.Context, token, realm, idOfClient, scopeID string) error + + GetPolicy(ctx context.Context, token, realm, idOfClient, policyID string) (*PolicyRepresentation, error) + GetPolicies(ctx context.Context, token, realm, idOfClient string, params GetPolicyParams) ([]*PolicyRepresentation, error) + CreatePolicy(ctx context.Context, token, realm, idOfClient string, policy PolicyRepresentation) (*PolicyRepresentation, error) + UpdatePolicy(ctx context.Context, token, realm, idOfClient string, policy PolicyRepresentation) error + DeletePolicy(ctx context.Context, token, realm, idOfClient, policyID string) error + + GetResourcePolicy(ctx context.Context, token, realm, permissionID string) (*ResourcePolicyRepresentation, error) + GetResourcePolicies(ctx context.Context, token, realm string, params GetResourcePoliciesParams) ([]*ResourcePolicyRepresentation, error) + CreateResourcePolicy(ctx context.Context, token, realm, resourceID string, policy ResourcePolicyRepresentation) (*ResourcePolicyRepresentation, error) + UpdateResourcePolicy(ctx context.Context, token, realm, permissionID string, policy ResourcePolicyRepresentation) error + DeleteResourcePolicy(ctx context.Context, token, realm, permissionID string) error + + GetPermission(ctx context.Context, token, realm, idOfClient, permissionID string) (*PermissionRepresentation, error) + GetPermissions(ctx context.Context, token, realm, idOfClient string, params GetPermissionParams) ([]*PermissionRepresentation, error) + GetPermissionResources(ctx context.Context, token, realm, idOfClient, permissionID string) ([]*PermissionResource, error) + GetPermissionScopes(ctx context.Context, token, realm, idOfClient, permissionID string) ([]*PermissionScope, error) + GetDependentPermissions(ctx context.Context, token, realm, idOfClient, policyID string) ([]*PermissionRepresentation, error) + CreatePermission(ctx context.Context, token, realm, idOfClient string, permission PermissionRepresentation) (*PermissionRepresentation, error) + UpdatePermission(ctx context.Context, token, realm, idOfClient string, permission PermissionRepresentation) error + DeletePermission(ctx context.Context, token, realm, idOfClient, permissionID string) error + + CreatePermissionTicket(ctx context.Context, token, realm string, permissions []CreatePermissionTicketParams) (*PermissionTicketResponseRepresentation, error) + GrantUserPermission(ctx context.Context, token, realm string, permission PermissionGrantParams) (*PermissionGrantResponseRepresentation, error) + UpdateUserPermission(ctx context.Context, token, realm string, permission PermissionGrantParams) (*PermissionGrantResponseRepresentation, error) + GetUserPermissions(ctx context.Context, token, realm string, params GetUserPermissionParams) ([]*PermissionGrantResponseRepresentation, error) + DeleteUserPermission(ctx context.Context, token, realm, ticketID string) error + + // *** Credentials API *** + + GetCredentialRegistrators(ctx context.Context, token, realm string) ([]string, error) + GetConfiguredUserStorageCredentialTypes(ctx context.Context, token, realm, userID string) ([]string, error) + GetCredentials(ctx context.Context, token, realm, UserID string) ([]*CredentialRepresentation, error) + DeleteCredentials(ctx context.Context, token, realm, UserID, CredentialID string) error + UpdateCredentialUserLabel(ctx context.Context, token, realm, userID, credentialID, userLabel string) error + DisableAllCredentialsByType(ctx context.Context, token, realm, userID string, types []string) error + MoveCredentialBehind(ctx context.Context, token, realm, userID, credentialID, newPreviousCredentialID string) error + MoveCredentialToFirst(ctx context.Context, token, realm, userID, credentialID string) error + +// *** Authentication Flows *** +GetAuthenticationFlows(ctx context.Context, token, realm string) ([]*AuthenticationFlowRepresentation, error) +GetAuthenticationFlow(ctx context.Context, token, realm string, authenticationFlowID string) (*AuthenticationFlowRepresentation, error) +CreateAuthenticationFlow(ctx context.Context, token, realm string, flow AuthenticationFlowRepresentation) error +UpdateAuthenticationFlow(ctx context.Context, token, realm string, flow AuthenticationFlowRepresentation, authenticationFlowID string) (*AuthenticationFlowRepresentation, error) +DeleteAuthenticationFlow(ctx context.Context, token, realm, flowID string) error + +// *** Identity Providers *** + + CreateIdentityProvider(ctx context.Context, token, realm string, providerRep IdentityProviderRepresentation) (string, error) + GetIdentityProvider(ctx context.Context, token, realm, alias string) (*IdentityProviderRepresentation, error) + GetIdentityProviders(ctx context.Context, token, realm string) ([]*IdentityProviderRepresentation, error) + UpdateIdentityProvider(ctx context.Context, token, realm, alias string, providerRep IdentityProviderRepresentation) error + DeleteIdentityProvider(ctx context.Context, token, realm, alias string) error + + CreateIdentityProviderMapper(ctx context.Context, token, realm, alias string, mapper IdentityProviderMapper) (string, error) + GetIdentityProviderMapper(ctx context.Context, token string, realm string, alias string, mapperID string) (*IdentityProviderMapper, error) + CreateUserFederatedIdentity(ctx context.Context, token, realm, userID, providerID string, federatedIdentityRep FederatedIdentityRepresentation) error + GetUserFederatedIdentities(ctx context.Context, token, realm, userID string) ([]*FederatedIdentityRepresentation, error) + DeleteUserFederatedIdentity(ctx context.Context, token, realm, userID, providerID string) error + + // *** Events API *** + GetEvents(ctx context.Context, token string, realm string, params GetEventsParams) ([]*EventRepresentation, error) + +} +``` + +## Configure gocloak to skip TLS Insecure Verification + +```go + client := gocloak.NewClient(serverURL) + restyClient := client.RestyClient() + restyClient.SetDebug(true) + restyClient.SetTLSClientConfig(&tls.Config{ InsecureSkipVerify: true }) +``` + +## developing & testing + +For local testing you need to start a docker container. Simply run following commands prior to starting the tests: + +```shell +docker pull quay.io/keycloak/keycloak +docker run -d \ + -e KEYCLOAK_USER=admin \ + -e KEYCLOAK_PASSWORD=secret \ + -e KEYCLOAK_IMPORT=/tmp/gocloak-realm.json \ + -v "`pwd`/testdata/gocloak-realm.json:/tmp/gocloak-realm.json" \ + -p 8080:8080 \ + --name gocloak-test \ + quay.io/keycloak/keycloak:latest -Dkeycloak.profile.feature.upload_scripts=enabled + +go test +``` + +Or you can run with docker compose using the run-tests script + +```shell +./run-tests.sh +``` + +or + +```shell +./run-tests.sh +``` + +Or you can run the tests on you own keycloak: + +```shell +export GOCLOAK_TEST_CONFIG=/path/to/gocloak/config.json +``` + +All resources created as a result of unit tests will be deleted, except for the test user defined in the configuration file. + +To remove running docker container after completion of tests: + +```shell +docker stop gocloak-test +docker rm gocloak-test +``` + +### Inspecting custom types + +The custom types contain many pointers, so printing them yields mostly pointer values, which aren't much help when debugging your application. For example + +```go +someRealmRepresentation := gocloak.RealmRepresentation{ + +} + +fmt.Println(someRealmRepresentation) + +``` + +yields a large set of pointer values + +```bash +{ 0xc00000e960 0xc000093cf0 null } +``` + +For convenience, the ```String()``` interface has been added so you can easily see the contents, even for nested custom types. For example, + +```go +fmt.Println(someRealmRepresentation.String()) +``` + +yields + +```json +{ + "clients": [ + { + "name": "someClient", + "protocolMappers": [ + { + "config": { + "bar": "foo", + "ping": "pong" + }, + "name": "someMapper" + } + ] + }, + { + "name": "AnotherClient" + } + ], + "displayName": "someRealm" +} +``` + +Note that empty parameters are not included, because of the use of ```omitempty``` in the type definitions. + +## Examples + +* [Add client role to user](./examples/ADD_CLIENT_ROLE_TO_USER.md) + +* [Create User Federation & Sync](./examples/USER_FEDERATION.md) + +* [Create User Federation & Sync with group ldap mapper](./examples/USER_FEDERATION_GROUP_LDAP_MAPPER.md) + +* [Create User Federation & Sync with role ldap mapper](./examples/USER_FEDERATION_ROLE_LDAP_MAPPER.md) + +* [Create User Federation & Sync with user attribute ldap mapper](./examples/USER_FEDERATION_USER_ATTRIBUTE_LDAP_MAPPER.md) + +## License + +[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FNerzal%2Fgocloak.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2FNerzal%2Fgocloak?ref=badge_large) + +## Related Projects + +[GocloakSession](https://github.com/Clarilab/gocloaksession) diff --git a/vendor/github.com/Nerzal/gocloak/v13/client.go b/vendor/github.com/Nerzal/gocloak/v13/client.go new file mode 100644 index 000000000..9f06b215b --- /dev/null +++ b/vendor/github.com/Nerzal/gocloak/v13/client.go @@ -0,0 +1,4306 @@ +// Package gocloak is a golang keycloak adaptor. +package gocloak + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/go-resty/resty/v2" + "github.com/golang-jwt/jwt/v4" + "github.com/opentracing/opentracing-go" + "github.com/pkg/errors" + "github.com/segmentio/ksuid" + + "github.com/Nerzal/gocloak/v13/pkg/jwx" +) + +// GoCloak provides functionalities to talk to Keycloak. +type GoCloak struct { + basePath string + certsCache sync.Map + certsLock sync.Mutex + restyClient *resty.Client + Config struct { + CertsInvalidateTime time.Duration + authAdminRealms string + authRealms string + tokenEndpoint string + revokeEndpoint string + logoutEndpoint string + openIDConnect string + attackDetection string + } +} + +const ( + adminClientID string = "admin-cli" + urlSeparator string = "/" +) + +func makeURL(path ...string) string { + return strings.Join(path, urlSeparator) +} + +// GetRequest returns a request for calling endpoints. +func (g *GoCloak) GetRequest(ctx context.Context) *resty.Request { + var err HTTPErrorResponse + return injectTracingHeaders( + ctx, g.restyClient.R(). + SetContext(ctx). + SetError(&err), + ) +} + +// GetRequestWithBearerAuthNoCache returns a JSON base request configured with an auth token and no-cache header. +func (g *GoCloak) GetRequestWithBearerAuthNoCache(ctx context.Context, token string) *resty.Request { + return g.GetRequest(ctx). + SetAuthToken(token). + SetHeader("Content-Type", "application/json"). + SetHeader("Cache-Control", "no-cache") +} + +// GetRequestWithBearerAuth returns a JSON base request configured with an auth token. +func (g *GoCloak) GetRequestWithBearerAuth(ctx context.Context, token string) *resty.Request { + return g.GetRequest(ctx). + SetAuthToken(token). + SetHeader("Content-Type", "application/json") +} + +// GetRequestWithBearerAuthXMLHeader returns an XML base request configured with an auth token. +func (g *GoCloak) GetRequestWithBearerAuthXMLHeader(ctx context.Context, token string) *resty.Request { + return g.GetRequest(ctx). + SetAuthToken(token). + SetHeader("Content-Type", "application/xml;charset=UTF-8") +} + +// GetRequestWithBasicAuth returns a form data base request configured with basic auth. +func (g *GoCloak) GetRequestWithBasicAuth(ctx context.Context, clientID, clientSecret string) *resty.Request { + req := g.GetRequest(ctx). + SetHeader("Content-Type", "application/x-www-form-urlencoded") + // Public client doesn't require Basic Auth + if len(clientID) > 0 && len(clientSecret) > 0 { + httpBasicAuth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret)) + req.SetHeader("Authorization", "Basic "+httpBasicAuth) + } + + return req +} + +func (g *GoCloak) getRequestingParty(ctx context.Context, token string, realm string, options RequestingPartyTokenOptions, res interface{}) (*resty.Response, error) { + return g.GetRequestWithBearerAuth(ctx, token). + SetFormData(options.FormData()). + SetFormDataFromValues(url.Values{"permission": PStringSlice(options.Permissions)}). + SetResult(&res). + Post(g.getRealmURL(realm, g.Config.tokenEndpoint)) +} + +func checkForError(resp *resty.Response, err error, errMessage string) error { + if err != nil { + return &APIError{ + Code: 0, + Message: errors.Wrap(err, errMessage).Error(), + Type: ParseAPIErrType(err), + } + } + + if resp == nil { + return &APIError{ + Message: "empty response", + Type: ParseAPIErrType(err), + } + } + + if resp.IsError() { + var msg string + + if e, ok := resp.Error().(*HTTPErrorResponse); ok && e.NotEmpty() { + msg = fmt.Sprintf("%s: %s", resp.Status(), e) + } else { + msg = resp.Status() + } + + return &APIError{ + Code: resp.StatusCode(), + Message: msg, + Type: ParseAPIErrType(err), + } + } + + return nil +} + +func getID(resp *resty.Response) string { + header := resp.Header().Get("Location") + splittedPath := strings.Split(header, urlSeparator) + + return splittedPath[len(splittedPath)-1] +} + +func findUsedKey(usedKeyID string, keys []CertResponseKey) *CertResponseKey { + for _, key := range keys { + if *(key.Kid) == usedKeyID { + return &key + } + } + + return nil +} + +func injectTracingHeaders(ctx context.Context, req *resty.Request) *resty.Request { + // look for span in context, do nothing if span is not found + span := opentracing.SpanFromContext(ctx) + if span == nil { + return req + } + + // look for tracer in context, use global tracer if not found + tracer, ok := ctx.Value(tracerContextKey).(opentracing.Tracer) + if !ok || tracer == nil { + tracer = opentracing.GlobalTracer() + } + + // inject tracing header into request + err := tracer.Inject(span.Context(), opentracing.HTTPHeaders, opentracing.HTTPHeadersCarrier(req.Header)) + if err != nil { + return req + } + + return req +} + +// =============== +// Keycloak client +// =============== + +// NewClient creates a new Client +func NewClient(basePath string, options ...func(*GoCloak)) *GoCloak { + c := GoCloak{ + basePath: strings.TrimRight(basePath, urlSeparator), + restyClient: resty.New(), + } + + c.Config.CertsInvalidateTime = 10 * time.Minute + c.Config.authAdminRealms = makeURL("admin", "realms") + c.Config.authRealms = makeURL("realms") + c.Config.tokenEndpoint = makeURL("protocol", "openid-connect", "token") + c.Config.logoutEndpoint = makeURL("protocol", "openid-connect", "logout") + c.Config.revokeEndpoint = makeURL("protocol", "openid-connect", "revoke") + c.Config.openIDConnect = makeURL("protocol", "openid-connect") + c.Config.attackDetection = makeURL("attack-detection", "brute-force") + + for _, option := range options { + option(&c) + } + + return &c +} + +// RestyClient returns the internal resty g. +// This can be used to configure the g. +func (g *GoCloak) RestyClient() *resty.Client { + return g.restyClient +} + +// SetRestyClient overwrites the internal resty g. +func (g *GoCloak) SetRestyClient(restyClient *resty.Client) { + g.restyClient = restyClient +} + +func (g *GoCloak) getRealmURL(realm string, path ...string) string { + path = append([]string{g.basePath, g.Config.authRealms, realm}, path...) + return makeURL(path...) +} + +func (g *GoCloak) getAdminRealmURL(realm string, path ...string) string { + path = append([]string{g.basePath, g.Config.authAdminRealms, realm}, path...) + return makeURL(path...) +} + +func (g *GoCloak) getAttackDetectionURL(realm string, user string, path ...string) string { + path = append([]string{g.basePath, g.Config.authAdminRealms, realm, g.Config.attackDetection, user}, path...) + return makeURL(path...) +} + +// ==== Functional Options === + +// SetLegacyWildFlySupport maintain legacy WildFly support. +func SetLegacyWildFlySupport() func(g *GoCloak) { + return func(g *GoCloak) { + g.Config.authAdminRealms = makeURL("auth", "admin", "realms") + g.Config.authRealms = makeURL("auth", "realms") + } +} + +// SetAuthRealms sets the auth realm +func SetAuthRealms(url string) func(g *GoCloak) { + return func(g *GoCloak) { + g.Config.authRealms = url + } +} + +// SetAuthAdminRealms sets the auth admin realm +func SetAuthAdminRealms(url string) func(g *GoCloak) { + return func(g *GoCloak) { + g.Config.authAdminRealms = url + } +} + +// SetTokenEndpoint sets the token endpoint +func SetTokenEndpoint(url string) func(g *GoCloak) { + return func(g *GoCloak) { + g.Config.tokenEndpoint = url + } +} + +// SetRevokeEndpoint sets the revoke endpoint +func SetRevokeEndpoint(url string) func(g *GoCloak) { + return func(g *GoCloak) { + g.Config.revokeEndpoint = url + } +} + +// SetLogoutEndpoint sets the logout +func SetLogoutEndpoint(url string) func(g *GoCloak) { + return func(g *GoCloak) { + g.Config.logoutEndpoint = url + } +} + +// SetOpenIDConnectEndpoint sets the logout +func SetOpenIDConnectEndpoint(url string) func(g *GoCloak) { + return func(g *GoCloak) { + g.Config.openIDConnect = url + } +} + +// SetCertCacheInvalidationTime sets the logout +func SetCertCacheInvalidationTime(duration time.Duration) func(g *GoCloak) { + return func(g *GoCloak) { + g.Config.CertsInvalidateTime = duration + } +} + +// GetServerInfo fetches the server info. +func (g *GoCloak) GetServerInfo(ctx context.Context, accessToken string) (*ServerInfoRepresentation, error) { + errMessage := "could not get server info" + var result *ServerInfoRepresentation + + resp, err := g.GetRequestWithBearerAuth(ctx, accessToken). + SetResult(&result). + Get(makeURL(g.basePath, "admin", "serverinfo")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetUserInfo calls the UserInfo endpoint +func (g *GoCloak) GetUserInfo(ctx context.Context, accessToken, realm string) (*UserInfo, error) { + const errMessage = "could not get user info" + + var result UserInfo + resp, err := g.GetRequestWithBearerAuth(ctx, accessToken). + SetResult(&result). + Get(g.getRealmURL(realm, g.Config.openIDConnect, "userinfo")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetRawUserInfo calls the UserInfo endpoint and returns a raw json object +func (g *GoCloak) GetRawUserInfo(ctx context.Context, accessToken, realm string) (map[string]interface{}, error) { + const errMessage = "could not get user info" + + var result map[string]interface{} + resp, err := g.GetRequestWithBearerAuth(ctx, accessToken). + SetResult(&result). + Get(g.getRealmURL(realm, g.Config.openIDConnect, "userinfo")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +func (g *GoCloak) getNewCerts(ctx context.Context, realm string) (*CertResponse, error) { + const errMessage = "could not get newCerts" + + var result CertResponse + resp, err := g.GetRequest(ctx). + SetResult(&result). + Get(g.getRealmURL(realm, g.Config.openIDConnect, "certs")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetCerts fetches certificates for the given realm from the public /open-id-connect/certs endpoint +func (g *GoCloak) GetCerts(ctx context.Context, realm string) (*CertResponse, error) { + const errMessage = "could not get certs" + + if cert, ok := g.certsCache.Load(realm); ok { + return cert.(*CertResponse), nil + } + + g.certsLock.Lock() + defer g.certsLock.Unlock() + + if cert, ok := g.certsCache.Load(realm); ok { + return cert.(*CertResponse), nil + } + + cert, err := g.getNewCerts(ctx, realm) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + + g.certsCache.Store(realm, cert) + time.AfterFunc(g.Config.CertsInvalidateTime, func() { + g.certsCache.Delete(realm) + }) + + return cert, nil +} + +// GetIssuer gets the issuer of the given realm +func (g *GoCloak) GetIssuer(ctx context.Context, realm string) (*IssuerResponse, error) { + const errMessage = "could not get issuer" + + var result IssuerResponse + resp, err := g.GetRequest(ctx). + SetResult(&result). + Get(g.getRealmURL(realm)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// RetrospectToken calls the openid-connect introspect endpoint +func (g *GoCloak) RetrospectToken(ctx context.Context, accessToken, clientID, clientSecret, realm string) (*IntroSpectTokenResult, error) { + const errMessage = "could not introspect requesting party token" + + var result IntroSpectTokenResult + resp, err := g.GetRequestWithBasicAuth(ctx, clientID, clientSecret). + SetFormData(map[string]string{ + "token_type_hint": "requesting_party_token", + "token": accessToken, + }). + SetResult(&result). + Post(g.getRealmURL(realm, g.Config.tokenEndpoint, "introspect")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +func (g *GoCloak) decodeAccessTokenWithClaims(ctx context.Context, accessToken, realm string, claims jwt.Claims) (*jwt.Token, error) { + const errMessage = "could not decode access token" + accessToken = strings.Replace(accessToken, "Bearer ", "", 1) + + decodedHeader, err := jwx.DecodeAccessTokenHeader(accessToken) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + + certResult, err := g.GetCerts(ctx, realm) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + if certResult.Keys == nil { + return nil, errors.Wrap(errors.New("there is no keys to decode the token"), errMessage) + } + usedKey := findUsedKey(decodedHeader.Kid, *certResult.Keys) + if usedKey == nil { + return nil, errors.Wrap(errors.New("cannot find a key to decode the token"), errMessage) + } + + if strings.HasPrefix(decodedHeader.Alg, "ES") { + return jwx.DecodeAccessTokenECDSACustomClaims(accessToken, usedKey.X, usedKey.Y, usedKey.Crv, claims) + } else if strings.HasPrefix(decodedHeader.Alg, "RS") { + return jwx.DecodeAccessTokenRSACustomClaims(accessToken, usedKey.E, usedKey.N, claims) + } + return nil, fmt.Errorf("unsupported algorithm") +} + +// DecodeAccessToken decodes the accessToken +func (g *GoCloak) DecodeAccessToken(ctx context.Context, accessToken, realm string) (*jwt.Token, *jwt.MapClaims, error) { + claims := jwt.MapClaims{} + token, err := g.decodeAccessTokenWithClaims(ctx, accessToken, realm, claims) + if err != nil { + return nil, nil, err + } + return token, &claims, nil +} + +// DecodeAccessTokenCustomClaims decodes the accessToken and writes claims into the given claims +func (g *GoCloak) DecodeAccessTokenCustomClaims(ctx context.Context, accessToken, realm string, claims jwt.Claims) (*jwt.Token, error) { + return g.decodeAccessTokenWithClaims(ctx, accessToken, realm, claims) +} + +// GetToken uses TokenOptions to fetch a token. +func (g *GoCloak) GetToken(ctx context.Context, realm string, options TokenOptions) (*JWT, error) { + const errMessage = "could not get token" + + var token JWT + var req *resty.Request + + if !NilOrEmpty(options.ClientSecret) { + req = g.GetRequestWithBasicAuth(ctx, *options.ClientID, *options.ClientSecret) + } else { + req = g.GetRequest(ctx) + } + + resp, err := req.SetFormData(options.FormData()). + SetResult(&token). + Post(g.getRealmURL(realm, g.Config.tokenEndpoint)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &token, nil +} + +// GetRequestingPartyToken returns a requesting party token with permissions granted by the server +func (g *GoCloak) GetRequestingPartyToken(ctx context.Context, token, realm string, options RequestingPartyTokenOptions) (*JWT, error) { + const errMessage = "could not get requesting party token" + + var res JWT + + resp, err := g.getRequestingParty(ctx, token, realm, options, &res) + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &res, nil +} + +// GetRequestingPartyPermissions returns a requesting party permissions granted by the server +func (g *GoCloak) GetRequestingPartyPermissions(ctx context.Context, token, realm string, options RequestingPartyTokenOptions) (*[]RequestingPartyPermission, error) { + const errMessage = "could not get requesting party token" + + var res []RequestingPartyPermission + + options.ResponseMode = StringP("permissions") + + resp, err := g.getRequestingParty(ctx, token, realm, options, &res) + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &res, nil +} + +// GetRequestingPartyPermissionDecision returns a requesting party permission decision granted by the server +func (g *GoCloak) GetRequestingPartyPermissionDecision(ctx context.Context, token, realm string, options RequestingPartyTokenOptions) (*RequestingPartyPermissionDecision, error) { + const errMessage = "could not get requesting party token" + + var res RequestingPartyPermissionDecision + + options.ResponseMode = StringP("decision") + + resp, err := g.getRequestingParty(ctx, token, realm, options, &res) + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &res, nil +} + +// RefreshToken refreshes the given token. +// May return a *APIError with further details about the issue. +func (g *GoCloak) RefreshToken(ctx context.Context, refreshToken, clientID, clientSecret, realm string) (*JWT, error) { + return g.GetToken(ctx, realm, TokenOptions{ + ClientID: &clientID, + ClientSecret: &clientSecret, + GrantType: StringP("refresh_token"), + RefreshToken: &refreshToken, + }) +} + +// LoginAdmin performs a login with Admin client +func (g *GoCloak) LoginAdmin(ctx context.Context, username, password, realm string) (*JWT, error) { + return g.GetToken(ctx, realm, TokenOptions{ + ClientID: StringP(adminClientID), + GrantType: StringP("password"), + Username: &username, + Password: &password, + }) +} + +// LoginClient performs a login with client credentials +func (g *GoCloak) LoginClient(ctx context.Context, clientID, clientSecret, realm string, scopes ...string) (*JWT, error) { + opts := TokenOptions{ + ClientID: &clientID, + ClientSecret: &clientSecret, + GrantType: StringP("client_credentials"), + } + + if len(scopes) > 0 { + opts.Scope = &scopes[0] + } + + return g.GetToken(ctx, realm, opts) +} + +// LoginClientTokenExchange will exchange the presented token for a user's token +// Requires Token-Exchange is enabled: https://www.keycloak.org/docs/latest/securing_apps/index.html#_token-exchange +func (g *GoCloak) LoginClientTokenExchange(ctx context.Context, clientID, token, clientSecret, realm, targetClient, userID string) (*JWT, error) { + tokenOptions := TokenOptions{ + ClientID: &clientID, + ClientSecret: &clientSecret, + GrantType: StringP("urn:ietf:params:oauth:grant-type:token-exchange"), + SubjectToken: &token, + RequestedTokenType: StringP("urn:ietf:params:oauth:token-type:refresh_token"), + Audience: &targetClient, + } + if userID != "" { + tokenOptions.RequestedSubject = &userID + } + return g.GetToken(ctx, realm, tokenOptions) +} + +// LoginClientSignedJWT performs a login with client credentials and signed jwt claims +func (g *GoCloak) LoginClientSignedJWT( + ctx context.Context, + clientID, + realm string, + key interface{}, + signedMethod jwt.SigningMethod, + expiresAt *jwt.NumericDate, +) (*JWT, error) { + claims := jwt.RegisteredClaims{ + ExpiresAt: expiresAt, + Issuer: clientID, + Subject: clientID, + ID: ksuid.New().String(), + Audience: jwt.ClaimStrings{ + g.getRealmURL(realm), + }, + } + assertion, err := jwx.SignClaims(claims, key, signedMethod) + if err != nil { + return nil, err + } + + return g.GetToken(ctx, realm, TokenOptions{ + ClientID: &clientID, + GrantType: StringP("client_credentials"), + ClientAssertionType: StringP("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"), + ClientAssertion: &assertion, + }) +} + +// Login performs a login with user credentials and a client +func (g *GoCloak) Login(ctx context.Context, clientID, clientSecret, realm, username, password string) (*JWT, error) { + return g.GetToken(ctx, realm, TokenOptions{ + ClientID: &clientID, + ClientSecret: &clientSecret, + GrantType: StringP("password"), + Username: &username, + Password: &password, + Scope: StringP("openid"), + }) +} + +// LoginOtp performs a login with user credentials and otp token +func (g *GoCloak) LoginOtp(ctx context.Context, clientID, clientSecret, realm, username, password, totp string) (*JWT, error) { + return g.GetToken(ctx, realm, TokenOptions{ + ClientID: &clientID, + ClientSecret: &clientSecret, + GrantType: StringP("password"), + Username: &username, + Password: &password, + Totp: &totp, + }) +} + +// Logout logs out users with refresh token +func (g *GoCloak) Logout(ctx context.Context, clientID, clientSecret, realm, refreshToken string) error { + const errMessage = "could not logout" + + resp, err := g.GetRequestWithBasicAuth(ctx, clientID, clientSecret). + SetFormData(map[string]string{ + "client_id": clientID, + "refresh_token": refreshToken, + }). + Post(g.getRealmURL(realm, g.Config.logoutEndpoint)) + + return checkForError(resp, err, errMessage) +} + +// LogoutPublicClient performs a logout using a public client and the accessToken. +func (g *GoCloak) LogoutPublicClient(ctx context.Context, clientID, realm, accessToken, refreshToken string) error { + const errMessage = "could not logout public client" + + resp, err := g.GetRequestWithBearerAuth(ctx, accessToken). + SetFormData(map[string]string{ + "client_id": clientID, + "refresh_token": refreshToken, + }). + Post(g.getRealmURL(realm, g.Config.logoutEndpoint)) + + return checkForError(resp, err, errMessage) +} + +// LogoutAllSessions logs out all sessions of a user given an id. +func (g *GoCloak) LogoutAllSessions(ctx context.Context, accessToken, realm, userID string) error { + const errMessage = "could not logout" + + resp, err := g.GetRequestWithBearerAuth(ctx, accessToken). + Post(g.getAdminRealmURL(realm, "users", userID, "logout")) + + return checkForError(resp, err, errMessage) +} + +// RevokeUserConsents revokes the given user consent. +func (g *GoCloak) RevokeUserConsents(ctx context.Context, accessToken, realm, userID, clientID string) error { + const errMessage = "could not revoke consents" + + resp, err := g.GetRequestWithBearerAuth(ctx, accessToken). + Delete(g.getAdminRealmURL(realm, "users", userID, "consents", clientID)) + + return checkForError(resp, err, errMessage) +} + +// LogoutUserSession logs out a single sessions of a user given a session id +func (g *GoCloak) LogoutUserSession(ctx context.Context, accessToken, realm, session string) error { + const errMessage = "could not logout" + + resp, err := g.GetRequestWithBearerAuth(ctx, accessToken). + Delete(g.getAdminRealmURL(realm, "sessions", session)) + + return checkForError(resp, err, errMessage) +} + +// ExecuteActionsEmail executes an actions email +func (g *GoCloak) ExecuteActionsEmail(ctx context.Context, token, realm string, params ExecuteActionsEmail) error { + const errMessage = "could not execute actions email" + + queryParams, err := GetQueryParams(params) + if err != nil { + return errors.Wrap(err, errMessage) + } + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(params.Actions). + SetQueryParams(queryParams). + Put(g.getAdminRealmURL(realm, "users", *(params.UserID), "execute-actions-email")) + + return checkForError(resp, err, errMessage) +} + +// SendVerifyEmail sends a verification e-mail to a user. +func (g *GoCloak) SendVerifyEmail(ctx context.Context, token, userID, realm string, params ...SendVerificationMailParams) error { + const errMessage = "could not execute actions email" + + queryParams := map[string]string{} + if params != nil { + if params[0].ClientID != nil { + queryParams["client_id"] = *params[0].ClientID + } + + if params[0].RedirectURI != nil { + queryParams["redirect_uri"] = *params[0].RedirectURI + } + } + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetQueryParams(queryParams). + Put(g.getAdminRealmURL(realm, "users", userID, "send-verify-email")) + + return checkForError(resp, err, errMessage) +} + +// CreateGroup creates a new group. +func (g *GoCloak) CreateGroup(ctx context.Context, token, realm string, group Group) (string, error) { + const errMessage = "could not create group" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(group). + Post(g.getAdminRealmURL(realm, "groups")) + + if err := checkForError(resp, err, errMessage); err != nil { + return "", err + } + return getID(resp), nil +} + +// CreateChildGroup creates a new child group +func (g *GoCloak) CreateChildGroup(ctx context.Context, token, realm, groupID string, group Group) (string, error) { + const errMessage = "could not create child group" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(group). + Post(g.getAdminRealmURL(realm, "groups", groupID, "children")) + + if err := checkForError(resp, err, errMessage); err != nil { + return "", err + } + + return getID(resp), nil +} + +// CreateComponent creates the given component. +func (g *GoCloak) CreateComponent(ctx context.Context, token, realm string, component Component) (string, error) { + const errMessage = "could not create component" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(component). + Post(g.getAdminRealmURL(realm, "components")) + + if err := checkForError(resp, err, errMessage); err != nil { + return "", err + } + + return getID(resp), nil +} + +// CreateClient creates the given g. +func (g *GoCloak) CreateClient(ctx context.Context, accessToken, realm string, newClient Client) (string, error) { + const errMessage = "could not create client" + + resp, err := g.GetRequestWithBearerAuth(ctx, accessToken). + SetBody(newClient). + Post(g.getAdminRealmURL(realm, "clients")) + + if err := checkForError(resp, err, errMessage); err != nil { + return "", err + } + + return getID(resp), nil +} + +// CreateClientRepresentation creates a new client representation +func (g *GoCloak) CreateClientRepresentation(ctx context.Context, token, realm string, newClient Client) (*Client, error) { + const errMessage = "could not create client representation" + + var result Client + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetBody(newClient). + Post(g.getRealmURL(realm, "clients-registrations", "default")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// CreateClientRole creates a new role for a client +func (g *GoCloak) CreateClientRole(ctx context.Context, token, realm, idOfClient string, role Role) (string, error) { + const errMessage = "could not create client role" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(role). + Post(g.getAdminRealmURL(realm, "clients", idOfClient, "roles")) + + if err := checkForError(resp, err, errMessage); err != nil { + return "", err + } + + return getID(resp), nil +} + +// CreateClientScope creates a new client scope +func (g *GoCloak) CreateClientScope(ctx context.Context, token, realm string, scope ClientScope) (string, error) { + const errMessage = "could not create client scope" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(scope). + Post(g.getAdminRealmURL(realm, "client-scopes")) + + if err := checkForError(resp, err, errMessage); err != nil { + return "", err + } + + return getID(resp), nil +} + +// CreateClientScopeProtocolMapper creates a new protocolMapper under the given client scope +func (g *GoCloak) CreateClientScopeProtocolMapper(ctx context.Context, token, realm, scopeID string, protocolMapper ProtocolMappers) (string, error) { + const errMessage = "could not create client scope protocol mapper" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(protocolMapper). + Post(g.getAdminRealmURL(realm, "client-scopes", scopeID, "protocol-mappers", "models")) + + if err := checkForError(resp, err, errMessage); err != nil { + return "", err + } + + return getID(resp), nil +} + +// UpdateGroup updates the given group. +func (g *GoCloak) UpdateGroup(ctx context.Context, token, realm string, updatedGroup Group) error { + const errMessage = "could not update group" + + if NilOrEmpty(updatedGroup.ID) { + return errors.Wrap(errors.New("ID of a group required"), errMessage) + } + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(updatedGroup). + Put(g.getAdminRealmURL(realm, "groups", PString(updatedGroup.ID))) + + return checkForError(resp, err, errMessage) +} + +// UpdateGroupManagementPermissions updates the given group management permissions +func (g *GoCloak) UpdateGroupManagementPermissions(ctx context.Context, accessToken, realm string, idOfGroup string, managementPermissions ManagementPermissionRepresentation) (*ManagementPermissionRepresentation, error) { + const errMessage = "could not update group management permissions" + + var result ManagementPermissionRepresentation + + resp, err := g.GetRequestWithBearerAuth(ctx, accessToken). + SetResult(&result). + SetBody(managementPermissions). + Put(g.getAdminRealmURL(realm, "groups", idOfGroup, "management", "permissions")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// UpdateClient updates the given Client +func (g *GoCloak) UpdateClient(ctx context.Context, token, realm string, updatedClient Client) error { + const errMessage = "could not update client" + + if NilOrEmpty(updatedClient.ID) { + return errors.Wrap(errors.New("ID of a client required"), errMessage) + } + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(updatedClient). + Put(g.getAdminRealmURL(realm, "clients", PString(updatedClient.ID))) + + return checkForError(resp, err, errMessage) +} + +// UpdateClientRepresentation updates the given client representation +func (g *GoCloak) UpdateClientRepresentation(ctx context.Context, accessToken, realm string, updatedClient Client) (*Client, error) { + const errMessage = "could not update client representation" + + if NilOrEmpty(updatedClient.ID) { + return nil, errors.Wrap(errors.New("ID of a client required"), errMessage) + } + + var result Client + + resp, err := g.GetRequestWithBearerAuth(ctx, accessToken). + SetResult(&result). + SetBody(updatedClient). + Put(g.getRealmURL(realm, "clients-registrations", "default", PString(updatedClient.ClientID))) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// UpdateClientManagementPermissions updates the given client management permissions +func (g *GoCloak) UpdateClientManagementPermissions(ctx context.Context, accessToken, realm string, idOfClient string, managementPermissions ManagementPermissionRepresentation) (*ManagementPermissionRepresentation, error) { + const errMessage = "could not update client management permissions" + + var result ManagementPermissionRepresentation + + resp, err := g.GetRequestWithBearerAuth(ctx, accessToken). + SetResult(&result). + SetBody(managementPermissions). + Put(g.getAdminRealmURL(realm, "clients", idOfClient, "management", "permissions")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// UpdateRole updates the given role. +func (g *GoCloak) UpdateRole(ctx context.Context, token, realm, idOfClient string, role Role) error { + const errMessage = "could not update role" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(role). + Put(g.getAdminRealmURL(realm, "clients", idOfClient, "roles", PString(role.Name))) + + return checkForError(resp, err, errMessage) +} + +// UpdateClientScope updates the given client scope. +func (g *GoCloak) UpdateClientScope(ctx context.Context, token, realm string, scope ClientScope) error { + const errMessage = "could not update client scope" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(scope). + Put(g.getAdminRealmURL(realm, "client-scopes", PString(scope.ID))) + + return checkForError(resp, err, errMessage) +} + +// UpdateClientScopeProtocolMapper updates the given protocol mapper for a client scope +func (g *GoCloak) UpdateClientScopeProtocolMapper(ctx context.Context, token, realm, scopeID string, protocolMapper ProtocolMappers) error { + const errMessage = "could not update client scope" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(protocolMapper). + Put(g.getAdminRealmURL(realm, "client-scopes", scopeID, "protocol-mappers", "models", PString(protocolMapper.ID))) + + return checkForError(resp, err, errMessage) +} + +// DeleteGroup deletes the group with the given groupID. +func (g *GoCloak) DeleteGroup(ctx context.Context, token, realm, groupID string) error { + const errMessage = "could not delete group" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "groups", groupID)) + + return checkForError(resp, err, errMessage) +} + +// DeleteClient deletes a given client +func (g *GoCloak) DeleteClient(ctx context.Context, token, realm, idOfClient string) error { + const errMessage = "could not delete client" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "clients", idOfClient)) + + return checkForError(resp, err, errMessage) +} + +// DeleteComponent deletes the component with the given id. +func (g *GoCloak) DeleteComponent(ctx context.Context, token, realm, componentID string) error { + const errMessage = "could not delete component" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "components", componentID)) + + return checkForError(resp, err, errMessage) +} + +// DeleteClientRepresentation deletes a given client representation. +func (g *GoCloak) DeleteClientRepresentation(ctx context.Context, accessToken, realm, clientID string) error { + const errMessage = "could not delete client representation" + + resp, err := g.GetRequestWithBearerAuth(ctx, accessToken). + Delete(g.getRealmURL(realm, "clients-registrations", "default", clientID)) + + return checkForError(resp, err, errMessage) +} + +// DeleteClientRole deletes a given role. +func (g *GoCloak) DeleteClientRole(ctx context.Context, token, realm, idOfClient, roleName string) error { + const errMessage = "could not delete client role" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "clients", idOfClient, "roles", roleName)) + + return checkForError(resp, err, errMessage) +} + +// DeleteClientScope deletes the scope with the given id. +func (g *GoCloak) DeleteClientScope(ctx context.Context, token, realm, scopeID string) error { + const errMessage = "could not delete client scope" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "client-scopes", scopeID)) + + return checkForError(resp, err, errMessage) +} + +// DeleteClientScopeProtocolMapper deletes the given protocol mapper from the client scope +func (g *GoCloak) DeleteClientScopeProtocolMapper(ctx context.Context, token, realm, scopeID, protocolMapperID string) error { + const errMessage = "could not delete client scope" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "client-scopes", scopeID, "protocol-mappers", "models", protocolMapperID)) + + return checkForError(resp, err, errMessage) +} + +// GetClient returns a client +func (g *GoCloak) GetClient(ctx context.Context, token, realm, idOfClient string) (*Client, error) { + const errMessage = "could not get client" + + var result Client + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetClientRepresentation returns a client representation +func (g *GoCloak) GetClientRepresentation(ctx context.Context, accessToken, realm, clientID string) (*Client, error) { + const errMessage = "could not get client representation" + + var result Client + + resp, err := g.GetRequestWithBearerAuth(ctx, accessToken). + SetResult(&result). + Get(g.getRealmURL(realm, "clients-registrations", "default", clientID)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetAdapterConfiguration returns a adapter configuration +func (g *GoCloak) GetAdapterConfiguration(ctx context.Context, accessToken, realm, clientID string) (*AdapterConfiguration, error) { + const errMessage = "could not get adapter configuration" + + var result AdapterConfiguration + + resp, err := g.GetRequestWithBearerAuth(ctx, accessToken). + SetResult(&result). + Get(g.getRealmURL(realm, "clients-registrations", "install", clientID)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetClientsDefaultScopes returns a list of the client's default scopes +func (g *GoCloak) GetClientsDefaultScopes(ctx context.Context, token, realm, idOfClient string) ([]*ClientScope, error) { + const errMessage = "could not get clients default scopes" + + var result []*ClientScope + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "default-client-scopes")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// AddDefaultScopeToClient adds a client scope to the list of client's default scopes +func (g *GoCloak) AddDefaultScopeToClient(ctx context.Context, token, realm, idOfClient, scopeID string) error { + const errMessage = "could not add default scope to client" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Put(g.getAdminRealmURL(realm, "clients", idOfClient, "default-client-scopes", scopeID)) + + return checkForError(resp, err, errMessage) +} + +// RemoveDefaultScopeFromClient removes a client scope from the list of client's default scopes +func (g *GoCloak) RemoveDefaultScopeFromClient(ctx context.Context, token, realm, idOfClient, scopeID string) error { + const errMessage = "could not remove default scope from client" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "clients", idOfClient, "default-client-scopes", scopeID)) + + return checkForError(resp, err, errMessage) +} + +// GetClientsOptionalScopes returns a list of the client's optional scopes +func (g *GoCloak) GetClientsOptionalScopes(ctx context.Context, token, realm, idOfClient string) ([]*ClientScope, error) { + const errMessage = "could not get clients optional scopes" + + var result []*ClientScope + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "optional-client-scopes")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// AddOptionalScopeToClient adds a client scope to the list of client's optional scopes +func (g *GoCloak) AddOptionalScopeToClient(ctx context.Context, token, realm, idOfClient, scopeID string) error { + const errMessage = "could not add optional scope to client" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Put(g.getAdminRealmURL(realm, "clients", idOfClient, "optional-client-scopes", scopeID)) + + return checkForError(resp, err, errMessage) +} + +// RemoveOptionalScopeFromClient deletes a client scope from the list of client's optional scopes +func (g *GoCloak) RemoveOptionalScopeFromClient(ctx context.Context, token, realm, idOfClient, scopeID string) error { + const errMessage = "could not remove optional scope from client" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "clients", idOfClient, "optional-client-scopes", scopeID)) + + return checkForError(resp, err, errMessage) +} + +// GetDefaultOptionalClientScopes returns a list of default realm optional scopes +func (g *GoCloak) GetDefaultOptionalClientScopes(ctx context.Context, token, realm string) ([]*ClientScope, error) { + const errMessage = "could not get default optional client scopes" + + var result []*ClientScope + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "default-optional-client-scopes")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetDefaultDefaultClientScopes returns a list of default realm default scopes +func (g *GoCloak) GetDefaultDefaultClientScopes(ctx context.Context, token, realm string) ([]*ClientScope, error) { + const errMessage = "could not get default client scopes" + + var result []*ClientScope + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "default-default-client-scopes")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetClientScope returns a clientscope +func (g *GoCloak) GetClientScope(ctx context.Context, token, realm, scopeID string) (*ClientScope, error) { + const errMessage = "could not get client scope" + + var result ClientScope + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "client-scopes", scopeID)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetClientScopes returns all client scopes +func (g *GoCloak) GetClientScopes(ctx context.Context, token, realm string) ([]*ClientScope, error) { + const errMessage = "could not get client scopes" + + var result []*ClientScope + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "client-scopes")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetClientScopeProtocolMappers returns all protocol mappers of a client scope +func (g *GoCloak) GetClientScopeProtocolMappers(ctx context.Context, token, realm, scopeID string) ([]*ProtocolMappers, error) { + const errMessage = "could not get client scope protocol mappers" + + var result []*ProtocolMappers + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "client-scopes", scopeID, "protocol-mappers", "models")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetClientScopeProtocolMapper returns a protocol mapper of a client scope +func (g *GoCloak) GetClientScopeProtocolMapper(ctx context.Context, token, realm, scopeID, protocolMapperID string) (*ProtocolMappers, error) { + const errMessage = "could not get client scope protocol mappers" + + var result *ProtocolMappers + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "client-scopes", scopeID, "protocol-mappers", "models", protocolMapperID)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetClientScopeMappings returns all scope mappings for the client +func (g *GoCloak) GetClientScopeMappings(ctx context.Context, token, realm, idOfClient string) (*MappingsRepresentation, error) { + const errMessage = "could not get all scope mappings for the client" + + var result *MappingsRepresentation + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "scope-mappings")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetClientScopeMappingsRealmRoles returns realm-level roles associated with the client’s scope +func (g *GoCloak) GetClientScopeMappingsRealmRoles(ctx context.Context, token, realm, idOfClient string) ([]*Role, error) { + const errMessage = "could not get realm-level roles with the client’s scope" + + var result []*Role + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "scope-mappings", "realm")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetClientScopeMappingsRealmRolesAvailable returns realm-level roles that are available to attach to this client’s scope +func (g *GoCloak) GetClientScopeMappingsRealmRolesAvailable(ctx context.Context, token, realm, idOfClient string) ([]*Role, error) { + const errMessage = "could not get available realm-level roles with the client’s scope" + + var result []*Role + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "scope-mappings", "realm", "available")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// CreateClientScopeMappingsRealmRoles create realm-level roles to the client’s scope +func (g *GoCloak) CreateClientScopeMappingsRealmRoles(ctx context.Context, token, realm, idOfClient string, roles []Role) error { + const errMessage = "could not create realm-level roles to the client’s scope" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(roles). + Post(g.getAdminRealmURL(realm, "clients", idOfClient, "scope-mappings", "realm")) + + return checkForError(resp, err, errMessage) +} + +// DeleteClientScopeMappingsRealmRoles deletes realm-level roles from the client’s scope +func (g *GoCloak) DeleteClientScopeMappingsRealmRoles(ctx context.Context, token, realm, idOfClient string, roles []Role) error { + const errMessage = "could not delete realm-level roles from the client’s scope" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(roles). + Delete(g.getAdminRealmURL(realm, "clients", idOfClient, "scope-mappings", "realm")) + + return checkForError(resp, err, errMessage) +} + +// GetClientScopeMappingsClientRoles returns roles associated with a client’s scope +func (g *GoCloak) GetClientScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClient, idOfSelectedClient string) ([]*Role, error) { + const errMessage = "could not get roles associated with a client’s scope" + + var result []*Role + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "scope-mappings", "clients", idOfSelectedClient)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetClientScopeMappingsClientRolesAvailable returns available roles associated with a client’s scope +func (g *GoCloak) GetClientScopeMappingsClientRolesAvailable(ctx context.Context, token, realm, idOfClient, idOfSelectedClient string) ([]*Role, error) { + const errMessage = "could not get available roles associated with a client’s scope" + + var result []*Role + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "scope-mappings", "clients", idOfSelectedClient, "available")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// CreateClientScopeMappingsClientRoles creates client-level roles from the client’s scope +func (g *GoCloak) CreateClientScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClient, idOfSelectedClient string, roles []Role) error { + const errMessage = "could not create client-level roles from the client’s scope" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(roles). + Post(g.getAdminRealmURL(realm, "clients", idOfClient, "scope-mappings", "clients", idOfSelectedClient)) + + return checkForError(resp, err, errMessage) +} + +// DeleteClientScopeMappingsClientRoles deletes client-level roles from the client’s scope +func (g *GoCloak) DeleteClientScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClient, idOfSelectedClient string, roles []Role) error { + const errMessage = "could not delete client-level roles from the client’s scope" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(roles). + Delete(g.getAdminRealmURL(realm, "clients", idOfClient, "scope-mappings", "clients", idOfSelectedClient)) + + return checkForError(resp, err, errMessage) +} + +// GetClientSecret returns a client's secret +func (g *GoCloak) GetClientSecret(ctx context.Context, token, realm, idOfClient string) (*CredentialRepresentation, error) { + const errMessage = "could not get client secret" + + var result CredentialRepresentation + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "client-secret")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetClientServiceAccount retrieves the service account "user" for a client if enabled +func (g *GoCloak) GetClientServiceAccount(ctx context.Context, token, realm, idOfClient string) (*User, error) { + const errMessage = "could not get client service account" + + var result User + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "service-account-user")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// RegenerateClientSecret triggers the creation of the new client secret. +func (g *GoCloak) RegenerateClientSecret(ctx context.Context, token, realm, idOfClient string) (*CredentialRepresentation, error) { + const errMessage = "could not regenerate client secret" + + var result CredentialRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Post(g.getAdminRealmURL(realm, "clients", idOfClient, "client-secret")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetClientOfflineSessions returns offline sessions associated with the client +func (g *GoCloak) GetClientOfflineSessions(ctx context.Context, token, realm, idOfClient string, params ...GetClientUserSessionsParams) ([]*UserSessionRepresentation, error) { + const errMessage = "could not get client offline sessions" + var res []*UserSessionRepresentation + + queryParams := map[string]string{} + if params != nil && len(params) > 0 { + var err error + + queryParams, err = GetQueryParams(params[0]) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + } + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&res). + SetQueryParams(queryParams). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "offline-sessions")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return res, nil +} + +// GetClientUserSessions returns user sessions associated with the client +func (g *GoCloak) GetClientUserSessions(ctx context.Context, token, realm, idOfClient string, params ...GetClientUserSessionsParams) ([]*UserSessionRepresentation, error) { + const errMessage = "could not get client user sessions" + var res []*UserSessionRepresentation + + queryParams := map[string]string{} + if params != nil && len(params) > 0 { + var err error + + queryParams, err = GetQueryParams(params[0]) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + } + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&res). + SetQueryParams(queryParams). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "user-sessions")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return res, nil +} + +// CreateClientProtocolMapper creates a protocol mapper in client scope +func (g *GoCloak) CreateClientProtocolMapper(ctx context.Context, token, realm, idOfClient string, mapper ProtocolMapperRepresentation) (string, error) { + const errMessage = "could not create client protocol mapper" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(mapper). + Post(g.getAdminRealmURL(realm, "clients", idOfClient, "protocol-mappers", "models")) + + if err := checkForError(resp, err, errMessage); err != nil { + return "", err + } + + return getID(resp), nil +} + +// UpdateClientProtocolMapper updates a protocol mapper in client scope +func (g *GoCloak) UpdateClientProtocolMapper(ctx context.Context, token, realm, idOfClient, mapperID string, mapper ProtocolMapperRepresentation) error { + const errMessage = "could not update client protocol mapper" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(mapper). + Put(g.getAdminRealmURL(realm, "clients", idOfClient, "protocol-mappers", "models", mapperID)) + + return checkForError(resp, err, errMessage) +} + +// DeleteClientProtocolMapper deletes a protocol mapper in client scope +func (g *GoCloak) DeleteClientProtocolMapper(ctx context.Context, token, realm, idOfClient, mapperID string) error { + const errMessage = "could not delete client protocol mapper" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "clients", idOfClient, "protocol-mappers", "models", mapperID)) + + return checkForError(resp, err, errMessage) +} + +// GetKeyStoreConfig get keystoreconfig of the realm +func (g *GoCloak) GetKeyStoreConfig(ctx context.Context, token, realm string) (*KeyStoreConfig, error) { + const errMessage = "could not get key store config" + + var result KeyStoreConfig + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "keys")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetComponents get all components in realm +func (g *GoCloak) GetComponents(ctx context.Context, token, realm string) ([]*Component, error) { + const errMessage = "could not get components" + + var result []*Component + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "components")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetComponentsWithParams get all components in realm with query params +func (g *GoCloak) GetComponentsWithParams(ctx context.Context, token, realm string, params GetComponentsParams) ([]*Component, error) { + const errMessage = "could not get components" + var result []*Component + + queryParams, err := GetQueryParams(params) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetQueryParams(queryParams). + Get(g.getAdminRealmURL(realm, "components")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetComponent get exactly one component by ID +func (g *GoCloak) GetComponent(ctx context.Context, token, realm string, componentID string) (*Component, error) { + const errMessage = "could not get components" + var result *Component + + componentURL := fmt.Sprintf("components/%s", componentID) + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, componentURL)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// UpdateComponent updates the given component +func (g *GoCloak) UpdateComponent(ctx context.Context, token, realm string, component Component) error { + const errMessage = "could not update component" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(component). + Put(g.getAdminRealmURL(realm, "components", PString(component.ID))) + + return checkForError(resp, err, errMessage) +} + +// GetDefaultGroups returns a list of default groups +func (g *GoCloak) GetDefaultGroups(ctx context.Context, token, realm string) ([]*Group, error) { + const errMessage = "could not get default groups" + + var result []*Group + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "default-groups")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// AddDefaultGroup adds group to the list of default groups +func (g *GoCloak) AddDefaultGroup(ctx context.Context, token, realm, groupID string) error { + const errMessage = "could not add default group" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Put(g.getAdminRealmURL(realm, "default-groups", groupID)) + + return checkForError(resp, err, errMessage) +} + +// RemoveDefaultGroup removes group from the list of default groups +func (g *GoCloak) RemoveDefaultGroup(ctx context.Context, token, realm, groupID string) error { + const errMessage = "could not remove default group" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "default-groups", groupID)) + + return checkForError(resp, err, errMessage) +} + +func (g *GoCloak) getRoleMappings(ctx context.Context, token, realm, path, objectID string) (*MappingsRepresentation, error) { + const errMessage = "could not get role mappings" + + var result MappingsRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, path, objectID, "role-mappings")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetRoleMappingByGroupID gets the role mappings by group +func (g *GoCloak) GetRoleMappingByGroupID(ctx context.Context, token, realm, groupID string) (*MappingsRepresentation, error) { + return g.getRoleMappings(ctx, token, realm, "groups", groupID) +} + +// GetRoleMappingByUserID gets the role mappings by user +func (g *GoCloak) GetRoleMappingByUserID(ctx context.Context, token, realm, userID string) (*MappingsRepresentation, error) { + return g.getRoleMappings(ctx, token, realm, "users", userID) +} + +// GetGroup get group with id in realm +func (g *GoCloak) GetGroup(ctx context.Context, token, realm, groupID string) (*Group, error) { + const errMessage = "could not get group" + + var result Group + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "groups", groupID)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetGroupByPath get group with path in realm +func (g *GoCloak) GetGroupByPath(ctx context.Context, token, realm, groupPath string) (*Group, error) { + const errMessage = "could not get group" + + var result Group + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "group-by-path", groupPath)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetGroups get all groups in realm +func (g *GoCloak) GetGroups(ctx context.Context, token, realm string, params GetGroupsParams) ([]*Group, error) { + const errMessage = "could not get groups" + + var result []*Group + queryParams, err := GetQueryParams(params) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetQueryParams(queryParams). + Get(g.getAdminRealmURL(realm, "groups")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetGroupManagementPermissions returns whether group Authorization permissions have been initialized or not and a reference +// to the managed permissions +func (g *GoCloak) GetGroupManagementPermissions(ctx context.Context, token, realm string, idOfGroup string) (*ManagementPermissionRepresentation, error) { + const errMessage = "could not get management permissions" + + var result ManagementPermissionRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "groups", idOfGroup, "management", "permissions")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetGroupsByRole gets groups assigned with a specific role of a realm +func (g *GoCloak) GetGroupsByRole(ctx context.Context, token, realm string, roleName string) ([]*Group, error) { + const errMessage = "could not get groups" + + var result []*Group + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "roles", roleName, "groups")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetGroupsByClientRole gets groups with specified roles assigned of given client within a realm +func (g *GoCloak) GetGroupsByClientRole(ctx context.Context, token, realm string, roleName string, clientID string) ([]*Group, error) { + const errMessage = "could not get groups" + + var result []*Group + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", clientID, "roles", roleName, "groups")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetGroupsCount gets the groups count in the realm +func (g *GoCloak) GetGroupsCount(ctx context.Context, token, realm string, params GetGroupsParams) (int, error) { + const errMessage = "could not get groups count" + + var result GroupsCount + queryParams, err := GetQueryParams(params) + if err != nil { + return 0, errors.Wrap(err, errMessage) + } + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetQueryParams(queryParams). + Get(g.getAdminRealmURL(realm, "groups", "count")) + + if err := checkForError(resp, err, errMessage); err != nil { + return -1, errors.Wrap(err, errMessage) + } + + return result.Count, nil +} + +// GetGroupMembers get a list of users of group with id in realm +func (g *GoCloak) GetGroupMembers(ctx context.Context, token, realm, groupID string, params GetGroupsParams) ([]*User, error) { + const errMessage = "could not get group members" + + var result []*User + queryParams, err := GetQueryParams(params) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetQueryParams(queryParams). + Get(g.getAdminRealmURL(realm, "groups", groupID, "members")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetClientRoles get all roles for the given client in realm +func (g *GoCloak) GetClientRoles(ctx context.Context, token, realm, idOfClient string, params GetRoleParams) ([]*Role, error) { + const errMessage = "could not get client roles" + + var result []*Role + queryParams, err := GetQueryParams(params) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetQueryParams(queryParams). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "roles")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetClientRoleByID gets role for the given client in realm using role ID +func (g *GoCloak) GetClientRoleByID(ctx context.Context, token, realm, roleID string) (*Role, error) { + const errMessage = "could not get client role" + + var result Role + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "roles-by-id", roleID)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetClientRolesByUserID returns all client roles assigned to the given user +func (g *GoCloak) GetClientRolesByUserID(ctx context.Context, token, realm, idOfClient, userID string) ([]*Role, error) { + const errMessage = "could not client roles by user id" + + var result []*Role + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "users", userID, "role-mappings", "clients", idOfClient)) + + if err = checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetClientRolesByGroupID returns all client roles assigned to the given group +func (g *GoCloak) GetClientRolesByGroupID(ctx context.Context, token, realm, idOfClient, groupID string) ([]*Role, error) { + const errMessage = "could not get client roles by group id" + + var result []*Role + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "groups", groupID, "role-mappings", "clients", idOfClient)) + + if err = checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetCompositeClientRolesByRoleID returns all client composite roles associated with the given client role +func (g *GoCloak) GetCompositeClientRolesByRoleID(ctx context.Context, token, realm, idOfClient, roleID string) ([]*Role, error) { + const errMessage = "could not get composite client roles by role id" + + var result []*Role + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "roles-by-id", roleID, "composites", "clients", idOfClient)) + + if err = checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetCompositeClientRolesByUserID returns all client roles and composite roles assigned to the given user +func (g *GoCloak) GetCompositeClientRolesByUserID(ctx context.Context, token, realm, idOfClient, userID string) ([]*Role, error) { + const errMessage = "could not get composite client roles by user id" + + var result []*Role + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "users", userID, "role-mappings", "clients", idOfClient, "composite")) + + if err = checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetAvailableClientRolesByUserID returns all available client roles to the given user +func (g *GoCloak) GetAvailableClientRolesByUserID(ctx context.Context, token, realm, idOfClient, userID string) ([]*Role, error) { + const errMessage = "could not get available client roles by user id" + + var result []*Role + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "users", userID, "role-mappings", "clients", idOfClient, "available")) + + if err = checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetAvailableClientRolesByGroupID returns all available roles to the given group +func (g *GoCloak) GetAvailableClientRolesByGroupID(ctx context.Context, token, realm, idOfClient, groupID string) ([]*Role, error) { + const errMessage = "could not get available client roles by user id" + + var result []*Role + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "groups", groupID, "role-mappings", "clients", idOfClient, "available")) + + if err = checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetCompositeClientRolesByGroupID returns all client roles and composite roles assigned to the given group +func (g *GoCloak) GetCompositeClientRolesByGroupID(ctx context.Context, token, realm, idOfClient, groupID string) ([]*Role, error) { + const errMessage = "could not get composite client roles by group id" + + var result []*Role + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "groups", groupID, "role-mappings", "clients", idOfClient, "composite")) + + if err = checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetClientRole get a role for the given client in a realm by role name +func (g *GoCloak) GetClientRole(ctx context.Context, token, realm, idOfClient, roleName string) (*Role, error) { + const errMessage = "could not get client role" + + var result Role + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "roles", roleName)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetClients gets all clients in realm +func (g *GoCloak) GetClients(ctx context.Context, token, realm string, params GetClientsParams) ([]*Client, error) { + const errMessage = "could not get clients" + + var result []*Client + queryParams, err := GetQueryParams(params) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetQueryParams(queryParams). + Get(g.getAdminRealmURL(realm, "clients")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetClientManagementPermissions returns whether client Authorization permissions have been initialized or not and a reference +// to the managed permissions +func (g *GoCloak) GetClientManagementPermissions(ctx context.Context, token, realm string, idOfClient string) (*ManagementPermissionRepresentation, error) { + const errMessage = "could not get management permissions" + + var result ManagementPermissionRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "management", "permissions")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// UserAttributeContains checks if the given attribute value is set +func UserAttributeContains(attributes map[string][]string, attribute, value string) bool { + for _, item := range attributes[attribute] { + if item == value { + return true + } + } + return false +} + +// ----------- +// Realm Roles +// ----------- + +// CreateRealmRole creates a role in a realm +func (g *GoCloak) CreateRealmRole(ctx context.Context, token string, realm string, role Role) (string, error) { + const errMessage = "could not create realm role" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(role). + Post(g.getAdminRealmURL(realm, "roles")) + + if err := checkForError(resp, err, errMessage); err != nil { + return "", err + } + + return getID(resp), nil +} + +// GetRealmRole returns a role from a realm by role's name +func (g *GoCloak) GetRealmRole(ctx context.Context, token, realm, roleName string) (*Role, error) { + const errMessage = "could not get realm role" + + var result Role + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "roles", roleName)) + + if err = checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetRealmRoleByID returns a role from a realm by role's ID +func (g *GoCloak) GetRealmRoleByID(ctx context.Context, token, realm, roleID string) (*Role, error) { + const errMessage = "could not get realm role" + + var result Role + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "roles-by-id", roleID)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetRealmRoles get all roles of the given realm. +func (g *GoCloak) GetRealmRoles(ctx context.Context, token, realm string, params GetRoleParams) ([]*Role, error) { + const errMessage = "could not get realm roles" + + var result []*Role + queryParams, err := GetQueryParams(params) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetQueryParams(queryParams). + Get(g.getAdminRealmURL(realm, "roles")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetRealmRolesByUserID returns all roles assigned to the given user +func (g *GoCloak) GetRealmRolesByUserID(ctx context.Context, token, realm, userID string) ([]*Role, error) { + const errMessage = "could not get realm roles by user id" + + var result []*Role + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "users", userID, "role-mappings", "realm")) + + if err = checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetRealmRolesByGroupID returns all roles assigned to the given group +func (g *GoCloak) GetRealmRolesByGroupID(ctx context.Context, token, realm, groupID string) ([]*Role, error) { + const errMessage = "could not get realm roles by group id" + + var result []*Role + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "groups", groupID, "role-mappings", "realm")) + + if err = checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// UpdateRealmRole updates a role in a realm +func (g *GoCloak) UpdateRealmRole(ctx context.Context, token, realm, roleName string, role Role) error { + const errMessage = "could not update realm role" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(role). + Put(g.getAdminRealmURL(realm, "roles", roleName)) + + return checkForError(resp, err, errMessage) +} + +// UpdateRealmRoleByID updates a role in a realm by role's ID +func (g *GoCloak) UpdateRealmRoleByID(ctx context.Context, token, realm, roleID string, role Role) error { + const errMessage = "could not update realm role" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(role). + Put(g.getAdminRealmURL(realm, "roles-by-id", roleID)) + + return checkForError(resp, err, errMessage) +} + +// DeleteRealmRole deletes a role in a realm by role's name +func (g *GoCloak) DeleteRealmRole(ctx context.Context, token, realm, roleName string) error { + const errMessage = "could not delete realm role" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "roles", roleName)) + + return checkForError(resp, err, errMessage) +} + +// AddRealmRoleToUser adds realm-level role mappings +func (g *GoCloak) AddRealmRoleToUser(ctx context.Context, token, realm, userID string, roles []Role) error { + const errMessage = "could not add realm role to user" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(roles). + Post(g.getAdminRealmURL(realm, "users", userID, "role-mappings", "realm")) + + return checkForError(resp, err, errMessage) +} + +// DeleteRealmRoleFromUser deletes realm-level role mappings +func (g *GoCloak) DeleteRealmRoleFromUser(ctx context.Context, token, realm, userID string, roles []Role) error { + const errMessage = "could not delete realm role from user" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(roles). + Delete(g.getAdminRealmURL(realm, "users", userID, "role-mappings", "realm")) + + return checkForError(resp, err, errMessage) +} + +// AddRealmRoleToGroup adds realm-level role mappings +func (g *GoCloak) AddRealmRoleToGroup(ctx context.Context, token, realm, groupID string, roles []Role) error { + const errMessage = "could not add realm role to group" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(roles). + Post(g.getAdminRealmURL(realm, "groups", groupID, "role-mappings", "realm")) + + return checkForError(resp, err, errMessage) +} + +// DeleteRealmRoleFromGroup deletes realm-level role mappings +func (g *GoCloak) DeleteRealmRoleFromGroup(ctx context.Context, token, realm, groupID string, roles []Role) error { + const errMessage = "could not delete realm role from group" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(roles). + Delete(g.getAdminRealmURL(realm, "groups", groupID, "role-mappings", "realm")) + + return checkForError(resp, err, errMessage) +} + +// AddRealmRoleComposite adds a role to the composite. +func (g *GoCloak) AddRealmRoleComposite(ctx context.Context, token, realm, roleName string, roles []Role) error { + const errMessage = "could not add realm role composite" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(roles). + Post(g.getAdminRealmURL(realm, "roles", roleName, "composites")) + + return checkForError(resp, err, errMessage) +} + +// DeleteRealmRoleComposite deletes a role from the composite. +func (g *GoCloak) DeleteRealmRoleComposite(ctx context.Context, token, realm, roleName string, roles []Role) error { + const errMessage = "could not delete realm role composite" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(roles). + Delete(g.getAdminRealmURL(realm, "roles", roleName, "composites")) + + return checkForError(resp, err, errMessage) +} + +// GetCompositeRealmRoles returns all realm composite roles associated with the given realm role +func (g *GoCloak) GetCompositeRealmRoles(ctx context.Context, token, realm, roleName string) ([]*Role, error) { + const errMessage = "could not get composite realm roles by role" + + var result []*Role + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "roles", roleName, "composites")) + + if err = checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetCompositeRolesByRoleID returns all realm composite roles associated with the given client role +func (g *GoCloak) GetCompositeRolesByRoleID(ctx context.Context, token, realm, roleID string) ([]*Role, error) { + const errMessage = "could not get composite client roles by role id" + + var result []*Role + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "roles-by-id", roleID, "composites")) + + if err = checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetCompositeRealmRolesByRoleID returns all realm composite roles associated with the given client role +func (g *GoCloak) GetCompositeRealmRolesByRoleID(ctx context.Context, token, realm, roleID string) ([]*Role, error) { + const errMessage = "could not get composite client roles by role id" + + var result []*Role + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "roles-by-id", roleID, "composites", "realm")) + + if err = checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetCompositeRealmRolesByUserID returns all realm roles and composite roles assigned to the given user +func (g *GoCloak) GetCompositeRealmRolesByUserID(ctx context.Context, token, realm, userID string) ([]*Role, error) { + const errMessage = "could not get composite client roles by user id" + + var result []*Role + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "users", userID, "role-mappings", "realm", "composite")) + + if err = checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetCompositeRealmRolesByGroupID returns all realm roles and composite roles assigned to the given group +func (g *GoCloak) GetCompositeRealmRolesByGroupID(ctx context.Context, token, realm, groupID string) ([]*Role, error) { + const errMessage = "could not get composite client roles by user id" + + var result []*Role + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "groups", groupID, "role-mappings", "realm", "composite")) + + if err = checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetAvailableRealmRolesByUserID returns all available realm roles to the given user +func (g *GoCloak) GetAvailableRealmRolesByUserID(ctx context.Context, token, realm, userID string) ([]*Role, error) { + const errMessage = "could not get available client roles by user id" + + var result []*Role + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "users", userID, "role-mappings", "realm", "available")) + + if err = checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetAvailableRealmRolesByGroupID returns all available realm roles to the given group +func (g *GoCloak) GetAvailableRealmRolesByGroupID(ctx context.Context, token, realm, groupID string) ([]*Role, error) { + const errMessage = "could not get available client roles by user id" + + var result []*Role + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "groups", groupID, "role-mappings", "realm", "available")) + + if err = checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// ----- +// Realm +// ----- + +// GetRealm returns top-level representation of the realm +func (g *GoCloak) GetRealm(ctx context.Context, token, realm string) (*RealmRepresentation, error) { + const errMessage = "could not get realm" + + var result RealmRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm)) + + if err = checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetRealms returns top-level representation of all realms +func (g *GoCloak) GetRealms(ctx context.Context, token string) ([]*RealmRepresentation, error) { + const errMessage = "could not get realms" + + var result []*RealmRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL("")) + + if err = checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// CreateRealm creates a realm +func (g *GoCloak) CreateRealm(ctx context.Context, token string, realm RealmRepresentation) (string, error) { + const errMessage = "could not create realm" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(&realm). + Post(g.getAdminRealmURL("")) + + if err := checkForError(resp, err, errMessage); err != nil { + return "", err + } + return getID(resp), nil +} + +// UpdateRealm updates a given realm +func (g *GoCloak) UpdateRealm(ctx context.Context, token string, realm RealmRepresentation) error { + const errMessage = "could not update realm" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(realm). + Put(g.getAdminRealmURL(PString(realm.Realm))) + + return checkForError(resp, err, errMessage) +} + +// DeleteRealm removes a realm +func (g *GoCloak) DeleteRealm(ctx context.Context, token, realm string) error { + const errMessage = "could not delete realm" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm)) + + return checkForError(resp, err, errMessage) +} + +// ClearRealmCache clears realm cache +func (g *GoCloak) ClearRealmCache(ctx context.Context, token, realm string) error { + const errMessage = "could not clear realm cache" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Post(g.getAdminRealmURL(realm, "clear-realm-cache")) + + return checkForError(resp, err, errMessage) +} + +// ClearUserCache clears realm cache +func (g *GoCloak) ClearUserCache(ctx context.Context, token, realm string) error { + const errMessage = "could not clear user cache" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Post(g.getAdminRealmURL(realm, "clear-user-cache")) + + return checkForError(resp, err, errMessage) +} + +// ClearKeysCache clears realm cache +func (g *GoCloak) ClearKeysCache(ctx context.Context, token, realm string) error { + const errMessage = "could not clear keys cache" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Post(g.getAdminRealmURL(realm, "clear-keys-cache")) + + return checkForError(resp, err, errMessage) +} + +// GetAuthenticationFlows get all authentication flows from a realm +func (g *GoCloak) GetAuthenticationFlows(ctx context.Context, token, realm string) ([]*AuthenticationFlowRepresentation, error) { + const errMessage = "could not retrieve authentication flows" + var result []*AuthenticationFlowRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "authentication", "flows")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + return result, nil +} + +// GetAuthenticationFlow get an authentication flow with the given ID +func (g *GoCloak) GetAuthenticationFlow(ctx context.Context, token, realm string, authenticationFlowID string) (*AuthenticationFlowRepresentation, error) { + const errMessage = "could not retrieve authentication flows" + var result *AuthenticationFlowRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "authentication", "flows", authenticationFlowID)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + return result, nil +} + +// CreateAuthenticationFlow creates a new Authentication flow in a realm +func (g *GoCloak) CreateAuthenticationFlow(ctx context.Context, token, realm string, flow AuthenticationFlowRepresentation) error { + const errMessage = "could not create authentication flows" + var result []*AuthenticationFlowRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result).SetBody(flow). + Post(g.getAdminRealmURL(realm, "authentication", "flows")) + + return checkForError(resp, err, errMessage) +} + +// UpdateAuthenticationFlow a given Authentication Flow +func (g *GoCloak) UpdateAuthenticationFlow(ctx context.Context, token, realm string, flow AuthenticationFlowRepresentation, authenticationFlowID string) (*AuthenticationFlowRepresentation, error) { + const errMessage = "could not create authentication flows" + var result *AuthenticationFlowRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result).SetBody(flow). + Put(g.getAdminRealmURL(realm, "authentication", "flows", authenticationFlowID)) + + if err = checkForError(resp, err, errMessage); err != nil { + return nil, err + } + return result, nil +} + +// DeleteAuthenticationFlow deletes a flow in a realm with the given ID +func (g *GoCloak) DeleteAuthenticationFlow(ctx context.Context, token, realm, flowID string) error { + const errMessage = "could not delete authentication flows" + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "authentication", "flows", flowID)) + + return checkForError(resp, err, errMessage) +} + +// GetAuthenticationExecutions retrieves all executions of a given flow +func (g *GoCloak) GetAuthenticationExecutions(ctx context.Context, token, realm, flow string) ([]*ModifyAuthenticationExecutionRepresentation, error) { + const errMessage = "could not retrieve authentication flows" + var result []*ModifyAuthenticationExecutionRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "authentication", "flows", flow, "executions")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + return result, nil +} + +// CreateAuthenticationExecution creates a new execution for the given flow name in the given realm +func (g *GoCloak) CreateAuthenticationExecution(ctx context.Context, token, realm, flow string, execution CreateAuthenticationExecutionRepresentation) error { + const errMessage = "could not create authentication execution" + resp, err := g.GetRequestWithBearerAuth(ctx, token).SetBody(execution). + Post(g.getAdminRealmURL(realm, "authentication", "flows", flow, "executions", "execution")) + + return checkForError(resp, err, errMessage) +} + +// UpdateAuthenticationExecution updates an authentication execution for the given flow in the given realm +func (g *GoCloak) UpdateAuthenticationExecution(ctx context.Context, token, realm, flow string, execution ModifyAuthenticationExecutionRepresentation) error { + const errMessage = "could not update authentication execution" + resp, err := g.GetRequestWithBearerAuth(ctx, token).SetBody(execution). + Put(g.getAdminRealmURL(realm, "authentication", "flows", flow, "executions")) + + return checkForError(resp, err, errMessage) +} + +// DeleteAuthenticationExecution delete a single execution with the given ID +func (g *GoCloak) DeleteAuthenticationExecution(ctx context.Context, token, realm, executionID string) error { + const errMessage = "could not delete authentication execution" + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "authentication", "executions", executionID)) + + return checkForError(resp, err, errMessage) +} + +// CreateAuthenticationExecutionFlow creates a new execution for the given flow name in the given realm +func (g *GoCloak) CreateAuthenticationExecutionFlow(ctx context.Context, token, realm, flow string, executionFlow CreateAuthenticationExecutionFlowRepresentation) error { + const errMessage = "could not create authentication execution flow" + resp, err := g.GetRequestWithBearerAuth(ctx, token).SetBody(executionFlow). + Post(g.getAdminRealmURL(realm, "authentication", "flows", flow, "executions", "flow")) + + return checkForError(resp, err, errMessage) +} + +// ----- +// Users +// ----- + +// CreateUser creates the given user in the given realm and returns it's userID +// Note: Keycloak has not documented what members of the User object are actually being accepted, when creating a user. +// Things like RealmRoles must be attached using followup calls to the respective functions. +func (g *GoCloak) CreateUser(ctx context.Context, token, realm string, user User) (string, error) { + const errMessage = "could not create user" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(user). + Post(g.getAdminRealmURL(realm, "users")) + + if err := checkForError(resp, err, errMessage); err != nil { + return "", err + } + + return getID(resp), nil +} + +// DeleteUser delete a given user +func (g *GoCloak) DeleteUser(ctx context.Context, token, realm, userID string) error { + const errMessage = "could not delete user" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "users", userID)) + + return checkForError(resp, err, errMessage) +} + +// GetUserByID fetches a user from the given realm with the given userID +func (g *GoCloak) GetUserByID(ctx context.Context, accessToken, realm, userID string) (*User, error) { + const errMessage = "could not get user by id" + + if userID == "" { + return nil, errors.Wrap(errors.New("userID shall not be empty"), errMessage) + } + + var result User + resp, err := g.GetRequestWithBearerAuth(ctx, accessToken). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "users", userID)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetUserCount gets the user count in the realm +func (g *GoCloak) GetUserCount(ctx context.Context, token string, realm string, params GetUsersParams) (int, error) { + const errMessage = "could not get user count" + + var result int + queryParams, err := GetQueryParams(params) + if err != nil { + return 0, errors.Wrap(err, errMessage) + } + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetQueryParams(queryParams). + Get(g.getAdminRealmURL(realm, "users", "count")) + + if err := checkForError(resp, err, errMessage); err != nil { + return -1, errors.Wrap(err, errMessage) + } + + return result, nil +} + +// GetUserGroups get all groups for user +func (g *GoCloak) GetUserGroups(ctx context.Context, token, realm, userID string, params GetGroupsParams) ([]*Group, error) { + const errMessage = "could not get user groups" + + var result []*Group + queryParams, err := GetQueryParams(params) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetQueryParams(queryParams). + Get(g.getAdminRealmURL(realm, "users", userID, "groups")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetUsers get all users in realm +func (g *GoCloak) GetUsers(ctx context.Context, token, realm string, params GetUsersParams) ([]*User, error) { + const errMessage = "could not get users" + + var result []*User + queryParams, err := GetQueryParams(params) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetQueryParams(queryParams). + Get(g.getAdminRealmURL(realm, "users")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetUsersByRoleName returns all users have a given role +func (g *GoCloak) GetUsersByRoleName(ctx context.Context, token, realm, roleName string, params GetUsersByRoleParams) ([]*User, error) { + const errMessage = "could not get users by role name" + + var result []*User + queryParams, err := GetQueryParams(params) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetQueryParams(queryParams). + Get(g.getAdminRealmURL(realm, "roles", roleName, "users")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetUsersByClientRoleName returns all users have a given client role +func (g *GoCloak) GetUsersByClientRoleName(ctx context.Context, token, realm, idOfClient, roleName string, params GetUsersByRoleParams) ([]*User, error) { + const errMessage = "could not get users by client role name" + + var result []*User + queryParams, err := GetQueryParams(params) + if err != nil { + return nil, err + } + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetQueryParams(queryParams). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "roles", roleName, "users")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// SetPassword sets a new password for the user with the given id. Needs elevated privileges +func (g *GoCloak) SetPassword(ctx context.Context, token, userID, realm, password string, temporary bool) error { + const errMessage = "could not set password" + + requestBody := SetPasswordRequest{Password: &password, Temporary: &temporary, Type: StringP("password")} + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(requestBody). + Put(g.getAdminRealmURL(realm, "users", userID, "reset-password")) + + return checkForError(resp, err, errMessage) +} + +// UpdateUser updates a given user +func (g *GoCloak) UpdateUser(ctx context.Context, token, realm string, user User) error { + const errMessage = "could not update user" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(user). + Put(g.getAdminRealmURL(realm, "users", PString(user.ID))) + + return checkForError(resp, err, errMessage) +} + +// AddUserToGroup puts given user to given group +func (g *GoCloak) AddUserToGroup(ctx context.Context, token, realm, userID, groupID string) error { + const errMessage = "could not add user to group" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Put(g.getAdminRealmURL(realm, "users", userID, "groups", groupID)) + + return checkForError(resp, err, errMessage) +} + +// DeleteUserFromGroup deletes given user from given group +func (g *GoCloak) DeleteUserFromGroup(ctx context.Context, token, realm, userID, groupID string) error { + const errMessage = "could not delete user from group" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "users", userID, "groups", groupID)) + + return checkForError(resp, err, errMessage) +} + +// GetUserSessions returns user sessions associated with the user +func (g *GoCloak) GetUserSessions(ctx context.Context, token, realm, userID string) ([]*UserSessionRepresentation, error) { + const errMessage = "could not get user sessions" + + var res []*UserSessionRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&res). + Get(g.getAdminRealmURL(realm, "users", userID, "sessions")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return res, nil +} + +// GetUserOfflineSessionsForClient returns offline sessions associated with the user and client +func (g *GoCloak) GetUserOfflineSessionsForClient(ctx context.Context, token, realm, userID, idOfClient string) ([]*UserSessionRepresentation, error) { + const errMessage = "could not get user offline sessions for client" + + var res []*UserSessionRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&res). + Get(g.getAdminRealmURL(realm, "users", userID, "offline-sessions", idOfClient)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return res, nil +} + +// AddClientRolesToUser adds client-level role mappings +func (g *GoCloak) AddClientRolesToUser(ctx context.Context, token, realm, idOfClient, userID string, roles []Role) error { + const errMessage = "could not add client role to user" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(roles). + Post(g.getAdminRealmURL(realm, "users", userID, "role-mappings", "clients", idOfClient)) + + return checkForError(resp, err, errMessage) +} + +// AddClientRoleToUser adds client-level role mappings +// +// Deprecated: replaced by AddClientRolesToUser +func (g *GoCloak) AddClientRoleToUser(ctx context.Context, token, realm, idOfClient, userID string, roles []Role) error { + return g.AddClientRolesToUser(ctx, token, realm, idOfClient, userID, roles) +} + +// AddClientRolesToGroup adds a client role to the group +func (g *GoCloak) AddClientRolesToGroup(ctx context.Context, token, realm, idOfClient, groupID string, roles []Role) error { + const errMessage = "could not add client role to group" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(roles). + Post(g.getAdminRealmURL(realm, "groups", groupID, "role-mappings", "clients", idOfClient)) + + return checkForError(resp, err, errMessage) +} + +// AddClientRoleToGroup adds a client role to the group +// +// Deprecated: replaced by AddClientRolesToGroup +func (g *GoCloak) AddClientRoleToGroup(ctx context.Context, token, realm, idOfClient, groupID string, roles []Role) error { + return g.AddClientRolesToGroup(ctx, token, realm, idOfClient, groupID, roles) +} + +// DeleteClientRolesFromUser adds client-level role mappings +func (g *GoCloak) DeleteClientRolesFromUser(ctx context.Context, token, realm, idOfClient, userID string, roles []Role) error { + const errMessage = "could not delete client role from user" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(roles). + Delete(g.getAdminRealmURL(realm, "users", userID, "role-mappings", "clients", idOfClient)) + + return checkForError(resp, err, errMessage) +} + +// DeleteClientRoleFromUser adds client-level role mappings +// +// Deprecated: replaced by DeleteClientRolesFrom +func (g *GoCloak) DeleteClientRoleFromUser(ctx context.Context, token, realm, idOfClient, userID string, roles []Role) error { + return g.DeleteClientRolesFromUser(ctx, token, realm, idOfClient, userID, roles) +} + +// DeleteClientRoleFromGroup removes a client role from from the group +func (g *GoCloak) DeleteClientRoleFromGroup(ctx context.Context, token, realm, idOfClient, groupID string, roles []Role) error { + const errMessage = "could not client role from group" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(roles). + Delete(g.getAdminRealmURL(realm, "groups", groupID, "role-mappings", "clients", idOfClient)) + + return checkForError(resp, err, errMessage) +} + +// AddClientRoleComposite adds roles as composite +func (g *GoCloak) AddClientRoleComposite(ctx context.Context, token, realm, roleID string, roles []Role) error { + const errMessage = "could not add client role composite" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(roles). + Post(g.getAdminRealmURL(realm, "roles-by-id", roleID, "composites")) + + return checkForError(resp, err, errMessage) +} + +// DeleteClientRoleComposite deletes composites from a role +func (g *GoCloak) DeleteClientRoleComposite(ctx context.Context, token, realm, roleID string, roles []Role) error { + const errMessage = "could not delete client role composite" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(roles). + Delete(g.getAdminRealmURL(realm, "roles-by-id", roleID, "composites")) + + return checkForError(resp, err, errMessage) +} + +// GetUserFederatedIdentities gets all user federated identities +func (g *GoCloak) GetUserFederatedIdentities(ctx context.Context, token, realm, userID string) ([]*FederatedIdentityRepresentation, error) { + const errMessage = "could not get user federated identities" + + var res []*FederatedIdentityRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&res). + Get(g.getAdminRealmURL(realm, "users", userID, "federated-identity")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return res, err +} + +// CreateUserFederatedIdentity creates an user federated identity +func (g *GoCloak) CreateUserFederatedIdentity(ctx context.Context, token, realm, userID, providerID string, federatedIdentityRep FederatedIdentityRepresentation) error { + const errMessage = "could not create user federeated identity" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(federatedIdentityRep). + Post(g.getAdminRealmURL(realm, "users", userID, "federated-identity", providerID)) + + return checkForError(resp, err, errMessage) +} + +// DeleteUserFederatedIdentity deletes an user federated identity +func (g *GoCloak) DeleteUserFederatedIdentity(ctx context.Context, token, realm, userID, providerID string) error { + const errMessage = "could not delete user federeated identity" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "users", userID, "federated-identity", providerID)) + + return checkForError(resp, err, errMessage) +} + +// GetUserBruteForceDetectionStatus fetches a user status regarding brute force protection +func (g *GoCloak) GetUserBruteForceDetectionStatus(ctx context.Context, accessToken, realm, userID string) (*BruteForceStatus, error) { + const errMessage = "could not brute force detection Status" + var result BruteForceStatus + + resp, err := g.GetRequestWithBearerAuth(ctx, accessToken). + SetResult(&result). + Get(g.getAttackDetectionURL(realm, "users", userID)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// ------------------ +// Identity Providers +// ------------------ + +// CreateIdentityProvider creates an identity provider in a realm +func (g *GoCloak) CreateIdentityProvider(ctx context.Context, token string, realm string, providerRep IdentityProviderRepresentation) (string, error) { + const errMessage = "could not create identity provider" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(providerRep). + Post(g.getAdminRealmURL(realm, "identity-provider", "instances")) + + if err := checkForError(resp, err, errMessage); err != nil { + return "", err + } + + return getID(resp), nil +} + +// GetIdentityProviders returns list of identity providers in a realm +func (g *GoCloak) GetIdentityProviders(ctx context.Context, token, realm string) ([]*IdentityProviderRepresentation, error) { + const errMessage = "could not get identity providers" + + var result []*IdentityProviderRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "identity-provider", "instances")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetIdentityProvider gets the identity provider in a realm +func (g *GoCloak) GetIdentityProvider(ctx context.Context, token, realm, alias string) (*IdentityProviderRepresentation, error) { + const errMessage = "could not get identity provider" + + var result IdentityProviderRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "identity-provider", "instances", alias)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// UpdateIdentityProvider updates the identity provider in a realm +func (g *GoCloak) UpdateIdentityProvider(ctx context.Context, token, realm, alias string, providerRep IdentityProviderRepresentation) error { + const errMessage = "could not update identity provider" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(providerRep). + Put(g.getAdminRealmURL(realm, "identity-provider", "instances", alias)) + + return checkForError(resp, err, errMessage) +} + +// DeleteIdentityProvider deletes the identity provider in a realm +func (g *GoCloak) DeleteIdentityProvider(ctx context.Context, token, realm, alias string) error { + const errMessage = "could not delete identity provider" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "identity-provider", "instances", alias)) + + return checkForError(resp, err, errMessage) +} + +// ExportIDPPublicBrokerConfig exports the broker config for a given alias +func (g *GoCloak) ExportIDPPublicBrokerConfig(ctx context.Context, token, realm, alias string) (*string, error) { + const errMessage = "could not get public identity provider configuration" + + resp, err := g.GetRequestWithBearerAuthXMLHeader(ctx, token). + Get(g.getAdminRealmURL(realm, "identity-provider", "instances", alias, "export")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + result := resp.String() + return &result, nil +} + +// ImportIdentityProviderConfig parses and returns the identity provider config at a given URL +func (g *GoCloak) ImportIdentityProviderConfig(ctx context.Context, token, realm, fromURL, providerID string) (map[string]string, error) { + const errMessage = "could not import config" + + result := make(map[string]string) + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetBody(map[string]string{ + "fromUrl": fromURL, + "providerId": providerID, + }). + Post(g.getAdminRealmURL(realm, "identity-provider", "import-config")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// ImportIdentityProviderConfigFromFile parses and returns the identity provider config from a given file +func (g *GoCloak) ImportIdentityProviderConfigFromFile(ctx context.Context, token, realm, providerID, fileName string, fileBody io.Reader) (map[string]string, error) { + const errMessage = "could not import config" + + result := make(map[string]string) + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetFileReader("file", fileName, fileBody). + SetFormData(map[string]string{ + "providerId": providerID, + }). + Post(g.getAdminRealmURL(realm, "identity-provider", "import-config")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// CreateIdentityProviderMapper creates an instance of an identity provider mapper associated with the given alias +func (g *GoCloak) CreateIdentityProviderMapper(ctx context.Context, token, realm, alias string, mapper IdentityProviderMapper) (string, error) { + const errMessage = "could not create mapper for identity provider" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(mapper). + Post(g.getAdminRealmURL(realm, "identity-provider", "instances", alias, "mappers")) + + if err := checkForError(resp, err, errMessage); err != nil { + return "", err + } + + return getID(resp), nil +} + +// GetIdentityProviderMapper gets the mapper by id for the given identity provider alias in a realm +func (g *GoCloak) GetIdentityProviderMapper(ctx context.Context, token string, realm string, alias string, mapperID string) (*IdentityProviderMapper, error) { + const errMessage = "could not get identity provider mapper" + + result := IdentityProviderMapper{} + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "identity-provider", "instances", alias, "mappers", mapperID)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// DeleteIdentityProviderMapper deletes an instance of an identity provider mapper associated with the given alias and mapper ID +func (g *GoCloak) DeleteIdentityProviderMapper(ctx context.Context, token, realm, alias, mapperID string) error { + const errMessage = "could not delete mapper for identity provider" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "identity-provider", "instances", alias, "mappers", mapperID)) + + return checkForError(resp, err, errMessage) +} + +// GetIdentityProviderMappers returns list of mappers associated with an identity provider +func (g *GoCloak) GetIdentityProviderMappers(ctx context.Context, token, realm, alias string) ([]*IdentityProviderMapper, error) { + const errMessage = "could not get identity provider mappers" + + var result []*IdentityProviderMapper + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "identity-provider", "instances", alias, "mappers")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetIdentityProviderMapperByID gets the mapper of an identity provider +func (g *GoCloak) GetIdentityProviderMapperByID(ctx context.Context, token, realm, alias, mapperID string) (*IdentityProviderMapper, error) { + const errMessage = "could not get identity provider mappers" + + var result IdentityProviderMapper + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "identity-provider", "instances", alias, "mappers", mapperID)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// UpdateIdentityProviderMapper updates mapper of an identity provider +func (g *GoCloak) UpdateIdentityProviderMapper(ctx context.Context, token, realm, alias string, mapper IdentityProviderMapper) error { + const errMessage = "could not update identity provider mapper" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(mapper). + Put(g.getAdminRealmURL(realm, "identity-provider", "instances", alias, "mappers", PString(mapper.ID))) + + return checkForError(resp, err, errMessage) +} + +// ------------------ +// Protection API +// ------------------ + +// GetResource returns a client's resource with the given id, using access token from admin +func (g *GoCloak) GetResource(ctx context.Context, token, realm, idOfClient, resourceID string) (*ResourceRepresentation, error) { + const errMessage = "could not get resource" + + var result ResourceRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "resource", resourceID)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetResourceClient returns a client's resource with the given id, using access token from client +func (g *GoCloak) GetResourceClient(ctx context.Context, token, realm, resourceID string) (*ResourceRepresentation, error) { + const errMessage = "could not get resource" + + var result ResourceRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getRealmURL(realm, "authz", "protection", "resource_set", resourceID)) + + // http://${host}:${port}/auth/realms/${realm_name}/authz/protection/resource_set/{resource_id} + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetResources returns resources associated with the client, using access token from admin +func (g *GoCloak) GetResources(ctx context.Context, token, realm, idOfClient string, params GetResourceParams) ([]*ResourceRepresentation, error) { + const errMessage = "could not get resources" + + queryParams, err := GetQueryParams(params) + if err != nil { + return nil, err + } + + var result []*ResourceRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetQueryParams(queryParams). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "resource")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetResourcesClient returns resources associated with the client, using access token from client +func (g *GoCloak) GetResourcesClient(ctx context.Context, token, realm string, params GetResourceParams) ([]*ResourceRepresentation, error) { + const errMessage = "could not get resources" + + queryParams, err := GetQueryParams(params) + if err != nil { + return nil, err + } + + var result []*ResourceRepresentation + var resourceIDs []string + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&resourceIDs). + SetQueryParams(queryParams). + Get(g.getRealmURL(realm, "authz", "protection", "resource_set")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + for _, resourceID := range resourceIDs { + resource, err := g.GetResourceClient(ctx, token, realm, resourceID) + if err == nil { + result = append(result, resource) + } + } + + return result, nil +} + +// GetResourceServer returns resource server settings. +// The access token must have the realm view_clients role on its service +// account to be allowed to call this endpoint. +func (g *GoCloak) GetResourceServer(ctx context.Context, token, realm, idOfClient string) (*ResourceServerRepresentation, error) { + const errMessage = "could not get resource server settings" + + var result *ResourceServerRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "settings")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// UpdateResource updates a resource associated with the client, using access token from admin +func (g *GoCloak) UpdateResource(ctx context.Context, token, realm, idOfClient string, resource ResourceRepresentation) error { + const errMessage = "could not update resource" + + if NilOrEmpty(resource.ID) { + return errors.New("ID of a resource required") + } + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(resource). + Put(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "resource", *(resource.ID))) + + return checkForError(resp, err, errMessage) +} + +// UpdateResourceClient updates a resource associated with the client, using access token from client +func (g *GoCloak) UpdateResourceClient(ctx context.Context, token, realm string, resource ResourceRepresentation) error { + const errMessage = "could not update resource" + + if NilOrEmpty(resource.ID) { + return errors.New("ID of a resource required") + } + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(resource). + Put(g.getRealmURL(realm, "authz", "protection", "resource_set", *(resource.ID))) + + return checkForError(resp, err, errMessage) +} + +// CreateResource creates a resource associated with the client, using access token from admin +func (g *GoCloak) CreateResource(ctx context.Context, token, realm string, idOfClient string, resource ResourceRepresentation) (*ResourceRepresentation, error) { + const errMessage = "could not create resource" + + var result ResourceRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetBody(resource). + Post(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "resource")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// CreateResourceClient creates a resource associated with the client, using access token from client +func (g *GoCloak) CreateResourceClient(ctx context.Context, token, realm string, resource ResourceRepresentation) (*ResourceRepresentation, error) { + const errMessage = "could not create resource" + + var result ResourceRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetBody(resource). + Post(g.getRealmURL(realm, "authz", "protection", "resource_set")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// DeleteResource deletes a resource associated with the client (using an admin token) +func (g *GoCloak) DeleteResource(ctx context.Context, token, realm, idOfClient, resourceID string) error { + const errMessage = "could not delete resource" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "resource", resourceID)) + + return checkForError(resp, err, errMessage) +} + +// DeleteResourceClient deletes a resource associated with the client (using a client token) +func (g *GoCloak) DeleteResourceClient(ctx context.Context, token, realm, resourceID string) error { + const errMessage = "could not delete resource" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getRealmURL(realm, "authz", "protection", "resource_set", resourceID)) + + return checkForError(resp, err, errMessage) +} + +// GetScope returns a client's scope with the given id +func (g *GoCloak) GetScope(ctx context.Context, token, realm, idOfClient, scopeID string) (*ScopeRepresentation, error) { + const errMessage = "could not get scope" + + var result ScopeRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "scope", scopeID)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetScopes returns scopes associated with the client +func (g *GoCloak) GetScopes(ctx context.Context, token, realm, idOfClient string, params GetScopeParams) ([]*ScopeRepresentation, error) { + const errMessage = "could not get scopes" + + queryParams, err := GetQueryParams(params) + if err != nil { + return nil, err + } + var result []*ScopeRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetQueryParams(queryParams). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "scope")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// CreateScope creates a scope associated with the client +func (g *GoCloak) CreateScope(ctx context.Context, token, realm, idOfClient string, scope ScopeRepresentation) (*ScopeRepresentation, error) { + const errMessage = "could not create scope" + + var result ScopeRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetBody(scope). + Post(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "scope")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetPermissionScope gets the permission scope associated with the client +func (g *GoCloak) GetPermissionScope(ctx context.Context, token, realm, idOfClient string, idOfScope string) (*PolicyRepresentation, error) { + const errMessage = "could not get permission scope" + + var result PolicyRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetBody(result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "permission", "scope", idOfScope)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// UpdatePermissionScope updates a permission scope associated with the client +func (g *GoCloak) UpdatePermissionScope(ctx context.Context, token, realm, idOfClient string, idOfScope string, policy PolicyRepresentation) error { + const errMessage = "could not create permission scope" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(policy). + Put(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "permission", "scope", idOfScope)) + + return checkForError(resp, err, errMessage) +} + +// UpdateScope updates a scope associated with the client +func (g *GoCloak) UpdateScope(ctx context.Context, token, realm, idOfClient string, scope ScopeRepresentation) error { + const errMessage = "could not update scope" + + if NilOrEmpty(scope.ID) { + return errors.New("ID of a scope required") + } + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(scope). + Put(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "scope", *(scope.ID))) + + return checkForError(resp, err, errMessage) +} + +// DeleteScope deletes a scope associated with the client +func (g *GoCloak) DeleteScope(ctx context.Context, token, realm, idOfClient, scopeID string) error { + const errMessage = "could not delete scope" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "scope", scopeID)) + + return checkForError(resp, err, errMessage) +} + +// GetPolicy returns a client's policy with the given id +func (g *GoCloak) GetPolicy(ctx context.Context, token, realm, idOfClient, policyID string) (*PolicyRepresentation, error) { + const errMessage = "could not get policy" + + var result PolicyRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "policy", policyID)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetPolicies returns policies associated with the client +func (g *GoCloak) GetPolicies(ctx context.Context, token, realm, idOfClient string, params GetPolicyParams) ([]*PolicyRepresentation, error) { + const errMessage = "could not get policies" + + queryParams, err := GetQueryParams(params) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + + path := []string{"clients", idOfClient, "authz", "resource-server", "policy"} + if !NilOrEmpty(params.Type) { + path = append(path, *params.Type) + } + + var result []*PolicyRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetQueryParams(queryParams). + Get(g.getAdminRealmURL(realm, path...)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// CreatePolicy creates a policy associated with the client +func (g *GoCloak) CreatePolicy(ctx context.Context, token, realm, idOfClient string, policy PolicyRepresentation) (*PolicyRepresentation, error) { + const errMessage = "could not create policy" + + if NilOrEmpty(policy.Type) { + return nil, errors.New("type of a policy required") + } + + var result PolicyRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetBody(policy). + Post(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "policy", *(policy.Type))) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// UpdatePolicy updates a policy associated with the client +func (g *GoCloak) UpdatePolicy(ctx context.Context, token, realm, idOfClient string, policy PolicyRepresentation) error { + const errMessage = "could not update policy" + + if NilOrEmpty(policy.ID) { + return errors.New("ID of a policy required") + } + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(policy). + Put(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "policy", *(policy.Type), *(policy.ID))) + + return checkForError(resp, err, errMessage) +} + +// DeletePolicy deletes a policy associated with the client +func (g *GoCloak) DeletePolicy(ctx context.Context, token, realm, idOfClient, policyID string) error { + const errMessage = "could not delete policy" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "policy", policyID)) + + return checkForError(resp, err, errMessage) +} + +// GetAuthorizationPolicyAssociatedPolicies returns a client's associated policies of specific policy with the given policy id, using access token from admin +func (g *GoCloak) GetAuthorizationPolicyAssociatedPolicies(ctx context.Context, token, realm, idOfClient, policyID string) ([]*PolicyRepresentation, error) { + const errMessage = "could not get policy associated policies" + + var result []*PolicyRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "policy", policyID, "associatedPolicies")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetAuthorizationPolicyResources returns a client's resources of specific policy with the given policy id, using access token from admin +func (g *GoCloak) GetAuthorizationPolicyResources(ctx context.Context, token, realm, idOfClient, policyID string) ([]*PolicyResourceRepresentation, error) { + const errMessage = "could not get policy resources" + + var result []*PolicyResourceRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "policy", policyID, "resources")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetAuthorizationPolicyScopes returns a client's scopes of specific policy with the given policy id, using access token from admin +func (g *GoCloak) GetAuthorizationPolicyScopes(ctx context.Context, token, realm, idOfClient, policyID string) ([]*PolicyScopeRepresentation, error) { + const errMessage = "could not get policy scopes" + + var result []*PolicyScopeRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "policy", policyID, "scopes")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetResourcePolicy updates a permission for a specific resource, using token obtained by Resource Owner Password Credentials Grant or Token exchange +func (g *GoCloak) GetResourcePolicy(ctx context.Context, token, realm, permissionID string) (*ResourcePolicyRepresentation, error) { + const errMessage = "could not get resource policy" + + var result ResourcePolicyRepresentation + resp, err := g.GetRequestWithBearerAuthNoCache(ctx, token). + SetResult(&result). + Get(g.getRealmURL(realm, "authz", "protection", "uma-policy", permissionID)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetResourcePolicies returns resources associated with the client, using token obtained by Resource Owner Password Credentials Grant or Token exchange +func (g *GoCloak) GetResourcePolicies(ctx context.Context, token, realm string, params GetResourcePoliciesParams) ([]*ResourcePolicyRepresentation, error) { + const errMessage = "could not get resource policies" + + queryParams, err := GetQueryParams(params) + if err != nil { + return nil, err + } + + var result []*ResourcePolicyRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetQueryParams(queryParams). + Get(g.getRealmURL(realm, "authz", "protection", "uma-policy")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// CreateResourcePolicy associates a permission with a specific resource, using token obtained by Resource Owner Password Credentials Grant or Token exchange +func (g *GoCloak) CreateResourcePolicy(ctx context.Context, token, realm, resourceID string, policy ResourcePolicyRepresentation) (*ResourcePolicyRepresentation, error) { + const errMessage = "could not create resource policy" + + var result ResourcePolicyRepresentation + resp, err := g.GetRequestWithBearerAuthNoCache(ctx, token). + SetResult(&result). + SetBody(policy). + Post(g.getRealmURL(realm, "authz", "protection", "uma-policy", resourceID)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// UpdateResourcePolicy updates a permission for a specific resource, using token obtained by Resource Owner Password Credentials Grant or Token exchange +func (g *GoCloak) UpdateResourcePolicy(ctx context.Context, token, realm, permissionID string, policy ResourcePolicyRepresentation) error { + const errMessage = "could not update resource policy" + + resp, err := g.GetRequestWithBearerAuthNoCache(ctx, token). + SetBody(policy). + Put(g.getRealmURL(realm, "authz", "protection", "uma-policy", permissionID)) + + return checkForError(resp, err, errMessage) +} + +// DeleteResourcePolicy deletes a permission for a specific resource, using token obtained by Resource Owner Password Credentials Grant or Token exchange +func (g *GoCloak) DeleteResourcePolicy(ctx context.Context, token, realm, permissionID string) error { + const errMessage = "could not delete resource policy" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getRealmURL(realm, "authz", "protection", "uma-policy", permissionID)) + + return checkForError(resp, err, errMessage) +} + +// GetPermission returns a client's permission with the given id +func (g *GoCloak) GetPermission(ctx context.Context, token, realm, idOfClient, permissionID string) (*PermissionRepresentation, error) { + const errMessage = "could not get permission" + + var result PermissionRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "permission", permissionID)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetDependentPermissions returns a client's permission with the given policy id +func (g *GoCloak) GetDependentPermissions(ctx context.Context, token, realm, idOfClient, policyID string) ([]*PermissionRepresentation, error) { + const errMessage = "could not get permission" + + var result []*PermissionRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "policy", policyID, "dependentPolicies")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetPermissionResources returns a client's resource attached for the given permission id +func (g *GoCloak) GetPermissionResources(ctx context.Context, token, realm, idOfClient, permissionID string) ([]*PermissionResource, error) { + const errMessage = "could not get permission resource" + + var result []*PermissionResource + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "permission", permissionID, "resources")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetPermissionScopes returns a client's scopes configured for the given permission id +func (g *GoCloak) GetPermissionScopes(ctx context.Context, token, realm, idOfClient, permissionID string) ([]*PermissionScope, error) { + const errMessage = "could not get permission scopes" + + var result []*PermissionScope + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "permission", permissionID, "scopes")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetPermissions returns permissions associated with the client +func (g *GoCloak) GetPermissions(ctx context.Context, token, realm, idOfClient string, params GetPermissionParams) ([]*PermissionRepresentation, error) { + const errMessage = "could not get permissions" + + queryParams, err := GetQueryParams(params) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + + path := []string{"clients", idOfClient, "authz", "resource-server", "permission"} + if !NilOrEmpty(params.Type) { + path = append(path, *params.Type) + } + + var result []*PermissionRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetQueryParams(queryParams). + Get(g.getAdminRealmURL(realm, path...)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// checkPermissionTicketParams checks that mandatory fields are present +func checkPermissionTicketParams(permissions []CreatePermissionTicketParams) error { + if len(permissions) == 0 { + return errors.New("at least one permission ticket must be requested") + } + + for _, pt := range permissions { + + if NilOrEmpty(pt.ResourceID) { + return errors.New("resourceID required for permission ticket") + } + if NilOrEmptyArray(pt.ResourceScopes) { + return errors.New("at least one resourceScope required for permission ticket") + } + } + + return nil +} + +// CreatePermissionTicket creates a permission ticket, using access token from client +func (g *GoCloak) CreatePermissionTicket(ctx context.Context, token, realm string, permissions []CreatePermissionTicketParams) (*PermissionTicketResponseRepresentation, error) { + const errMessage = "could not create permission ticket" + + err := checkPermissionTicketParams(permissions) + if err != nil { + return nil, err + } + + var result PermissionTicketResponseRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetBody(permissions). + Post(g.getRealmURL(realm, "authz", "protection", "permission")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// checkPermissionGrantParams checks for mandatory fields +func checkPermissionGrantParams(permission PermissionGrantParams) error { + if NilOrEmpty(permission.RequesterID) { + return errors.New("requesterID required to grant user permission") + } + if NilOrEmpty(permission.ResourceID) { + return errors.New("resourceID required to grant user permission") + } + if NilOrEmpty(permission.ScopeName) { + return errors.New("scopeName required to grant user permission") + } + + return nil +} + +// GrantUserPermission lets resource owner grant permission for specific resource ID to specific user ID +func (g *GoCloak) GrantUserPermission(ctx context.Context, token, realm string, permission PermissionGrantParams) (*PermissionGrantResponseRepresentation, error) { + const errMessage = "could not grant user permission" + + err := checkPermissionGrantParams(permission) + if err != nil { + return nil, err + } + + permission.Granted = BoolP(true) + + var result PermissionGrantResponseRepresentation + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetBody(permission). + Post(g.getRealmURL(realm, "authz", "protection", "permission", "ticket")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// checkPermissionUpdateParams +func checkPermissionUpdateParams(permission PermissionGrantParams) error { + err := checkPermissionGrantParams(permission) + if err != nil { + return err + } + + if permission.Granted == nil { + return errors.New("granted required to update user permission") + } + return nil +} + +// UpdateUserPermission updates user permissions. +func (g *GoCloak) UpdateUserPermission(ctx context.Context, token, realm string, permission PermissionGrantParams) (*PermissionGrantResponseRepresentation, error) { + const errMessage = "could not update user permission" + + err := checkPermissionUpdateParams(permission) + if err != nil { + return nil, err + } + + var result PermissionGrantResponseRepresentation + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetBody(permission). + Put(g.getRealmURL(realm, "authz", "protection", "permission", "ticket")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + if resp.StatusCode() == http.StatusNoContent { // permission updated to 'not granted' removes permission + return nil, nil + } + + return &result, nil +} + +// GetUserPermissions gets granted permissions according query parameters +func (g *GoCloak) GetUserPermissions(ctx context.Context, token, realm string, params GetUserPermissionParams) ([]*PermissionGrantResponseRepresentation, error) { + const errMessage = "could not get user permissions" + + queryParams, err := GetQueryParams(params) + if err != nil { + return nil, err + } + + var result []*PermissionGrantResponseRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetQueryParams(queryParams). + Get(g.getRealmURL(realm, "authz", "protection", "permission", "ticket")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// DeleteUserPermission revokes permissions according query parameters +func (g *GoCloak) DeleteUserPermission(ctx context.Context, token, realm, ticketID string) error { + const errMessage = "could not delete user permission" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getRealmURL(realm, "authz", "protection", "permission", "ticket", ticketID)) + + return checkForError(resp, err, errMessage) +} + +// CreatePermission creates a permission associated with the client +func (g *GoCloak) CreatePermission(ctx context.Context, token, realm, idOfClient string, permission PermissionRepresentation) (*PermissionRepresentation, error) { + const errMessage = "could not create permission" + + if NilOrEmpty(permission.Type) { + return nil, errors.New("type of a permission required") + } + + var result PermissionRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetBody(permission). + Post(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "permission", *(permission.Type))) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// UpdatePermission updates a permission associated with the client +func (g *GoCloak) UpdatePermission(ctx context.Context, token, realm, idOfClient string, permission PermissionRepresentation) error { + const errMessage = "could not update permission" + + if NilOrEmpty(permission.ID) { + return errors.New("ID of a permission required") + } + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(permission). + Put(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "permission", *permission.Type, *permission.ID)) + + return checkForError(resp, err, errMessage) +} + +// DeletePermission deletes a policy associated with the client +func (g *GoCloak) DeletePermission(ctx context.Context, token, realm, idOfClient, permissionID string) error { + const errMessage = "could not delete permission" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "permission", permissionID)) + + return checkForError(resp, err, errMessage) +} + +// --------------- +// Credentials API +// --------------- + +// GetCredentialRegistrators returns credentials registrators +func (g *GoCloak) GetCredentialRegistrators(ctx context.Context, token, realm string) ([]string, error) { + const errMessage = "could not get user credential registrators" + + var result []string + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "credential-registrators")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetConfiguredUserStorageCredentialTypes returns credential types, which are provided by the user storage where user is stored +func (g *GoCloak) GetConfiguredUserStorageCredentialTypes(ctx context.Context, token, realm, userID string) ([]string, error) { + const errMessage = "could not get user credential registrators" + + var result []string + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "users", userID, "configured-user-storage-credential-types")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetCredentials returns credentials available for a given user +func (g *GoCloak) GetCredentials(ctx context.Context, token, realm, userID string) ([]*CredentialRepresentation, error) { + const errMessage = "could not get user credentials" + + var result []*CredentialRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "users", userID, "credentials")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// DeleteCredentials deletes the given credential for a given user +func (g *GoCloak) DeleteCredentials(ctx context.Context, token, realm, userID, credentialID string) error { + const errMessage = "could not delete user credentials" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "users", userID, "credentials", credentialID)) + + return checkForError(resp, err, errMessage) +} + +// UpdateCredentialUserLabel updates label for the given credential for the given user +func (g *GoCloak) UpdateCredentialUserLabel(ctx context.Context, token, realm, userID, credentialID, userLabel string) error { + const errMessage = "could not update credential label for a user" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetHeader("Content-Type", "text/plain"). + SetBody(userLabel). + Put(g.getAdminRealmURL(realm, "users", userID, "credentials", credentialID, "userLabel")) + + return checkForError(resp, err, errMessage) +} + +// DisableAllCredentialsByType disables all credentials for a user of a specific type +func (g *GoCloak) DisableAllCredentialsByType(ctx context.Context, token, realm, userID string, types []string) error { + const errMessage = "could not update disable credentials" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(types). + Put(g.getAdminRealmURL(realm, "users", userID, "disable-credential-types")) + + return checkForError(resp, err, errMessage) +} + +// MoveCredentialBehind move a credential to a position behind another credential +func (g *GoCloak) MoveCredentialBehind(ctx context.Context, token, realm, userID, credentialID, newPreviousCredentialID string) error { + const errMessage = "could not move credential" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Post(g.getAdminRealmURL(realm, "users", userID, "credentials", credentialID, "moveAfter", newPreviousCredentialID)) + + return checkForError(resp, err, errMessage) +} + +// MoveCredentialToFirst move a credential to a first position in the credentials list of the user +func (g *GoCloak) MoveCredentialToFirst(ctx context.Context, token, realm, userID, credentialID string) error { + const errMessage = "could not move credential" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Post(g.getAdminRealmURL(realm, "users", userID, "credentials", credentialID, "moveToFirst")) + + return checkForError(resp, err, errMessage) +} + +// GetEvents returns events +func (g *GoCloak) GetEvents(ctx context.Context, token string, realm string, params GetEventsParams) ([]*EventRepresentation, error) { + const errMessage = "could not get events" + + queryParams, err := GetQueryParams(params) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + + var result []*EventRepresentation + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + SetQueryParams(queryParams). + Get(g.getAdminRealmURL(realm, "events")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetClientScopesScopeMappingsRealmRolesAvailable returns realm-level roles that are available to attach to this client scope +func (g *GoCloak) GetClientScopesScopeMappingsRealmRolesAvailable(ctx context.Context, token, realm, clientScopeID string) ([]*Role, error) { + const errMessage = "could not get available realm-level roles with the client-scope" + + var result []*Role + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "client-scopes", clientScopeID, "scope-mappings", "realm", "available")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetClientScopesScopeMappingsRealmRoles returns roles associated with a client-scope +func (g *GoCloak) GetClientScopesScopeMappingsRealmRoles(ctx context.Context, token, realm, clientScopeID string) ([]*Role, error) { + const errMessage = "could not get realm-level roles with the client-scope" + + var result []*Role + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "client-scopes", clientScopeID, "scope-mappings", "realm")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// DeleteClientScopesScopeMappingsRealmRoles deletes realm-level roles from the client-scope +func (g *GoCloak) DeleteClientScopesScopeMappingsRealmRoles(ctx context.Context, token, realm, clientScopeID string, roles []Role) error { + const errMessage = "could not delete realm-level roles from the client-scope" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(roles). + Delete(g.getAdminRealmURL(realm, "client-scopes", clientScopeID, "scope-mappings", "realm")) + + return checkForError(resp, err, errMessage) +} + +// CreateClientScopesScopeMappingsRealmRoles creates realm-level roles to the client scope +func (g *GoCloak) CreateClientScopesScopeMappingsRealmRoles(ctx context.Context, token, realm, clientScopeID string, roles []Role) error { + const errMessage = "could not create realm-level roles to the client-scope" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(roles). + Post(g.getAdminRealmURL(realm, "client-scopes", clientScopeID, "scope-mappings", "realm")) + + return checkForError(resp, err, errMessage) +} + +// RegisterRequiredAction creates a required action for a given realm +func (g *GoCloak) RegisterRequiredAction(ctx context.Context, token string, realm string, requiredAction RequiredActionProviderRepresentation) error { + const errMessage = "could not create required action" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(requiredAction). + Post(g.getAdminRealmURL(realm, "authentication", "register-required-action")) + + if err := checkForError(resp, err, errMessage); err != nil { + return err + } + + return err +} + +// GetRequiredActions gets a list of required actions for a given realm +func (g *GoCloak) GetRequiredActions(ctx context.Context, token string, realm string) ([]*RequiredActionProviderRepresentation, error) { + const errMessage = "could not get required actions" + var result []*RequiredActionProviderRepresentation + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "authentication", "required-actions")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, err +} + +// GetRequiredAction gets a required action for a given realm +func (g *GoCloak) GetRequiredAction(ctx context.Context, token string, realm string, alias string) (*RequiredActionProviderRepresentation, error) { + const errMessage = "could not get required action" + var result RequiredActionProviderRepresentation + + if alias == "" { + return nil, errors.New("alias is required for getting a required action") + } + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "authentication", "required-actions", alias)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, err +} + +// UpdateRequiredAction updates a required action for a given realm +func (g *GoCloak) UpdateRequiredAction(ctx context.Context, token string, realm string, requiredAction RequiredActionProviderRepresentation) error { + const errMessage = "could not update required action" + + if NilOrEmpty(requiredAction.ProviderID) { + return errors.New("providerId is required for updating a required action") + } + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(requiredAction). + Put(g.getAdminRealmURL(realm, "authentication", "required-actions", *requiredAction.ProviderID)) + + return checkForError(resp, err, errMessage) +} + +// DeleteRequiredAction updates a required action for a given realm +func (g *GoCloak) DeleteRequiredAction(ctx context.Context, token string, realm string, alias string) error { + const errMessage = "could not delete required action" + + if alias == "" { + return errors.New("alias is required for deleting a required action") + } + resp, err := g.GetRequestWithBearerAuth(ctx, token). + Delete(g.getAdminRealmURL(realm, "authentication", "required-actions", alias)) + + if err := checkForError(resp, err, errMessage); err != nil { + return err + } + + return err +} + +// CreateClientScopesScopeMappingsClientRoles attaches a client role to a client scope (not client's scope) +func (g *GoCloak) CreateClientScopesScopeMappingsClientRoles( + ctx context.Context, token, realm, idOfClientScope, idOfClient string, roles []Role, +) error { + const errMessage = "could not create client-level roles to the client-scope" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(roles). + Post(g.getAdminRealmURL(realm, "client-scopes", idOfClientScope, "scope-mappings", "clients", idOfClient)) + + return checkForError(resp, err, errMessage) +} + +// GetClientScopesScopeMappingsClientRolesAvailable returns available (i.e. not attached via +// CreateClientScopesScopeMappingsClientRoles) client roles for a specific client, for a client scope +// (not client's scope). +func (g *GoCloak) GetClientScopesScopeMappingsClientRolesAvailable(ctx context.Context, token, realm, idOfClientScope, idOfClient string) ([]*Role, error) { + const errMessage = "could not get available client-level roles with the client-scope" + + var result []*Role + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "client-scopes", idOfClientScope, "scope-mappings", "clients", idOfClient, "available")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// GetClientScopesScopeMappingsClientRoles returns attached client roles for a specific client, for a client scope +// (not client's scope). +func (g *GoCloak) GetClientScopesScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClientScope, idOfClient string) ([]*Role, error) { + const errMessage = "could not get client-level roles with the client-scope" + + var result []*Role + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "client-scopes", idOfClientScope, "scope-mappings", "clients", idOfClient)) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return result, nil +} + +// DeleteClientScopesScopeMappingsClientRoles removes attachment of client roles from a client scope +// (not client's scope). +func (g *GoCloak) DeleteClientScopesScopeMappingsClientRoles(ctx context.Context, token, realm, idOfClientScope, idOfClient string, roles []Role) error { + const errMessage = "could not delete client-level roles from the client-scope" + + resp, err := g.GetRequestWithBearerAuth(ctx, token). + SetBody(roles). + Delete(g.getAdminRealmURL(realm, "client-scopes", idOfClientScope, "scope-mappings", "clients", idOfClient)) + + return checkForError(resp, err, errMessage) +} + +// RevokeToken revokes the passed token. The token can either be an access or refresh token. +func (g *GoCloak) RevokeToken(ctx context.Context, realm, clientID, clientSecret, refreshToken string) error { + const errMessage = "could not revoke token" + + resp, err := g.GetRequestWithBasicAuth(ctx, clientID, clientSecret). + SetFormData(map[string]string{ + "client_id": clientID, + "client_secret": clientSecret, + "token": refreshToken, + }). + Post(g.getRealmURL(realm, g.Config.revokeEndpoint)) + + return checkForError(resp, err, errMessage) +} diff --git a/vendor/github.com/Nerzal/gocloak/v13/docker-compose.yml b/vendor/github.com/Nerzal/gocloak/v13/docker-compose.yml new file mode 100644 index 000000000..b422ddbb1 --- /dev/null +++ b/vendor/github.com/Nerzal/gocloak/v13/docker-compose.yml @@ -0,0 +1,23 @@ +version: '3' + +services: + keycloak: + build: . + command: -Dauto-build -Dfeatures=preview + environment: + KEYCLOAK_USER: admin + KEYCLOAK_PASSWORD: secret + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: secret + KC_HEALTH_ENABLED: "true" + ports: + - "8080:8080" + healthcheck: + test: curl --fail --silent http://localhost:8080/health/ready 2>&1 || exit 1 + interval: 10s + timeout: 10s + retries: 5 + volumes: + - ./testdata/gocloak-realm.json:/opt/keycloak/data/import/gocloak-realm.json + entrypoint: ["/opt/keycloak/bin/kc.sh", "start-dev --features=preview --import-realm"] + \ No newline at end of file diff --git a/vendor/github.com/Nerzal/gocloak/v13/errors.go b/vendor/github.com/Nerzal/gocloak/v13/errors.go new file mode 100644 index 000000000..b20c4b296 --- /dev/null +++ b/vendor/github.com/Nerzal/gocloak/v13/errors.go @@ -0,0 +1,38 @@ +package gocloak + +import ( + "strings" +) + +// HTTPErrorResponse is a model of an error response +type HTTPErrorResponse struct { + Error string `json:"error,omitempty"` + Message string `json:"errorMessage,omitempty"` + Description string `json:"error_description,omitempty"` +} + +// String returns a string representation of an error +func (e HTTPErrorResponse) String() string { + var res strings.Builder + if len(e.Error) > 0 { + res.WriteString(e.Error) + } + if len(e.Message) > 0 { + if res.Len() > 0 { + res.WriteString(": ") + } + res.WriteString(e.Message) + } + if len(e.Description) > 0 { + if res.Len() > 0 { + res.WriteString(": ") + } + res.WriteString(e.Description) + } + return res.String() +} + +// NotEmpty validates that error is not emptyp +func (e HTTPErrorResponse) NotEmpty() bool { + return len(e.Error) > 0 || len(e.Message) > 0 || len(e.Description) > 0 +} diff --git a/vendor/github.com/Nerzal/gocloak/v13/gocloak-gopher.png b/vendor/github.com/Nerzal/gocloak/v13/gocloak-gopher.png new file mode 100644 index 0000000000000000000000000000000000000000..e57f0210f463b2ab977ddce3bb3b332947734eef GIT binary patch literal 308991 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
=`%rynPgY*s+7wE#+R1z)H&W$7SUy_Ldo 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

wAk|hkaQIc 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�Yb5vSXv@Gqr&f8xiCUlg( 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 literal 0 HcmV?d00001 diff --git a/vendor/github.com/Nerzal/gocloak/v13/models.go b/vendor/github.com/Nerzal/gocloak/v13/models.go new file mode 100644 index 000000000..ee0512178 --- /dev/null +++ b/vendor/github.com/Nerzal/gocloak/v13/models.go @@ -0,0 +1,1518 @@ +package gocloak + +import ( + "bytes" + "encoding/json" + "strings" + + "github.com/golang-jwt/jwt/v4" +) + +// GetQueryParams converts the struct to map[string]string +// The fields tags must have `json:",string,omitempty"` format for all types, except strings +// The string fields must have: `json:",omitempty"`. The `json:",string,omitempty"` tag for string field +// will add additional double quotes. +// "string" tag allows to convert the non-string fields of a structure to map[string]string. +// "omitempty" allows to skip the fields with default values. +func GetQueryParams(s interface{}) (map[string]string, error) { + // if obj, ok := s.(GetGroupsParams); ok { + // obj.OnMarshal() + // s = obj + // } + b, err := json.Marshal(s) + if err != nil { + return nil, err + } + var res map[string]string + err = json.Unmarshal(b, &res) + if err != nil { + return nil, err + } + return res, nil +} + +// StringOrArray represents a value that can either be a string or an array of strings +type StringOrArray []string + +// UnmarshalJSON unmarshals a string or an array object from a JSON array or a JSON string +func (s *StringOrArray) UnmarshalJSON(data []byte) error { + if len(data) > 1 && data[0] == '[' { + var obj []string + if err := json.Unmarshal(data, &obj); err != nil { + return err + } + *s = StringOrArray(obj) + return nil + } + + var obj string + if err := json.Unmarshal(data, &obj); err != nil { + return err + } + *s = StringOrArray([]string{obj}) + return nil +} + +// MarshalJSON converts the array of strings to a JSON array or JSON string if there is only one item in the array +func (s *StringOrArray) MarshalJSON() ([]byte, error) { + if len(*s) == 1 { + return json.Marshal([]string(*s)[0]) + } + return json.Marshal([]string(*s)) +} + +// EnforcedString can be used when the expected value is string but Keycloak in some cases gives you mixed types +type EnforcedString string + +// UnmarshalJSON modify data as string before json unmarshal +func (s *EnforcedString) UnmarshalJSON(data []byte) error { + if data[0] != '"' { + // Escape unescaped quotes + data = bytes.ReplaceAll(data, []byte(`"`), []byte(`\"`)) + data = bytes.ReplaceAll(data, []byte(`\\"`), []byte(`\"`)) + + // Wrap data in quotes + data = append([]byte(`"`), data...) + data = append(data, []byte(`"`)...) + } + + var val string + err := json.Unmarshal(data, &val) + *s = EnforcedString(val) + return err +} + +// MarshalJSON return json marshal +func (s *EnforcedString) MarshalJSON() ([]byte, error) { + return json.Marshal(*s) +} + +// APIErrType is a field containing more specific API error types +// that may be checked by the receiver. +type APIErrType string + +const ( + // APIErrTypeUnknown is for API errors that are not strongly + // typed. + APIErrTypeUnknown APIErrType = "unknown" + + // APIErrTypeInvalidGrant corresponds with Keycloak's + // OAuthErrorException due to "invalid_grant". + APIErrTypeInvalidGrant = "oauth: invalid grant" +) + +// ParseAPIErrType is a convenience method for returning strongly +// typed API errors. +func ParseAPIErrType(err error) APIErrType { + if err == nil { + return APIErrTypeUnknown + } + switch { + case strings.Contains(err.Error(), "invalid_grant"): + return APIErrTypeInvalidGrant + default: + return APIErrTypeUnknown + } +} + +// APIError holds message and statusCode for api errors +type APIError struct { + Code int `json:"code"` + Message string `json:"message"` + Type APIErrType `json:"type"` +} + +// Error stringifies the APIError +func (apiError APIError) Error() string { + return apiError.Message +} + +// CertResponseKey is returned by the certs endpoint. +// JSON Web Key structure is described here: +// https://self-issued.info/docs/draft-ietf-jose-json-web-key.html#JWKContents +type CertResponseKey struct { + Kid *string `json:"kid,omitempty"` + Kty *string `json:"kty,omitempty"` + Alg *string `json:"alg,omitempty"` + Use *string `json:"use,omitempty"` + N *string `json:"n,omitempty"` + E *string `json:"e,omitempty"` + X *string `json:"x,omitempty"` + Y *string `json:"y,omitempty"` + Crv *string `json:"crv,omitempty"` + KeyOps *[]string `json:"key_ops,omitempty"` + X5u *string `json:"x5u,omitempty"` + X5c *[]string `json:"x5c,omitempty"` + X5t *string `json:"x5t,omitempty"` + X5tS256 *string `json:"x5t#S256,omitempty"` +} + +// CertResponse is returned by the certs endpoint +type CertResponse struct { + Keys *[]CertResponseKey `json:"keys,omitempty"` +} + +// IssuerResponse is returned by the issuer endpoint +type IssuerResponse struct { + Realm *string `json:"realm,omitempty"` + PublicKey *string `json:"public_key,omitempty"` + TokenService *string `json:"token-service,omitempty"` + AccountService *string `json:"account-service,omitempty"` + TokensNotBefore *int `json:"tokens-not-before,omitempty"` +} + +// ResourcePermission represents a permission granted to a resource +type ResourcePermission struct { + RSID *string `json:"rsid,omitempty"` + ResourceID *string `json:"resource_id,omitempty"` + RSName *string `json:"rsname,omitempty"` + Scopes *[]string `json:"scopes,omitempty"` + ResourceScopes *[]string `json:"resource_scopes,omitempty"` +} + +// PermissionResource represents a resources asscoiated with a permission +type PermissionResource struct { + ResourceID *string `json:"_id,omitempty"` + ResourceName *string `json:"name,omitempty"` +} + +// PermissionScope represents scopes associated with a permission +type PermissionScope struct { + ScopeID *string `json:"id,omitempty"` + ScopeName *string `json:"name,omitempty"` +} + +// IntroSpectTokenResult is returned when a token was checked +type IntroSpectTokenResult struct { + Permissions *[]ResourcePermission `json:"permissions,omitempty"` + Exp *int `json:"exp,omitempty"` + Nbf *int `json:"nbf,omitempty"` + Iat *int `json:"iat,omitempty"` + Aud *StringOrArray `json:"aud,omitempty"` + Active *bool `json:"active,omitempty"` + AuthTime *int `json:"auth_time,omitempty"` + Jti *string `json:"jti,omitempty"` + Type *string `json:"typ,omitempty"` +} + +// User represents the Keycloak User Structure +type User struct { + ID *string `json:"id,omitempty"` + CreatedTimestamp *int64 `json:"createdTimestamp,omitempty"` + Username *string `json:"username,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Totp *bool `json:"totp,omitempty"` + EmailVerified *bool `json:"emailVerified,omitempty"` + FirstName *string `json:"firstName,omitempty"` + LastName *string `json:"lastName,omitempty"` + Email *string `json:"email,omitempty"` + FederationLink *string `json:"federationLink,omitempty"` + Attributes *map[string][]string `json:"attributes,omitempty"` + DisableableCredentialTypes *[]interface{} `json:"disableableCredentialTypes,omitempty"` + RequiredActions *[]string `json:"requiredActions,omitempty"` + Access *map[string]bool `json:"access,omitempty"` + ClientRoles *map[string][]string `json:"clientRoles,omitempty"` + RealmRoles *[]string `json:"realmRoles,omitempty"` + Groups *[]string `json:"groups,omitempty"` + ServiceAccountClientID *string `json:"serviceAccountClientId,omitempty"` + Credentials *[]CredentialRepresentation `json:"credentials,omitempty"` +} + +// SetPasswordRequest sets a new password +type SetPasswordRequest struct { + Type *string `json:"type,omitempty"` + Temporary *bool `json:"temporary,omitempty"` + Password *string `json:"value,omitempty"` +} + +// Component is a component +type Component struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + ProviderID *string `json:"providerId,omitempty"` + ProviderType *string `json:"providerType,omitempty"` + ParentID *string `json:"parentId,omitempty"` + ComponentConfig *map[string][]string `json:"config,omitempty"` + SubType *string `json:"subType,omitempty"` +} + +// KeyStoreConfig holds the keyStoreConfig +type KeyStoreConfig struct { + ActiveKeys *ActiveKeys `json:"active,omitempty"` + Key *[]Key `json:"keys,omitempty"` +} + +// ActiveKeys holds the active keys +type ActiveKeys struct { + HS256 *string `json:"HS256,omitempty"` + RS256 *string `json:"RS256,omitempty"` + AES *string `json:"AES,omitempty"` +} + +// Key is a key +type Key struct { + ProviderID *string `json:"providerId,omitempty"` + ProviderPriority *int `json:"providerPriority,omitempty"` + Kid *string `json:"kid,omitempty"` + Status *string `json:"status,omitempty"` + Type *string `json:"type,omitempty"` + Algorithm *string `json:"algorithm,omitempty"` + PublicKey *string `json:"publicKey,omitempty"` + Certificate *string `json:"certificate,omitempty"` +} + +// Attributes holds Attributes +type Attributes struct { + LDAPENTRYDN *[]string `json:"LDAP_ENTRY_DN,omitempty"` + LDAPID *[]string `json:"LDAP_ID,omitempty"` +} + +// Access represents access +type Access struct { + ManageGroupMembership *bool `json:"manageGroupMembership,omitempty"` + View *bool `json:"view,omitempty"` + MapRoles *bool `json:"mapRoles,omitempty"` + Impersonate *bool `json:"impersonate,omitempty"` + Manage *bool `json:"manage,omitempty"` +} + +// UserGroup is a UserGroup +type UserGroup struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Path *string `json:"path,omitempty"` +} + +// GetUsersParams represents the optional parameters for getting users +type GetUsersParams struct { + BriefRepresentation *bool `json:"briefRepresentation,string,omitempty"` + Email *string `json:"email,omitempty"` + EmailVerified *bool `json:"emailVerified,string,omitempty"` + Enabled *bool `json:"enabled,string,omitempty"` + Exact *bool `json:"exact,string,omitempty"` + First *int `json:"first,string,omitempty"` + FirstName *string `json:"firstName,omitempty"` + IDPAlias *string `json:"idpAlias,omitempty"` + IDPUserID *string `json:"idpUserId,omitempty"` + LastName *string `json:"lastName,omitempty"` + Max *int `json:"max,string,omitempty"` + Q *string `json:"q,omitempty"` + Search *string `json:"search,omitempty"` + Username *string `json:"username,omitempty"` +} + +// GetComponentsParams represents the optional parameters for getting components +type GetComponentsParams struct { + Name *string `json:"name,omitempty"` + ProviderType *string `json:"provider,omitempty"` + ParentID *string `json:"parent,omitempty"` +} + +// ExecuteActionsEmail represents parameters for executing action emails +type ExecuteActionsEmail struct { + UserID *string `json:"-"` + ClientID *string `json:"client_id,omitempty"` + Lifespan *int `json:"lifespan,string,omitempty"` + RedirectURI *string `json:"redirect_uri,omitempty"` + Actions *[]string `json:"-"` +} + +// SendVerificationMailParams is being used to send verification params +type SendVerificationMailParams struct { + ClientID *string + RedirectURI *string +} + +// Group is a Group +type Group struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Path *string `json:"path,omitempty"` + SubGroups *[]Group `json:"subGroups,omitempty"` + Attributes *map[string][]string `json:"attributes,omitempty"` + Access *map[string]bool `json:"access,omitempty"` + ClientRoles *map[string][]string `json:"clientRoles,omitempty"` + RealmRoles *[]string `json:"realmRoles,omitempty"` +} + +// GroupsCount represents the groups count response from keycloak +type GroupsCount struct { + Count int `json:"count,omitempty"` +} + +// GetGroupsParams represents the optional parameters for getting groups +type GetGroupsParams struct { + BriefRepresentation *bool `json:"briefRepresentation,string,omitempty"` + Exact *bool `json:"exact,string,omitempty"` + First *int `json:"first,string,omitempty"` + Full *bool `json:"full,string,omitempty"` + Max *int `json:"max,string,omitempty"` + Q *string `json:"q,omitempty"` + Search *string `json:"search,omitempty"` +} + +// MarshalJSON is a custom json marshaling function to automatically set the Full and BriefRepresentation properties +// for backward compatibility +func (obj GetGroupsParams) MarshalJSON() ([]byte, error) { + type Alias GetGroupsParams + a := (Alias)(obj) + if a.BriefRepresentation != nil { + a.Full = BoolP(!*a.BriefRepresentation) + } else if a.Full != nil { + a.BriefRepresentation = BoolP(!*a.Full) + } + return json.Marshal(a) +} + +// CompositesRepresentation represents the composite roles of a role +type CompositesRepresentation struct { + Client *map[string][]string `json:"client,omitempty"` + Realm *[]string `json:"realm,omitempty"` +} + +// Role is a role +type Role struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + ScopeParamRequired *bool `json:"scopeParamRequired,omitempty"` + Composite *bool `json:"composite,omitempty"` + Composites *CompositesRepresentation `json:"composites,omitempty"` + ClientRole *bool `json:"clientRole,omitempty"` + ContainerID *string `json:"containerId,omitempty"` + Description *string `json:"description,omitempty"` + Attributes *map[string][]string `json:"attributes,omitempty"` +} + +// GetRoleParams represents the optional parameters for getting roles +type GetRoleParams struct { + First *int `json:"first,string,omitempty"` + Max *int `json:"max,string,omitempty"` + Search *string `json:"search,omitempty"` + BriefRepresentation *bool `json:"briefRepresentation,string,omitempty"` +} + +// ClientMappingsRepresentation is a client role mappings +type ClientMappingsRepresentation struct { + ID *string `json:"id,omitempty"` + Client *string `json:"client,omitempty"` + Mappings *[]Role `json:"mappings,omitempty"` +} + +// MappingsRepresentation is a representation of role mappings +type MappingsRepresentation struct { + ClientMappings map[string]*ClientMappingsRepresentation `json:"clientMappings,omitempty"` + RealmMappings *[]Role `json:"realmMappings,omitempty"` +} + +// ClientScope is a ClientScope +type ClientScope struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Protocol *string `json:"protocol,omitempty"` + ClientScopeAttributes *ClientScopeAttributes `json:"attributes,omitempty"` + ProtocolMappers *[]ProtocolMappers `json:"protocolMappers,omitempty"` +} + +// ClientScopeAttributes are attributes of client scopes +type ClientScopeAttributes struct { + ConsentScreenText *string `json:"consent.screen.text,omitempty"` + DisplayOnConsentScreen *string `json:"display.on.consent.screen,omitempty"` + IncludeInTokenScope *string `json:"include.in.token.scope,omitempty"` +} + +// ProtocolMappers are protocolmappers +type ProtocolMappers struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Protocol *string `json:"protocol,omitempty"` + ProtocolMapper *string `json:"protocolMapper,omitempty"` + ConsentRequired *bool `json:"consentRequired,omitempty"` + ProtocolMappersConfig *ProtocolMappersConfig `json:"config,omitempty"` +} + +// ProtocolMappersConfig is a config of a protocol mapper +type ProtocolMappersConfig struct { + UserinfoTokenClaim *string `json:"userinfo.token.claim,omitempty"` + UserAttribute *string `json:"user.attribute,omitempty"` + IDTokenClaim *string `json:"id.token.claim,omitempty"` + AccessTokenClaim *string `json:"access.token.claim,omitempty"` + ClaimName *string `json:"claim.name,omitempty"` + ClaimValue *string `json:"claim.value,omitempty"` + JSONTypeLabel *string `json:"jsonType.label,omitempty"` + Multivalued *string `json:"multivalued,omitempty"` + UsermodelClientRoleMappingClientID *string `json:"usermodel.clientRoleMapping.clientId,omitempty"` + IncludedClientAudience *string `json:"included.client.audience,omitempty"` + FullPath *string `json:"full.path,omitempty"` + AttributeName *string `json:"attribute.name,omitempty"` + AttributeNameFormat *string `json:"attribute.nameformat,omitempty"` + Single *string `json:"single,omitempty"` + Script *string `json:"script,omitempty"` +} + +// Client is a ClientRepresentation +type Client struct { + Access *map[string]interface{} `json:"access,omitempty"` + AdminURL *string `json:"adminUrl,omitempty"` + Attributes *map[string]string `json:"attributes,omitempty"` + AuthenticationFlowBindingOverrides *map[string]string `json:"authenticationFlowBindingOverrides,omitempty"` + AuthorizationServicesEnabled *bool `json:"authorizationServicesEnabled,omitempty"` + AuthorizationSettings *ResourceServerRepresentation `json:"authorizationSettings,omitempty"` + BaseURL *string `json:"baseUrl,omitempty"` + BearerOnly *bool `json:"bearerOnly,omitempty"` + ClientAuthenticatorType *string `json:"clientAuthenticatorType,omitempty"` + ClientID *string `json:"clientId,omitempty"` + ConsentRequired *bool `json:"consentRequired,omitempty"` + DefaultClientScopes *[]string `json:"defaultClientScopes,omitempty"` + DefaultRoles *[]string `json:"defaultRoles,omitempty"` + Description *string `json:"description,omitempty"` + DirectAccessGrantsEnabled *bool `json:"directAccessGrantsEnabled,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + FrontChannelLogout *bool `json:"frontchannelLogout,omitempty"` + FullScopeAllowed *bool `json:"fullScopeAllowed,omitempty"` + ID *string `json:"id,omitempty"` + ImplicitFlowEnabled *bool `json:"implicitFlowEnabled,omitempty"` + Name *string `json:"name,omitempty"` + NodeReRegistrationTimeout *int32 `json:"nodeReRegistrationTimeout,omitempty"` + NotBefore *int32 `json:"notBefore,omitempty"` + OptionalClientScopes *[]string `json:"optionalClientScopes,omitempty"` + Origin *string `json:"origin,omitempty"` + Protocol *string `json:"protocol,omitempty"` + ProtocolMappers *[]ProtocolMapperRepresentation `json:"protocolMappers,omitempty"` + PublicClient *bool `json:"publicClient,omitempty"` + RedirectURIs *[]string `json:"redirectUris,omitempty"` + RegisteredNodes *map[string]int `json:"registeredNodes,omitempty"` + RegistrationAccessToken *string `json:"registrationAccessToken,omitempty"` + RootURL *string `json:"rootUrl,omitempty"` + Secret *string `json:"secret,omitempty"` + ServiceAccountsEnabled *bool `json:"serviceAccountsEnabled,omitempty"` + StandardFlowEnabled *bool `json:"standardFlowEnabled,omitempty"` + SurrogateAuthRequired *bool `json:"surrogateAuthRequired,omitempty"` + WebOrigins *[]string `json:"webOrigins,omitempty"` +} + +// ResourceServerRepresentation represents the resources of a Server +type ResourceServerRepresentation struct { + AllowRemoteResourceManagement *bool `json:"allowRemoteResourceManagement,omitempty"` + ClientID *string `json:"clientId,omitempty"` + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Policies *[]PolicyRepresentation `json:"policies,omitempty"` + PolicyEnforcementMode *PolicyEnforcementMode `json:"policyEnforcementMode,omitempty"` + Resources *[]ResourceRepresentation `json:"resources,omitempty"` + Scopes *[]ScopeRepresentation `json:"scopes,omitempty"` + DecisionStrategy *DecisionStrategy `json:"decisionStrategy,omitempty"` +} + +// RoleDefinition represents a role in a RolePolicyRepresentation +type RoleDefinition struct { + ID *string `json:"id,omitempty"` + Private *bool `json:"private,omitempty"` + Required *bool `json:"required,omitempty"` +} + +// AdapterConfiguration represents adapter configuration of a client +type AdapterConfiguration struct { + Realm *string `json:"realm"` + AuthServerURL *string `json:"auth-server-url"` + SSLRequired *string `json:"ssl-required"` + Resource *string `json:"resource"` + Credentials interface{} `json:"credentials"` + ConfidentialPort *int `json:"confidential-port"` +} + +// PolicyEnforcementMode is an enum type for PolicyEnforcementMode of ResourceServerRepresentation +type PolicyEnforcementMode string + +// PolicyEnforcementMode values +var ( + ENFORCING = PolicyEnforcementModeP("ENFORCING") + PERMISSIVE = PolicyEnforcementModeP("PERMISSIVE") + DISABLED = PolicyEnforcementModeP("DISABLED") +) + +// Logic is an enum type for policy logic +type Logic string + +// Logic values +var ( + POSITIVE = LogicP("POSITIVE") + NEGATIVE = LogicP("NEGATIVE") +) + +// DecisionStrategy is an enum type for DecisionStrategy of PolicyRepresentation +type DecisionStrategy string + +// DecisionStrategy values +var ( + AFFIRMATIVE = DecisionStrategyP("AFFIRMATIVE") + UNANIMOUS = DecisionStrategyP("UNANIMOUS") + CONSENSUS = DecisionStrategyP("CONSENSUS") +) + +// PolicyRepresentation is a representation of a Policy +type PolicyRepresentation struct { + Config *map[string]string `json:"config,omitempty"` + DecisionStrategy *DecisionStrategy `json:"decisionStrategy,omitempty"` + Description *string `json:"description,omitempty"` + ID *string `json:"id,omitempty"` + Logic *Logic `json:"logic,omitempty"` + Name *string `json:"name,omitempty"` + Owner *string `json:"owner,omitempty"` + Policies *[]string `json:"policies,omitempty"` + Resources *[]string `json:"resources,omitempty"` + Scopes *[]string `json:"scopes,omitempty"` + Type *string `json:"type,omitempty"` + RolePolicyRepresentation + JSPolicyRepresentation + ClientPolicyRepresentation + TimePolicyRepresentation + UserPolicyRepresentation + AggregatedPolicyRepresentation + GroupPolicyRepresentation +} + +// RolePolicyRepresentation represents role based policies +type RolePolicyRepresentation struct { + Roles *[]RoleDefinition `json:"roles,omitempty"` +} + +// JSPolicyRepresentation represents js based policies +type JSPolicyRepresentation struct { + Code *string `json:"code,omitempty"` +} + +// ClientPolicyRepresentation represents client based policies +type ClientPolicyRepresentation struct { + Clients *[]string `json:"clients,omitempty"` +} + +// TimePolicyRepresentation represents time based policies +type TimePolicyRepresentation struct { + NotBefore *string `json:"notBefore,omitempty"` + NotOnOrAfter *string `json:"notOnOrAfter,omitempty"` + DayMonth *string `json:"dayMonth,omitempty"` + DayMonthEnd *string `json:"dayMonthEnd,omitempty"` + Month *string `json:"month,omitempty"` + MonthEnd *string `json:"monthEnd,omitempty"` + Year *string `json:"year,omitempty"` + YearEnd *string `json:"yearEnd,omitempty"` + Hour *string `json:"hour,omitempty"` + HourEnd *string `json:"hourEnd,omitempty"` + Minute *string `json:"minute,omitempty"` + MinuteEnd *string `json:"minuteEnd,omitempty"` +} + +// UserPolicyRepresentation represents user based policies +type UserPolicyRepresentation struct { + Users *[]string `json:"users,omitempty"` +} + +// AggregatedPolicyRepresentation represents aggregated policies +type AggregatedPolicyRepresentation struct { + Policies *[]string `json:"policies,omitempty"` +} + +// GroupPolicyRepresentation represents group based policies +type GroupPolicyRepresentation struct { + Groups *[]GroupDefinition `json:"groups,omitempty"` + GroupsClaim *string `json:"groupsClaim,omitempty"` +} + +// GroupDefinition represents a group in a GroupPolicyRepresentation +type GroupDefinition struct { + ID *string `json:"id,omitempty"` + Path *string `json:"path,omitempty"` + ExtendChildren *bool `json:"extendChildren,omitempty"` +} + +// ResourceRepresentation is a representation of a Resource +type ResourceRepresentation struct { + ID *string `json:"_id,omitempty"` // TODO: is marked "_optional" in template, input error or deliberate? + Attributes *map[string][]string `json:"attributes,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + IconURI *string `json:"icon_uri,omitempty"` // TODO: With "_" because that's how it's written down in the template + Name *string `json:"name,omitempty"` + Owner *ResourceOwnerRepresentation `json:"owner,omitempty"` + OwnerManagedAccess *bool `json:"ownerManagedAccess,omitempty"` + ResourceScopes *[]ScopeRepresentation `json:"resource_scopes,omitempty"` + Scopes *[]ScopeRepresentation `json:"scopes,omitempty"` + Type *string `json:"type,omitempty"` + URIs *[]string `json:"uris,omitempty"` +} + +// ResourceOwnerRepresentation represents a resource's owner +type ResourceOwnerRepresentation struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` +} + +// ScopeRepresentation is a represents a Scope +type ScopeRepresentation struct { + DisplayName *string `json:"displayName,omitempty"` + IconURI *string `json:"iconUri,omitempty"` + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Policies *[]PolicyRepresentation `json:"policies,omitempty"` + Resources *[]ResourceRepresentation `json:"resources,omitempty"` +} + +// ProtocolMapperRepresentation represents.... +type ProtocolMapperRepresentation struct { + Config *map[string]string `json:"config,omitempty"` + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Protocol *string `json:"protocol,omitempty"` + ProtocolMapper *string `json:"protocolMapper,omitempty"` + ConsentRequired *bool `json:"consentRequired,omitempty"` +} + +// GetClientsParams represents the query parameters +type GetClientsParams struct { + ClientID *string `json:"clientId,omitempty"` + ViewableOnly *bool `json:"viewableOnly,string,omitempty"` + First *int `json:"first,string,omitempty"` + Max *int `json:"max,string,omitempty"` + Search *bool `json:"search,string,omitempty"` + SearchableAttributes *string `json:"q,omitempty"` +} + +// UserInfoAddress is representation of the address sub-filed of UserInfo +// https://openid.net/specs/openid-connect-core-1_0.html#AddressClaim +type UserInfoAddress struct { + Formatted *string `json:"formatted,omitempty"` + StreetAddress *string `json:"street_address,omitempty"` + Locality *string `json:"locality,omitempty"` + Region *string `json:"region,omitempty"` + PostalCode *string `json:"postal_code,omitempty"` + Country *string `json:"country,omitempty"` +} + +// UserInfo is returned by the userinfo endpoint +// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims +type UserInfo struct { + Sub *string `json:"sub,omitempty"` + Name *string `json:"name,omitempty"` + GivenName *string `json:"given_name,omitempty"` + FamilyName *string `json:"family_name,omitempty"` + MiddleName *string `json:"middle_name,omitempty"` + Nickname *string `json:"nickname,omitempty"` + PreferredUsername *string `json:"preferred_username,omitempty"` + Profile *string `json:"profile,omitempty"` + Picture *string `json:"picture,omitempty"` + Website *string `json:"website,omitempty"` + Email *string `json:"email,omitempty"` + EmailVerified *bool `json:"email_verified,omitempty"` + Gender *string `json:"gender,omitempty"` + ZoneInfo *string `json:"zoneinfo,omitempty"` + Locale *string `json:"locale,omitempty"` + PhoneNumber *string `json:"phone_number,omitempty"` + PhoneNumberVerified *bool `json:"phone_number_verified,omitempty"` + Address *UserInfoAddress `json:"address,omitempty"` + UpdatedAt *int `json:"updated_at,omitempty"` +} + +// RolesRepresentation represents the roles of a realm +type RolesRepresentation struct { + Client *map[string][]Role `json:"client,omitempty"` + Realm *[]Role `json:"realm,omitempty"` +} + +// RealmRepresentation represents a realm +type RealmRepresentation struct { + AccessCodeLifespan *int `json:"accessCodeLifespan,omitempty"` + AccessCodeLifespanLogin *int `json:"accessCodeLifespanLogin,omitempty"` + AccessCodeLifespanUserAction *int `json:"accessCodeLifespanUserAction,omitempty"` + AccessTokenLifespan *int `json:"accessTokenLifespan,omitempty"` + AccessTokenLifespanForImplicitFlow *int `json:"accessTokenLifespanForImplicitFlow,omitempty"` + AccountTheme *string `json:"accountTheme,omitempty"` + ActionTokenGeneratedByAdminLifespan *int `json:"actionTokenGeneratedByAdminLifespan,omitempty"` + ActionTokenGeneratedByUserLifespan *int `json:"actionTokenGeneratedByUserLifespan,omitempty"` + AdminEventsDetailsEnabled *bool `json:"adminEventsDetailsEnabled,omitempty"` + AdminEventsEnabled *bool `json:"adminEventsEnabled,omitempty"` + AdminTheme *string `json:"adminTheme,omitempty"` + Attributes *map[string]string `json:"attributes,omitempty"` + AuthenticationFlows *[]interface{} `json:"authenticationFlows,omitempty"` + AuthenticatorConfig *[]interface{} `json:"authenticatorConfig,omitempty"` + BrowserFlow *string `json:"browserFlow,omitempty"` + BrowserSecurityHeaders *map[string]string `json:"browserSecurityHeaders,omitempty"` + BruteForceProtected *bool `json:"bruteForceProtected,omitempty"` + ClientAuthenticationFlow *string `json:"clientAuthenticationFlow,omitempty"` + ClientPolicies *map[string][]interface{} `json:"clientPolicies,omitempty"` + ClientProfiles *map[string][]interface{} `json:"clientProfiles,omitempty"` + ClientScopeMappings *map[string][]interface{} `json:"clientScopeMappings,omitempty"` + ClientScopes *[]ClientScope `json:"clientScopes,omitempty"` + Clients *[]Client `json:"clients,omitempty"` + Components interface{} `json:"components,omitempty"` + DefaultDefaultClientScopes *[]string `json:"defaultDefaultClientScopes,omitempty"` + DefaultGroups *[]string `json:"defaultGroups,omitempty"` + DefaultLocale *string `json:"defaultLocale,omitempty"` + DefaultOptionalClientScopes *[]string `json:"defaultOptionalClientScopes,omitempty"` + DefaultRole *Role `json:"defaultRole,omitempty"` + DefaultRoles *[]string `json:"defaultRoles,omitempty"` + DefaultSignatureAlgorithm *string `json:"defaultSignatureAlgorithm,omitempty"` + DirectGrantFlow *string `json:"directGrantFlow,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + DisplayNameHTML *string `json:"displayNameHtml,omitempty"` + DockerAuthenticationFlow *string `json:"dockerAuthenticationFlow,omitempty"` + DuplicateEmailsAllowed *bool `json:"duplicateEmailsAllowed,omitempty"` + EditUsernameAllowed *bool `json:"editUsernameAllowed,omitempty"` + EmailTheme *string `json:"emailTheme,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + EnabledEventTypes *[]string `json:"enabledEventTypes,omitempty"` + EventsEnabled *bool `json:"eventsEnabled,omitempty"` + EventsExpiration *int64 `json:"eventsExpiration,omitempty"` + EventsListeners *[]string `json:"eventsListeners,omitempty"` + FailureFactor *int `json:"failureFactor,omitempty"` + FederatedUsers *[]interface{} `json:"federatedUsers,omitempty"` + Groups *[]interface{} `json:"groups,omitempty"` + ID *string `json:"id,omitempty"` + IdentityProviderMappers *[]interface{} `json:"identityProviderMappers,omitempty"` + IdentityProviders *[]interface{} `json:"identityProviders,omitempty"` + InternationalizationEnabled *bool `json:"internationalizationEnabled,omitempty"` + KeycloakVersion *string `json:"keycloakVersion,omitempty"` + LoginTheme *string `json:"loginTheme,omitempty"` + LoginWithEmailAllowed *bool `json:"loginWithEmailAllowed,omitempty"` + MaxDeltaTimeSeconds *int `json:"maxDeltaTimeSeconds,omitempty"` + MaxFailureWaitSeconds *int `json:"maxFailureWaitSeconds,omitempty"` + MinimumQuickLoginWaitSeconds *int `json:"minimumQuickLoginWaitSeconds,omitempty"` + NotBefore *int `json:"notBefore,omitempty"` + OfflineSessionIdleTimeout *int `json:"offlineSessionIdleTimeout,omitempty"` + OfflineSessionMaxLifespan *int `json:"offlineSessionMaxLifespan,omitempty"` + OfflineSessionMaxLifespanEnabled *bool `json:"offlineSessionMaxLifespanEnabled,omitempty"` + OtpPolicyAlgorithm *string `json:"otpPolicyAlgorithm,omitempty"` + OtpPolicyDigits *int `json:"otpPolicyDigits,omitempty"` + OtpPolicyInitialCounter *int `json:"otpPolicyInitialCounter,omitempty"` + OtpPolicyLookAheadWindow *int `json:"otpPolicyLookAheadWindow,omitempty"` + OtpPolicyPeriod *int `json:"otpPolicyPeriod,omitempty"` + OtpPolicyType *string `json:"otpPolicyType,omitempty"` + OtpSupportedApplications *[]string `json:"otpSupportedApplications,omitempty"` + PasswordPolicy *string `json:"passwordPolicy,omitempty"` + PermanentLockout *bool `json:"permanentLockout,omitempty"` + ProtocolMappers *[]interface{} `json:"protocolMappers,omitempty"` + QuickLoginCheckMilliSeconds *int64 `json:"quickLoginCheckMilliSeconds,omitempty"` + Realm *string `json:"realm,omitempty"` + RefreshTokenMaxReuse *int `json:"refreshTokenMaxReuse,omitempty"` + RegistrationAllowed *bool `json:"registrationAllowed,omitempty"` + RegistrationEmailAsUsername *bool `json:"registrationEmailAsUsername,omitempty"` + RegistrationFlow *string `json:"registrationFlow,omitempty"` + RememberMe *bool `json:"rememberMe,omitempty"` + RequiredActions *[]interface{} `json:"requiredActions,omitempty"` + ResetCredentialsFlow *string `json:"resetCredentialsFlow,omitempty"` + ResetPasswordAllowed *bool `json:"resetPasswordAllowed,omitempty"` + RevokeRefreshToken *bool `json:"revokeRefreshToken,omitempty"` + Roles *RolesRepresentation `json:"roles,omitempty"` + ScopeMappings *[]interface{} `json:"scopeMappings,omitempty"` + SMTPServer *map[string]string `json:"smtpServer,omitempty"` + SslRequired *string `json:"sslRequired,omitempty"` + SsoSessionIdleTimeout *int `json:"ssoSessionIdleTimeout,omitempty"` + SsoSessionIdleTimeoutRememberMe *int `json:"ssoSessionIdleTimeoutRememberMe,omitempty"` + SsoSessionMaxLifespan *int `json:"ssoSessionMaxLifespan,omitempty"` + SsoSessionMaxLifespanRememberMe *int `json:"ssoSessionMaxLifespanRememberMe,omitempty"` + SupportedLocales *[]string `json:"supportedLocales,omitempty"` + UserFederationMappers *[]interface{} `json:"userFederationMappers,omitempty"` + UserFederationProviders *[]interface{} `json:"userFederationProviders,omitempty"` + UserManagedAccessAllowed *bool `json:"userManagedAccessAllowed,omitempty"` + Users *[]User `json:"users,omitempty"` + VerifyEmail *bool `json:"verifyEmail,omitempty"` + WaitIncrementSeconds *int `json:"waitIncrementSeconds,omitempty"` + WebAuthnPolicyAcceptableAaguids *[]string `json:"webAuthnPolicyAcceptableAaguids,omitempty"` + WebAuthnPolicyAttestationConveyancePreference *string `json:"webAuthnPolicyAttestationConveyancePreference,omitempty"` + WebAuthnPolicyAuthenticatorAttachment *string `json:"webAuthnPolicyAuthenticatorAttachment,omitempty"` + WebAuthnPolicyAvoidSameAuthenticatorRegister *bool `json:"webAuthnPolicyAvoidSameAuthenticatorRegister,omitempty"` + WebAuthnPolicyCreateTimeout *int `json:"webAuthnPolicyCreateTimeout,omitempty"` + WebAuthnPolicyPasswordlessAcceptableAaguids *[]string `json:"webAuthnPolicyPasswordlessAcceptableAaguids,omitempty"` + WebAuthnPolicyPasswordlessAttestationConveyancePreference *string `json:"webAuthnPolicyPasswordlessAttestationConveyancePreference,omitempty"` + WebAuthnPolicyPasswordlessAuthenticatorAttachment *string `json:"webAuthnPolicyPasswordlessAuthenticatorAttachment,omitempty"` + WebAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister *bool `json:"webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister,omitempty"` + WebAuthnPolicyPasswordlessCreateTimeout *int `json:"webAuthnPolicyPasswordlessCreateTimeout,omitempty"` + WebAuthnPolicyPasswordlessRequireResidentKey *string `json:"webAuthnPolicyPasswordlessRequireResidentKey,omitempty"` + WebAuthnPolicyPasswordlessRpEntityName *string `json:"webAuthnPolicyPasswordlessRpEntityName,omitempty"` + WebAuthnPolicyPasswordlessRpID *string `json:"webAuthnPolicyPasswordlessRpId,omitempty"` + WebAuthnPolicyPasswordlessSignatureAlgorithms *[]string `json:"webAuthnPolicyPasswordlessSignatureAlgorithms,omitempty"` + WebAuthnPolicyPasswordlessUserVerificationRequirement *string `json:"webAuthnPolicyPasswordlessUserVerificationRequirement,omitempty"` + WebAuthnPolicyRequireResidentKey *string `json:"webAuthnPolicyRequireResidentKey,omitempty"` + WebAuthnPolicyRpEntityName *string `json:"webAuthnPolicyRpEntityName,omitempty"` + WebAuthnPolicyRpID *string `json:"webAuthnPolicyRpId,omitempty"` + WebAuthnPolicySignatureAlgorithms *[]string `json:"webAuthnPolicySignatureAlgorithms,omitempty"` + WebAuthnPolicyUserVerificationRequirement *string `json:"webAuthnPolicyUserVerificationRequirement,omitempty"` +} + +// AuthenticationFlowRepresentation represents an authentication flow of a realm +type AuthenticationFlowRepresentation struct { + Alias *string `json:"alias,omitempty"` + AuthenticationExecutions *[]AuthenticationExecutionRepresentation `json:"authenticationExecutions,omitempty"` + BuiltIn *bool `json:"builtIn,omitempty"` + Description *string `json:"description,omitempty"` + ID *string `json:"id,omitempty"` + ProviderID *string `json:"providerId,omitempty"` + TopLevel *bool `json:"topLevel,omitempty"` +} + +// AuthenticationExecutionRepresentation represents the authentication execution of an AuthenticationFlowRepresentation +type AuthenticationExecutionRepresentation struct { + Authenticator *string `json:"authenticator,omitempty"` + AuthenticatorConfig *string `json:"authenticatorConfig,omitempty"` + AuthenticatorFlow *bool `json:"authenticatorFlow,omitempty"` + AutheticatorFlow *bool `json:"autheticatorFlow,omitempty"` + FlowAlias *string `json:"flowAlias,omitempty"` + Priority *int `json:"priority,omitempty"` + Requirement *string `json:"requirement,omitempty"` + UserSetupAllowed *bool `json:"userSetupAllowed,omitempty"` +} + +// CreateAuthenticationExecutionRepresentation contains the provider to be used for a new authentication representation +type CreateAuthenticationExecutionRepresentation struct { + Provider *string `json:"provider,omitempty"` +} + +// CreateAuthenticationExecutionFlowRepresentation contains the provider to be used for a new authentication representation +type CreateAuthenticationExecutionFlowRepresentation struct { + Alias *string `json:"alias,omitempty"` + Description *string `json:"description,omitempty"` + Provider *string `json:"provider,omitempty"` + Type *string `json:"type,omitempty"` +} + +// ModifyAuthenticationExecutionRepresentation is the payload for updating an execution representation +type ModifyAuthenticationExecutionRepresentation struct { + ID *string `json:"id,omitempty"` + ProviderID *string `json:"providerId,omitempty"` + AuthenticationConfig *string `json:"authenticationConfig,omitempty"` + AuthenticationFlow *bool `json:"authenticationFlow,omitempty"` + Requirement *string `json:"requirement,omitempty"` + FlowID *string `json:"flowId"` + DisplayName *string `json:"displayName,omitempty"` + Alias *string `json:"alias,omitempty"` + RequirementChoices *[]string `json:"requirementChoices,omitempty"` + Configurable *bool `json:"configurable,omitempty"` + Level *int `json:"level,omitempty"` + Index *int `json:"index,omitempty"` + Description *string `json:"description"` +} + +// MultiValuedHashMap represents something +type MultiValuedHashMap struct { + Empty *bool `json:"empty,omitempty"` + LoadFactor *float32 `json:"loadFactor,omitempty"` + Threshold *int32 `json:"threshold,omitempty"` +} + +// AuthorizationParameters represents the options to obtain get an authorization +type AuthorizationParameters struct { + ResponseType *string `json:"code,omitempty"` + ClientID *string `json:"client_id,omitempty"` + Scope *string `json:"scope,omitempty"` + RedirectURI *string `json:"redirect_uri,omitempty"` + State *string `json:"state,omitempty"` + Nonce *string `json:"nonce,omitempty"` + IDTokenHint *string `json:"id_token_hint,omitempty"` +} + +// FormData returns a map of options to be used in SetFormData function +func (p *AuthorizationParameters) FormData() map[string]string { + m, _ := json.Marshal(p) + var res map[string]string + _ = json.Unmarshal(m, &res) + return res +} + +// AuthorizationResponse represents the response to an authorization request. +type AuthorizationResponse struct { +} + +// TokenOptions represents the options to obtain a token +type TokenOptions struct { + ClientID *string `json:"client_id,omitempty"` + ClientSecret *string `json:"-"` + GrantType *string `json:"grant_type,omitempty"` + RefreshToken *string `json:"refresh_token,omitempty"` + Scopes *[]string `json:"-"` + Scope *string `json:"scope,omitempty"` + ResponseTypes *[]string `json:"-"` + ResponseType *string `json:"response_type,omitempty"` + Permission *string `json:"permission,omitempty"` + Username *string `json:"username,omitempty"` + Password *string `json:"password,omitempty"` + Totp *string `json:"totp,omitempty"` + Code *string `json:"code,omitempty"` + RedirectURI *string `json:"redirect_uri,omitempty"` + ClientAssertionType *string `json:"client_assertion_type,omitempty"` + ClientAssertion *string `json:"client_assertion,omitempty"` + SubjectToken *string `json:"subject_token,omitempty"` + RequestedSubject *string `json:"requested_subject,omitempty"` + Audience *string `json:"audience,omitempty"` + RequestedTokenType *string `json:"requested_token_type,omitempty"` +} + +// FormData returns a map of options to be used in SetFormData function +func (t *TokenOptions) FormData() map[string]string { + if !NilOrEmptySlice(t.Scopes) { + t.Scope = StringP(strings.Join(*t.Scopes, " ")) + } + if !NilOrEmptySlice(t.ResponseTypes) { + t.ResponseType = StringP(strings.Join(*t.ResponseTypes, " ")) + } + if NilOrEmpty(t.ResponseType) { + t.ResponseType = StringP("token") + } + m, _ := json.Marshal(t) + var res map[string]string + _ = json.Unmarshal(m, &res) + return res +} + +// RequestingPartyTokenOptions represents the options to obtain a requesting party token +type RequestingPartyTokenOptions struct { + GrantType *string `json:"grant_type,omitempty"` + Ticket *string `json:"ticket,omitempty"` + ClaimToken *string `json:"claim_token,omitempty"` + ClaimTokenFormat *string `json:"claim_token_format,omitempty"` + RPT *string `json:"rpt,omitempty"` + Permissions *[]string `json:"-"` + Audience *string `json:"audience,omitempty"` + ResponseIncludeResourceName *bool `json:"response_include_resource_name,string,omitempty"` + ResponsePermissionsLimit *uint32 `json:"response_permissions_limit,omitempty"` + SubmitRequest *bool `json:"submit_request,string,omitempty"` + ResponseMode *string `json:"response_mode,omitempty"` + SubjectToken *string `json:"subject_token,omitempty"` +} + +// FormData returns a map of options to be used in SetFormData function +func (t *RequestingPartyTokenOptions) FormData() map[string]string { + if NilOrEmpty(t.GrantType) { // required grant type for RPT + t.GrantType = StringP("urn:ietf:params:oauth:grant-type:uma-ticket") + } + if t.ResponseIncludeResourceName == nil { // defaults to true if no value set + t.ResponseIncludeResourceName = BoolP(true) + } + + m, _ := json.Marshal(t) + var res map[string]string + _ = json.Unmarshal(m, &res) + return res +} + +// RequestingPartyPermission is returned by request party token with response type set to "permissions" +type RequestingPartyPermission struct { + Claims *map[string]string `json:"claims,omitempty"` + ResourceID *string `json:"rsid,omitempty"` + ResourceName *string `json:"rsname,omitempty"` + Scopes *[]string `json:"scopes,omitempty"` +} + +// RequestingPartyPermissionDecision is returned by request party token with response type set to "decision" +type RequestingPartyPermissionDecision struct { + Result *bool `json:"result,omitempty"` +} + +// UserSessionRepresentation represents a list of user's sessions +type UserSessionRepresentation struct { + Clients *map[string]string `json:"clients,omitempty"` + ID *string `json:"id,omitempty"` + IPAddress *string `json:"ipAddress,omitempty"` + LastAccess *int64 `json:"lastAccess,omitempty"` + Start *int64 `json:"start,omitempty"` + UserID *string `json:"userId,omitempty"` + Username *string `json:"username,omitempty"` +} + +// SystemInfoRepresentation represents a system info +type SystemInfoRepresentation struct { + FileEncoding *string `json:"fileEncoding,omitempty"` + JavaHome *string `json:"javaHome,omitempty"` + JavaRuntime *string `json:"javaRuntime,omitempty"` + JavaVendor *string `json:"javaVendor,omitempty"` + JavaVersion *string `json:"javaVersion,omitempty"` + JavaVM *string `json:"javaVm,omitempty"` + JavaVMVersion *string `json:"javaVmVersion,omitempty"` + OSArchitecture *string `json:"osArchitecture,omitempty"` + OSName *string `json:"osName,omitempty"` + OSVersion *string `json:"osVersion,omitempty"` + ServerTime *string `json:"serverTime,omitempty"` + Uptime *string `json:"uptime,omitempty"` + UptimeMillis *int `json:"uptimeMillis,omitempty"` + UserDir *string `json:"userDir,omitempty"` + UserLocale *string `json:"userLocale,omitempty"` + UserName *string `json:"userName,omitempty"` + UserTimezone *string `json:"userTimezone,omitempty"` + Version *string `json:"version,omitempty"` +} + +// MemoryInfoRepresentation represents a memory info +type MemoryInfoRepresentation struct { + Free *int `json:"free,omitempty"` + FreeFormated *string `json:"freeFormated,omitempty"` + FreePercentage *int `json:"freePercentage,omitempty"` + Total *int `json:"total,omitempty"` + TotalFormated *string `json:"totalFormated,omitempty"` + Used *int `json:"used,omitempty"` + UsedFormated *string `json:"usedFormated,omitempty"` +} + +// PasswordPolicy represents the configuration for a supported password policy +type PasswordPolicy struct { + ConfigType string `json:"configType,omitempty"` + DefaultValue string `json:"defaultValue,omitempty"` + DisplayName string `json:"displayName,omitempty"` + ID string `json:"id,omitempty"` + MultipleSupported bool `json:"multipleSupported,omitempty"` +} + +// ProtocolMapperTypeProperty represents a property of a ProtocolMapperType +type ProtocolMapperTypeProperty struct { + Name string `json:"name,omitempty"` + Label string `json:"label,omitempty"` + HelpText string `json:"helpText,omitempty"` + Type string `json:"type,omitempty"` + Options []string `json:"options,omitempty"` + DefaultValue EnforcedString `json:"defaultValue,omitempty"` + Secret bool `json:"secret,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` +} + +// ProtocolMapperType represents a type of protocol mapper +type ProtocolMapperType struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Category string `json:"category,omitempty"` + HelpText string `json:"helpText,omitempty"` + Priority int `json:"priority,omitempty"` + Properties []ProtocolMapperTypeProperty `json:"properties,omitempty"` +} + +// ProtocolMapperTypes holds the currently available ProtocolMapperType-s grouped by protocol +type ProtocolMapperTypes struct { + DockerV2 []ProtocolMapperType `json:"docker-v2,omitempty"` + SAML []ProtocolMapperType `json:"saml,omitempty"` + OpenIDConnect []ProtocolMapperType `json:"openid-connect,omitempty"` +} + +// BuiltinProtocolMappers holds the currently available built-in blueprints of ProtocolMapper-s grouped by protocol +type BuiltinProtocolMappers struct { + SAML []ProtocolMapperRepresentation `json:"saml,omitempty"` + OpenIDConnect []ProtocolMapperRepresentation `json:"openid-connect,omitempty"` +} + +// ServerInfoRepresentation represents a server info +type ServerInfoRepresentation struct { + SystemInfo *SystemInfoRepresentation `json:"systemInfo,omitempty"` + MemoryInfo *MemoryInfoRepresentation `json:"memoryInfo,omitempty"` + PasswordPolicies []*PasswordPolicy `json:"passwordPolicies,omitempty"` + ProtocolMapperTypes *ProtocolMapperTypes `json:"protocolMapperTypes,omitempty"` + BuiltinProtocolMappers *BuiltinProtocolMappers `json:"builtinProtocolMappers,omitempty"` + Themes *Themes `json:"themes,omitempty"` +} + +// ThemeRepresentation contains the theme name and locales +type ThemeRepresentation struct { + Name string `json:"name,omitempty"` + Locales []string `json:"locales,omitempty"` +} + +// Themes contains the available keycloak themes with locales +type Themes struct { + Accounts []ThemeRepresentation `json:"account,omitempty"` + Admin []ThemeRepresentation `json:"admin,omitempty"` + Common []ThemeRepresentation `json:"common,omitempty"` + Email []ThemeRepresentation `json:"email,omitempty"` + Login []ThemeRepresentation `json:"login,omitempty"` + Welcome []ThemeRepresentation `json:"welcome,omitempty"` +} + +// FederatedIdentityRepresentation represents an user federated identity +type FederatedIdentityRepresentation struct { + IdentityProvider *string `json:"identityProvider,omitempty"` + UserID *string `json:"userId,omitempty"` + UserName *string `json:"userName,omitempty"` +} + +// IdentityProviderRepresentation represents an identity provider +type IdentityProviderRepresentation struct { + AddReadTokenRoleOnCreate *bool `json:"addReadTokenRoleOnCreate,omitempty"` + Alias *string `json:"alias,omitempty"` + Config *map[string]string `json:"config,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + FirstBrokerLoginFlowAlias *string `json:"firstBrokerLoginFlowAlias,omitempty"` + InternalID *string `json:"internalId,omitempty"` + LinkOnly *bool `json:"linkOnly,omitempty"` + PostBrokerLoginFlowAlias *string `json:"postBrokerLoginFlowAlias,omitempty"` + ProviderID *string `json:"providerId,omitempty"` + StoreToken *bool `json:"storeToken,omitempty"` + TrustEmail *bool `json:"trustEmail,omitempty"` +} + +// IdentityProviderMapper represents the body of a call to add a mapper to +// an identity provider +type IdentityProviderMapper struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + IdentityProviderMapper *string `json:"identityProviderMapper,omitempty"` + IdentityProviderAlias *string `json:"identityProviderAlias,omitempty"` + Config *map[string]string `json:"config"` +} + +// GetResourceParams represents the optional parameters for getting resources +type GetResourceParams struct { + Deep *bool `json:"deep,string,omitempty"` + First *int `json:"first,string,omitempty"` + Max *int `json:"max,string,omitempty"` + Name *string `json:"name,omitempty"` + Owner *string `json:"owner,omitempty"` + Type *string `json:"type,omitempty"` + URI *string `json:"uri,omitempty"` + Scope *string `json:"scope,omitempty"` + MatchingURI *bool `json:"matchingUri,string,omitempty"` + ExactName *bool `json:"exactName,string,omitempty"` +} + +// GetScopeParams represents the optional parameters for getting scopes +type GetScopeParams struct { + Deep *bool `json:"deep,string,omitempty"` + First *int `json:"first,string,omitempty"` + Max *int `json:"max,string,omitempty"` + Name *string `json:"name,omitempty"` +} + +// GetPolicyParams represents the optional parameters for getting policies +// TODO: more policy params? +type GetPolicyParams struct { + First *int `json:"first,string,omitempty"` + Max *int `json:"max,string,omitempty"` + Name *string `json:"name,omitempty"` + Permission *bool `json:"permission,string,omitempty"` + Type *string `json:"type,omitempty"` +} + +// GetPermissionParams represents the optional parameters for getting permissions +type GetPermissionParams struct { + First *int `json:"first,string,omitempty"` + Max *int `json:"max,string,omitempty"` + Name *string `json:"name,omitempty"` + Resource *string `json:"resource,omitempty"` + Scope *string `json:"scope,omitempty"` + Type *string `json:"type,omitempty"` +} + +// GetUsersByRoleParams represents the optional parameters for getting users by role +type GetUsersByRoleParams struct { + First *int `json:"first,string,omitempty"` + Max *int `json:"max,string,omitempty"` +} + +// PermissionRepresentation is a representation of a RequestingPartyPermission +type PermissionRepresentation struct { + DecisionStrategy *DecisionStrategy `json:"decisionStrategy,omitempty"` + Description *string `json:"description,omitempty"` + ID *string `json:"id,omitempty"` + Logic *Logic `json:"logic,omitempty"` + Name *string `json:"name,omitempty"` + Policies *[]string `json:"policies,omitempty"` + Resources *[]string `json:"resources,omitempty"` + ResourceType *string `json:"resourceType,omitempty"` + Scopes *[]string `json:"scopes,omitempty"` + Type *string `json:"type,omitempty"` +} + +// CreatePermissionTicketParams represents the optional parameters for getting a permission ticket +type CreatePermissionTicketParams struct { + ResourceID *string `json:"resource_id,omitempty"` + ResourceScopes *[]string `json:"resource_scopes,omitempty"` + Claims *map[string][]string `json:"claims,omitempty"` +} + +// PermissionTicketDescriptionRepresentation represents the parameters returned along with a permission ticket +type PermissionTicketDescriptionRepresentation struct { + ID *string `json:"id,omitempty"` + CreatedTimeStamp *int64 `json:"createdTimestamp,omitempty"` + UserName *string `json:"username,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + TOTP *bool `json:"totp,omitempty"` + EmailVerified *bool `json:"emailVerified,omitempty"` + FirstName *string `json:"firstName,omitempty"` + LastName *string `json:"lastName,omitempty"` + Email *string `json:"email,omitempty"` + DisableCredentialTypes *[]string `json:"disableCredentialTypes,omitempty"` + RequiredActions *[]string `json:"requiredActions,omitempty"` + NotBefore *int64 `json:"notBefore,omitempty"` + Access *AccessRepresentation `json:"access,omitempty"` +} + +// AccessRepresentation represents the access parameters returned in the permission ticket description +type AccessRepresentation struct { + ManageGroupMembership *bool `json:"manageGroupMembership,omitempty"` + View *bool `json:"view,omitempty"` + MapRoles *bool `json:"mapRoles,omitempty"` + Impersonate *bool `json:"impersonate,omitempty"` + Manage *bool `json:"manage,omitempty"` +} + +// PermissionTicketResponseRepresentation represents the keycloak response containing the permission ticket +type PermissionTicketResponseRepresentation struct { + Ticket *string `json:"ticket,omitempty"` +} + +// PermissionTicketRepresentation represents the permission ticket contents +type PermissionTicketRepresentation struct { + AZP *string `json:"azp,omitempty"` + Claims *map[string][]string `json:"claims,omitempty"` + Permissions *[]PermissionTicketPermissionRepresentation `json:"permissions,omitempty"` + jwt.StandardClaims +} + +// PermissionTicketPermissionRepresentation represents the individual permissions in a permission ticket +type PermissionTicketPermissionRepresentation struct { + Scopes *[]string `json:"scopes,omitempty"` + RSID *string `json:"rsid,omitempty"` +} + +// PermissionGrantParams represents the permission which the resource owner is granting to a specific user +type PermissionGrantParams struct { + ResourceID *string `json:"resource,omitempty"` + RequesterID *string `json:"requester,omitempty"` + Granted *bool `json:"granted,omitempty"` + ScopeName *string `json:"scopeName,omitempty"` + TicketID *string `json:"id,omitempty"` +} + +// PermissionGrantResponseRepresentation represents the reply from Keycloack after granting permission +type PermissionGrantResponseRepresentation struct { + ID *string `json:"id,omitempty"` + Owner *string `json:"owner,omitempty"` + ResourceID *string `json:"resource,omitempty"` + Scope *string `json:"scope,omitempty"` + Granted *bool `json:"granted,omitempty"` + RequesterID *string `json:"requester,omitempty"` +} + +// GetUserPermissionParams represents the optional parameters for getting user permissions +type GetUserPermissionParams struct { + ScopeID *string `json:"scopeId,omitempty"` + ResourceID *string `json:"resourceId,omitempty"` + Owner *string `json:"owner,omitempty"` + Requester *string `json:"requester,omitempty"` + Granted *bool `json:"granted,omitempty"` + ReturnNames *string `json:"returnNames,omitempty"` + First *int `json:"first,string,omitempty"` + Max *int `json:"max,string,omitempty"` +} + +// ResourcePolicyRepresentation is a representation of a Policy applied to a resource +type ResourcePolicyRepresentation struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Scopes *[]string `json:"scopes,omitempty"` + Roles *[]string `json:"roles,omitempty"` + Groups *[]string `json:"groups,omitempty"` + Clients *[]string `json:"clients,omitempty"` + ID *string `json:"id,omitempty"` + Logic *Logic `json:"logic,omitempty"` + DecisionStrategy *DecisionStrategy `json:"decisionStrategy,omitempty"` + Owner *string `json:"owner,omitempty"` + Type *string `json:"type,omitempty"` + Users *[]string `json:"users,omitempty"` +} + +// PolicyScopeRepresentation is a representation of a scopes of specific policy +type PolicyScopeRepresentation struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` +} + +// PolicyResourceRepresentation is a representation of a resource of specific policy +type PolicyResourceRepresentation struct { + ID *string `json:"_id,omitempty"` + Name *string `json:"name,omitempty"` +} + +// GetResourcePoliciesParams is a representation of the query params for getting policies +type GetResourcePoliciesParams struct { + ResourceID *string `json:"resource,omitempty"` + Name *string `json:"name,omitempty"` + Scope *string `json:"scope,omitempty"` + First *int `json:"first,string,omitempty"` + Max *int `json:"max,string,omitempty"` +} + +// GetEventsParams represents the optional parameters for getting events +type GetEventsParams struct { + Client *string `json:"client,omitempty"` + DateFrom *string `json:"dateFrom,omitempty"` + DateTo *string `json:"dateTo,omitempty"` + First *int32 `json:"first,string,omitempty"` + IPAddress *string `json:"ipAddress,omitempty"` + Max *int32 `json:"max,string,omitempty"` + Type []string `json:"type,omitempty"` + UserID *string `json:"user,omitempty"` +} + +// EventRepresentation is a representation of a Event +type EventRepresentation struct { + Time int64 `json:"time,omitempty"` + Type *string `json:"type,omitempty"` + RealmID *string `json:"realmId,omitempty"` + ClientID *string `json:"clientId,omitempty"` + UserID *string `json:"userId,omitempty"` + SessionID *string `json:"sessionId,omitempty"` + IPAddress *string `json:"ipAddress,omitempty"` + Details map[string]string `json:"details,omitempty"` +} + +// CredentialRepresentation is a representations of the credentials +// v7: https://www.keycloak.org/docs-api/7.0/rest-api/index.html#_credentialrepresentation +// v8: https://www.keycloak.org/docs-api/8.0/rest-api/index.html#_credentialrepresentation +type CredentialRepresentation struct { + // Common part + CreatedDate *int64 `json:"createdDate,omitempty"` + Temporary *bool `json:"temporary,omitempty"` + Type *string `json:"type,omitempty"` + Value *string `json:"value,omitempty"` + + // <= v7 + Algorithm *string `json:"algorithm,omitempty"` + Config *MultiValuedHashMap `json:"config,omitempty"` + Counter *int32 `json:"counter,omitempty"` + Device *string `json:"device,omitempty"` + Digits *int32 `json:"digits,omitempty"` + HashIterations *int32 `json:"hashIterations,omitempty"` + HashedSaltedValue *string `json:"hashedSaltedValue,omitempty"` + Period *int32 `json:"period,omitempty"` + Salt *string `json:"salt,omitempty"` + + // >= v8 + CredentialData *string `json:"credentialData,omitempty"` + ID *string `json:"id,omitempty"` + Priority *int32 `json:"priority,omitempty"` + SecretData *string `json:"secretData,omitempty"` + UserLabel *string `json:"userLabel,omitempty"` +} + +// BruteForceStatus is a representation of realm user regarding brute force attack +type BruteForceStatus struct { + NumFailures *int `json:"numFailures,omitempty"` + Disabled *bool `json:"disabled,omitempty"` + LastIPFailure *string `json:"lastIPFailure,omitempty"` + LastFailure *int `json:"lastFailure,omitempty"` +} + +// RequiredActionProviderRepresentation is a representation of required actions +// v15: https://www.keycloak.org/docs-api/15.0/rest-api/index.html#_requiredactionproviderrepresentation +type RequiredActionProviderRepresentation struct { + Alias *string `json:"alias,omitempty"` + Config *map[string]string `json:"config,omitempty"` + DefaultAction *bool `json:"defaultAction,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Name *string `json:"name,omitempty"` + Priority *int32 `json:"priority,omitempty"` + ProviderID *string `json:"providerId,omitempty"` +} + +// ManagementPermissionRepresentation is a representation of management permissions +// v18: https://www.keycloak.org/docs-api/18.0/rest-api/#_managementpermissionreference +type ManagementPermissionRepresentation struct { + Enabled *bool `json:"enabled,omitempty"` + Resource *string `json:"resource,omitempty"` + ScopePermissions *map[string]string `json:"scopePermissions,omitempty"` +} + +// GetClientUserSessionsParams represents the optional parameters for getting user sessions associated with the client +type GetClientUserSessionsParams struct { + First *int `json:"first,string,omitempty"` + Max *int `json:"max,string,omitempty"` +} + +// prettyStringStruct returns struct formatted into pretty string +func prettyStringStruct(t interface{}) string { + json, err := json.MarshalIndent(t, "", "\t") + if err != nil { + return "" + } + + return string(json) +} + +// Stringer implementations for all struct types +func (v *CertResponseKey) String() string { return prettyStringStruct(v) } +func (v *CertResponse) String() string { return prettyStringStruct(v) } +func (v *IssuerResponse) String() string { return prettyStringStruct(v) } +func (v *ResourcePermission) String() string { return prettyStringStruct(v) } +func (v *PermissionResource) String() string { return prettyStringStruct(v) } +func (v *PermissionScope) String() string { return prettyStringStruct(v) } +func (v *IntroSpectTokenResult) String() string { return prettyStringStruct(v) } +func (v *User) String() string { return prettyStringStruct(v) } +func (v *SetPasswordRequest) String() string { return prettyStringStruct(v) } +func (v *Component) String() string { return prettyStringStruct(v) } +func (v *KeyStoreConfig) String() string { return prettyStringStruct(v) } +func (v *ActiveKeys) String() string { return prettyStringStruct(v) } +func (v *Key) String() string { return prettyStringStruct(v) } +func (v *Attributes) String() string { return prettyStringStruct(v) } +func (v *Access) String() string { return prettyStringStruct(v) } +func (v *UserGroup) String() string { return prettyStringStruct(v) } +func (v *GetUsersParams) String() string { return prettyStringStruct(v) } +func (v *GetComponentsParams) String() string { return prettyStringStruct(v) } +func (v *ExecuteActionsEmail) String() string { return prettyStringStruct(v) } +func (v *Group) String() string { return prettyStringStruct(v) } +func (v *GroupsCount) String() string { return prettyStringStruct(v) } +func (obj *GetGroupsParams) String() string { return prettyStringStruct(obj) } +func (v *CompositesRepresentation) String() string { return prettyStringStruct(v) } +func (v *Role) String() string { return prettyStringStruct(v) } +func (v *GetRoleParams) String() string { return prettyStringStruct(v) } +func (v *ClientMappingsRepresentation) String() string { return prettyStringStruct(v) } +func (v *MappingsRepresentation) String() string { return prettyStringStruct(v) } +func (v *ClientScope) String() string { return prettyStringStruct(v) } +func (v *ClientScopeAttributes) String() string { return prettyStringStruct(v) } +func (v *ProtocolMappers) String() string { return prettyStringStruct(v) } +func (v *ProtocolMappersConfig) String() string { return prettyStringStruct(v) } +func (v *Client) String() string { return prettyStringStruct(v) } +func (v *ResourceServerRepresentation) String() string { return prettyStringStruct(v) } +func (v *RoleDefinition) String() string { return prettyStringStruct(v) } +func (v *PolicyRepresentation) String() string { return prettyStringStruct(v) } +func (v *RolePolicyRepresentation) String() string { return prettyStringStruct(v) } +func (v *JSPolicyRepresentation) String() string { return prettyStringStruct(v) } +func (v *ClientPolicyRepresentation) String() string { return prettyStringStruct(v) } +func (v *TimePolicyRepresentation) String() string { return prettyStringStruct(v) } +func (v *UserPolicyRepresentation) String() string { return prettyStringStruct(v) } +func (v *AggregatedPolicyRepresentation) String() string { return prettyStringStruct(v) } +func (v *GroupPolicyRepresentation) String() string { return prettyStringStruct(v) } +func (v *GroupDefinition) String() string { return prettyStringStruct(v) } +func (v *ResourceRepresentation) String() string { return prettyStringStruct(v) } +func (v *ResourceOwnerRepresentation) String() string { return prettyStringStruct(v) } +func (v *ScopeRepresentation) String() string { return prettyStringStruct(v) } +func (v *ProtocolMapperRepresentation) String() string { return prettyStringStruct(v) } +func (v *GetClientsParams) String() string { return prettyStringStruct(v) } +func (v *UserInfoAddress) String() string { return prettyStringStruct(v) } +func (v *UserInfo) String() string { return prettyStringStruct(v) } +func (v *RolesRepresentation) String() string { return prettyStringStruct(v) } +func (v *RealmRepresentation) String() string { return prettyStringStruct(v) } +func (v *MultiValuedHashMap) String() string { return prettyStringStruct(v) } +func (t *TokenOptions) String() string { return prettyStringStruct(t) } +func (t *RequestingPartyTokenOptions) String() string { return prettyStringStruct(t) } +func (v *RequestingPartyPermission) String() string { return prettyStringStruct(v) } +func (v *UserSessionRepresentation) String() string { return prettyStringStruct(v) } +func (v *SystemInfoRepresentation) String() string { return prettyStringStruct(v) } +func (v *MemoryInfoRepresentation) String() string { return prettyStringStruct(v) } +func (v *ServerInfoRepresentation) String() string { return prettyStringStruct(v) } +func (v *FederatedIdentityRepresentation) String() string { return prettyStringStruct(v) } +func (v *IdentityProviderRepresentation) String() string { return prettyStringStruct(v) } +func (v *GetResourceParams) String() string { return prettyStringStruct(v) } +func (v *GetScopeParams) String() string { return prettyStringStruct(v) } +func (v *GetPolicyParams) String() string { return prettyStringStruct(v) } +func (v *GetPermissionParams) String() string { return prettyStringStruct(v) } +func (v *GetUsersByRoleParams) String() string { return prettyStringStruct(v) } +func (v *PermissionRepresentation) String() string { return prettyStringStruct(v) } +func (v *CreatePermissionTicketParams) String() string { return prettyStringStruct(v) } +func (v *PermissionTicketDescriptionRepresentation) String() string { return prettyStringStruct(v) } +func (v *AccessRepresentation) String() string { return prettyStringStruct(v) } +func (v *PermissionTicketResponseRepresentation) String() string { return prettyStringStruct(v) } +func (v *PermissionTicketRepresentation) String() string { return prettyStringStruct(v) } +func (v *PermissionTicketPermissionRepresentation) String() string { return prettyStringStruct(v) } +func (v *PermissionGrantParams) String() string { return prettyStringStruct(v) } +func (v *PermissionGrantResponseRepresentation) String() string { return prettyStringStruct(v) } +func (v *GetUserPermissionParams) String() string { return prettyStringStruct(v) } +func (v *ResourcePolicyRepresentation) String() string { return prettyStringStruct(v) } +func (v *GetResourcePoliciesParams) String() string { return prettyStringStruct(v) } +func (v *CredentialRepresentation) String() string { return prettyStringStruct(v) } +func (v *RequiredActionProviderRepresentation) String() string { return prettyStringStruct(v) } +func (v *BruteForceStatus) String() string { return prettyStringStruct(v) } +func (v *GetClientUserSessionsParams) String() string { return prettyStringStruct(v) } diff --git a/vendor/github.com/Nerzal/gocloak/v13/pkg/jwx/jwx.go b/vendor/github.com/Nerzal/gocloak/v13/pkg/jwx/jwx.go new file mode 100644 index 000000000..ccf000f56 --- /dev/null +++ b/vendor/github.com/Nerzal/gocloak/v13/pkg/jwx/jwx.go @@ -0,0 +1,162 @@ +package jwx + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "math/big" + "strings" + + "github.com/golang-jwt/jwt/v4" + "github.com/pkg/errors" +) + +// SignClaims signs the given claims using a given key and a method +func SignClaims(claims jwt.Claims, key interface{}, method jwt.SigningMethod) (string, error) { + token := jwt.NewWithClaims(method, claims) + return token.SignedString(key) +} + +// DecodeAccessTokenHeader decodes the header of the accessToken +func DecodeAccessTokenHeader(token string) (*DecodedAccessTokenHeader, error) { + const errMessage = "could not decode access token header" + token = strings.Replace(token, "Bearer ", "", 1) + headerString := strings.Split(token, ".") + decodedData, err := base64.RawStdEncoding.DecodeString(headerString[0]) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + + result := &DecodedAccessTokenHeader{} + err = json.Unmarshal(decodedData, result) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + + return result, nil +} + +func toBigInt(v string) (*big.Int, error) { + decRes, err := base64.RawURLEncoding.DecodeString(v) + if err != nil { + return nil, err + } + + res := big.NewInt(0) + res.SetBytes(decRes) + return res, nil +} + +var ( + curves = map[string]elliptic.Curve{ + "P-224": elliptic.P224(), + "P-256": elliptic.P256(), + "P-384": elliptic.P384(), + "P-521": elliptic.P521(), + } +) + +func decodeECDSAPublicKey(x, y, crv *string) (*ecdsa.PublicKey, error) { + const errMessage = "could not decode public key" + + xInt, err := toBigInt(*x) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + + yInt, err := toBigInt(*y) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + var c elliptic.Curve + var ok bool + if c, ok = curves[*crv]; !ok { + return nil, errors.Wrap(fmt.Errorf("unknown curve alg: %s", *crv), errMessage) + } + return &ecdsa.PublicKey{X: xInt, Y: yInt, Curve: c}, nil +} + +func decodeRSAPublicKey(e, n *string) (*rsa.PublicKey, error) { + const errMessage = "could not decode public key" + + nInt, err := toBigInt(*n) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + + decE, err := base64.RawURLEncoding.DecodeString(*e) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + + var eBytes []byte + if len(decE) < 8 { + eBytes = make([]byte, 8-len(decE), 8) + eBytes = append(eBytes, decE...) + } else { + eBytes = decE + } + + eReader := bytes.NewReader(eBytes) + var eInt uint64 + err = binary.Read(eReader, binary.BigEndian, &eInt) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + + pKey := rsa.PublicKey{N: nInt, E: int(eInt)} + return &pKey, nil +} + +// DecodeAccessTokenRSACustomClaims decodes string access token into jwt.Token +func DecodeAccessTokenRSACustomClaims(accessToken string, e, n *string, customClaims jwt.Claims) (*jwt.Token, error) { + const errMessage = "could not decode accessToken with custom claims" + accessToken = strings.Replace(accessToken, "Bearer ", "", 1) + + rsaPublicKey, err := decodeRSAPublicKey(e, n) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + + token2, err := jwt.ParseWithClaims(accessToken, customClaims, func(token *jwt.Token) (interface{}, error) { + // Don't forget to validate the alg is what you expect: + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return rsaPublicKey, nil + }) + + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + return token2, nil +} + +// DecodeAccessTokenECDSACustomClaims decodes string access token into jwt.Token +func DecodeAccessTokenECDSACustomClaims(accessToken string, x, y, crv *string, customClaims jwt.Claims) (*jwt.Token, error) { + const errMessage = "could not decode accessToken" + accessToken = strings.Replace(accessToken, "Bearer ", "", 1) + + publicKey, err := decodeECDSAPublicKey(x, y, crv) + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + + token2, err := jwt.ParseWithClaims(accessToken, customClaims, func(token *jwt.Token) (interface{}, error) { + // Don't forget to validate the alg is what you expect: + if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return publicKey, nil + }) + + if err != nil { + return nil, errors.Wrap(err, errMessage) + } + return token2, nil +} diff --git a/vendor/github.com/Nerzal/gocloak/v13/pkg/jwx/models.go b/vendor/github.com/Nerzal/gocloak/v13/pkg/jwx/models.go new file mode 100644 index 000000000..48b7449a9 --- /dev/null +++ b/vendor/github.com/Nerzal/gocloak/v13/pkg/jwx/models.go @@ -0,0 +1,59 @@ +package jwx + +import jwt "github.com/golang-jwt/jwt/v4" + +// DecodedAccessTokenHeader is the decoded header from the access token +type DecodedAccessTokenHeader struct { + Alg string `json:"alg"` + Typ string `json:"typ"` + Kid string `json:"kid"` +} + +// Claims served by keycloak inside the accessToken +type Claims struct { + jwt.StandardClaims + Typ string `json:"typ,omitempty"` + Azp string `json:"azp,omitempty"` + AuthTime int `json:"auth_time,omitempty"` + SessionState string `json:"session_state,omitempty"` + Acr string `json:"acr,omitempty"` + AllowedOrigins []string `json:"allowed-origins,omitempty"` + RealmAccess RealmAccess `json:"realm_access,omitempty"` + ResourceAccess ResourceAccess `json:"resource_access,omitempty"` + Scope string `json:"scope,omitempty"` + EmailVerified bool `json:"email_verified,omitempty"` + Address Address `json:"address,omitempty"` + Name string `json:"name,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + GivenName string `json:"given_name,omitempty"` + FamilyName string `json:"family_name,omitempty"` + Email string `json:"email,omitempty"` + ClientID string `json:"clientId,omitempty"` + ClientHost string `json:"clientHost,omitempty"` + ClientIP string `json:"clientAddress,omitempty"` +} + +// Address TODO what fields does any address have? +type Address struct { +} + +// RealmAccess holds roles of the user +type RealmAccess struct { + Roles []string `json:"roles,omitempty"` +} + +// ResourceAccess holds TODO: What does it hold? +type ResourceAccess struct { + RealmManagement RealmManagement `json:"realm-management,omitempty"` + Account Account `json:"account,omitempty"` +} + +// RealmManagement holds TODO: What does it hold? +type RealmManagement struct { + Roles []string `json:"roles,omitempty"` +} + +// Account holds TODO: What does it hold? +type Account struct { + Roles []string `json:"roles,omitempty"` +} diff --git a/vendor/github.com/Nerzal/gocloak/v13/run-tests.sh b/vendor/github.com/Nerzal/gocloak/v13/run-tests.sh new file mode 100644 index 000000000..57875f8e7 --- /dev/null +++ b/vendor/github.com/Nerzal/gocloak/v13/run-tests.sh @@ -0,0 +1,26 @@ +#!/bin/sh + +docker-compose down +docker-compose up -d + +keycloakServer=http://localhost:8080 +url="${keycloakServer}/health" +echo "Checking service availability at $url (CTRL+C to exit)" +while true; do + response=$(curl -s -o /dev/null -w "%{http_code}" $url) + if [ $response -eq 200 ]; then + break + fi + sleep 1 +done +echo "Service is now available at ${keycloakServer}" + +ARGS=() +if [ $# -gt 0 ]; then + ARGS+=("-run") + ARGS+=("^($@)$") +fi + +go test -failfast -race -cover -coverprofile=coverage.out -covermode=atomic -p 10 -cpu 1,2 -bench . -benchmem ${ARGS[@]} + +docker-compose down diff --git a/vendor/github.com/Nerzal/gocloak/v13/test.json b/vendor/github.com/Nerzal/gocloak/v13/test.json new file mode 100644 index 000000000..e69de29bb diff --git a/vendor/github.com/Nerzal/gocloak/v13/token.go b/vendor/github.com/Nerzal/gocloak/v13/token.go new file mode 100644 index 000000000..473510217 --- /dev/null +++ b/vendor/github.com/Nerzal/gocloak/v13/token.go @@ -0,0 +1,14 @@ +package gocloak + +// JWT is a JWT +type JWT struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + ExpiresIn int `json:"expires_in"` + RefreshExpiresIn int `json:"refresh_expires_in"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + NotBeforePolicy int `json:"not-before-policy"` + SessionState string `json:"session_state"` + Scope string `json:"scope"` +} diff --git a/vendor/github.com/Nerzal/gocloak/v13/utils.go b/vendor/github.com/Nerzal/gocloak/v13/utils.go new file mode 100644 index 000000000..fce663bff --- /dev/null +++ b/vendor/github.com/Nerzal/gocloak/v13/utils.go @@ -0,0 +1,151 @@ +package gocloak + +import ( + "context" + + "github.com/opentracing/opentracing-go" +) + +type contextKey string + +var tracerContextKey = contextKey("tracer") + +// StringP returns a pointer of a string variable +func StringP(value string) *string { + return &value +} + +// PString returns a string value from a pointer +func PString(value *string) string { + if value == nil { + return "" + } + return *value +} + +// BoolP returns a pointer of a boolean variable +func BoolP(value bool) *bool { + return &value +} + +// PBool returns a boolean value from a pointer +func PBool(value *bool) bool { + if value == nil { + return false + } + return *value +} + +// IntP returns a pointer of an integer variable +func IntP(value int) *int { + return &value +} + +// Int32P returns a pointer of an int32 variable +func Int32P(value int32) *int32 { + return &value +} + +// Int64P returns a pointer of an int64 variable +func Int64P(value int64) *int64 { + return &value +} + +// PInt returns an integer value from a pointer +func PInt(value *int) int { + if value == nil { + return 0 + } + return *value +} + +// PInt32 returns an int32 value from a pointer +func PInt32(value *int32) int32 { + if value == nil { + return 0 + } + return *value +} + +// PInt64 returns an int64 value from a pointer +func PInt64(value *int64) int64 { + if value == nil { + return 0 + } + return *value +} + +// Float32P returns a pointer of a float32 variable +func Float32P(value float32) *float32 { + return &value +} + +// Float64P returns a pointer of a float64 variable +func Float64P(value float64) *float64 { + return &value +} + +// PFloat32 returns an flaot32 value from a pointer +func PFloat32(value *float32) float32 { + if value == nil { + return 0 + } + return *value +} + +// PFloat64 returns an flaot64 value from a pointer +func PFloat64(value *float64) float64 { + if value == nil { + return 0 + } + return *value +} + +// NilOrEmpty returns true if string is empty or has a nil value +func NilOrEmpty(value *string) bool { + return value == nil || len(*value) == 0 +} + +// NilOrEmptyArray returns true if string is empty or has a nil value +func NilOrEmptyArray(value *[]string) bool { + + if value == nil || len(*value) == 0 { + return true + } + + return (*value)[0] == "" + +} + +// DecisionStrategyP returns a pointer for a DecisionStrategy value +func DecisionStrategyP(value DecisionStrategy) *DecisionStrategy { + return &value +} + +// LogicP returns a pointer for a Logic value +func LogicP(value Logic) *Logic { + return &value +} + +// PolicyEnforcementModeP returns a pointer for a PolicyEnforcementMode value +func PolicyEnforcementModeP(value PolicyEnforcementMode) *PolicyEnforcementMode { + return &value +} + +// PStringSlice converts a pointer to []string or returns ampty slice if nill value +func PStringSlice(value *[]string) []string { + if value == nil { + return []string{} + } + return *value +} + +// NilOrEmptySlice returns true if list is empty or has a nil value +func NilOrEmptySlice(value *[]string) bool { + return value == nil || len(*value) == 0 +} + +// WithTracer generates a context that has a tracer attached +func WithTracer(ctx context.Context, tracer opentracing.Tracer) context.Context { + return context.WithValue(ctx, tracerContextKey, tracer) +} diff --git a/vendor/github.com/go-resty/resty/v2/.gitignore b/vendor/github.com/go-resty/resty/v2/.gitignore new file mode 100644 index 000000000..9e856bd48 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/.gitignore @@ -0,0 +1,30 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof + +coverage.out +coverage.txt + +# Exclude intellij IDE folders +.idea/* diff --git a/vendor/github.com/go-resty/resty/v2/BUILD.bazel b/vendor/github.com/go-resty/resty/v2/BUILD.bazel new file mode 100644 index 000000000..f461c29db --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/BUILD.bazel @@ -0,0 +1,51 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") +load("@bazel_gazelle//:def.bzl", "gazelle") + +# gazelle:prefix github.com/go-resty/resty/v2 +# gazelle:go_naming_convention import_alias +gazelle(name = "gazelle") + +go_library( + name = "resty", + srcs = [ + "client.go", + "digest.go", + "middleware.go", + "redirect.go", + "request.go", + "response.go", + "resty.go", + "retry.go", + "trace.go", + "transport_js.go", + "transport_other.go", + "transport.go", + "transport112.go", + "util.go", + ], + importpath = "github.com/go-resty/resty/v2", + visibility = ["//visibility:public"], + deps = ["@org_golang_x_net//publicsuffix:go_default_library"], +) + +go_test( + name = "resty_test", + srcs = [ + "client_test.go", + "context_test.go", + "example_test.go", + "request_test.go", + "resty_test.go", + "retry_test.go", + "util_test.go", + ], + data = glob([".testdata/*"]), + embed = [":resty"], + deps = ["@org_golang_x_net//proxy:go_default_library"], +) + +alias( + name = "go_default_library", + actual = ":resty", + visibility = ["//visibility:public"], +) diff --git a/vendor/github.com/go-resty/resty/v2/LICENSE b/vendor/github.com/go-resty/resty/v2/LICENSE new file mode 100644 index 000000000..0c2d38a38 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-2023 Jeevanandam M., https://myjeeva.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/go-resty/resty/v2/README.md b/vendor/github.com/go-resty/resty/v2/README.md new file mode 100644 index 000000000..d6d501ef8 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/README.md @@ -0,0 +1,925 @@ +

+

Resty

+

Simple HTTP and REST client library for Go (inspired by Ruby rest-client)

+

Features section describes in detail about Resty capabilities

+

+

+

Build Status Code Coverage Go Report Card Release Version GoDoc License Mentioned in Awesome Go

+

+

+

Resty Communication Channels

+

Chat on Gitter - Resty Community Twitter @go_resty

+

+ +## News + + * v2.11.0 [released](https://github.com/go-resty/resty/releases/tag/v2.11.0) and tagged on Dec 27, 2023. + * v2.0.0 [released](https://github.com/go-resty/resty/releases/tag/v2.0.0) and tagged on Jul 16, 2019. + * v1.12.0 [released](https://github.com/go-resty/resty/releases/tag/v1.12.0) and tagged on Feb 27, 2019. + * v1.0 released and tagged on Sep 25, 2017. - Resty's first version was released on Sep 15, 2015 then it grew gradually as a very handy and helpful library. Its been a two years since first release. I'm very thankful to Resty users and its [contributors](https://github.com/go-resty/resty/graphs/contributors). + +## Features + + * GET, POST, PUT, DELETE, HEAD, PATCH, OPTIONS, etc. + * Simple and chainable methods for settings and request + * [Request](https://pkg.go.dev/github.com/go-resty/resty/v2#Request) Body can be `string`, `[]byte`, `struct`, `map`, `slice` and `io.Reader` too + * Auto detects `Content-Type` + * Buffer less processing for `io.Reader` + * Native `*http.Request` instance may be accessed during middleware and request execution via `Request.RawRequest` + * Request Body can be read multiple times via `Request.RawRequest.GetBody()` + * [Response](https://pkg.go.dev/github.com/go-resty/resty/v2#Response) object gives you more possibility + * Access as `[]byte` array - `response.Body()` OR Access as `string` - `response.String()` + * Know your `response.Time()` and when we `response.ReceivedAt()` + * Automatic marshal and unmarshal for `JSON` and `XML` content type + * Default is `JSON`, if you supply `struct/map` without header `Content-Type` + * For auto-unmarshal, refer to - + - Success scenario [Request.SetResult()](https://pkg.go.dev/github.com/go-resty/resty/v2#Request.SetResult) and [Response.Result()](https://pkg.go.dev/github.com/go-resty/resty/v2#Response.Result). + - Error scenario [Request.SetError()](https://pkg.go.dev/github.com/go-resty/resty/v2#Request.SetError) and [Response.Error()](https://pkg.go.dev/github.com/go-resty/resty/v2#Response.Error). + - Supports [RFC7807](https://tools.ietf.org/html/rfc7807) - `application/problem+json` & `application/problem+xml` + * Resty provides an option to override [JSON Marshal/Unmarshal and XML Marshal/Unmarshal](#override-json--xml-marshalunmarshal) + * Easy to upload one or more file(s) via `multipart/form-data` + * Auto detects file content type + * Request URL [Path Params (aka URI Params)](https://pkg.go.dev/github.com/go-resty/resty/v2#Request.SetPathParams) + * Backoff Retry Mechanism with retry condition function [reference](retry_test.go) + * Resty client HTTP & REST [Request](https://pkg.go.dev/github.com/go-resty/resty/v2#Client.OnBeforeRequest) and [Response](https://pkg.go.dev/github.com/go-resty/resty/v2#Client.OnAfterResponse) middlewares + * `Request.SetContext` supported + * Authorization option of `BasicAuth` and `Bearer` token + * Set request `ContentLength` value for all request or particular request + * Custom [Root Certificates](https://pkg.go.dev/github.com/go-resty/resty/v2#Client.SetRootCertificate) and Client [Certificates](https://pkg.go.dev/github.com/go-resty/resty/v2#Client.SetCertificates) + * Download/Save HTTP response directly into File, like `curl -o` flag. See [SetOutputDirectory](https://pkg.go.dev/github.com/go-resty/resty/v2#Client.SetOutputDirectory) & [SetOutput](https://pkg.go.dev/github.com/go-resty/resty/v2#Request.SetOutput). + * Cookies for your request and CookieJar support + * SRV Record based request instead of Host URL + * Client settings like `Timeout`, `RedirectPolicy`, `Proxy`, `TLSClientConfig`, `Transport`, etc. + * Optionally allows GET request with payload, see [SetAllowGetMethodPayload](https://pkg.go.dev/github.com/go-resty/resty/v2#Client.SetAllowGetMethodPayload) + * Supports registering external JSON library into resty, see [how to use](https://github.com/go-resty/resty/issues/76#issuecomment-314015250) + * Exposes Response reader without reading response (no auto-unmarshaling) if need be, see [how to use](https://github.com/go-resty/resty/issues/87#issuecomment-322100604) + * Option to specify expected `Content-Type` when response `Content-Type` header missing. Refer to [#92](https://github.com/go-resty/resty/issues/92) + * Resty design + * Have client level settings & options and also override at Request level if you want to + * Request and Response middleware + * Create Multiple clients if you want to `resty.New()` + * Supports `http.RoundTripper` implementation, see [SetTransport](https://pkg.go.dev/github.com/go-resty/resty/v2#Client.SetTransport) + * goroutine concurrent safe + * Resty Client trace, see [Client.EnableTrace](https://pkg.go.dev/github.com/go-resty/resty/v2#Client.EnableTrace) and [Request.EnableTrace](https://pkg.go.dev/github.com/go-resty/resty/v2#Request.EnableTrace) + * Since v2.4.0, trace info contains a `RequestAttempt` value, and the `Request` object contains an `Attempt` attribute + * Debug mode - clean and informative logging presentation + * Gzip - Go does it automatically also resty has fallback handling too + * Works fine with `HTTP/2` and `HTTP/1.1` + * [Bazel support](#bazel-support) + * Easily mock Resty for testing, [for e.g.](#mocking-http-requests-using-httpmock-library) + * Well tested client library + +### Included Batteries + + * Redirect Policies - see [how to use](#redirect-policy) + * NoRedirectPolicy + * FlexibleRedirectPolicy + * DomainCheckRedirectPolicy + * etc. [more info](redirect.go) + * Retry Mechanism [how to use](#retries) + * Backoff Retry + * Conditional Retry + * Since v2.6.0, Retry Hooks - [Client](https://pkg.go.dev/github.com/go-resty/resty/v2#Client.AddRetryHook), [Request](https://pkg.go.dev/github.com/go-resty/resty/v2#Request.AddRetryHook) + * SRV Record based request instead of Host URL [how to use](resty_test.go#L1412) + * etc (upcoming - throw your idea's [here](https://github.com/go-resty/resty/issues)). + + +#### Supported Go Versions + +Recommended to use `go1.16` and above. + +Initially Resty started supporting `go modules` since `v1.10.0` release. + +Starting Resty v2 and higher versions, it fully embraces [go modules](https://github.com/golang/go/wiki/Modules) package release. It requires a Go version capable of understanding `/vN` suffixed imports: + +- 1.9.7+ +- 1.10.3+ +- 1.11+ + + +## It might be beneficial for your project :smile: + +Resty author also published following projects for Go Community. + + * [aah framework](https://aahframework.org) - A secure, flexible, rapid Go web framework. + * [THUMBAI](https://thumbai.app) - Go Mod Repository, Go Vanity Service and Simple Proxy Server. + * [go-model](https://github.com/jeevatkm/go-model) - Robust & Easy to use model mapper and utility methods for Go `struct`. + + +## Installation + +```bash +# Go Modules +require github.com/go-resty/resty/v2 v2.11.0 +``` + +## Usage + +The following samples will assist you to become as comfortable as possible with resty library. + +```go +// Import resty into your code and refer it as `resty`. +import "github.com/go-resty/resty/v2" +``` + +#### Simple GET + +```go +// Create a Resty Client +client := resty.New() + +resp, err := client.R(). + EnableTrace(). + Get("https://httpbin.org/get") + +// Explore response object +fmt.Println("Response Info:") +fmt.Println(" Error :", err) +fmt.Println(" Status Code:", resp.StatusCode()) +fmt.Println(" Status :", resp.Status()) +fmt.Println(" Proto :", resp.Proto()) +fmt.Println(" Time :", resp.Time()) +fmt.Println(" Received At:", resp.ReceivedAt()) +fmt.Println(" Body :\n", resp) +fmt.Println() + +// Explore trace info +fmt.Println("Request Trace Info:") +ti := resp.Request.TraceInfo() +fmt.Println(" DNSLookup :", ti.DNSLookup) +fmt.Println(" ConnTime :", ti.ConnTime) +fmt.Println(" TCPConnTime :", ti.TCPConnTime) +fmt.Println(" TLSHandshake :", ti.TLSHandshake) +fmt.Println(" ServerTime :", ti.ServerTime) +fmt.Println(" ResponseTime :", ti.ResponseTime) +fmt.Println(" TotalTime :", ti.TotalTime) +fmt.Println(" IsConnReused :", ti.IsConnReused) +fmt.Println(" IsConnWasIdle :", ti.IsConnWasIdle) +fmt.Println(" ConnIdleTime :", ti.ConnIdleTime) +fmt.Println(" RequestAttempt:", ti.RequestAttempt) +fmt.Println(" RemoteAddr :", ti.RemoteAddr.String()) + +/* Output +Response Info: + Error : + Status Code: 200 + Status : 200 OK + Proto : HTTP/2.0 + Time : 457.034718ms + Received At: 2020-09-14 15:35:29.784681 -0700 PDT m=+0.458137045 + Body : + { + "args": {}, + "headers": { + "Accept-Encoding": "gzip", + "Host": "httpbin.org", + "User-Agent": "go-resty/2.4.0 (https://github.com/go-resty/resty)", + "X-Amzn-Trace-Id": "Root=1-5f5ff031-000ff6292204aa6898e4de49" + }, + "origin": "0.0.0.0", + "url": "https://httpbin.org/get" + } + +Request Trace Info: + DNSLookup : 4.074657ms + ConnTime : 381.709936ms + TCPConnTime : 77.428048ms + TLSHandshake : 299.623597ms + ServerTime : 75.414703ms + ResponseTime : 79.337µs + TotalTime : 457.034718ms + IsConnReused : false + IsConnWasIdle : false + ConnIdleTime : 0s + RequestAttempt: 1 + RemoteAddr : 3.221.81.55:443 +*/ +``` + +#### Enhanced GET + +```go +// Create a Resty Client +client := resty.New() + +resp, err := client.R(). + SetQueryParams(map[string]string{ + "page_no": "1", + "limit": "20", + "sort":"name", + "order": "asc", + "random":strconv.FormatInt(time.Now().Unix(), 10), + }). + SetHeader("Accept", "application/json"). + SetAuthToken("BC594900518B4F7EAC75BD37F019E08FBC594900518B4F7EAC75BD37F019E08F"). + Get("/search_result") + + +// Sample of using Request.SetQueryString method +resp, err := client.R(). + SetQueryString("productId=232&template=fresh-sample&cat=resty&source=google&kw=buy a lot more"). + SetHeader("Accept", "application/json"). + SetAuthToken("BC594900518B4F7EAC75BD37F019E08FBC594900518B4F7EAC75BD37F019E08F"). + Get("/show_product") + + +// If necessary, you can force response content type to tell Resty to parse a JSON response into your struct +resp, err := client.R(). + SetResult(result). + ForceContentType("application/json"). + Get("v2/alpine/manifests/latest") +``` + +#### Various POST method combinations + +```go +// Create a Resty Client +client := resty.New() + +// POST JSON string +// No need to set content type, if you have client level setting +resp, err := client.R(). + SetHeader("Content-Type", "application/json"). + SetBody(`{"username":"testuser", "password":"testpass"}`). + SetResult(&AuthSuccess{}). // or SetResult(AuthSuccess{}). + Post("https://myapp.com/login") + +// POST []byte array +// No need to set content type, if you have client level setting +resp, err := client.R(). + SetHeader("Content-Type", "application/json"). + SetBody([]byte(`{"username":"testuser", "password":"testpass"}`)). + SetResult(&AuthSuccess{}). // or SetResult(AuthSuccess{}). + Post("https://myapp.com/login") + +// POST Struct, default is JSON content type. No need to set one +resp, err := client.R(). + SetBody(User{Username: "testuser", Password: "testpass"}). + SetResult(&AuthSuccess{}). // or SetResult(AuthSuccess{}). + SetError(&AuthError{}). // or SetError(AuthError{}). + Post("https://myapp.com/login") + +// POST Map, default is JSON content type. No need to set one +resp, err := client.R(). + SetBody(map[string]interface{}{"username": "testuser", "password": "testpass"}). + SetResult(&AuthSuccess{}). // or SetResult(AuthSuccess{}). + SetError(&AuthError{}). // or SetError(AuthError{}). + Post("https://myapp.com/login") + +// POST of raw bytes for file upload. For example: upload file to Dropbox +fileBytes, _ := os.ReadFile("/Users/jeeva/mydocument.pdf") + +// See we are not setting content-type header, since go-resty automatically detects Content-Type for you +resp, err := client.R(). + SetBody(fileBytes). + SetContentLength(true). // Dropbox expects this value + SetAuthToken(""). + SetError(&DropboxError{}). // or SetError(DropboxError{}). + Post("https://content.dropboxapi.com/1/files_put/auto/resty/mydocument.pdf") // for upload Dropbox supports PUT too + +// Note: resty detects Content-Type for request body/payload if content type header is not set. +// * For struct and map data type defaults to 'application/json' +// * Fallback is plain text content type +``` + +#### Sample PUT + +You can use various combinations of `PUT` method call like demonstrated for `POST`. + +```go +// Note: This is one sample of PUT method usage, refer POST for more combination + +// Create a Resty Client +client := resty.New() + +// Request goes as JSON content type +// No need to set auth token, error, if you have client level settings +resp, err := client.R(). + SetBody(Article{ + Title: "go-resty", + Content: "This is my article content, oh ya!", + Author: "Jeevanandam M", + Tags: []string{"article", "sample", "resty"}, + }). + SetAuthToken("C6A79608-782F-4ED0-A11D-BD82FAD829CD"). + SetError(&Error{}). // or SetError(Error{}). + Put("https://myapp.com/article/1234") +``` + +#### Sample PATCH + +You can use various combinations of `PATCH` method call like demonstrated for `POST`. + +```go +// Note: This is one sample of PUT method usage, refer POST for more combination + +// Create a Resty Client +client := resty.New() + +// Request goes as JSON content type +// No need to set auth token, error, if you have client level settings +resp, err := client.R(). + SetBody(Article{ + Tags: []string{"new tag1", "new tag2"}, + }). + SetAuthToken("C6A79608-782F-4ED0-A11D-BD82FAD829CD"). + SetError(&Error{}). // or SetError(Error{}). + Patch("https://myapp.com/articles/1234") +``` + +#### Sample DELETE, HEAD, OPTIONS + +```go +// Create a Resty Client +client := resty.New() + +// DELETE a article +// No need to set auth token, error, if you have client level settings +resp, err := client.R(). + SetAuthToken("C6A79608-782F-4ED0-A11D-BD82FAD829CD"). + SetError(&Error{}). // or SetError(Error{}). + Delete("https://myapp.com/articles/1234") + +// DELETE a articles with payload/body as a JSON string +// No need to set auth token, error, if you have client level settings +resp, err := client.R(). + SetAuthToken("C6A79608-782F-4ED0-A11D-BD82FAD829CD"). + SetError(&Error{}). // or SetError(Error{}). + SetHeader("Content-Type", "application/json"). + SetBody(`{article_ids: [1002, 1006, 1007, 87683, 45432] }`). + Delete("https://myapp.com/articles") + +// HEAD of resource +// No need to set auth token, if you have client level settings +resp, err := client.R(). + SetAuthToken("C6A79608-782F-4ED0-A11D-BD82FAD829CD"). + Head("https://myapp.com/videos/hi-res-video") + +// OPTIONS of resource +// No need to set auth token, if you have client level settings +resp, err := client.R(). + SetAuthToken("C6A79608-782F-4ED0-A11D-BD82FAD829CD"). + Options("https://myapp.com/servers/nyc-dc-01") +``` + +#### Override JSON & XML Marshal/Unmarshal + +User could register choice of JSON/XML library into resty or write your own. By default resty registers standard `encoding/json` and `encoding/xml` respectively. +```go +// Example of registering json-iterator +import jsoniter "github.com/json-iterator/go" + +json := jsoniter.ConfigCompatibleWithStandardLibrary + +client := resty.New(). + SetJSONMarshaler(json.Marshal). + SetJSONUnmarshaler(json.Unmarshal) + +// similarly user could do for XML too with - +client.SetXMLMarshaler(xml.Marshal). + SetXMLUnmarshaler(xml.Unmarshal) +``` + +### Multipart File(s) upload + +#### Using io.Reader + +```go +profileImgBytes, _ := os.ReadFile("/Users/jeeva/test-img.png") +notesBytes, _ := os.ReadFile("/Users/jeeva/text-file.txt") + +// Create a Resty Client +client := resty.New() + +resp, err := client.R(). + SetFileReader("profile_img", "test-img.png", bytes.NewReader(profileImgBytes)). + SetFileReader("notes", "text-file.txt", bytes.NewReader(notesBytes)). + SetFormData(map[string]string{ + "first_name": "Jeevanandam", + "last_name": "M", + }). + Post("http://myapp.com/upload") +``` + +#### Using File directly from Path + +```go +// Create a Resty Client +client := resty.New() + +// Single file scenario +resp, err := client.R(). + SetFile("profile_img", "/Users/jeeva/test-img.png"). + Post("http://myapp.com/upload") + +// Multiple files scenario +resp, err := client.R(). + SetFiles(map[string]string{ + "profile_img": "/Users/jeeva/test-img.png", + "notes": "/Users/jeeva/text-file.txt", + }). + Post("http://myapp.com/upload") + +// Multipart of form fields and files +resp, err := client.R(). + SetFiles(map[string]string{ + "profile_img": "/Users/jeeva/test-img.png", + "notes": "/Users/jeeva/text-file.txt", + }). + SetFormData(map[string]string{ + "first_name": "Jeevanandam", + "last_name": "M", + "zip_code": "00001", + "city": "my city", + "access_token": "C6A79608-782F-4ED0-A11D-BD82FAD829CD", + }). + Post("http://myapp.com/profile") +``` + +#### Sample Form submission + +```go +// Create a Resty Client +client := resty.New() + +// just mentioning about POST as an example with simple flow +// User Login +resp, err := client.R(). + SetFormData(map[string]string{ + "username": "jeeva", + "password": "mypass", + }). + Post("http://myapp.com/login") + +// Followed by profile update +resp, err := client.R(). + SetFormData(map[string]string{ + "first_name": "Jeevanandam", + "last_name": "M", + "zip_code": "00001", + "city": "new city update", + }). + Post("http://myapp.com/profile") + +// Multi value form data +criteria := url.Values{ + "search_criteria": []string{"book", "glass", "pencil"}, +} +resp, err := client.R(). + SetFormDataFromValues(criteria). + Post("http://myapp.com/search") +``` + +#### Save HTTP Response into File + +```go +// Create a Resty Client +client := resty.New() + +// Setting output directory path, If directory not exists then resty creates one! +// This is optional one, if you're planning using absolute path in +// `Request.SetOutput` and can used together. +client.SetOutputDirectory("/Users/jeeva/Downloads") + +// HTTP response gets saved into file, similar to curl -o flag +_, err := client.R(). + SetOutput("plugin/ReplyWithHeader-v5.1-beta.zip"). + Get("http://bit.ly/1LouEKr") + +// OR using absolute path +// Note: output directory path is not used for absolute path +_, err := client.R(). + SetOutput("/MyDownloads/plugin/ReplyWithHeader-v5.1-beta.zip"). + Get("http://bit.ly/1LouEKr") +``` + +#### Request URL Path Params + +Resty provides easy to use dynamic request URL path params. Params can be set at client and request level. Client level params value can be overridden at request level. + +```go +// Create a Resty Client +client := resty.New() + +client.R().SetPathParams(map[string]string{ + "userId": "sample@sample.com", + "subAccountId": "100002", +}). +Get("/v1/users/{userId}/{subAccountId}/details") + +// Result: +// Composed URL - /v1/users/sample@sample.com/100002/details +``` + +#### Request and Response Middleware + +Resty provides middleware ability to manipulate for Request and Response. It is more flexible than callback approach. + +```go +// Create a Resty Client +client := resty.New() + +// Registering Request Middleware +client.OnBeforeRequest(func(c *resty.Client, req *resty.Request) error { + // Now you have access to Client and current Request object + // manipulate it as per your need + + return nil // if its success otherwise return error + }) + +// Registering Response Middleware +client.OnAfterResponse(func(c *resty.Client, resp *resty.Response) error { + // Now you have access to Client and current Response object + // manipulate it as per your need + + return nil // if its success otherwise return error + }) +``` + +#### OnError Hooks + +Resty provides OnError hooks that may be called because: + +- The client failed to send the request due to connection timeout, TLS handshake failure, etc... +- The request was retried the maximum amount of times, and still failed. + +If there was a response from the server, the original error will be wrapped in `*resty.ResponseError` which contains the last response received. + +```go +// Create a Resty Client +client := resty.New() + +client.OnError(func(req *resty.Request, err error) { + if v, ok := err.(*resty.ResponseError); ok { + // v.Response contains the last response from the server + // v.Err contains the original error + } + // Log the error, increment a metric, etc... +}) +``` + +#### Redirect Policy + +Resty provides few ready to use redirect policy(s) also it supports multiple policies together. + +```go +// Create a Resty Client +client := resty.New() + +// Assign Client Redirect Policy. Create one as per you need +client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(15)) + +// Wanna multiple policies such as redirect count, domain name check, etc +client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20), + resty.DomainCheckRedirectPolicy("host1.com", "host2.org", "host3.net")) +``` + +##### Custom Redirect Policy + +Implement [RedirectPolicy](redirect.go#L20) interface and register it with resty client. Have a look [redirect.go](redirect.go) for more information. + +```go +// Create a Resty Client +client := resty.New() + +// Using raw func into resty.SetRedirectPolicy +client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error { + // Implement your logic here + + // return nil for continue redirect otherwise return error to stop/prevent redirect + return nil +})) + +//--------------------------------------------------- + +// Using struct create more flexible redirect policy +type CustomRedirectPolicy struct { + // variables goes here +} + +func (c *CustomRedirectPolicy) Apply(req *http.Request, via []*http.Request) error { + // Implement your logic here + + // return nil for continue redirect otherwise return error to stop/prevent redirect + return nil +} + +// Registering in resty +client.SetRedirectPolicy(CustomRedirectPolicy{/* initialize variables */}) +``` + +#### Custom Root Certificates and Client Certificates + +```go +// Create a Resty Client +client := resty.New() + +// Custom Root certificates, just supply .pem file. +// you can add one or more root certificates, its get appended +client.SetRootCertificate("/path/to/root/pemFile1.pem") +client.SetRootCertificate("/path/to/root/pemFile2.pem") +// ... and so on! + +// Adding Client Certificates, you add one or more certificates +// Sample for creating certificate object +// Parsing public/private key pair from a pair of files. The files must contain PEM encoded data. +cert1, err := tls.LoadX509KeyPair("certs/client.pem", "certs/client.key") +if err != nil { + log.Fatalf("ERROR client certificate: %s", err) +} +// ... + +// You add one or more certificates +client.SetCertificates(cert1, cert2, cert3) +``` + +#### Custom Root Certificates and Client Certificates from string + +```go +// Custom Root certificates from string +// You can pass you certificates through env variables as strings +// you can add one or more root certificates, its get appended +client.SetRootCertificateFromString("-----BEGIN CERTIFICATE-----content-----END CERTIFICATE-----") +client.SetRootCertificateFromString("-----BEGIN CERTIFICATE-----content-----END CERTIFICATE-----") +// ... and so on! + +// Adding Client Certificates, you add one or more certificates +// Sample for creating certificate object +// Parsing public/private key pair from a pair of files. The files must contain PEM encoded data. +cert1, err := tls.X509KeyPair([]byte("-----BEGIN CERTIFICATE-----content-----END CERTIFICATE-----"), []byte("-----BEGIN CERTIFICATE-----content-----END CERTIFICATE-----")) +if err != nil { + log.Fatalf("ERROR client certificate: %s", err) +} +// ... + +// You add one or more certificates +client.SetCertificates(cert1, cert2, cert3) +``` + +#### Proxy Settings + +Default `Go` supports Proxy via environment variable `HTTP_PROXY`. Resty provides support via `SetProxy` & `RemoveProxy`. +Choose as per your need. + +**Client Level Proxy** settings applied to all the request + +```go +// Create a Resty Client +client := resty.New() + +// Setting a Proxy URL and Port +client.SetProxy("http://proxyserver:8888") + +// Want to remove proxy setting +client.RemoveProxy() +``` + +#### Retries + +Resty uses [backoff](http://www.awsarchitectureblog.com/2015/03/backoff.html) +to increase retry intervals after each attempt. + +Usage example: + +```go +// Create a Resty Client +client := resty.New() + +// Retries are configured per client +client. + // Set retry count to non zero to enable retries + SetRetryCount(3). + // You can override initial retry wait time. + // Default is 100 milliseconds. + SetRetryWaitTime(5 * time.Second). + // MaxWaitTime can be overridden as well. + // Default is 2 seconds. + SetRetryMaxWaitTime(20 * time.Second). + // SetRetryAfter sets callback to calculate wait time between retries. + // Default (nil) implies exponential backoff with jitter + SetRetryAfter(func(client *resty.Client, resp *resty.Response) (time.Duration, error) { + return 0, errors.New("quota exceeded") + }) +``` + +By default, resty will retry requests that return a non-nil error during execution. +Therefore, the above setup will result in resty retrying requests with non-nil errors up to 3 times, +with the delay increasing after each attempt. + +You can optionally provide client with [custom retry conditions](https://pkg.go.dev/github.com/go-resty/resty/v2#RetryConditionFunc): + +```go +// Create a Resty Client +client := resty.New() + +client.AddRetryCondition( + // RetryConditionFunc type is for retry condition function + // input: non-nil Response OR request execution error + func(r *resty.Response, err error) bool { + return r.StatusCode() == http.StatusTooManyRequests + }, +) +``` + +The above example will make resty retry requests that end with a `429 Too Many Requests` status code. +It's important to note that when you specify conditions using `AddRetryCondition`, +it will override the default retry behavior, which retries on errors encountered during the request. +If you want to retry on errors encountered during the request, similar to the default behavior, +you'll need to configure it as follows: + +```go +// Create a Resty Client +client := resty.New() + +client.AddRetryCondition( + func(r *resty.Response, err error) bool { + // Including "err != nil" emulates the default retry behavior for errors encountered during the request. + return err != nil || r.StatusCode() == http.StatusTooManyRequests + }, +) +``` + +Multiple retry conditions can be added. +Note that if multiple conditions are specified, a retry will occur if any of the conditions are met. + +It is also possible to use `resty.Backoff(...)` to get arbitrary retry scenarios +implemented. [Reference](retry_test.go). + +#### Allow GET request with Payload + +```go +// Create a Resty Client +client := resty.New() + +// Allow GET request with Payload. This is disabled by default. +client.SetAllowGetMethodPayload(true) +``` + +#### Wanna Multiple Clients + +```go +// Here you go! +// Client 1 +client1 := resty.New() +client1.R().Get("http://httpbin.org") +// ... + +// Client 2 +client2 := resty.New() +client2.R().Head("http://httpbin.org") +// ... + +// Bend it as per your need!!! +``` + +#### Remaining Client Settings & its Options + +```go +// Create a Resty Client +client := resty.New() + +// Unique settings at Client level +//-------------------------------- +// Enable debug mode +client.SetDebug(true) + +// Assign Client TLSClientConfig +// One can set custom root-certificate. Refer: http://golang.org/pkg/crypto/tls/#example_Dial +client.SetTLSClientConfig(&tls.Config{ RootCAs: roots }) + +// or One can disable security check (https) +client.SetTLSClientConfig(&tls.Config{ InsecureSkipVerify: true }) + +// Set client timeout as per your need +client.SetTimeout(1 * time.Minute) + + +// You can override all below settings and options at request level if you want to +//-------------------------------------------------------------------------------- +// Host URL for all request. So you can use relative URL in the request +client.SetHostURL("http://httpbin.org") + +// Headers for all request +client.SetHeader("Accept", "application/json") +client.SetHeaders(map[string]string{ + "Content-Type": "application/json", + "User-Agent": "My custom User Agent String", + }) + +// Cookies for all request +client.SetCookie(&http.Cookie{ + Name:"go-resty", + Value:"This is cookie value", + Path: "/", + Domain: "sample.com", + MaxAge: 36000, + HttpOnly: true, + Secure: false, + }) +client.SetCookies(cookies) + +// URL query parameters for all request +client.SetQueryParam("user_id", "00001") +client.SetQueryParams(map[string]string{ // sample of those who use this manner + "api_key": "api-key-here", + "api_secret": "api-secret", + }) +client.R().SetQueryString("productId=232&template=fresh-sample&cat=resty&source=google&kw=buy a lot more") + +// Form data for all request. Typically used with POST and PUT +client.SetFormData(map[string]string{ + "access_token": "BC594900-518B-4F7E-AC75-BD37F019E08F", + }) + +// Basic Auth for all request +client.SetBasicAuth("myuser", "mypass") + +// Bearer Auth Token for all request +client.SetAuthToken("BC594900518B4F7EAC75BD37F019E08FBC594900518B4F7EAC75BD37F019E08F") + +// Enabling Content length value for all request +client.SetContentLength(true) + +// Registering global Error object structure for JSON/XML request +client.SetError(&Error{}) // or resty.SetError(Error{}) +``` + +#### Unix Socket + +```go +unixSocket := "/var/run/my_socket.sock" + +// Create a Go's http.Transport so we can set it in resty. +transport := http.Transport{ + Dial: func(_, _ string) (net.Conn, error) { + return net.Dial("unix", unixSocket) + }, +} + +// Create a Resty Client +client := resty.New() + +// Set the previous transport that we created, set the scheme of the communication to the +// socket and set the unixSocket as the HostURL. +client.SetTransport(&transport).SetScheme("http").SetHostURL(unixSocket) + +// No need to write the host's URL on the request, just the path. +client.R().Get("http://localhost/index.html") +``` + +#### Bazel Support + +Resty can be built, tested and depended upon via [Bazel](https://bazel.build). +For example, to run all tests: + +```shell +bazel test :resty_test +``` + +#### Mocking http requests using [httpmock](https://github.com/jarcoal/httpmock) library + +In order to mock the http requests when testing your application you +could use the `httpmock` library. + +When using the default resty client, you should pass the client to the library as follow: + +```go +// Create a Resty Client +client := resty.New() + +// Get the underlying HTTP Client and set it to Mock +httpmock.ActivateNonDefault(client.GetClient()) +``` + +More detailed example of mocking resty http requests using ginko could be found [here](https://github.com/jarcoal/httpmock#ginkgo--resty-example). + +## Versioning + +Resty releases versions according to [Semantic Versioning](http://semver.org) + + * Resty v2 does not use `gopkg.in` service for library versioning. + * Resty fully adapted to `go mod` capabilities since `v1.10.0` release. + * Resty v1 series was using `gopkg.in` to provide versioning. `gopkg.in/resty.vX` points to appropriate tagged versions; `X` denotes version series number and it's a stable release for production use. For e.g. `gopkg.in/resty.v0`. + * Development takes place at the master branch. Although the code in master should always compile and test successfully, it might break API's. I aim to maintain backwards compatibility, but sometimes API's and behavior might be changed to fix a bug. + +## Contribution + +I would welcome your contribution! If you find any improvement or issue you want to fix, feel free to send a pull request, I like pull requests that include test cases for fix/enhancement. I have done my best to bring pretty good code coverage. Feel free to write tests. + +BTW, I'd like to know what you think about `Resty`. Kindly open an issue or send me an email; it'd mean a lot to me. + +## Creator + +[Jeevanandam M.](https://github.com/jeevatkm) (jeeva@myjeeva.com) + +## Core Team + +Have a look on [Members](https://github.com/orgs/go-resty/people) page. + +## Contributors + +Have a look on [Contributors](https://github.com/go-resty/resty/graphs/contributors) page. + +## License + +Resty released under MIT license, refer [LICENSE](LICENSE) file. diff --git a/vendor/github.com/go-resty/resty/v2/WORKSPACE b/vendor/github.com/go-resty/resty/v2/WORKSPACE new file mode 100644 index 000000000..9ef03e95a --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/WORKSPACE @@ -0,0 +1,31 @@ +workspace(name = "resty") + +load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") + +http_archive( + name = "io_bazel_rules_go", + sha256 = "69de5c704a05ff37862f7e0f5534d4f479418afc21806c887db544a316f3cb6b", + urls = [ + "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.27.0/rules_go-v0.27.0.tar.gz", + "https://github.com/bazelbuild/rules_go/releases/download/v0.27.0/rules_go-v0.27.0.tar.gz", + ], +) + +http_archive( + name = "bazel_gazelle", + sha256 = "62ca106be173579c0a167deb23358fdfe71ffa1e4cfdddf5582af26520f1c66f", + urls = [ + "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.23.0/bazel-gazelle-v0.23.0.tar.gz", + "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.23.0/bazel-gazelle-v0.23.0.tar.gz", + ], +) + +load("@io_bazel_rules_go//go:deps.bzl", "go_register_toolchains", "go_rules_dependencies") + +go_rules_dependencies() + +go_register_toolchains(version = "1.16") + +load("@bazel_gazelle//:deps.bzl", "gazelle_dependencies") + +gazelle_dependencies() diff --git a/vendor/github.com/go-resty/resty/v2/client.go b/vendor/github.com/go-resty/resty/v2/client.go new file mode 100644 index 000000000..446ba8517 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/client.go @@ -0,0 +1,1391 @@ +// Copyright (c) 2015-2023 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "bytes" + "compress/gzip" + "crypto/tls" + "crypto/x509" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "math" + "net/http" + "net/url" + "os" + "reflect" + "regexp" + "strings" + "sync" + "time" +) + +const ( + // MethodGet HTTP method + MethodGet = "GET" + + // MethodPost HTTP method + MethodPost = "POST" + + // MethodPut HTTP method + MethodPut = "PUT" + + // MethodDelete HTTP method + MethodDelete = "DELETE" + + // MethodPatch HTTP method + MethodPatch = "PATCH" + + // MethodHead HTTP method + MethodHead = "HEAD" + + // MethodOptions HTTP method + MethodOptions = "OPTIONS" +) + +var ( + hdrUserAgentKey = http.CanonicalHeaderKey("User-Agent") + hdrAcceptKey = http.CanonicalHeaderKey("Accept") + hdrContentTypeKey = http.CanonicalHeaderKey("Content-Type") + hdrContentLengthKey = http.CanonicalHeaderKey("Content-Length") + hdrContentEncodingKey = http.CanonicalHeaderKey("Content-Encoding") + hdrLocationKey = http.CanonicalHeaderKey("Location") + hdrAuthorizationKey = http.CanonicalHeaderKey("Authorization") + hdrWwwAuthenticateKey = http.CanonicalHeaderKey("WWW-Authenticate") + + plainTextType = "text/plain; charset=utf-8" + jsonContentType = "application/json" + formContentType = "application/x-www-form-urlencoded" + + jsonCheck = regexp.MustCompile(`(?i:(application|text)/(.*json.*)(;|$))`) + xmlCheck = regexp.MustCompile(`(?i:(application|text)/(.*xml.*)(;|$))`) + + hdrUserAgentValue = "go-resty/" + Version + " (https://github.com/go-resty/resty)" + bufPool = &sync.Pool{New: func() interface{} { return &bytes.Buffer{} }} +) + +type ( + // RequestMiddleware type is for request middleware, called before a request is sent + RequestMiddleware func(*Client, *Request) error + + // ResponseMiddleware type is for response middleware, called after a response has been received + ResponseMiddleware func(*Client, *Response) error + + // PreRequestHook type is for the request hook, called right before the request is sent + PreRequestHook func(*Client, *http.Request) error + + // RequestLogCallback type is for request logs, called before the request is logged + RequestLogCallback func(*RequestLog) error + + // ResponseLogCallback type is for response logs, called before the response is logged + ResponseLogCallback func(*ResponseLog) error + + // ErrorHook type is for reacting to request errors, called after all retries were attempted + ErrorHook func(*Request, error) + + // SuccessHook type is for reacting to request success + SuccessHook func(*Client, *Response) +) + +// Client struct is used to create Resty client with client level settings, +// these settings are applicable to all the request raised from the client. +// +// Resty also provides an options to override most of the client settings +// at request level. +type Client struct { + BaseURL string + HostURL string // Deprecated: use BaseURL instead. To be removed in v3.0.0 release. + QueryParam url.Values + FormData url.Values + PathParams map[string]string + RawPathParams map[string]string + Header http.Header + UserInfo *User + Token string + AuthScheme string + Cookies []*http.Cookie + Error reflect.Type + Debug bool + DisableWarn bool + AllowGetMethodPayload bool + RetryCount int + RetryWaitTime time.Duration + RetryMaxWaitTime time.Duration + RetryConditions []RetryConditionFunc + RetryHooks []OnRetryFunc + RetryAfter RetryAfterFunc + RetryResetReaders bool + JSONMarshal func(v interface{}) ([]byte, error) + JSONUnmarshal func(data []byte, v interface{}) error + XMLMarshal func(v interface{}) ([]byte, error) + XMLUnmarshal func(data []byte, v interface{}) error + + // HeaderAuthorizationKey is used to set/access Request Authorization header + // value when `SetAuthToken` option is used. + HeaderAuthorizationKey string + + jsonEscapeHTML bool + setContentLength bool + closeConnection bool + notParseResponse bool + trace bool + debugBodySizeLimit int64 + outputDirectory string + scheme string + log Logger + httpClient *http.Client + proxyURL *url.URL + beforeRequest []RequestMiddleware + udBeforeRequest []RequestMiddleware + udBeforeRequestLock sync.RWMutex + preReqHook PreRequestHook + successHooks []SuccessHook + afterResponse []ResponseMiddleware + afterResponseLock sync.RWMutex + requestLog RequestLogCallback + responseLog ResponseLogCallback + errorHooks []ErrorHook + invalidHooks []ErrorHook + panicHooks []ErrorHook + rateLimiter RateLimiter +} + +// User type is to hold an username and password information +type User struct { + Username, Password string +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Client methods +//___________________________________ + +// SetHostURL method is to set Host URL in the client instance. It will be used with request +// raised from this client with relative URL +// +// // Setting HTTP address +// client.SetHostURL("http://myjeeva.com") +// +// // Setting HTTPS address +// client.SetHostURL("https://myjeeva.com") +// +// Deprecated: use SetBaseURL instead. To be removed in v3.0.0 release. +func (c *Client) SetHostURL(url string) *Client { + c.SetBaseURL(url) + return c +} + +// SetBaseURL method is to set Base URL in the client instance. It will be used with request +// raised from this client with relative URL +// +// // Setting HTTP address +// client.SetBaseURL("http://myjeeva.com") +// +// // Setting HTTPS address +// client.SetBaseURL("https://myjeeva.com") +// +// Since v2.7.0 +func (c *Client) SetBaseURL(url string) *Client { + c.BaseURL = strings.TrimRight(url, "/") + c.HostURL = c.BaseURL + return c +} + +// SetHeader method sets a single header field and its value in the client instance. +// These headers will be applied to all requests raised from this client instance. +// Also it can be overridden at request level header options. +// +// See `Request.SetHeader` or `Request.SetHeaders`. +// +// For Example: To set `Content-Type` and `Accept` as `application/json` +// +// client. +// SetHeader("Content-Type", "application/json"). +// SetHeader("Accept", "application/json") +func (c *Client) SetHeader(header, value string) *Client { + c.Header.Set(header, value) + return c +} + +// SetHeaders method sets multiple headers field and its values at one go in the client instance. +// These headers will be applied to all requests raised from this client instance. Also it can be +// overridden at request level headers options. +// +// See `Request.SetHeaders` or `Request.SetHeader`. +// +// For Example: To set `Content-Type` and `Accept` as `application/json` +// +// client.SetHeaders(map[string]string{ +// "Content-Type": "application/json", +// "Accept": "application/json", +// }) +func (c *Client) SetHeaders(headers map[string]string) *Client { + for h, v := range headers { + c.Header.Set(h, v) + } + return c +} + +// SetHeaderVerbatim method is to set a single header field and its value verbatim in the current request. +// +// For Example: To set `all_lowercase` and `UPPERCASE` as `available`. +// +// client.R(). +// SetHeaderVerbatim("all_lowercase", "available"). +// SetHeaderVerbatim("UPPERCASE", "available") +// +// Also you can override header value, which was set at client instance level. +// +// Since v2.6.0 +func (c *Client) SetHeaderVerbatim(header, value string) *Client { + c.Header[header] = []string{value} + return c +} + +// SetCookieJar method sets custom http.CookieJar in the resty client. Its way to override default. +// +// For Example: sometimes we don't want to save cookies in api contacting, we can remove the default +// CookieJar in resty client. +// +// client.SetCookieJar(nil) +func (c *Client) SetCookieJar(jar http.CookieJar) *Client { + c.httpClient.Jar = jar + return c +} + +// SetCookie method appends a single cookie in the client instance. +// These cookies will be added to all the request raised from this client instance. +// +// client.SetCookie(&http.Cookie{ +// Name:"go-resty", +// Value:"This is cookie value", +// }) +func (c *Client) SetCookie(hc *http.Cookie) *Client { + c.Cookies = append(c.Cookies, hc) + return c +} + +// SetCookies method sets an array of cookies in the client instance. +// These cookies will be added to all the request raised from this client instance. +// +// cookies := []*http.Cookie{ +// &http.Cookie{ +// Name:"go-resty-1", +// Value:"This is cookie 1 value", +// }, +// &http.Cookie{ +// Name:"go-resty-2", +// Value:"This is cookie 2 value", +// }, +// } +// +// // Setting a cookies into resty +// client.SetCookies(cookies) +func (c *Client) SetCookies(cs []*http.Cookie) *Client { + c.Cookies = append(c.Cookies, cs...) + return c +} + +// SetQueryParam method sets single parameter and its value in the client instance. +// It will be formed as query string for the request. +// +// For Example: `search=kitchen%20papers&size=large` +// in the URL after `?` mark. These query params will be added to all the request raised from +// this client instance. Also it can be overridden at request level Query Param options. +// +// See `Request.SetQueryParam` or `Request.SetQueryParams`. +// +// client. +// SetQueryParam("search", "kitchen papers"). +// SetQueryParam("size", "large") +func (c *Client) SetQueryParam(param, value string) *Client { + c.QueryParam.Set(param, value) + return c +} + +// SetQueryParams method sets multiple parameters and their values at one go in the client instance. +// It will be formed as query string for the request. +// +// For Example: `search=kitchen%20papers&size=large` +// in the URL after `?` mark. These query params will be added to all the request raised from this +// client instance. Also it can be overridden at request level Query Param options. +// +// See `Request.SetQueryParams` or `Request.SetQueryParam`. +// +// client.SetQueryParams(map[string]string{ +// "search": "kitchen papers", +// "size": "large", +// }) +func (c *Client) SetQueryParams(params map[string]string) *Client { + for p, v := range params { + c.SetQueryParam(p, v) + } + return c +} + +// SetFormData method sets Form parameters and their values in the client instance. +// It's applicable only HTTP method `POST` and `PUT` and request content type would be set as +// `application/x-www-form-urlencoded`. These form data will be added to all the request raised from +// this client instance. Also it can be overridden at request level form data. +// +// See `Request.SetFormData`. +// +// client.SetFormData(map[string]string{ +// "access_token": "BC594900-518B-4F7E-AC75-BD37F019E08F", +// "user_id": "3455454545", +// }) +func (c *Client) SetFormData(data map[string]string) *Client { + for k, v := range data { + c.FormData.Set(k, v) + } + return c +} + +// SetBasicAuth method sets the basic authentication header in the HTTP request. For Example: +// +// Authorization: Basic +// +// For Example: To set the header for username "go-resty" and password "welcome" +// +// client.SetBasicAuth("go-resty", "welcome") +// +// This basic auth information gets added to all the request raised from this client instance. +// Also it can be overridden or set one at the request level is supported. +// +// See `Request.SetBasicAuth`. +func (c *Client) SetBasicAuth(username, password string) *Client { + c.UserInfo = &User{Username: username, Password: password} + return c +} + +// SetAuthToken method sets the auth token of the `Authorization` header for all HTTP requests. +// The default auth scheme is `Bearer`, it can be customized with the method `SetAuthScheme`. For Example: +// +// Authorization: +// +// For Example: To set auth token BC594900518B4F7EAC75BD37F019E08FBC594900518B4F7EAC75BD37F019E08F +// +// client.SetAuthToken("BC594900518B4F7EAC75BD37F019E08FBC594900518B4F7EAC75BD37F019E08F") +// +// This auth token gets added to all the requests raised from this client instance. +// Also it can be overridden or set one at the request level is supported. +// +// See `Request.SetAuthToken`. +func (c *Client) SetAuthToken(token string) *Client { + c.Token = token + return c +} + +// SetAuthScheme method sets the auth scheme type in the HTTP request. For Example: +// +// Authorization: +// +// For Example: To set the scheme to use OAuth +// +// client.SetAuthScheme("OAuth") +// +// This auth scheme gets added to all the requests raised from this client instance. +// Also it can be overridden or set one at the request level is supported. +// +// Information about auth schemes can be found in RFC7235 which is linked to below +// along with the page containing the currently defined official authentication schemes: +// +// https://tools.ietf.org/html/rfc7235 +// https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml#authschemes +// +// See `Request.SetAuthToken`. +func (c *Client) SetAuthScheme(scheme string) *Client { + c.AuthScheme = scheme + return c +} + +// SetDigestAuth method sets the Digest Access auth scheme for the client. If a server responds with 401 and sends +// a Digest challenge in the WWW-Authenticate Header, requests will be resent with the appropriate Authorization Header. +// +// For Example: To set the Digest scheme with user "Mufasa" and password "Circle Of Life" +// +// client.SetDigestAuth("Mufasa", "Circle Of Life") +// +// Information about Digest Access Authentication can be found in RFC7616: +// +// https://datatracker.ietf.org/doc/html/rfc7616 +// +// See `Request.SetDigestAuth`. +func (c *Client) SetDigestAuth(username, password string) *Client { + oldTransport := c.httpClient.Transport + c.OnBeforeRequest(func(c *Client, _ *Request) error { + c.httpClient.Transport = &digestTransport{ + digestCredentials: digestCredentials{username, password}, + transport: oldTransport, + } + return nil + }) + c.OnAfterResponse(func(c *Client, _ *Response) error { + c.httpClient.Transport = oldTransport + return nil + }) + return c +} + +// R method creates a new request instance, its used for Get, Post, Put, Delete, Patch, Head, Options, etc. +func (c *Client) R() *Request { + r := &Request{ + QueryParam: url.Values{}, + FormData: url.Values{}, + Header: http.Header{}, + Cookies: make([]*http.Cookie, 0), + PathParams: map[string]string{}, + RawPathParams: map[string]string{}, + Debug: c.Debug, + + client: c, + multipartFiles: []*File{}, + multipartFields: []*MultipartField{}, + jsonEscapeHTML: c.jsonEscapeHTML, + log: c.log, + } + return r +} + +// NewRequest is an alias for method `R()`. Creates a new request instance, its used for +// Get, Post, Put, Delete, Patch, Head, Options, etc. +func (c *Client) NewRequest() *Request { + return c.R() +} + +// OnBeforeRequest method appends a request middleware into the before request chain. +// The user defined middlewares get applied before the default Resty request middlewares. +// After all middlewares have been applied, the request is sent from Resty to the host server. +// +// client.OnBeforeRequest(func(c *resty.Client, r *resty.Request) error { +// // Now you have access to Client and Request instance +// // manipulate it as per your need +// +// return nil // if its success otherwise return error +// }) +func (c *Client) OnBeforeRequest(m RequestMiddleware) *Client { + c.udBeforeRequestLock.Lock() + defer c.udBeforeRequestLock.Unlock() + + c.udBeforeRequest = append(c.udBeforeRequest, m) + + return c +} + +// OnAfterResponse method appends response middleware into the after response chain. +// Once we receive response from host server, default Resty response middleware +// gets applied and then user assigned response middlewares applied. +// +// client.OnAfterResponse(func(c *resty.Client, r *resty.Response) error { +// // Now you have access to Client and Response instance +// // manipulate it as per your need +// +// return nil // if its success otherwise return error +// }) +func (c *Client) OnAfterResponse(m ResponseMiddleware) *Client { + c.afterResponseLock.Lock() + defer c.afterResponseLock.Unlock() + + c.afterResponse = append(c.afterResponse, m) + + return c +} + +// OnError method adds a callback that will be run whenever a request execution fails. +// This is called after all retries have been attempted (if any). +// If there was a response from the server, the error will be wrapped in *ResponseError +// which has the last response received from the server. +// +// client.OnError(func(req *resty.Request, err error) { +// if v, ok := err.(*resty.ResponseError); ok { +// // Do something with v.Response +// } +// // Log the error, increment a metric, etc... +// }) +// +// Out of the OnSuccess, OnError, OnInvalid, OnPanic callbacks, exactly one +// set will be invoked for each call to Request.Execute() that completes. +func (c *Client) OnError(h ErrorHook) *Client { + c.errorHooks = append(c.errorHooks, h) + return c +} + +// OnSuccess method adds a callback that will be run whenever a request execution +// succeeds. This is called after all retries have been attempted (if any). +// +// Out of the OnSuccess, OnError, OnInvalid, OnPanic callbacks, exactly one +// set will be invoked for each call to Request.Execute() that completes. +// +// Since v2.8.0 +func (c *Client) OnSuccess(h SuccessHook) *Client { + c.successHooks = append(c.successHooks, h) + return c +} + +// OnInvalid method adds a callback that will be run whenever a request execution +// fails before it starts because the request is invalid. +// +// Out of the OnSuccess, OnError, OnInvalid, OnPanic callbacks, exactly one +// set will be invoked for each call to Request.Execute() that completes. +// +// Since v2.8.0 +func (c *Client) OnInvalid(h ErrorHook) *Client { + c.invalidHooks = append(c.invalidHooks, h) + return c +} + +// OnPanic method adds a callback that will be run whenever a request execution +// panics. +// +// Out of the OnSuccess, OnError, OnInvalid, OnPanic callbacks, exactly one +// set will be invoked for each call to Request.Execute() that completes. +// If an OnSuccess, OnError, or OnInvalid callback panics, then the exactly +// one rule can be violated. +// +// Since v2.8.0 +func (c *Client) OnPanic(h ErrorHook) *Client { + c.panicHooks = append(c.panicHooks, h) + return c +} + +// SetPreRequestHook method sets the given pre-request function into resty client. +// It is called right before the request is fired. +// +// Note: Only one pre-request hook can be registered. Use `client.OnBeforeRequest` for multiple. +func (c *Client) SetPreRequestHook(h PreRequestHook) *Client { + if c.preReqHook != nil { + c.log.Warnf("Overwriting an existing pre-request hook: %s", functionName(h)) + } + c.preReqHook = h + return c +} + +// SetDebug method enables the debug mode on Resty client. Client logs details of every request and response. +// For `Request` it logs information such as HTTP verb, Relative URL path, Host, Headers, Body if it has one. +// For `Response` it logs information such as Status, Response Time, Headers, Body if it has one. +// +// client.SetDebug(true) +// +// Also it can be enabled at request level for particular request, see `Request.SetDebug`. +func (c *Client) SetDebug(d bool) *Client { + c.Debug = d + return c +} + +// SetDebugBodyLimit sets the maximum size for which the response and request body will be logged in debug mode. +// +// client.SetDebugBodyLimit(1000000) +func (c *Client) SetDebugBodyLimit(sl int64) *Client { + c.debugBodySizeLimit = sl + return c +} + +// OnRequestLog method used to set request log callback into Resty. Registered callback gets +// called before the resty actually logs the information. +func (c *Client) OnRequestLog(rl RequestLogCallback) *Client { + if c.requestLog != nil { + c.log.Warnf("Overwriting an existing on-request-log callback from=%s to=%s", + functionName(c.requestLog), functionName(rl)) + } + c.requestLog = rl + return c +} + +// OnResponseLog method used to set response log callback into Resty. Registered callback gets +// called before the resty actually logs the information. +func (c *Client) OnResponseLog(rl ResponseLogCallback) *Client { + if c.responseLog != nil { + c.log.Warnf("Overwriting an existing on-response-log callback from=%s to=%s", + functionName(c.responseLog), functionName(rl)) + } + c.responseLog = rl + return c +} + +// SetDisableWarn method disables the warning message on Resty client. +// +// For Example: Resty warns the user when BasicAuth used on non-TLS mode. +// +// client.SetDisableWarn(true) +func (c *Client) SetDisableWarn(d bool) *Client { + c.DisableWarn = d + return c +} + +// SetAllowGetMethodPayload method allows the GET method with payload on Resty client. +// +// For Example: Resty allows the user sends request with a payload on HTTP GET method. +// +// client.SetAllowGetMethodPayload(true) +func (c *Client) SetAllowGetMethodPayload(a bool) *Client { + c.AllowGetMethodPayload = a + return c +} + +// SetLogger method sets given writer for logging Resty request and response details. +// +// Compliant to interface `resty.Logger`. +func (c *Client) SetLogger(l Logger) *Client { + c.log = l + return c +} + +// SetContentLength method enables the HTTP header `Content-Length` value for every request. +// By default Resty won't set `Content-Length`. +// +// client.SetContentLength(true) +// +// Also you have an option to enable for particular request. See `Request.SetContentLength` +func (c *Client) SetContentLength(l bool) *Client { + c.setContentLength = l + return c +} + +// SetTimeout method sets timeout for request raised from client. +// +// client.SetTimeout(time.Duration(1 * time.Minute)) +func (c *Client) SetTimeout(timeout time.Duration) *Client { + c.httpClient.Timeout = timeout + return c +} + +// SetError method is to register the global or client common `Error` object into Resty. +// It is used for automatic unmarshalling if response status code is greater than 399 and +// content type either JSON or XML. Can be pointer or non-pointer. +// +// client.SetError(&Error{}) +// // OR +// client.SetError(Error{}) +func (c *Client) SetError(err interface{}) *Client { + c.Error = typeOf(err) + return c +} + +// SetRedirectPolicy method sets the client redirect policy. Resty provides ready to use +// redirect policies. Wanna create one for yourself refer to `redirect.go`. +// +// client.SetRedirectPolicy(FlexibleRedirectPolicy(20)) +// +// // Need multiple redirect policies together +// client.SetRedirectPolicy(FlexibleRedirectPolicy(20), DomainCheckRedirectPolicy("host1.com", "host2.net")) +func (c *Client) SetRedirectPolicy(policies ...interface{}) *Client { + for _, p := range policies { + if _, ok := p.(RedirectPolicy); !ok { + c.log.Errorf("%v does not implement resty.RedirectPolicy (missing Apply method)", + functionName(p)) + } + } + + c.httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + for _, p := range policies { + if err := p.(RedirectPolicy).Apply(req, via); err != nil { + return err + } + } + return nil // looks good, go ahead + } + + return c +} + +// SetRetryCount method enables retry on Resty client and allows you +// to set no. of retry count. Resty uses a Backoff mechanism. +func (c *Client) SetRetryCount(count int) *Client { + c.RetryCount = count + return c +} + +// SetRetryWaitTime method sets default wait time to sleep before retrying +// request. +// +// Default is 100 milliseconds. +func (c *Client) SetRetryWaitTime(waitTime time.Duration) *Client { + c.RetryWaitTime = waitTime + return c +} + +// SetRetryMaxWaitTime method sets max wait time to sleep before retrying +// request. +// +// Default is 2 seconds. +func (c *Client) SetRetryMaxWaitTime(maxWaitTime time.Duration) *Client { + c.RetryMaxWaitTime = maxWaitTime + return c +} + +// SetRetryAfter sets callback to calculate wait time between retries. +// Default (nil) implies exponential backoff with jitter +func (c *Client) SetRetryAfter(callback RetryAfterFunc) *Client { + c.RetryAfter = callback + return c +} + +// SetJSONMarshaler method sets the JSON marshaler function to marshal the request body. +// By default, Resty uses `encoding/json` package to marshal the request body. +// +// Since v2.8.0 +func (c *Client) SetJSONMarshaler(marshaler func(v interface{}) ([]byte, error)) *Client { + c.JSONMarshal = marshaler + return c +} + +// SetJSONUnmarshaler method sets the JSON unmarshaler function to unmarshal the response body. +// By default, Resty uses `encoding/json` package to unmarshal the response body. +// +// Since v2.8.0 +func (c *Client) SetJSONUnmarshaler(unmarshaler func(data []byte, v interface{}) error) *Client { + c.JSONUnmarshal = unmarshaler + return c +} + +// SetXMLMarshaler method sets the XML marshaler function to marshal the request body. +// By default, Resty uses `encoding/xml` package to marshal the request body. +// +// Since v2.8.0 +func (c *Client) SetXMLMarshaler(marshaler func(v interface{}) ([]byte, error)) *Client { + c.XMLMarshal = marshaler + return c +} + +// SetXMLUnmarshaler method sets the XML unmarshaler function to unmarshal the response body. +// By default, Resty uses `encoding/xml` package to unmarshal the response body. +// +// Since v2.8.0 +func (c *Client) SetXMLUnmarshaler(unmarshaler func(data []byte, v interface{}) error) *Client { + c.XMLUnmarshal = unmarshaler + return c +} + +// AddRetryCondition method adds a retry condition function to array of functions +// that are checked to determine if the request is retried. The request will +// retry if any of the functions return true and error is nil. +// +// Note: These retry conditions are applied on all Request made using this Client. +// For Request specific retry conditions check *Request.AddRetryCondition +func (c *Client) AddRetryCondition(condition RetryConditionFunc) *Client { + c.RetryConditions = append(c.RetryConditions, condition) + return c +} + +// AddRetryAfterErrorCondition adds the basic condition of retrying after encountering +// an error from the http response +// +// Since v2.6.0 +func (c *Client) AddRetryAfterErrorCondition() *Client { + c.AddRetryCondition(func(response *Response, err error) bool { + return response.IsError() + }) + return c +} + +// AddRetryHook adds a side-effecting retry hook to an array of hooks +// that will be executed on each retry. +// +// Since v2.6.0 +func (c *Client) AddRetryHook(hook OnRetryFunc) *Client { + c.RetryHooks = append(c.RetryHooks, hook) + return c +} + +// SetRetryResetReaders method enables the Resty client to seek the start of all +// file readers given as multipart files, if the given object implements `io.ReadSeeker`. +// +// Since ... +func (c *Client) SetRetryResetReaders(b bool) *Client { + c.RetryResetReaders = b + return c +} + +// SetTLSClientConfig method sets TLSClientConfig for underling client Transport. +// +// For Example: +// +// // One can set custom root-certificate. Refer: http://golang.org/pkg/crypto/tls/#example_Dial +// client.SetTLSClientConfig(&tls.Config{ RootCAs: roots }) +// +// // or One can disable security check (https) +// client.SetTLSClientConfig(&tls.Config{ InsecureSkipVerify: true }) +// +// Note: This method overwrites existing `TLSClientConfig`. +func (c *Client) SetTLSClientConfig(config *tls.Config) *Client { + transport, err := c.Transport() + if err != nil { + c.log.Errorf("%v", err) + return c + } + transport.TLSClientConfig = config + return c +} + +// SetProxy method sets the Proxy URL and Port for Resty client. +// +// client.SetProxy("http://proxyserver:8888") +// +// OR Without this `SetProxy` method, you could also set Proxy via environment variable. +// +// Refer to godoc `http.ProxyFromEnvironment`. +func (c *Client) SetProxy(proxyURL string) *Client { + transport, err := c.Transport() + if err != nil { + c.log.Errorf("%v", err) + return c + } + + pURL, err := url.Parse(proxyURL) + if err != nil { + c.log.Errorf("%v", err) + return c + } + + c.proxyURL = pURL + transport.Proxy = http.ProxyURL(c.proxyURL) + return c +} + +// RemoveProxy method removes the proxy configuration from Resty client +// +// client.RemoveProxy() +func (c *Client) RemoveProxy() *Client { + transport, err := c.Transport() + if err != nil { + c.log.Errorf("%v", err) + return c + } + c.proxyURL = nil + transport.Proxy = nil + return c +} + +// SetCertificates method helps to set client certificates into Resty conveniently. +func (c *Client) SetCertificates(certs ...tls.Certificate) *Client { + config, err := c.tlsConfig() + if err != nil { + c.log.Errorf("%v", err) + return c + } + config.Certificates = append(config.Certificates, certs...) + return c +} + +// SetRootCertificate method helps to add one or more root certificates into Resty client +// +// client.SetRootCertificate("/path/to/root/pemFile.pem") +func (c *Client) SetRootCertificate(pemFilePath string) *Client { + rootPemData, err := os.ReadFile(pemFilePath) + if err != nil { + c.log.Errorf("%v", err) + return c + } + + config, err := c.tlsConfig() + if err != nil { + c.log.Errorf("%v", err) + return c + } + if config.RootCAs == nil { + config.RootCAs = x509.NewCertPool() + } + + config.RootCAs.AppendCertsFromPEM(rootPemData) + return c +} + +// SetRootCertificateFromString method helps to add one or more root certificates into Resty client +// +// client.SetRootCertificateFromString("pem file content") +func (c *Client) SetRootCertificateFromString(pemContent string) *Client { + config, err := c.tlsConfig() + if err != nil { + c.log.Errorf("%v", err) + return c + } + if config.RootCAs == nil { + config.RootCAs = x509.NewCertPool() + } + + config.RootCAs.AppendCertsFromPEM([]byte(pemContent)) + return c +} + +// SetOutputDirectory method sets output directory for saving HTTP response into file. +// If the output directory not exists then resty creates one. This setting is optional one, +// if you're planning using absolute path in `Request.SetOutput` and can used together. +// +// client.SetOutputDirectory("/save/http/response/here") +func (c *Client) SetOutputDirectory(dirPath string) *Client { + c.outputDirectory = dirPath + return c +} + +// SetRateLimiter sets an optional `RateLimiter`. If set the rate limiter will control +// all requests made with this client. +// +// Since v2.9.0 +func (c *Client) SetRateLimiter(rl RateLimiter) *Client { + c.rateLimiter = rl + return c +} + +// SetTransport method sets custom `*http.Transport` or any `http.RoundTripper` +// compatible interface implementation in the resty client. +// +// Note: +// +// - If transport is not type of `*http.Transport` then you may not be able to +// take advantage of some of the Resty client settings. +// +// - It overwrites the Resty client transport instance and it's configurations. +// +// transport := &http.Transport{ +// // something like Proxying to httptest.Server, etc... +// Proxy: func(req *http.Request) (*url.URL, error) { +// return url.Parse(server.URL) +// }, +// } +// +// client.SetTransport(transport) +func (c *Client) SetTransport(transport http.RoundTripper) *Client { + if transport != nil { + c.httpClient.Transport = transport + } + return c +} + +// SetScheme method sets custom scheme in the Resty client. It's way to override default. +// +// client.SetScheme("http") +func (c *Client) SetScheme(scheme string) *Client { + if !IsStringEmpty(scheme) { + c.scheme = strings.TrimSpace(scheme) + } + return c +} + +// SetCloseConnection method sets variable `Close` in http request struct with the given +// value. More info: https://golang.org/src/net/http/request.go +func (c *Client) SetCloseConnection(close bool) *Client { + c.closeConnection = close + return c +} + +// SetDoNotParseResponse method instructs `Resty` not to parse the response body automatically. +// Resty exposes the raw response body as `io.ReadCloser`. Also do not forget to close the body, +// otherwise you might get into connection leaks, no connection reuse. +// +// Note: Response middlewares are not applicable, if you use this option. Basically you have +// taken over the control of response parsing from `Resty`. +func (c *Client) SetDoNotParseResponse(parse bool) *Client { + c.notParseResponse = parse + return c +} + +// SetPathParam method sets single URL path key-value pair in the +// Resty client instance. +// +// client.SetPathParam("userId", "sample@sample.com") +// +// Result: +// URL - /v1/users/{userId}/details +// Composed URL - /v1/users/sample@sample.com/details +// +// It replaces the value of the key while composing the request URL. +// The value will be escaped using `url.PathEscape` function. +// +// Also it can be overridden at request level Path Params options, +// see `Request.SetPathParam` or `Request.SetPathParams`. +func (c *Client) SetPathParam(param, value string) *Client { + c.PathParams[param] = value + return c +} + +// SetPathParams method sets multiple URL path key-value pairs at one go in the +// Resty client instance. +// +// client.SetPathParams(map[string]string{ +// "userId": "sample@sample.com", +// "subAccountId": "100002", +// "path": "groups/developers", +// }) +// +// Result: +// URL - /v1/users/{userId}/{subAccountId}/{path}/details +// Composed URL - /v1/users/sample@sample.com/100002/groups%2Fdevelopers/details +// +// It replaces the value of the key while composing the request URL. +// The values will be escaped using `url.PathEscape` function. +// +// Also it can be overridden at request level Path Params options, +// see `Request.SetPathParam` or `Request.SetPathParams`. +func (c *Client) SetPathParams(params map[string]string) *Client { + for p, v := range params { + c.SetPathParam(p, v) + } + return c +} + +// SetRawPathParam method sets single URL path key-value pair in the +// Resty client instance. +// +// client.SetPathParam("userId", "sample@sample.com") +// +// Result: +// URL - /v1/users/{userId}/details +// Composed URL - /v1/users/sample@sample.com/details +// +// client.SetPathParam("path", "groups/developers") +// +// Result: +// URL - /v1/users/{userId}/details +// Composed URL - /v1/users/groups%2Fdevelopers/details +// +// It replaces the value of the key while composing the request URL. +// The value will be used as it is and will not be escaped. +// +// Also it can be overridden at request level Path Params options, +// see `Request.SetPathParam` or `Request.SetPathParams`. +// +// Since v2.8.0 +func (c *Client) SetRawPathParam(param, value string) *Client { + c.RawPathParams[param] = value + return c +} + +// SetRawPathParams method sets multiple URL path key-value pairs at one go in the +// Resty client instance. +// +// client.SetPathParams(map[string]string{ +// "userId": "sample@sample.com", +// "subAccountId": "100002", +// "path": "groups/developers", +// }) +// +// Result: +// URL - /v1/users/{userId}/{subAccountId}/{path}/details +// Composed URL - /v1/users/sample@sample.com/100002/groups/developers/details +// +// It replaces the value of the key while composing the request URL. +// The values will be used as they are and will not be escaped. +// +// Also it can be overridden at request level Path Params options, +// see `Request.SetPathParam` or `Request.SetPathParams`. +// +// Since v2.8.0 +func (c *Client) SetRawPathParams(params map[string]string) *Client { + for p, v := range params { + c.SetRawPathParam(p, v) + } + return c +} + +// SetJSONEscapeHTML method is to enable/disable the HTML escape on JSON marshal. +// +// Note: This option only applicable to standard JSON Marshaller. +func (c *Client) SetJSONEscapeHTML(b bool) *Client { + c.jsonEscapeHTML = b + return c +} + +// EnableTrace method enables the Resty client trace for the requests fired from +// the client using `httptrace.ClientTrace` and provides insights. +// +// client := resty.New().EnableTrace() +// +// resp, err := client.R().Get("https://httpbin.org/get") +// fmt.Println("Error:", err) +// fmt.Println("Trace Info:", resp.Request.TraceInfo()) +// +// Also `Request.EnableTrace` available too to get trace info for single request. +// +// Since v2.0.0 +func (c *Client) EnableTrace() *Client { + c.trace = true + return c +} + +// DisableTrace method disables the Resty client trace. Refer to `Client.EnableTrace`. +// +// Since v2.0.0 +func (c *Client) DisableTrace() *Client { + c.trace = false + return c +} + +// IsProxySet method returns the true is proxy is set from resty client otherwise +// false. By default proxy is set from environment, refer to `http.ProxyFromEnvironment`. +func (c *Client) IsProxySet() bool { + return c.proxyURL != nil +} + +// GetClient method returns the current `http.Client` used by the resty client. +func (c *Client) GetClient() *http.Client { + return c.httpClient +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Client Unexported methods +//_______________________________________________________________________ + +// Executes method executes the given `Request` object and returns response +// error. +func (c *Client) execute(req *Request) (*Response, error) { + // Lock the user-defined pre-request hooks. + c.udBeforeRequestLock.RLock() + defer c.udBeforeRequestLock.RUnlock() + + // Lock the post-request hooks. + c.afterResponseLock.RLock() + defer c.afterResponseLock.RUnlock() + + // Apply Request middleware + var err error + + // user defined on before request methods + // to modify the *resty.Request object + for _, f := range c.udBeforeRequest { + if err = f(c, req); err != nil { + return nil, wrapNoRetryErr(err) + } + } + + // If there is a rate limiter set for this client, the Execute call + // will return an error if the rate limit is exceeded. + if req.client.rateLimiter != nil { + if !req.client.rateLimiter.Allow() { + return nil, wrapNoRetryErr(ErrRateLimitExceeded) + } + } + + // resty middlewares + for _, f := range c.beforeRequest { + if err = f(c, req); err != nil { + return nil, wrapNoRetryErr(err) + } + } + + if hostHeader := req.Header.Get("Host"); hostHeader != "" { + req.RawRequest.Host = hostHeader + } + + // call pre-request if defined + if c.preReqHook != nil { + if err = c.preReqHook(c, req.RawRequest); err != nil { + return nil, wrapNoRetryErr(err) + } + } + + if err = requestLogger(c, req); err != nil { + return nil, wrapNoRetryErr(err) + } + + req.RawRequest.Body = newRequestBodyReleaser(req.RawRequest.Body, req.bodyBuf) + + req.Time = time.Now() + resp, err := c.httpClient.Do(req.RawRequest) + + response := &Response{ + Request: req, + RawResponse: resp, + } + + if err != nil || req.notParseResponse || c.notParseResponse { + response.setReceivedAt() + return response, err + } + + if !req.isSaveResponse { + defer closeq(resp.Body) + body := resp.Body + + // GitHub #142 & #187 + if strings.EqualFold(resp.Header.Get(hdrContentEncodingKey), "gzip") && resp.ContentLength != 0 { + if _, ok := body.(*gzip.Reader); !ok { + body, err = gzip.NewReader(body) + if err != nil { + response.setReceivedAt() + return response, err + } + defer closeq(body) + } + } + + if response.body, err = io.ReadAll(body); err != nil { + response.setReceivedAt() + return response, err + } + + response.size = int64(len(response.body)) + } + + response.setReceivedAt() // after we read the body + + // Apply Response middleware + for _, f := range c.afterResponse { + if err = f(c, response); err != nil { + break + } + } + + return response, wrapNoRetryErr(err) +} + +// getting TLS client config if not exists then create one +func (c *Client) tlsConfig() (*tls.Config, error) { + transport, err := c.Transport() + if err != nil { + return nil, err + } + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{} + } + return transport.TLSClientConfig, nil +} + +// Transport method returns `*http.Transport` currently in use or error +// in case currently used `transport` is not a `*http.Transport`. +// +// Since v2.8.0 become exported method. +func (c *Client) Transport() (*http.Transport, error) { + if transport, ok := c.httpClient.Transport.(*http.Transport); ok { + return transport, nil + } + return nil, errors.New("current transport is not an *http.Transport instance") +} + +// just an internal helper method +func (c *Client) outputLogTo(w io.Writer) *Client { + c.log.(*logger).l.SetOutput(w) + return c +} + +// ResponseError is a wrapper for including the server response with an error. +// Neither the err nor the response should be nil. +type ResponseError struct { + Response *Response + Err error +} + +func (e *ResponseError) Error() string { + return e.Err.Error() +} + +func (e *ResponseError) Unwrap() error { + return e.Err +} + +// Helper to run errorHooks hooks. +// It wraps the error in a ResponseError if the resp is not nil +// so hooks can access it. +func (c *Client) onErrorHooks(req *Request, resp *Response, err error) { + if err != nil { + if resp != nil { // wrap with ResponseError + err = &ResponseError{Response: resp, Err: err} + } + for _, h := range c.errorHooks { + h(req, err) + } + } else { + for _, h := range c.successHooks { + h(c, resp) + } + } +} + +// Helper to run panicHooks hooks. +func (c *Client) onPanicHooks(req *Request, err error) { + for _, h := range c.panicHooks { + h(req, err) + } +} + +// Helper to run invalidHooks hooks. +func (c *Client) onInvalidHooks(req *Request, err error) { + for _, h := range c.invalidHooks { + h(req, err) + } +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// File struct and its methods +//_______________________________________________________________________ + +// File struct represent file information for multipart request +type File struct { + Name string + ParamName string + io.Reader +} + +// String returns string value of current file details +func (f *File) String() string { + return fmt.Sprintf("ParamName: %v; FileName: %v", f.ParamName, f.Name) +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// MultipartField struct +//_______________________________________________________________________ + +// MultipartField struct represent custom data part for multipart request +type MultipartField struct { + Param string + FileName string + ContentType string + io.Reader +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Unexported package methods +//_______________________________________________________________________ + +func createClient(hc *http.Client) *Client { + if hc.Transport == nil { + hc.Transport = createTransport(nil) + } + + c := &Client{ // not setting lang default values + QueryParam: url.Values{}, + FormData: url.Values{}, + Header: http.Header{}, + Cookies: make([]*http.Cookie, 0), + RetryWaitTime: defaultWaitTime, + RetryMaxWaitTime: defaultMaxWaitTime, + PathParams: make(map[string]string), + RawPathParams: make(map[string]string), + JSONMarshal: json.Marshal, + JSONUnmarshal: json.Unmarshal, + XMLMarshal: xml.Marshal, + XMLUnmarshal: xml.Unmarshal, + HeaderAuthorizationKey: http.CanonicalHeaderKey("Authorization"), + + jsonEscapeHTML: true, + httpClient: hc, + debugBodySizeLimit: math.MaxInt32, + } + + // Logger + c.SetLogger(createLogger()) + + // default before request middlewares + c.beforeRequest = []RequestMiddleware{ + parseRequestURL, + parseRequestHeader, + parseRequestBody, + createHTTPRequest, + addCredentials, + } + + // user defined request middlewares + c.udBeforeRequest = []RequestMiddleware{} + + // default after response middlewares + c.afterResponse = []ResponseMiddleware{ + responseLogger, + parseResponseBody, + saveResponseIntoFile, + } + + return c +} diff --git a/vendor/github.com/go-resty/resty/v2/digest.go b/vendor/github.com/go-resty/resty/v2/digest.go new file mode 100644 index 000000000..9dd3a13b5 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/digest.go @@ -0,0 +1,295 @@ +// Copyright (c) 2015-2023 Jeevanandam M (jeeva@myjeeva.com) +// 2023 Segev Dagan (https://github.com/segevda) +// All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "crypto/md5" + "crypto/rand" + "crypto/sha256" + "crypto/sha512" + "errors" + "fmt" + "hash" + "io" + "net/http" + "strings" +) + +var ( + ErrDigestBadChallenge = errors.New("digest: challenge is bad") + ErrDigestCharset = errors.New("digest: unsupported charset") + ErrDigestAlgNotSupported = errors.New("digest: algorithm is not supported") + ErrDigestQopNotSupported = errors.New("digest: no supported qop in list") + ErrDigestNoQop = errors.New("digest: qop must be specified") +) + +var hashFuncs = map[string]func() hash.Hash{ + "": md5.New, + "MD5": md5.New, + "MD5-sess": md5.New, + "SHA-256": sha256.New, + "SHA-256-sess": sha256.New, + "SHA-512-256": sha512.New, + "SHA-512-256-sess": sha512.New, +} + +type digestCredentials struct { + username, password string +} + +type digestTransport struct { + digestCredentials + transport http.RoundTripper +} + +func (dt *digestTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Copy the request, so we don't modify the input. + req2 := new(http.Request) + *req2 = *req + req2.Header = make(http.Header) + for k, s := range req.Header { + req2.Header[k] = s + } + + // Fix http: ContentLength=xxx with Body length 0 + if req2.Body == nil { + req2.ContentLength = 0 + } else if req2.GetBody != nil { + var err error + req2.Body, err = req2.GetBody() + if err != nil { + return nil, err + } + } + + // Make a request to get the 401 that contains the challenge. + resp, err := dt.transport.RoundTrip(req) + if err != nil || resp.StatusCode != http.StatusUnauthorized { + return resp, err + } + chal := resp.Header.Get(hdrWwwAuthenticateKey) + if chal == "" { + return resp, ErrDigestBadChallenge + } + + c, err := parseChallenge(chal) + if err != nil { + return resp, err + } + + // Form credentials based on the challenge + cr := dt.newCredentials(req2, c) + auth, err := cr.authorize() + if err != nil { + return resp, err + } + err = resp.Body.Close() + if err != nil { + return nil, err + } + + // Make authenticated request + req2.Header.Set(hdrAuthorizationKey, auth) + return dt.transport.RoundTrip(req2) +} + +func (dt *digestTransport) newCredentials(req *http.Request, c *challenge) *credentials { + return &credentials{ + username: dt.username, + userhash: c.userhash, + realm: c.realm, + nonce: c.nonce, + digestURI: req.URL.RequestURI(), + algorithm: c.algorithm, + sessionAlg: strings.HasSuffix(c.algorithm, "-sess"), + opaque: c.opaque, + messageQop: c.qop, + nc: 0, + method: req.Method, + password: dt.password, + } +} + +type challenge struct { + realm string + domain string + nonce string + opaque string + stale string + algorithm string + qop string + userhash string +} + +func parseChallenge(input string) (*challenge, error) { + const ws = " \n\r\t" + const qs = `"` + s := strings.Trim(input, ws) + if !strings.HasPrefix(s, "Digest ") { + return nil, ErrDigestBadChallenge + } + s = strings.Trim(s[7:], ws) + sl := strings.Split(s, ",") + c := &challenge{} + var r []string + for i := range sl { + sl[i] = strings.TrimSpace(sl[i]) + r = strings.SplitN(sl[i], "=", 2) + if len(r) != 2 { + return nil, ErrDigestBadChallenge + } + r[0] = strings.TrimSpace(r[0]) + r[1] = strings.TrimSpace(r[1]) + switch r[0] { + case "realm": + c.realm = strings.Trim(r[1], qs) + case "domain": + c.domain = strings.Trim(r[1], qs) + case "nonce": + c.nonce = strings.Trim(r[1], qs) + case "opaque": + c.opaque = strings.Trim(r[1], qs) + case "stale": + c.stale = strings.Trim(r[1], qs) + case "algorithm": + c.algorithm = strings.Trim(r[1], qs) + case "qop": + c.qop = strings.Trim(r[1], qs) + case "charset": + if strings.ToUpper(strings.Trim(r[1], qs)) != "UTF-8" { + return nil, ErrDigestCharset + } + case "userhash": + c.userhash = strings.Trim(r[1], qs) + default: + return nil, ErrDigestBadChallenge + } + } + return c, nil +} + +type credentials struct { + username string + userhash string + realm string + nonce string + digestURI string + algorithm string + sessionAlg bool + cNonce string + opaque string + messageQop string + nc int + method string + password string +} + +func (c *credentials) authorize() (string, error) { + if _, ok := hashFuncs[c.algorithm]; !ok { + return "", ErrDigestAlgNotSupported + } + + if err := c.validateQop(); err != nil { + return "", err + } + + resp, err := c.resp() + if err != nil { + return "", err + } + + sl := make([]string, 0, 10) + if c.userhash == "true" { + // RFC 7616 3.4.4 + c.username = c.h(fmt.Sprintf("%s:%s", c.username, c.realm)) + sl = append(sl, fmt.Sprintf(`userhash=%s`, c.userhash)) + } + sl = append(sl, fmt.Sprintf(`username="%s"`, c.username)) + sl = append(sl, fmt.Sprintf(`realm="%s"`, c.realm)) + sl = append(sl, fmt.Sprintf(`nonce="%s"`, c.nonce)) + sl = append(sl, fmt.Sprintf(`uri="%s"`, c.digestURI)) + sl = append(sl, fmt.Sprintf(`response="%s"`, resp)) + sl = append(sl, fmt.Sprintf(`algorithm=%s`, c.algorithm)) + if c.opaque != "" { + sl = append(sl, fmt.Sprintf(`opaque="%s"`, c.opaque)) + } + if c.messageQop != "" { + sl = append(sl, fmt.Sprintf("qop=%s", c.messageQop)) + sl = append(sl, fmt.Sprintf("nc=%08x", c.nc)) + sl = append(sl, fmt.Sprintf(`cnonce="%s"`, c.cNonce)) + } + + return fmt.Sprintf("Digest %s", strings.Join(sl, ", ")), nil +} + +func (c *credentials) validateQop() error { + // Currently only supporting auth quality of protection. TODO: add auth-int support + // NOTE: cURL support auth-int qop for requests other than POST and PUT (i.e. w/o body) by hashing an empty string + // is this applicable for resty? see: https://github.com/curl/curl/blob/307b7543ea1e73ab04e062bdbe4b5bb409eaba3a/lib/vauth/digest.c#L774 + if c.messageQop == "" { + return ErrDigestNoQop + } + possibleQops := strings.Split(c.messageQop, ", ") + var authSupport bool + for _, qop := range possibleQops { + if qop == "auth" { + authSupport = true + break + } + } + if !authSupport { + return ErrDigestQopNotSupported + } + + c.messageQop = "auth" + + return nil +} + +func (c *credentials) h(data string) string { + hfCtor := hashFuncs[c.algorithm] + hf := hfCtor() + _, _ = hf.Write([]byte(data)) // Hash.Write never returns an error + return fmt.Sprintf("%x", hf.Sum(nil)) +} + +func (c *credentials) resp() (string, error) { + c.nc++ + + b := make([]byte, 16) + _, err := io.ReadFull(rand.Reader, b) + if err != nil { + return "", err + } + c.cNonce = fmt.Sprintf("%x", b)[:32] + + ha1 := c.ha1() + ha2 := c.ha2() + + return c.kd(ha1, fmt.Sprintf("%s:%08x:%s:%s:%s", + c.nonce, c.nc, c.cNonce, c.messageQop, ha2)), nil +} + +func (c *credentials) kd(secret, data string) string { + return c.h(fmt.Sprintf("%s:%s", secret, data)) +} + +// RFC 7616 3.4.2 +func (c *credentials) ha1() string { + ret := c.h(fmt.Sprintf("%s:%s:%s", c.username, c.realm, c.password)) + if c.sessionAlg { + return c.h(fmt.Sprintf("%s:%s:%s", ret, c.nonce, c.cNonce)) + } + + return ret +} + +// RFC 7616 3.4.3 +func (c *credentials) ha2() string { + // currently no auth-int support + return c.h(fmt.Sprintf("%s:%s", c.method, c.digestURI)) +} diff --git a/vendor/github.com/go-resty/resty/v2/middleware.go b/vendor/github.com/go-resty/resty/v2/middleware.go new file mode 100644 index 000000000..ac2bbc9e8 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/middleware.go @@ -0,0 +1,589 @@ +// Copyright (c) 2015-2023 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "bytes" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "time" +) + +const debugRequestLogKey = "__restyDebugRequestLog" + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Request Middleware(s) +//_______________________________________________________________________ + +func parseRequestURL(c *Client, r *Request) error { + if l := len(c.PathParams) + len(c.RawPathParams) + len(r.PathParams) + len(r.RawPathParams); l > 0 { + params := make(map[string]string, l) + + // GitHub #103 Path Params + for p, v := range r.PathParams { + params[p] = url.PathEscape(v) + } + for p, v := range c.PathParams { + if _, ok := params[p]; !ok { + params[p] = url.PathEscape(v) + } + } + + // GitHub #663 Raw Path Params + for p, v := range r.RawPathParams { + if _, ok := params[p]; !ok { + params[p] = v + } + } + for p, v := range c.RawPathParams { + if _, ok := params[p]; !ok { + params[p] = v + } + } + + if len(params) > 0 { + var prev int + buf := acquireBuffer() + defer releaseBuffer(buf) + // search for the next or first opened curly bracket + for curr := strings.Index(r.URL, "{"); curr > prev; curr = prev + strings.Index(r.URL[prev:], "{") { + // write everything form the previous position up to the current + if curr > prev { + buf.WriteString(r.URL[prev:curr]) + } + // search for the closed curly bracket from current position + next := curr + strings.Index(r.URL[curr:], "}") + // if not found, then write the remainder and exit + if next < curr { + buf.WriteString(r.URL[curr:]) + prev = len(r.URL) + break + } + // special case for {}, without parameter's name + if next == curr+1 { + buf.WriteString("{}") + } else { + // check for the replacement + key := r.URL[curr+1 : next] + value, ok := params[key] + /// keep the original string if the replacement not found + if !ok { + value = r.URL[curr : next+1] + } + buf.WriteString(value) + } + + // set the previous position after the closed curly bracket + prev = next + 1 + if prev >= len(r.URL) { + break + } + } + if buf.Len() > 0 { + // write remainder + if prev < len(r.URL) { + buf.WriteString(r.URL[prev:]) + } + r.URL = buf.String() + } + } + } + + // Parsing request URL + reqURL, err := url.Parse(r.URL) + if err != nil { + return err + } + + // If Request.URL is relative path then added c.HostURL into + // the request URL otherwise Request.URL will be used as-is + if !reqURL.IsAbs() { + r.URL = reqURL.String() + if len(r.URL) > 0 && r.URL[0] != '/' { + r.URL = "/" + r.URL + } + + // TODO: change to use c.BaseURL only in v3.0.0 + baseURL := c.BaseURL + if len(baseURL) == 0 { + baseURL = c.HostURL + } + reqURL, err = url.Parse(baseURL + r.URL) + if err != nil { + return err + } + } + + // GH #407 && #318 + if reqURL.Scheme == "" && len(c.scheme) > 0 { + reqURL.Scheme = c.scheme + } + + // Adding Query Param + if len(c.QueryParam)+len(r.QueryParam) > 0 { + for k, v := range c.QueryParam { + // skip query parameter if it was set in request + if _, ok := r.QueryParam[k]; ok { + continue + } + + r.QueryParam[k] = v[:] + } + + // GitHub #123 Preserve query string order partially. + // Since not feasible in `SetQuery*` resty methods, because + // standard package `url.Encode(...)` sorts the query params + // alphabetically + if len(r.QueryParam) > 0 { + if IsStringEmpty(reqURL.RawQuery) { + reqURL.RawQuery = r.QueryParam.Encode() + } else { + reqURL.RawQuery = reqURL.RawQuery + "&" + r.QueryParam.Encode() + } + } + } + + r.URL = reqURL.String() + + return nil +} + +func parseRequestHeader(c *Client, r *Request) error { + for k, v := range c.Header { + if _, ok := r.Header[k]; ok { + continue + } + r.Header[k] = v[:] + } + + if IsStringEmpty(r.Header.Get(hdrUserAgentKey)) { + r.Header.Set(hdrUserAgentKey, hdrUserAgentValue) + } + + if ct := r.Header.Get(hdrContentTypeKey); IsStringEmpty(r.Header.Get(hdrAcceptKey)) && !IsStringEmpty(ct) && (IsJSONType(ct) || IsXMLType(ct)) { + r.Header.Set(hdrAcceptKey, r.Header.Get(hdrContentTypeKey)) + } + + return nil +} + +func parseRequestBody(c *Client, r *Request) error { + if isPayloadSupported(r.Method, c.AllowGetMethodPayload) { + switch { + case r.isMultiPart: // Handling Multipart + if err := handleMultipart(c, r); err != nil { + return err + } + case len(c.FormData) > 0 || len(r.FormData) > 0: // Handling Form Data + handleFormData(c, r) + case r.Body != nil: // Handling Request body + handleContentType(c, r) + + if err := handleRequestBody(c, r); err != nil { + return err + } + } + } + + // by default resty won't set content length, you can if you want to :) + if c.setContentLength || r.setContentLength { + if r.bodyBuf == nil { + r.Header.Set(hdrContentLengthKey, "0") + } else { + r.Header.Set(hdrContentLengthKey, strconv.Itoa(r.bodyBuf.Len())) + } + } + + return nil +} + +func createHTTPRequest(c *Client, r *Request) (err error) { + if r.bodyBuf == nil { + if reader, ok := r.Body.(io.Reader); ok && isPayloadSupported(r.Method, c.AllowGetMethodPayload) { + r.RawRequest, err = http.NewRequest(r.Method, r.URL, reader) + } else if c.setContentLength || r.setContentLength { + r.RawRequest, err = http.NewRequest(r.Method, r.URL, http.NoBody) + } else { + r.RawRequest, err = http.NewRequest(r.Method, r.URL, nil) + } + } else { + // fix data race: must deep copy. + bodyBuf := bytes.NewBuffer(append([]byte{}, r.bodyBuf.Bytes()...)) + r.RawRequest, err = http.NewRequest(r.Method, r.URL, bodyBuf) + } + + if err != nil { + return + } + + // Assign close connection option + r.RawRequest.Close = c.closeConnection + + // Add headers into http request + r.RawRequest.Header = r.Header + + // Add cookies from client instance into http request + for _, cookie := range c.Cookies { + r.RawRequest.AddCookie(cookie) + } + + // Add cookies from request instance into http request + for _, cookie := range r.Cookies { + r.RawRequest.AddCookie(cookie) + } + + // Enable trace + if c.trace || r.trace { + r.clientTrace = &clientTrace{} + r.ctx = r.clientTrace.createContext(r.Context()) + } + + // Use context if it was specified + if r.ctx != nil { + r.RawRequest = r.RawRequest.WithContext(r.ctx) + } + + bodyCopy, err := getBodyCopy(r) + if err != nil { + return err + } + + // assign get body func for the underlying raw request instance + r.RawRequest.GetBody = func() (io.ReadCloser, error) { + if bodyCopy != nil { + return io.NopCloser(bytes.NewReader(bodyCopy.Bytes())), nil + } + return nil, nil + } + + return +} + +func addCredentials(c *Client, r *Request) error { + var isBasicAuth bool + // Basic Auth + if r.UserInfo != nil { // takes precedence + r.RawRequest.SetBasicAuth(r.UserInfo.Username, r.UserInfo.Password) + isBasicAuth = true + } else if c.UserInfo != nil { + r.RawRequest.SetBasicAuth(c.UserInfo.Username, c.UserInfo.Password) + isBasicAuth = true + } + + if !c.DisableWarn { + if isBasicAuth && !strings.HasPrefix(r.URL, "https") { + r.log.Warnf("Using Basic Auth in HTTP mode is not secure, use HTTPS") + } + } + + // Set the Authorization Header Scheme + var authScheme string + if !IsStringEmpty(r.AuthScheme) { + authScheme = r.AuthScheme + } else if !IsStringEmpty(c.AuthScheme) { + authScheme = c.AuthScheme + } else { + authScheme = "Bearer" + } + + // Build the Token Auth header + if !IsStringEmpty(r.Token) { // takes precedence + r.RawRequest.Header.Set(c.HeaderAuthorizationKey, authScheme+" "+r.Token) + } else if !IsStringEmpty(c.Token) { + r.RawRequest.Header.Set(c.HeaderAuthorizationKey, authScheme+" "+c.Token) + } + + return nil +} + +func requestLogger(c *Client, r *Request) error { + if r.Debug { + rr := r.RawRequest + rh := copyHeaders(rr.Header) + if c.GetClient().Jar != nil { + for _, cookie := range c.GetClient().Jar.Cookies(r.RawRequest.URL) { + s := fmt.Sprintf("%s=%s", cookie.Name, cookie.Value) + if c := rh.Get("Cookie"); c != "" { + rh.Set("Cookie", c+"; "+s) + } else { + rh.Set("Cookie", s) + } + } + } + rl := &RequestLog{Header: rh, Body: r.fmtBodyString(c.debugBodySizeLimit)} + if c.requestLog != nil { + if err := c.requestLog(rl); err != nil { + return err + } + } + + reqLog := "\n==============================================================================\n" + + "~~~ REQUEST ~~~\n" + + fmt.Sprintf("%s %s %s\n", r.Method, rr.URL.RequestURI(), rr.Proto) + + fmt.Sprintf("HOST : %s\n", rr.URL.Host) + + fmt.Sprintf("HEADERS:\n%s\n", composeHeaders(c, r, rl.Header)) + + fmt.Sprintf("BODY :\n%v\n", rl.Body) + + "------------------------------------------------------------------------------\n" + + r.initValuesMap() + r.values[debugRequestLogKey] = reqLog + } + + return nil +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Response Middleware(s) +//_______________________________________________________________________ + +func responseLogger(c *Client, res *Response) error { + if res.Request.Debug { + rl := &ResponseLog{Header: copyHeaders(res.Header()), Body: res.fmtBodyString(c.debugBodySizeLimit)} + if c.responseLog != nil { + if err := c.responseLog(rl); err != nil { + return err + } + } + + debugLog := res.Request.values[debugRequestLogKey].(string) + debugLog += "~~~ RESPONSE ~~~\n" + + fmt.Sprintf("STATUS : %s\n", res.Status()) + + fmt.Sprintf("PROTO : %s\n", res.RawResponse.Proto) + + fmt.Sprintf("RECEIVED AT : %v\n", res.ReceivedAt().Format(time.RFC3339Nano)) + + fmt.Sprintf("TIME DURATION: %v\n", res.Time()) + + "HEADERS :\n" + + composeHeaders(c, res.Request, rl.Header) + "\n" + if res.Request.isSaveResponse { + debugLog += "BODY :\n***** RESPONSE WRITTEN INTO FILE *****\n" + } else { + debugLog += fmt.Sprintf("BODY :\n%v\n", rl.Body) + } + debugLog += "==============================================================================\n" + + res.Request.log.Debugf("%s", debugLog) + } + + return nil +} + +func parseResponseBody(c *Client, res *Response) (err error) { + if res.StatusCode() == http.StatusNoContent { + res.Request.Error = nil + return + } + // Handles only JSON or XML content type + ct := firstNonEmpty(res.Request.forceContentType, res.Header().Get(hdrContentTypeKey), res.Request.fallbackContentType) + if IsJSONType(ct) || IsXMLType(ct) { + // HTTP status code > 199 and < 300, considered as Result + if res.IsSuccess() { + res.Request.Error = nil + if res.Request.Result != nil { + err = Unmarshalc(c, ct, res.body, res.Request.Result) + return + } + } + + // HTTP status code > 399, considered as Error + if res.IsError() { + // global error interface + if res.Request.Error == nil && c.Error != nil { + res.Request.Error = reflect.New(c.Error).Interface() + } + + if res.Request.Error != nil { + unmarshalErr := Unmarshalc(c, ct, res.body, res.Request.Error) + if unmarshalErr != nil { + c.log.Warnf("Cannot unmarshal response body: %s", unmarshalErr) + } + } + } + } + + return +} + +func handleMultipart(c *Client, r *Request) error { + r.bodyBuf = acquireBuffer() + w := multipart.NewWriter(r.bodyBuf) + + for k, v := range c.FormData { + for _, iv := range v { + if err := w.WriteField(k, iv); err != nil { + return err + } + } + } + + for k, v := range r.FormData { + for _, iv := range v { + if strings.HasPrefix(k, "@") { // file + if err := addFile(w, k[1:], iv); err != nil { + return err + } + } else { // form value + if err := w.WriteField(k, iv); err != nil { + return err + } + } + } + } + + // #21 - adding io.Reader support + for _, f := range r.multipartFiles { + if err := addFileReader(w, f); err != nil { + return err + } + } + + // GitHub #130 adding multipart field support with content type + for _, mf := range r.multipartFields { + if err := addMultipartFormField(w, mf); err != nil { + return err + } + } + + r.Header.Set(hdrContentTypeKey, w.FormDataContentType()) + return w.Close() +} + +func handleFormData(c *Client, r *Request) { + for k, v := range c.FormData { + if _, ok := r.FormData[k]; ok { + continue + } + r.FormData[k] = v[:] + } + + r.bodyBuf = acquireBuffer() + r.bodyBuf.WriteString(r.FormData.Encode()) + r.Header.Set(hdrContentTypeKey, formContentType) + r.isFormData = true +} + +func handleContentType(c *Client, r *Request) { + contentType := r.Header.Get(hdrContentTypeKey) + if IsStringEmpty(contentType) { + contentType = DetectContentType(r.Body) + r.Header.Set(hdrContentTypeKey, contentType) + } +} + +func handleRequestBody(c *Client, r *Request) error { + var bodyBytes []byte + r.bodyBuf = nil + + switch body := r.Body.(type) { + case io.Reader: + if c.setContentLength || r.setContentLength { // keep backward compatibility + r.bodyBuf = acquireBuffer() + if _, err := r.bodyBuf.ReadFrom(body); err != nil { + return err + } + r.Body = nil + } else { + // Otherwise buffer less processing for `io.Reader`, sounds good. + return nil + } + case []byte: + bodyBytes = body + case string: + bodyBytes = []byte(body) + default: + contentType := r.Header.Get(hdrContentTypeKey) + kind := kindOf(r.Body) + var err error + if IsJSONType(contentType) && (kind == reflect.Struct || kind == reflect.Map || kind == reflect.Slice) { + r.bodyBuf, err = jsonMarshal(c, r, r.Body) + } else if IsXMLType(contentType) && (kind == reflect.Struct) { + bodyBytes, err = c.XMLMarshal(r.Body) + } + if err != nil { + return err + } + } + + if bodyBytes == nil && r.bodyBuf == nil { + return errors.New("unsupported 'Body' type/value") + } + + // []byte into Buffer + if bodyBytes != nil && r.bodyBuf == nil { + r.bodyBuf = acquireBuffer() + _, _ = r.bodyBuf.Write(bodyBytes) + } + + return nil +} + +func saveResponseIntoFile(c *Client, res *Response) error { + if res.Request.isSaveResponse { + file := "" + + if len(c.outputDirectory) > 0 && !filepath.IsAbs(res.Request.outputFile) { + file += c.outputDirectory + string(filepath.Separator) + } + + file = filepath.Clean(file + res.Request.outputFile) + if err := createDirectory(filepath.Dir(file)); err != nil { + return err + } + + outFile, err := os.Create(file) + if err != nil { + return err + } + defer closeq(outFile) + + // io.Copy reads maximum 32kb size, it is perfect for large file download too + defer closeq(res.RawResponse.Body) + + written, err := io.Copy(outFile, res.RawResponse.Body) + if err != nil { + return err + } + + res.size = written + } + + return nil +} + +func getBodyCopy(r *Request) (*bytes.Buffer, error) { + // If r.bodyBuf present, return the copy + if r.bodyBuf != nil { + bodyCopy := acquireBuffer() + if _, err := io.Copy(bodyCopy, bytes.NewReader(r.bodyBuf.Bytes())); err != nil { + // cannot use io.Copy(bodyCopy, r.bodyBuf) because io.Copy reset r.bodyBuf + return nil, err + } + return bodyCopy, nil + } + + // Maybe body is `io.Reader`. + // Note: Resty user have to watchout for large body size of `io.Reader` + if r.RawRequest.Body != nil { + b, err := io.ReadAll(r.RawRequest.Body) + if err != nil { + return nil, err + } + + // Restore the Body + closeq(r.RawRequest.Body) + r.RawRequest.Body = io.NopCloser(bytes.NewBuffer(b)) + + // Return the Body bytes + return bytes.NewBuffer(b), nil + } + return nil, nil +} diff --git a/vendor/github.com/go-resty/resty/v2/redirect.go b/vendor/github.com/go-resty/resty/v2/redirect.go new file mode 100644 index 000000000..ed58d7352 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/redirect.go @@ -0,0 +1,109 @@ +// Copyright (c) 2015-2023 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "errors" + "fmt" + "net" + "net/http" + "strings" +) + +var ( + // Since v2.8.0 + ErrAutoRedirectDisabled = errors.New("auto redirect is disabled") +) + +type ( + // RedirectPolicy to regulate the redirects in the resty client. + // Objects implementing the RedirectPolicy interface can be registered as + // + // Apply function should return nil to continue the redirect journey, otherwise + // return error to stop the redirect. + RedirectPolicy interface { + Apply(req *http.Request, via []*http.Request) error + } + + // The RedirectPolicyFunc type is an adapter to allow the use of ordinary functions as RedirectPolicy. + // If f is a function with the appropriate signature, RedirectPolicyFunc(f) is a RedirectPolicy object that calls f. + RedirectPolicyFunc func(*http.Request, []*http.Request) error +) + +// Apply calls f(req, via). +func (f RedirectPolicyFunc) Apply(req *http.Request, via []*http.Request) error { + return f(req, via) +} + +// NoRedirectPolicy is used to disable redirects in the HTTP client +// +// resty.SetRedirectPolicy(NoRedirectPolicy()) +func NoRedirectPolicy() RedirectPolicy { + return RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error { + return ErrAutoRedirectDisabled + }) +} + +// FlexibleRedirectPolicy is convenient method to create No of redirect policy for HTTP client. +// +// resty.SetRedirectPolicy(FlexibleRedirectPolicy(20)) +func FlexibleRedirectPolicy(noOfRedirect int) RedirectPolicy { + return RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error { + if len(via) >= noOfRedirect { + return fmt.Errorf("stopped after %d redirects", noOfRedirect) + } + checkHostAndAddHeaders(req, via[0]) + return nil + }) +} + +// DomainCheckRedirectPolicy is convenient method to define domain name redirect rule in resty client. +// Redirect is allowed for only mentioned host in the policy. +// +// resty.SetRedirectPolicy(DomainCheckRedirectPolicy("host1.com", "host2.org", "host3.net")) +func DomainCheckRedirectPolicy(hostnames ...string) RedirectPolicy { + hosts := make(map[string]bool) + for _, h := range hostnames { + hosts[strings.ToLower(h)] = true + } + + fn := RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error { + if ok := hosts[getHostname(req.URL.Host)]; !ok { + return errors.New("redirect is not allowed as per DomainCheckRedirectPolicy") + } + + return nil + }) + + return fn +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Package Unexported methods +//_______________________________________________________________________ + +func getHostname(host string) (hostname string) { + if strings.Index(host, ":") > 0 { + host, _, _ = net.SplitHostPort(host) + } + hostname = strings.ToLower(host) + return +} + +// By default Golang will not redirect request headers +// after go throwing various discussion comments from thread +// https://github.com/golang/go/issues/4800 +// Resty will add all the headers during a redirect for the same host +func checkHostAndAddHeaders(cur *http.Request, pre *http.Request) { + curHostname := getHostname(cur.URL.Host) + preHostname := getHostname(pre.URL.Host) + if strings.EqualFold(curHostname, preHostname) { + for key, val := range pre.Header { + cur.Header[key] = val + } + } else { // only library User-Agent header is added + cur.Header.Set(hdrUserAgentKey, hdrUserAgentValue) + } +} diff --git a/vendor/github.com/go-resty/resty/v2/request.go b/vendor/github.com/go-resty/resty/v2/request.go new file mode 100644 index 000000000..fec097638 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/request.go @@ -0,0 +1,1093 @@ +// Copyright (c) 2015-2023 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "bytes" + "context" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net" + "net/http" + "net/url" + "reflect" + "strings" + "time" +) + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Request struct and methods +//_______________________________________________________________________ + +// Request struct is used to compose and fire individual request from +// resty client. Request provides an options to override client level +// settings and also an options for the request composition. +type Request struct { + URL string + Method string + Token string + AuthScheme string + QueryParam url.Values + FormData url.Values + PathParams map[string]string + RawPathParams map[string]string + Header http.Header + Time time.Time + Body interface{} + Result interface{} + Error interface{} + RawRequest *http.Request + SRV *SRVRecord + UserInfo *User + Cookies []*http.Cookie + Debug bool + + // Attempt is to represent the request attempt made during a Resty + // request execution flow, including retry count. + // + // Since v2.4.0 + Attempt int + + isMultiPart bool + isFormData bool + setContentLength bool + isSaveResponse bool + notParseResponse bool + jsonEscapeHTML bool + trace bool + outputFile string + fallbackContentType string + forceContentType string + ctx context.Context + values map[string]interface{} + client *Client + bodyBuf *bytes.Buffer + clientTrace *clientTrace + log Logger + multipartFiles []*File + multipartFields []*MultipartField + retryConditions []RetryConditionFunc +} + +// Context method returns the Context if its already set in request +// otherwise it creates new one using `context.Background()`. +func (r *Request) Context() context.Context { + if r.ctx == nil { + return context.Background() + } + return r.ctx +} + +// SetContext method sets the context.Context for current Request. It allows +// to interrupt the request execution if ctx.Done() channel is closed. +// See https://blog.golang.org/context article and the "context" package +// documentation. +func (r *Request) SetContext(ctx context.Context) *Request { + r.ctx = ctx + return r +} + +// SetHeader method is to set a single header field and its value in the current request. +// +// For Example: To set `Content-Type` and `Accept` as `application/json`. +// +// client.R(). +// SetHeader("Content-Type", "application/json"). +// SetHeader("Accept", "application/json") +// +// Also you can override header value, which was set at client instance level. +func (r *Request) SetHeader(header, value string) *Request { + r.Header.Set(header, value) + return r +} + +// SetHeaders method sets multiple headers field and its values at one go in the current request. +// +// For Example: To set `Content-Type` and `Accept` as `application/json` +// +// client.R(). +// SetHeaders(map[string]string{ +// "Content-Type": "application/json", +// "Accept": "application/json", +// }) +// +// Also you can override header value, which was set at client instance level. +func (r *Request) SetHeaders(headers map[string]string) *Request { + for h, v := range headers { + r.SetHeader(h, v) + } + return r +} + +// SetHeaderMultiValues sets multiple headers fields and its values is list of strings at one go in the current request. +// +// For Example: To set `Accept` as `text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8` +// +// client.R(). +// SetHeaderMultiValues(map[string][]string{ +// "Accept": []string{"text/html", "application/xhtml+xml", "application/xml;q=0.9", "image/webp", "*/*;q=0.8"}, +// }) +// +// Also you can override header value, which was set at client instance level. +func (r *Request) SetHeaderMultiValues(headers map[string][]string) *Request { + for key, values := range headers { + r.SetHeader(key, strings.Join(values, ", ")) + } + return r +} + +// SetHeaderVerbatim method is to set a single header field and its value verbatim in the current request. +// +// For Example: To set `all_lowercase` and `UPPERCASE` as `available`. +// +// client.R(). +// SetHeaderVerbatim("all_lowercase", "available"). +// SetHeaderVerbatim("UPPERCASE", "available") +// +// Also you can override header value, which was set at client instance level. +// +// Since v2.6.0 +func (r *Request) SetHeaderVerbatim(header, value string) *Request { + r.Header[header] = []string{value} + return r +} + +// SetQueryParam method sets single parameter and its value in the current request. +// It will be formed as query string for the request. +// +// For Example: `search=kitchen%20papers&size=large` in the URL after `?` mark. +// +// client.R(). +// SetQueryParam("search", "kitchen papers"). +// SetQueryParam("size", "large") +// +// Also you can override query params value, which was set at client instance level. +func (r *Request) SetQueryParam(param, value string) *Request { + r.QueryParam.Set(param, value) + return r +} + +// SetQueryParams method sets multiple parameters and its values at one go in the current request. +// It will be formed as query string for the request. +// +// For Example: `search=kitchen%20papers&size=large` in the URL after `?` mark. +// +// client.R(). +// SetQueryParams(map[string]string{ +// "search": "kitchen papers", +// "size": "large", +// }) +// +// Also you can override query params value, which was set at client instance level. +func (r *Request) SetQueryParams(params map[string]string) *Request { + for p, v := range params { + r.SetQueryParam(p, v) + } + return r +} + +// SetQueryParamsFromValues method appends multiple parameters with multi-value +// (`url.Values`) at one go in the current request. It will be formed as +// query string for the request. +// +// For Example: `status=pending&status=approved&status=open` in the URL after `?` mark. +// +// client.R(). +// SetQueryParamsFromValues(url.Values{ +// "status": []string{"pending", "approved", "open"}, +// }) +// +// Also you can override query params value, which was set at client instance level. +func (r *Request) SetQueryParamsFromValues(params url.Values) *Request { + for p, v := range params { + for _, pv := range v { + r.QueryParam.Add(p, pv) + } + } + return r +} + +// SetQueryString method provides ability to use string as an input to set URL query string for the request. +// +// Using String as an input +// +// client.R(). +// SetQueryString("productId=232&template=fresh-sample&cat=resty&source=google&kw=buy a lot more") +func (r *Request) SetQueryString(query string) *Request { + params, err := url.ParseQuery(strings.TrimSpace(query)) + if err == nil { + for p, v := range params { + for _, pv := range v { + r.QueryParam.Add(p, pv) + } + } + } else { + r.log.Errorf("%v", err) + } + return r +} + +// SetFormData method sets Form parameters and their values in the current request. +// It's applicable only HTTP method `POST` and `PUT` and requests content type would be set as +// `application/x-www-form-urlencoded`. +// +// client.R(). +// SetFormData(map[string]string{ +// "access_token": "BC594900-518B-4F7E-AC75-BD37F019E08F", +// "user_id": "3455454545", +// }) +// +// Also you can override form data value, which was set at client instance level. +func (r *Request) SetFormData(data map[string]string) *Request { + for k, v := range data { + r.FormData.Set(k, v) + } + return r +} + +// SetFormDataFromValues method appends multiple form parameters with multi-value +// (`url.Values`) at one go in the current request. +// +// client.R(). +// SetFormDataFromValues(url.Values{ +// "search_criteria": []string{"book", "glass", "pencil"}, +// }) +// +// Also you can override form data value, which was set at client instance level. +func (r *Request) SetFormDataFromValues(data url.Values) *Request { + for k, v := range data { + for _, kv := range v { + r.FormData.Add(k, kv) + } + } + return r +} + +// SetBody method sets the request body for the request. It supports various realtime needs as easy. +// We can say its quite handy or powerful. Supported request body data types is `string`, +// `[]byte`, `struct`, `map`, `slice` and `io.Reader`. Body value can be pointer or non-pointer. +// Automatic marshalling for JSON and XML content type, if it is `struct`, `map`, or `slice`. +// +// Note: `io.Reader` is processed as bufferless mode while sending request. +// +// For Example: Struct as a body input, based on content type, it will be marshalled. +// +// client.R(). +// SetBody(User{ +// Username: "jeeva@myjeeva.com", +// Password: "welcome2resty", +// }) +// +// Map as a body input, based on content type, it will be marshalled. +// +// client.R(). +// SetBody(map[string]interface{}{ +// "username": "jeeva@myjeeva.com", +// "password": "welcome2resty", +// "address": &Address{ +// Address1: "1111 This is my street", +// Address2: "Apt 201", +// City: "My City", +// State: "My State", +// ZipCode: 00000, +// }, +// }) +// +// String as a body input. Suitable for any need as a string input. +// +// client.R(). +// SetBody(`{ +// "username": "jeeva@getrightcare.com", +// "password": "admin" +// }`) +// +// []byte as a body input. Suitable for raw request such as file upload, serialize & deserialize, etc. +// +// client.R(). +// SetBody([]byte("This is my raw request, sent as-is")) +func (r *Request) SetBody(body interface{}) *Request { + r.Body = body + return r +} + +// SetResult method is to register the response `Result` object for automatic unmarshalling for the request, +// if response status code is between 200 and 299 and content type either JSON or XML. +// +// Note: Result object can be pointer or non-pointer. +// +// client.R().SetResult(&AuthToken{}) +// // OR +// client.R().SetResult(AuthToken{}) +// +// Accessing a result value from response instance. +// +// response.Result().(*AuthToken) +func (r *Request) SetResult(res interface{}) *Request { + if res != nil { + r.Result = getPointer(res) + } + return r +} + +// SetError method is to register the request `Error` object for automatic unmarshalling for the request, +// if response status code is greater than 399 and content type either JSON or XML. +// +// Note: Error object can be pointer or non-pointer. +// +// client.R().SetError(&AuthError{}) +// // OR +// client.R().SetError(AuthError{}) +// +// Accessing a error value from response instance. +// +// response.Error().(*AuthError) +func (r *Request) SetError(err interface{}) *Request { + r.Error = getPointer(err) + return r +} + +// SetFile method is to set single file field name and its path for multipart upload. +// +// client.R(). +// SetFile("my_file", "/Users/jeeva/Gas Bill - Sep.pdf") +func (r *Request) SetFile(param, filePath string) *Request { + r.isMultiPart = true + r.FormData.Set("@"+param, filePath) + return r +} + +// SetFiles method is to set multiple file field name and its path for multipart upload. +// +// client.R(). +// SetFiles(map[string]string{ +// "my_file1": "/Users/jeeva/Gas Bill - Sep.pdf", +// "my_file2": "/Users/jeeva/Electricity Bill - Sep.pdf", +// "my_file3": "/Users/jeeva/Water Bill - Sep.pdf", +// }) +func (r *Request) SetFiles(files map[string]string) *Request { + r.isMultiPart = true + for f, fp := range files { + r.FormData.Set("@"+f, fp) + } + return r +} + +// SetFileReader method is to set single file using io.Reader for multipart upload. +// +// client.R(). +// SetFileReader("profile_img", "my-profile-img.png", bytes.NewReader(profileImgBytes)). +// SetFileReader("notes", "user-notes.txt", bytes.NewReader(notesBytes)) +func (r *Request) SetFileReader(param, fileName string, reader io.Reader) *Request { + r.isMultiPart = true + r.multipartFiles = append(r.multipartFiles, &File{ + Name: fileName, + ParamName: param, + Reader: reader, + }) + return r +} + +// SetMultipartFormData method allows simple form data to be attached to the request as `multipart:form-data` +func (r *Request) SetMultipartFormData(data map[string]string) *Request { + for k, v := range data { + r = r.SetMultipartField(k, "", "", strings.NewReader(v)) + } + + return r +} + +// SetMultipartField method is to set custom data using io.Reader for multipart upload. +func (r *Request) SetMultipartField(param, fileName, contentType string, reader io.Reader) *Request { + r.isMultiPart = true + r.multipartFields = append(r.multipartFields, &MultipartField{ + Param: param, + FileName: fileName, + ContentType: contentType, + Reader: reader, + }) + return r +} + +// SetMultipartFields method is to set multiple data fields using io.Reader for multipart upload. +// +// For Example: +// +// client.R().SetMultipartFields( +// &resty.MultipartField{ +// Param: "uploadManifest1", +// FileName: "upload-file-1.json", +// ContentType: "application/json", +// Reader: strings.NewReader(`{"input": {"name": "Uploaded document 1", "_filename" : ["file1.txt"]}}`), +// }, +// &resty.MultipartField{ +// Param: "uploadManifest2", +// FileName: "upload-file-2.json", +// ContentType: "application/json", +// Reader: strings.NewReader(`{"input": {"name": "Uploaded document 2", "_filename" : ["file2.txt"]}}`), +// }) +// +// If you have slice already, then simply call- +// +// client.R().SetMultipartFields(fields...) +func (r *Request) SetMultipartFields(fields ...*MultipartField) *Request { + r.isMultiPart = true + r.multipartFields = append(r.multipartFields, fields...) + return r +} + +// SetContentLength method sets the HTTP header `Content-Length` value for current request. +// By default Resty won't set `Content-Length`. Also you have an option to enable for every +// request. +// +// See `Client.SetContentLength` +// +// client.R().SetContentLength(true) +func (r *Request) SetContentLength(l bool) *Request { + r.setContentLength = l + return r +} + +// SetBasicAuth method sets the basic authentication header in the current HTTP request. +// +// For Example: +// +// Authorization: Basic +// +// To set the header for username "go-resty" and password "welcome" +// +// client.R().SetBasicAuth("go-resty", "welcome") +// +// This method overrides the credentials set by method `Client.SetBasicAuth`. +func (r *Request) SetBasicAuth(username, password string) *Request { + r.UserInfo = &User{Username: username, Password: password} + return r +} + +// SetAuthToken method sets the auth token header(Default Scheme: Bearer) in the current HTTP request. Header example: +// +// Authorization: Bearer +// +// For Example: To set auth token BC594900518B4F7EAC75BD37F019E08FBC594900518B4F7EAC75BD37F019E08F +// +// client.R().SetAuthToken("BC594900518B4F7EAC75BD37F019E08FBC594900518B4F7EAC75BD37F019E08F") +// +// This method overrides the Auth token set by method `Client.SetAuthToken`. +func (r *Request) SetAuthToken(token string) *Request { + r.Token = token + return r +} + +// SetAuthScheme method sets the auth token scheme type in the HTTP request. For Example: +// +// Authorization: +// +// For Example: To set the scheme to use OAuth +// +// client.R().SetAuthScheme("OAuth") +// +// This auth header scheme gets added to all the request raised from this client instance. +// Also it can be overridden or set one at the request level is supported. +// +// Information about Auth schemes can be found in RFC7235 which is linked to below along with the page containing +// the currently defined official authentication schemes: +// +// https://tools.ietf.org/html/rfc7235 +// https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml#authschemes +// +// This method overrides the Authorization scheme set by method `Client.SetAuthScheme`. +func (r *Request) SetAuthScheme(scheme string) *Request { + r.AuthScheme = scheme + return r +} + +// SetDigestAuth method sets the Digest Access auth scheme for the HTTP request. If a server responds with 401 and sends +// a Digest challenge in the WWW-Authenticate Header, the request will be resent with the appropriate Authorization Header. +// +// For Example: To set the Digest scheme with username "Mufasa" and password "Circle Of Life" +// +// client.R().SetDigestAuth("Mufasa", "Circle Of Life") +// +// Information about Digest Access Authentication can be found in RFC7616: +// +// https://datatracker.ietf.org/doc/html/rfc7616 +// +// This method overrides the username and password set by method `Client.SetDigestAuth`. +func (r *Request) SetDigestAuth(username, password string) *Request { + oldTransport := r.client.httpClient.Transport + r.client.OnBeforeRequest(func(c *Client, _ *Request) error { + c.httpClient.Transport = &digestTransport{ + digestCredentials: digestCredentials{username, password}, + transport: oldTransport, + } + return nil + }) + r.client.OnAfterResponse(func(c *Client, _ *Response) error { + c.httpClient.Transport = oldTransport + return nil + }) + + return r +} + +// SetOutput method sets the output file for current HTTP request. Current HTTP response will be +// saved into given file. It is similar to `curl -o` flag. Absolute path or relative path can be used. +// If is it relative path then output file goes under the output directory, as mentioned +// in the `Client.SetOutputDirectory`. +// +// client.R(). +// SetOutput("/Users/jeeva/Downloads/ReplyWithHeader-v5.1-beta.zip"). +// Get("http://bit.ly/1LouEKr") +// +// Note: In this scenario `Response.Body` might be nil. +func (r *Request) SetOutput(file string) *Request { + r.outputFile = file + r.isSaveResponse = true + return r +} + +// SetSRV method sets the details to query the service SRV record and execute the +// request. +// +// client.R(). +// SetSRV(SRVRecord{"web", "testservice.com"}). +// Get("/get") +func (r *Request) SetSRV(srv *SRVRecord) *Request { + r.SRV = srv + return r +} + +// SetDoNotParseResponse method instructs `Resty` not to parse the response body automatically. +// Resty exposes the raw response body as `io.ReadCloser`. Also do not forget to close the body, +// otherwise you might get into connection leaks, no connection reuse. +// +// Note: Response middlewares are not applicable, if you use this option. Basically you have +// taken over the control of response parsing from `Resty`. +func (r *Request) SetDoNotParseResponse(parse bool) *Request { + r.notParseResponse = parse + return r +} + +// SetPathParam method sets single URL path key-value pair in the +// Resty current request instance. +// +// client.R().SetPathParam("userId", "sample@sample.com") +// +// Result: +// URL - /v1/users/{userId}/details +// Composed URL - /v1/users/sample@sample.com/details +// +// client.R().SetPathParam("path", "groups/developers") +// +// Result: +// URL - /v1/users/{userId}/details +// Composed URL - /v1/users/groups%2Fdevelopers/details +// +// It replaces the value of the key while composing the request URL. +// The values will be escaped using `url.PathEscape` function. +// +// Also you can override Path Params value, which was set at client instance +// level. +func (r *Request) SetPathParam(param, value string) *Request { + r.PathParams[param] = value + return r +} + +// SetPathParams method sets multiple URL path key-value pairs at one go in the +// Resty current request instance. +// +// client.R().SetPathParams(map[string]string{ +// "userId": "sample@sample.com", +// "subAccountId": "100002", +// "path": "groups/developers", +// }) +// +// Result: +// URL - /v1/users/{userId}/{subAccountId}/{path}/details +// Composed URL - /v1/users/sample@sample.com/100002/groups%2Fdevelopers/details +// +// It replaces the value of the key while composing request URL. +// The value will be used as it is and will not be escaped. +// +// Also you can override Path Params value, which was set at client instance +// level. +func (r *Request) SetPathParams(params map[string]string) *Request { + for p, v := range params { + r.SetPathParam(p, v) + } + return r +} + +// SetRawPathParam method sets single URL path key-value pair in the +// Resty current request instance. +// +// client.R().SetPathParam("userId", "sample@sample.com") +// +// Result: +// URL - /v1/users/{userId}/details +// Composed URL - /v1/users/sample@sample.com/details +// +// client.R().SetPathParam("path", "groups/developers") +// +// Result: +// URL - /v1/users/{userId}/details +// Composed URL - /v1/users/groups/developers/details +// +// It replaces the value of the key while composing the request URL. +// The value will be used as it is and will not be escaped. +// +// Also you can override Path Params value, which was set at client instance +// level. +// +// Since v2.8.0 +func (r *Request) SetRawPathParam(param, value string) *Request { + r.RawPathParams[param] = value + return r +} + +// SetRawPathParams method sets multiple URL path key-value pairs at one go in the +// Resty current request instance. +// +// client.R().SetPathParams(map[string]string{ +// "userId": "sample@sample.com", +// "subAccountId": "100002", +// "path": "groups/developers", +// }) +// +// Result: +// URL - /v1/users/{userId}/{subAccountId}/{path}/details +// Composed URL - /v1/users/sample@sample.com/100002/groups/developers/details +// +// It replaces the value of the key while composing request URL. +// The values will be used as they are and will not be escaped. +// +// Also you can override Path Params value, which was set at client instance +// level. +// +// Since v2.8.0 +func (r *Request) SetRawPathParams(params map[string]string) *Request { + for p, v := range params { + r.SetRawPathParam(p, v) + } + return r +} + +// ExpectContentType method allows to provide fallback `Content-Type` for automatic unmarshalling +// when `Content-Type` response header is unavailable. +func (r *Request) ExpectContentType(contentType string) *Request { + r.fallbackContentType = contentType + return r +} + +// ForceContentType method provides a strong sense of response `Content-Type` for automatic unmarshalling. +// Resty gives this a higher priority than the `Content-Type` response header. This means that if both +// `Request.ForceContentType` is set and the response `Content-Type` is available, `ForceContentType` will win. +func (r *Request) ForceContentType(contentType string) *Request { + r.forceContentType = contentType + return r +} + +// SetJSONEscapeHTML method is to enable/disable the HTML escape on JSON marshal. +// +// Note: This option only applicable to standard JSON Marshaller. +func (r *Request) SetJSONEscapeHTML(b bool) *Request { + r.jsonEscapeHTML = b + return r +} + +// SetCookie method appends a single cookie in the current request instance. +// +// client.R().SetCookie(&http.Cookie{ +// Name:"go-resty", +// Value:"This is cookie value", +// }) +// +// Note: Method appends the Cookie value into existing Cookie if already existing. +// +// Since v2.1.0 +func (r *Request) SetCookie(hc *http.Cookie) *Request { + r.Cookies = append(r.Cookies, hc) + return r +} + +// SetCookies method sets an array of cookies in the current request instance. +// +// cookies := []*http.Cookie{ +// &http.Cookie{ +// Name:"go-resty-1", +// Value:"This is cookie 1 value", +// }, +// &http.Cookie{ +// Name:"go-resty-2", +// Value:"This is cookie 2 value", +// }, +// } +// +// // Setting a cookies into resty's current request +// client.R().SetCookies(cookies) +// +// Note: Method appends the Cookie value into existing Cookie if already existing. +// +// Since v2.1.0 +func (r *Request) SetCookies(rs []*http.Cookie) *Request { + r.Cookies = append(r.Cookies, rs...) + return r +} + +// SetLogger method sets given writer for logging Resty request and response details. +// By default, requests and responses inherit their logger from the client. +// +// Compliant to interface `resty.Logger`. +func (r *Request) SetLogger(l Logger) *Request { + r.log = l + return r +} + +// SetDebug method enables the debug mode on current request Resty request, It logs +// the details current request and response. +// For `Request` it logs information such as HTTP verb, Relative URL path, Host, Headers, Body if it has one. +// For `Response` it logs information such as Status, Response Time, Headers, Body if it has one. +// +// client.R().SetDebug(true) +func (r *Request) SetDebug(d bool) *Request { + r.Debug = d + return r +} + +// AddRetryCondition method adds a retry condition function to the request's +// array of functions that are checked to determine if the request is retried. +// The request will retry if any of the functions return true and error is nil. +// +// Note: These retry conditions are checked before all retry conditions of the client. +// +// Since v2.7.0 +func (r *Request) AddRetryCondition(condition RetryConditionFunc) *Request { + r.retryConditions = append(r.retryConditions, condition) + return r +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// HTTP request tracing +//_______________________________________________________________________ + +// EnableTrace method enables trace for the current request +// using `httptrace.ClientTrace` and provides insights. +// +// client := resty.New() +// +// resp, err := client.R().EnableTrace().Get("https://httpbin.org/get") +// fmt.Println("Error:", err) +// fmt.Println("Trace Info:", resp.Request.TraceInfo()) +// +// See `Client.EnableTrace` available too to get trace info for all requests. +// +// Since v2.0.0 +func (r *Request) EnableTrace() *Request { + r.trace = true + return r +} + +// TraceInfo method returns the trace info for the request. +// If either the Client or Request EnableTrace function has not been called +// prior to the request being made, an empty TraceInfo object will be returned. +// +// Since v2.0.0 +func (r *Request) TraceInfo() TraceInfo { + ct := r.clientTrace + + if ct == nil { + return TraceInfo{} + } + + ti := TraceInfo{ + DNSLookup: ct.dnsDone.Sub(ct.dnsStart), + TLSHandshake: ct.tlsHandshakeDone.Sub(ct.tlsHandshakeStart), + ServerTime: ct.gotFirstResponseByte.Sub(ct.gotConn), + IsConnReused: ct.gotConnInfo.Reused, + IsConnWasIdle: ct.gotConnInfo.WasIdle, + ConnIdleTime: ct.gotConnInfo.IdleTime, + RequestAttempt: r.Attempt, + } + + // Calculate the total time accordingly, + // when connection is reused + if ct.gotConnInfo.Reused { + ti.TotalTime = ct.endTime.Sub(ct.getConn) + } else { + ti.TotalTime = ct.endTime.Sub(ct.dnsStart) + } + + // Only calculate on successful connections + if !ct.connectDone.IsZero() { + ti.TCPConnTime = ct.connectDone.Sub(ct.dnsDone) + } + + // Only calculate on successful connections + if !ct.gotConn.IsZero() { + ti.ConnTime = ct.gotConn.Sub(ct.getConn) + } + + // Only calculate on successful connections + if !ct.gotFirstResponseByte.IsZero() { + ti.ResponseTime = ct.endTime.Sub(ct.gotFirstResponseByte) + } + + // Capture remote address info when connection is non-nil + if ct.gotConnInfo.Conn != nil { + ti.RemoteAddr = ct.gotConnInfo.Conn.RemoteAddr() + } + + return ti +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// HTTP verb method starts here +//_______________________________________________________________________ + +// Get method does GET HTTP request. It's defined in section 4.3.1 of RFC7231. +func (r *Request) Get(url string) (*Response, error) { + return r.Execute(MethodGet, url) +} + +// Head method does HEAD HTTP request. It's defined in section 4.3.2 of RFC7231. +func (r *Request) Head(url string) (*Response, error) { + return r.Execute(MethodHead, url) +} + +// Post method does POST HTTP request. It's defined in section 4.3.3 of RFC7231. +func (r *Request) Post(url string) (*Response, error) { + return r.Execute(MethodPost, url) +} + +// Put method does PUT HTTP request. It's defined in section 4.3.4 of RFC7231. +func (r *Request) Put(url string) (*Response, error) { + return r.Execute(MethodPut, url) +} + +// Delete method does DELETE HTTP request. It's defined in section 4.3.5 of RFC7231. +func (r *Request) Delete(url string) (*Response, error) { + return r.Execute(MethodDelete, url) +} + +// Options method does OPTIONS HTTP request. It's defined in section 4.3.7 of RFC7231. +func (r *Request) Options(url string) (*Response, error) { + return r.Execute(MethodOptions, url) +} + +// Patch method does PATCH HTTP request. It's defined in section 2 of RFC5789. +func (r *Request) Patch(url string) (*Response, error) { + return r.Execute(MethodPatch, url) +} + +// Send method performs the HTTP request using the method and URL already defined +// for current `Request`. +// +// req := client.R() +// req.Method = resty.GET +// req.URL = "http://httpbin.org/get" +// resp, err := req.Send() +func (r *Request) Send() (*Response, error) { + return r.Execute(r.Method, r.URL) +} + +// Execute method performs the HTTP request with given HTTP method and URL +// for current `Request`. +// +// resp, err := client.R().Execute(resty.GET, "http://httpbin.org/get") +func (r *Request) Execute(method, url string) (*Response, error) { + var addrs []*net.SRV + var resp *Response + var err error + + defer func() { + if rec := recover(); rec != nil { + if err, ok := rec.(error); ok { + r.client.onPanicHooks(r, err) + } else { + r.client.onPanicHooks(r, fmt.Errorf("panic %v", rec)) + } + panic(rec) + } + }() + + if r.isMultiPart && !(method == MethodPost || method == MethodPut || method == MethodPatch) { + // No OnError hook here since this is a request validation error + err := fmt.Errorf("multipart content is not allowed in HTTP verb [%v]", method) + r.client.onInvalidHooks(r, err) + return nil, err + } + + if r.SRV != nil { + _, addrs, err = net.LookupSRV(r.SRV.Service, "tcp", r.SRV.Domain) + if err != nil { + r.client.onErrorHooks(r, nil, err) + return nil, err + } + } + + r.Method = method + r.URL = r.selectAddr(addrs, url, 0) + + if r.client.RetryCount == 0 { + r.Attempt = 1 + resp, err = r.client.execute(r) + r.client.onErrorHooks(r, resp, unwrapNoRetryErr(err)) + return resp, unwrapNoRetryErr(err) + } + + err = Backoff( + func() (*Response, error) { + r.Attempt++ + + r.URL = r.selectAddr(addrs, url, r.Attempt) + + resp, err = r.client.execute(r) + if err != nil { + r.log.Warnf("%v, Attempt %v", err, r.Attempt) + } + + return resp, err + }, + Retries(r.client.RetryCount), + WaitTime(r.client.RetryWaitTime), + MaxWaitTime(r.client.RetryMaxWaitTime), + RetryConditions(append(r.retryConditions, r.client.RetryConditions...)), + RetryHooks(r.client.RetryHooks), + ResetMultipartReaders(r.client.RetryResetReaders), + ) + + if err != nil { + r.log.Errorf("%v", err) + } + + r.client.onErrorHooks(r, resp, unwrapNoRetryErr(err)) + return resp, unwrapNoRetryErr(err) +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// SRVRecord struct +//_______________________________________________________________________ + +// SRVRecord struct holds the data to query the SRV record for the +// following service. +type SRVRecord struct { + Service string + Domain string +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Request Unexported methods +//_______________________________________________________________________ + +func (r *Request) fmtBodyString(sl int64) (body string) { + body = "***** NO CONTENT *****" + if !isPayloadSupported(r.Method, r.client.AllowGetMethodPayload) { + return + } + + if _, ok := r.Body.(io.Reader); ok { + body = "***** BODY IS io.Reader *****" + return + } + + // multipart or form-data + if r.isMultiPart || r.isFormData { + bodySize := int64(r.bodyBuf.Len()) + if bodySize > sl { + body = fmt.Sprintf("***** REQUEST TOO LARGE (size - %d) *****", bodySize) + return + } + body = r.bodyBuf.String() + return + } + + // request body data + if r.Body == nil { + return + } + var prtBodyBytes []byte + var err error + + contentType := r.Header.Get(hdrContentTypeKey) + kind := kindOf(r.Body) + if canJSONMarshal(contentType, kind) { + prtBodyBytes, err = noescapeJSONMarshalIndent(&r.Body) + } else if IsXMLType(contentType) && (kind == reflect.Struct) { + prtBodyBytes, err = xml.MarshalIndent(&r.Body, "", " ") + } else if b, ok := r.Body.(string); ok { + if IsJSONType(contentType) { + bodyBytes := []byte(b) + out := acquireBuffer() + defer releaseBuffer(out) + if err = json.Indent(out, bodyBytes, "", " "); err == nil { + prtBodyBytes = out.Bytes() + } + } else { + body = b + } + } else if b, ok := r.Body.([]byte); ok { + body = fmt.Sprintf("***** BODY IS byte(s) (size - %d) *****", len(b)) + return + } + + if prtBodyBytes != nil && err == nil { + body = string(prtBodyBytes) + } + + if len(body) > 0 { + bodySize := int64(len([]byte(body))) + if bodySize > sl { + body = fmt.Sprintf("***** REQUEST TOO LARGE (size - %d) *****", bodySize) + } + } + + return +} + +func (r *Request) selectAddr(addrs []*net.SRV, path string, attempt int) string { + if addrs == nil { + return path + } + + idx := attempt % len(addrs) + domain := strings.TrimRight(addrs[idx].Target, ".") + path = strings.TrimLeft(path, "/") + + return fmt.Sprintf("%s://%s:%d/%s", r.client.scheme, domain, addrs[idx].Port, path) +} + +func (r *Request) initValuesMap() { + if r.values == nil { + r.values = make(map[string]interface{}) + } +} + +var noescapeJSONMarshal = func(v interface{}) (*bytes.Buffer, error) { + buf := acquireBuffer() + encoder := json.NewEncoder(buf) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(v); err != nil { + releaseBuffer(buf) + return nil, err + } + + return buf, nil +} + +var noescapeJSONMarshalIndent = func(v interface{}) ([]byte, error) { + buf := acquireBuffer() + defer releaseBuffer(buf) + + encoder := json.NewEncoder(buf) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + + if err := encoder.Encode(v); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} diff --git a/vendor/github.com/go-resty/resty/v2/response.go b/vendor/github.com/go-resty/resty/v2/response.go new file mode 100644 index 000000000..63c95c418 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/response.go @@ -0,0 +1,189 @@ +// Copyright (c) 2015-2023 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Response struct and methods +//_______________________________________________________________________ + +// Response struct holds response values of executed request. +type Response struct { + Request *Request + RawResponse *http.Response + + body []byte + size int64 + receivedAt time.Time +} + +// Body method returns HTTP response as []byte array for the executed request. +// +// Note: `Response.Body` might be nil, if `Request.SetOutput` is used. +func (r *Response) Body() []byte { + if r.RawResponse == nil { + return []byte{} + } + return r.body +} + +// SetBody method is to set Response body in byte slice. Typically, +// its helpful for test cases. +// +// resp.SetBody([]byte("This is test body content")) +// resp.SetBody(nil) +// +// Since v2.10.0 +func (r *Response) SetBody(b []byte) *Response { + r.body = b + return r +} + +// Status method returns the HTTP status string for the executed request. +// +// Example: 200 OK +func (r *Response) Status() string { + if r.RawResponse == nil { + return "" + } + return r.RawResponse.Status +} + +// StatusCode method returns the HTTP status code for the executed request. +// +// Example: 200 +func (r *Response) StatusCode() int { + if r.RawResponse == nil { + return 0 + } + return r.RawResponse.StatusCode +} + +// Proto method returns the HTTP response protocol used for the request. +func (r *Response) Proto() string { + if r.RawResponse == nil { + return "" + } + return r.RawResponse.Proto +} + +// Result method returns the response value as an object if it has one +func (r *Response) Result() interface{} { + return r.Request.Result +} + +// Error method returns the error object if it has one +func (r *Response) Error() interface{} { + return r.Request.Error +} + +// Header method returns the response headers +func (r *Response) Header() http.Header { + if r.RawResponse == nil { + return http.Header{} + } + return r.RawResponse.Header +} + +// Cookies method to access all the response cookies +func (r *Response) Cookies() []*http.Cookie { + if r.RawResponse == nil { + return make([]*http.Cookie, 0) + } + return r.RawResponse.Cookies() +} + +// String method returns the body of the server response as String. +func (r *Response) String() string { + if len(r.body) == 0 { + return "" + } + return strings.TrimSpace(string(r.body)) +} + +// Time method returns the time of HTTP response time that from request we sent and received a request. +// +// See `Response.ReceivedAt` to know when client received response and see `Response.Request.Time` to know +// when client sent a request. +func (r *Response) Time() time.Duration { + if r.Request.clientTrace != nil { + return r.Request.TraceInfo().TotalTime + } + return r.receivedAt.Sub(r.Request.Time) +} + +// ReceivedAt method returns when response got received from server for the request. +func (r *Response) ReceivedAt() time.Time { + return r.receivedAt +} + +// Size method returns the HTTP response size in bytes. Ya, you can relay on HTTP `Content-Length` header, +// however it won't be good for chucked transfer/compressed response. Since Resty calculates response size +// at the client end. You will get actual size of the http response. +func (r *Response) Size() int64 { + return r.size +} + +// RawBody method exposes the HTTP raw response body. Use this method in-conjunction with `SetDoNotParseResponse` +// option otherwise you get an error as `read err: http: read on closed response body`. +// +// Do not forget to close the body, otherwise you might get into connection leaks, no connection reuse. +// Basically you have taken over the control of response parsing from `Resty`. +func (r *Response) RawBody() io.ReadCloser { + if r.RawResponse == nil { + return nil + } + return r.RawResponse.Body +} + +// IsSuccess method returns true if HTTP status `code >= 200 and <= 299` otherwise false. +func (r *Response) IsSuccess() bool { + return r.StatusCode() > 199 && r.StatusCode() < 300 +} + +// IsError method returns true if HTTP status `code >= 400` otherwise false. +func (r *Response) IsError() bool { + return r.StatusCode() > 399 +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Response Unexported methods +//_______________________________________________________________________ + +func (r *Response) setReceivedAt() { + r.receivedAt = time.Now() + if r.Request.clientTrace != nil { + r.Request.clientTrace.endTime = r.receivedAt + } +} + +func (r *Response) fmtBodyString(sl int64) string { + if len(r.body) > 0 { + if int64(len(r.body)) > sl { + return fmt.Sprintf("***** RESPONSE TOO LARGE (size - %d) *****", len(r.body)) + } + ct := r.Header().Get(hdrContentTypeKey) + if IsJSONType(ct) { + out := acquireBuffer() + defer releaseBuffer(out) + err := json.Indent(out, r.body, "", " ") + if err != nil { + return fmt.Sprintf("*** Error: Unable to format response body - \"%s\" ***\n\nLog Body as-is:\n%s", err, r.String()) + } + return out.String() + } + return r.String() + } + + return "***** NO CONTENT *****" +} diff --git a/vendor/github.com/go-resty/resty/v2/resty.go b/vendor/github.com/go-resty/resty/v2/resty.go new file mode 100644 index 000000000..21dcd5655 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/resty.go @@ -0,0 +1,40 @@ +// Copyright (c) 2015-2023 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +// Package resty provides Simple HTTP and REST client library for Go. +package resty + +import ( + "net" + "net/http" + "net/http/cookiejar" + + "golang.org/x/net/publicsuffix" +) + +// Version # of resty +const Version = "2.10.0" + +// New method creates a new Resty client. +func New() *Client { + cookieJar, _ := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + return createClient(&http.Client{ + Jar: cookieJar, + }) +} + +// NewWithClient method creates a new Resty client with given `http.Client`. +func NewWithClient(hc *http.Client) *Client { + return createClient(hc) +} + +// NewWithLocalAddr method creates a new Resty client with given Local Address +// to dial from. +func NewWithLocalAddr(localAddr net.Addr) *Client { + cookieJar, _ := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + return createClient(&http.Client{ + Jar: cookieJar, + Transport: createTransport(localAddr), + }) +} diff --git a/vendor/github.com/go-resty/resty/v2/retry.go b/vendor/github.com/go-resty/resty/v2/retry.go new file mode 100644 index 000000000..c5eda26be --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/retry.go @@ -0,0 +1,252 @@ +// Copyright (c) 2015-2023 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "context" + "io" + "math" + "math/rand" + "sync" + "time" +) + +const ( + defaultMaxRetries = 3 + defaultWaitTime = time.Duration(100) * time.Millisecond + defaultMaxWaitTime = time.Duration(2000) * time.Millisecond +) + +type ( + // Option is to create convenient retry options like wait time, max retries, etc. + Option func(*Options) + + // RetryConditionFunc type is for retry condition function + // input: non-nil Response OR request execution error + RetryConditionFunc func(*Response, error) bool + + // OnRetryFunc is for side-effecting functions triggered on retry + OnRetryFunc func(*Response, error) + + // RetryAfterFunc returns time to wait before retry + // For example, it can parse HTTP Retry-After header + // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html + // Non-nil error is returned if it is found that request is not retryable + // (0, nil) is a special result means 'use default algorithm' + RetryAfterFunc func(*Client, *Response) (time.Duration, error) + + // Options struct is used to hold retry settings. + Options struct { + maxRetries int + waitTime time.Duration + maxWaitTime time.Duration + retryConditions []RetryConditionFunc + retryHooks []OnRetryFunc + resetReaders bool + } +) + +// Retries sets the max number of retries +func Retries(value int) Option { + return func(o *Options) { + o.maxRetries = value + } +} + +// WaitTime sets the default wait time to sleep between requests +func WaitTime(value time.Duration) Option { + return func(o *Options) { + o.waitTime = value + } +} + +// MaxWaitTime sets the max wait time to sleep between requests +func MaxWaitTime(value time.Duration) Option { + return func(o *Options) { + o.maxWaitTime = value + } +} + +// RetryConditions sets the conditions that will be checked for retry. +func RetryConditions(conditions []RetryConditionFunc) Option { + return func(o *Options) { + o.retryConditions = conditions + } +} + +// RetryHooks sets the hooks that will be executed after each retry +func RetryHooks(hooks []OnRetryFunc) Option { + return func(o *Options) { + o.retryHooks = hooks + } +} + +// ResetMultipartReaders sets a boolean value which will lead the start being seeked out +// on all multipart file readers, if they implement io.ReadSeeker +func ResetMultipartReaders(value bool) Option { + return func(o *Options) { + o.resetReaders = value + } +} + +// Backoff retries with increasing timeout duration up until X amount of retries +// (Default is 3 attempts, Override with option Retries(n)) +func Backoff(operation func() (*Response, error), options ...Option) error { + // Defaults + opts := Options{ + maxRetries: defaultMaxRetries, + waitTime: defaultWaitTime, + maxWaitTime: defaultMaxWaitTime, + retryConditions: []RetryConditionFunc{}, + } + + for _, o := range options { + o(&opts) + } + + var ( + resp *Response + err error + ) + + for attempt := 0; attempt <= opts.maxRetries; attempt++ { + resp, err = operation() + ctx := context.Background() + if resp != nil && resp.Request.ctx != nil { + ctx = resp.Request.ctx + } + if ctx.Err() != nil { + return err + } + + err1 := unwrapNoRetryErr(err) // raw error, it used for return users callback. + needsRetry := err != nil && err == err1 // retry on a few operation errors by default + + for _, condition := range opts.retryConditions { + needsRetry = condition(resp, err1) + if needsRetry { + break + } + } + + if !needsRetry { + return err + } + + if opts.resetReaders { + if err := resetFileReaders(resp.Request.multipartFiles); err != nil { + return err + } + } + + for _, hook := range opts.retryHooks { + hook(resp, err) + } + + // Don't need to wait when no retries left. + // Still run retry hooks even on last retry to keep compatibility. + if attempt == opts.maxRetries { + return err + } + + waitTime, err2 := sleepDuration(resp, opts.waitTime, opts.maxWaitTime, attempt) + if err2 != nil { + if err == nil { + err = err2 + } + return err + } + + select { + case <-time.After(waitTime): + case <-ctx.Done(): + return ctx.Err() + } + } + + return err +} + +func sleepDuration(resp *Response, min, max time.Duration, attempt int) (time.Duration, error) { + const maxInt = 1<<31 - 1 // max int for arch 386 + if max < 0 { + max = maxInt + } + if resp == nil { + return jitterBackoff(min, max, attempt), nil + } + + retryAfterFunc := resp.Request.client.RetryAfter + + // Check for custom callback + if retryAfterFunc == nil { + return jitterBackoff(min, max, attempt), nil + } + + result, err := retryAfterFunc(resp.Request.client, resp) + if err != nil { + return 0, err // i.e. 'API quota exceeded' + } + if result == 0 { + return jitterBackoff(min, max, attempt), nil + } + if result < 0 || max < result { + result = max + } + if result < min { + result = min + } + return result, nil +} + +// Return capped exponential backoff with jitter +// http://www.awsarchitectureblog.com/2015/03/backoff.html +func jitterBackoff(min, max time.Duration, attempt int) time.Duration { + base := float64(min) + capLevel := float64(max) + + temp := math.Min(capLevel, base*math.Exp2(float64(attempt))) + ri := time.Duration(temp / 2) + if ri == 0 { + ri = time.Nanosecond + } + result := randDuration(ri) + + if result < min { + result = min + } + + return result +} + +var rnd = newRnd() +var rndMu sync.Mutex + +func randDuration(center time.Duration) time.Duration { + rndMu.Lock() + defer rndMu.Unlock() + + var ri = int64(center) + var jitter = rnd.Int63n(ri) + return time.Duration(math.Abs(float64(ri + jitter))) +} + +func newRnd() *rand.Rand { + var seed = time.Now().UnixNano() + var src = rand.NewSource(seed) + return rand.New(src) +} + +func resetFileReaders(files []*File) error { + for _, f := range files { + if rs, ok := f.Reader.(io.ReadSeeker); ok { + if _, err := rs.Seek(0, io.SeekStart); err != nil { + return err + } + } + } + + return nil +} diff --git a/vendor/github.com/go-resty/resty/v2/trace.go b/vendor/github.com/go-resty/resty/v2/trace.go new file mode 100644 index 000000000..be7555c23 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/trace.go @@ -0,0 +1,130 @@ +// Copyright (c) 2015-2023 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "context" + "crypto/tls" + "net" + "net/http/httptrace" + "time" +) + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// TraceInfo struct +//_______________________________________________________________________ + +// TraceInfo struct is used provide request trace info such as DNS lookup +// duration, Connection obtain duration, Server processing duration, etc. +// +// Since v2.0.0 +type TraceInfo struct { + // DNSLookup is a duration that transport took to perform + // DNS lookup. + DNSLookup time.Duration + + // ConnTime is a duration that took to obtain a successful connection. + ConnTime time.Duration + + // TCPConnTime is a duration that took to obtain the TCP connection. + TCPConnTime time.Duration + + // TLSHandshake is a duration that TLS handshake took place. + TLSHandshake time.Duration + + // ServerTime is a duration that server took to respond first byte. + ServerTime time.Duration + + // ResponseTime is a duration since first response byte from server to + // request completion. + ResponseTime time.Duration + + // TotalTime is a duration that total request took end-to-end. + TotalTime time.Duration + + // IsConnReused is whether this connection has been previously + // used for another HTTP request. + IsConnReused bool + + // IsConnWasIdle is whether this connection was obtained from an + // idle pool. + IsConnWasIdle bool + + // ConnIdleTime is a duration how long the connection was previously + // idle, if IsConnWasIdle is true. + ConnIdleTime time.Duration + + // RequestAttempt is to represent the request attempt made during a Resty + // request execution flow, including retry count. + RequestAttempt int + + // RemoteAddr returns the remote network address. + RemoteAddr net.Addr +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// ClientTrace struct and its methods +//_______________________________________________________________________ + +// tracer struct maps the `httptrace.ClientTrace` hooks into Fields +// with same naming for easy understanding. Plus additional insights +// Request. +type clientTrace struct { + getConn time.Time + dnsStart time.Time + dnsDone time.Time + connectDone time.Time + tlsHandshakeStart time.Time + tlsHandshakeDone time.Time + gotConn time.Time + gotFirstResponseByte time.Time + endTime time.Time + gotConnInfo httptrace.GotConnInfo +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Trace unexported methods +//_______________________________________________________________________ + +func (t *clientTrace) createContext(ctx context.Context) context.Context { + return httptrace.WithClientTrace( + ctx, + &httptrace.ClientTrace{ + DNSStart: func(_ httptrace.DNSStartInfo) { + t.dnsStart = time.Now() + }, + DNSDone: func(_ httptrace.DNSDoneInfo) { + t.dnsDone = time.Now() + }, + ConnectStart: func(_, _ string) { + if t.dnsDone.IsZero() { + t.dnsDone = time.Now() + } + if t.dnsStart.IsZero() { + t.dnsStart = t.dnsDone + } + }, + ConnectDone: func(net, addr string, err error) { + t.connectDone = time.Now() + }, + GetConn: func(_ string) { + t.getConn = time.Now() + }, + GotConn: func(ci httptrace.GotConnInfo) { + t.gotConn = time.Now() + t.gotConnInfo = ci + }, + GotFirstResponseByte: func() { + t.gotFirstResponseByte = time.Now() + }, + TLSHandshakeStart: func() { + t.tlsHandshakeStart = time.Now() + }, + TLSHandshakeDone: func(_ tls.ConnectionState, _ error) { + t.tlsHandshakeDone = time.Now() + }, + }, + ) +} diff --git a/vendor/github.com/go-resty/resty/v2/transport.go b/vendor/github.com/go-resty/resty/v2/transport.go new file mode 100644 index 000000000..191cd5193 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/transport.go @@ -0,0 +1,36 @@ +//go:build go1.13 +// +build go1.13 + +// Copyright (c) 2015-2023 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "net" + "net/http" + "runtime" + "time" +) + +func createTransport(localAddr net.Addr) *http.Transport { + dialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + } + if localAddr != nil { + dialer.LocalAddr = localAddr + } + return &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: transportDialContext(dialer), + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + MaxIdleConnsPerHost: runtime.GOMAXPROCS(0) + 1, + } +} diff --git a/vendor/github.com/go-resty/resty/v2/transport112.go b/vendor/github.com/go-resty/resty/v2/transport112.go new file mode 100644 index 000000000..d4aa4175f --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/transport112.go @@ -0,0 +1,35 @@ +//go:build !go1.13 +// +build !go1.13 + +// Copyright (c) 2015-2023 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "net" + "net/http" + "runtime" + "time" +) + +func createTransport(localAddr net.Addr) *http.Transport { + dialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + } + if localAddr != nil { + dialer.LocalAddr = localAddr + } + return &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: dialer.DialContext, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + MaxIdleConnsPerHost: runtime.GOMAXPROCS(0) + 1, + } +} diff --git a/vendor/github.com/go-resty/resty/v2/transport_js.go b/vendor/github.com/go-resty/resty/v2/transport_js.go new file mode 100644 index 000000000..6227aa9ca --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/transport_js.go @@ -0,0 +1,17 @@ +// Copyright 2021 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build js && wasm +// +build js,wasm + +package resty + +import ( + "context" + "net" +) + +func transportDialContext(dialer *net.Dialer) func(context.Context, string, string) (net.Conn, error) { + return nil +} diff --git a/vendor/github.com/go-resty/resty/v2/transport_other.go b/vendor/github.com/go-resty/resty/v2/transport_other.go new file mode 100644 index 000000000..73553c36f --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/transport_other.go @@ -0,0 +1,17 @@ +// Copyright 2021 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !(js && wasm) +// +build !js !wasm + +package resty + +import ( + "context" + "net" +) + +func transportDialContext(dialer *net.Dialer) func(context.Context, string, string) (net.Conn, error) { + return dialer.DialContext +} diff --git a/vendor/github.com/go-resty/resty/v2/util.go b/vendor/github.com/go-resty/resty/v2/util.go new file mode 100644 index 000000000..27b466dc1 --- /dev/null +++ b/vendor/github.com/go-resty/resty/v2/util.go @@ -0,0 +1,384 @@ +// Copyright (c) 2015-2023 Jeevanandam M (jeeva@myjeeva.com), All rights reserved. +// resty source code and usage is governed by a MIT style +// license that can be found in the LICENSE file. + +package resty + +import ( + "bytes" + "errors" + "fmt" + "io" + "log" + "mime/multipart" + "net/http" + "net/textproto" + "os" + "path/filepath" + "reflect" + "runtime" + "sort" + "strings" + "sync" +) + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Logger interface +//_______________________________________________________________________ + +// Logger interface is to abstract the logging from Resty. Gives control to +// the Resty users, choice of the logger. +type Logger interface { + Errorf(format string, v ...interface{}) + Warnf(format string, v ...interface{}) + Debugf(format string, v ...interface{}) +} + +func createLogger() *logger { + l := &logger{l: log.New(os.Stderr, "", log.Ldate|log.Lmicroseconds)} + return l +} + +var _ Logger = (*logger)(nil) + +type logger struct { + l *log.Logger +} + +func (l *logger) Errorf(format string, v ...interface{}) { + l.output("ERROR RESTY "+format, v...) +} + +func (l *logger) Warnf(format string, v ...interface{}) { + l.output("WARN RESTY "+format, v...) +} + +func (l *logger) Debugf(format string, v ...interface{}) { + l.output("DEBUG RESTY "+format, v...) +} + +func (l *logger) output(format string, v ...interface{}) { + if len(v) == 0 { + l.l.Print(format) + return + } + l.l.Printf(format, v...) +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Rate Limiter interface +//_______________________________________________________________________ + +type RateLimiter interface { + Allow() bool +} + +var ErrRateLimitExceeded = errors.New("rate limit exceeded") + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Package Helper methods +//_______________________________________________________________________ + +// IsStringEmpty method tells whether given string is empty or not +func IsStringEmpty(str string) bool { + return len(strings.TrimSpace(str)) == 0 +} + +// DetectContentType method is used to figure out `Request.Body` content type for request header +func DetectContentType(body interface{}) string { + contentType := plainTextType + kind := kindOf(body) + switch kind { + case reflect.Struct, reflect.Map: + contentType = jsonContentType + case reflect.String: + contentType = plainTextType + default: + if b, ok := body.([]byte); ok { + contentType = http.DetectContentType(b) + } else if kind == reflect.Slice { + contentType = jsonContentType + } + } + + return contentType +} + +// IsJSONType method is to check JSON content type or not +func IsJSONType(ct string) bool { + return jsonCheck.MatchString(ct) +} + +// IsXMLType method is to check XML content type or not +func IsXMLType(ct string) bool { + return xmlCheck.MatchString(ct) +} + +// Unmarshalc content into object from JSON or XML +func Unmarshalc(c *Client, ct string, b []byte, d interface{}) (err error) { + if IsJSONType(ct) { + err = c.JSONUnmarshal(b, d) + } else if IsXMLType(ct) { + err = c.XMLUnmarshal(b, d) + } + + return +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// RequestLog and ResponseLog type +//_______________________________________________________________________ + +// RequestLog struct is used to collected information from resty request +// instance for debug logging. It sent to request log callback before resty +// actually logs the information. +type RequestLog struct { + Header http.Header + Body string +} + +// ResponseLog struct is used to collected information from resty response +// instance for debug logging. It sent to response log callback before resty +// actually logs the information. +type ResponseLog struct { + Header http.Header + Body string +} + +//‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +// Package Unexported methods +//_______________________________________________________________________ + +// way to disable the HTML escape as opt-in +func jsonMarshal(c *Client, r *Request, d interface{}) (*bytes.Buffer, error) { + if !r.jsonEscapeHTML || !c.jsonEscapeHTML { + return noescapeJSONMarshal(d) + } + + data, err := c.JSONMarshal(d) + if err != nil { + return nil, err + } + + buf := acquireBuffer() + _, _ = buf.Write(data) + return buf, nil +} + +func firstNonEmpty(v ...string) string { + for _, s := range v { + if !IsStringEmpty(s) { + return s + } + } + return "" +} + +var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") + +func escapeQuotes(s string) string { + return quoteEscaper.Replace(s) +} + +func createMultipartHeader(param, fileName, contentType string) textproto.MIMEHeader { + hdr := make(textproto.MIMEHeader) + + var contentDispositionValue string + if IsStringEmpty(fileName) { + contentDispositionValue = fmt.Sprintf(`form-data; name="%s"`, param) + } else { + contentDispositionValue = fmt.Sprintf(`form-data; name="%s"; filename="%s"`, + param, escapeQuotes(fileName)) + } + hdr.Set("Content-Disposition", contentDispositionValue) + + if !IsStringEmpty(contentType) { + hdr.Set(hdrContentTypeKey, contentType) + } + return hdr +} + +func addMultipartFormField(w *multipart.Writer, mf *MultipartField) error { + partWriter, err := w.CreatePart(createMultipartHeader(mf.Param, mf.FileName, mf.ContentType)) + if err != nil { + return err + } + + _, err = io.Copy(partWriter, mf.Reader) + return err +} + +func writeMultipartFormFile(w *multipart.Writer, fieldName, fileName string, r io.Reader) error { + // Auto detect actual multipart content type + cbuf := make([]byte, 512) + size, err := r.Read(cbuf) + if err != nil && err != io.EOF { + return err + } + + partWriter, err := w.CreatePart(createMultipartHeader(fieldName, fileName, http.DetectContentType(cbuf))) + if err != nil { + return err + } + + if _, err = partWriter.Write(cbuf[:size]); err != nil { + return err + } + + _, err = io.Copy(partWriter, r) + return err +} + +func addFile(w *multipart.Writer, fieldName, path string) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer closeq(file) + return writeMultipartFormFile(w, fieldName, filepath.Base(path), file) +} + +func addFileReader(w *multipart.Writer, f *File) error { + return writeMultipartFormFile(w, f.ParamName, f.Name, f.Reader) +} + +func getPointer(v interface{}) interface{} { + vv := valueOf(v) + if vv.Kind() == reflect.Ptr { + return v + } + return reflect.New(vv.Type()).Interface() +} + +func isPayloadSupported(m string, allowMethodGet bool) bool { + return !(m == MethodHead || m == MethodOptions || (m == MethodGet && !allowMethodGet)) +} + +func typeOf(i interface{}) reflect.Type { + return indirect(valueOf(i)).Type() +} + +func valueOf(i interface{}) reflect.Value { + return reflect.ValueOf(i) +} + +func indirect(v reflect.Value) reflect.Value { + return reflect.Indirect(v) +} + +func kindOf(v interface{}) reflect.Kind { + return typeOf(v).Kind() +} + +func createDirectory(dir string) (err error) { + if _, err = os.Stat(dir); err != nil { + if os.IsNotExist(err) { + if err = os.MkdirAll(dir, 0755); err != nil { + return + } + } + } + return +} + +func canJSONMarshal(contentType string, kind reflect.Kind) bool { + return IsJSONType(contentType) && (kind == reflect.Struct || kind == reflect.Map || kind == reflect.Slice) +} + +func functionName(i interface{}) string { + return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name() +} + +func acquireBuffer() *bytes.Buffer { + return bufPool.Get().(*bytes.Buffer) +} + +func releaseBuffer(buf *bytes.Buffer) { + if buf != nil { + buf.Reset() + bufPool.Put(buf) + } +} + +// requestBodyReleaser wraps requests's body and implements custom Close for it. +// The Close method closes original body and releases request body back to sync.Pool. +type requestBodyReleaser struct { + releaseOnce sync.Once + reqBuf *bytes.Buffer + io.ReadCloser +} + +func newRequestBodyReleaser(respBody io.ReadCloser, reqBuf *bytes.Buffer) io.ReadCloser { + if reqBuf == nil { + return respBody + } + + return &requestBodyReleaser{ + reqBuf: reqBuf, + ReadCloser: respBody, + } +} + +func (rr *requestBodyReleaser) Close() error { + err := rr.ReadCloser.Close() + rr.releaseOnce.Do(func() { + releaseBuffer(rr.reqBuf) + }) + + return err +} + +func closeq(v interface{}) { + if c, ok := v.(io.Closer); ok { + silently(c.Close()) + } +} + +func silently(_ ...interface{}) {} + +func composeHeaders(c *Client, r *Request, hdrs http.Header) string { + str := make([]string, 0, len(hdrs)) + for _, k := range sortHeaderKeys(hdrs) { + str = append(str, "\t"+strings.TrimSpace(fmt.Sprintf("%25s: %s", k, strings.Join(hdrs[k], ", ")))) + } + return strings.Join(str, "\n") +} + +func sortHeaderKeys(hdrs http.Header) []string { + keys := make([]string, 0, len(hdrs)) + for key := range hdrs { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func copyHeaders(hdrs http.Header) http.Header { + nh := http.Header{} + for k, v := range hdrs { + nh[k] = v + } + return nh +} + +type noRetryErr struct { + err error +} + +func (e *noRetryErr) Error() string { + return e.err.Error() +} + +func wrapNoRetryErr(err error) error { + if err != nil { + err = &noRetryErr{err: err} + } + return err +} + +func unwrapNoRetryErr(err error) error { + if e, ok := err.(*noRetryErr); ok { + err = e.err + } + return err +} diff --git a/vendor/github.com/opentracing/opentracing-go/.gitignore b/vendor/github.com/opentracing/opentracing-go/.gitignore new file mode 100644 index 000000000..c57100a59 --- /dev/null +++ b/vendor/github.com/opentracing/opentracing-go/.gitignore @@ -0,0 +1 @@ +coverage.txt diff --git a/vendor/github.com/opentracing/opentracing-go/.travis.yml b/vendor/github.com/opentracing/opentracing-go/.travis.yml new file mode 100644 index 000000000..b950e4296 --- /dev/null +++ b/vendor/github.com/opentracing/opentracing-go/.travis.yml @@ -0,0 +1,20 @@ +language: go + +matrix: + include: + - go: "1.13.x" + - go: "1.14.x" + - go: "tip" + env: + - LINT=true + - COVERAGE=true + +install: + - if [ "$LINT" == true ]; then go get -u golang.org/x/lint/golint/... ; else echo 'skipping lint'; fi + - go get -u github.com/stretchr/testify/... + +script: + - make test + - go build ./... + - if [ "$LINT" == true ]; then make lint ; else echo 'skipping lint'; fi + - if [ "$COVERAGE" == true ]; then make cover && bash <(curl -s https://codecov.io/bash) ; else echo 'skipping coverage'; fi diff --git a/vendor/github.com/opentracing/opentracing-go/CHANGELOG.md b/vendor/github.com/opentracing/opentracing-go/CHANGELOG.md new file mode 100644 index 000000000..d3bfcf623 --- /dev/null +++ b/vendor/github.com/opentracing/opentracing-go/CHANGELOG.md @@ -0,0 +1,63 @@ +Changes by Version +================== + + +1.2.0 (2020-07-01) +------------------- + +* Restore the ability to reset the current span in context to nil (#231) -- Yuri Shkuro +* Use error.object per OpenTracing Semantic Conventions (#179) -- Rahman Syed +* Convert nil pointer log field value to string "nil" (#230) -- Cyril Tovena +* Add Go module support (#215) -- Zaba505 +* Make SetTag helper types in ext public (#229) -- Blake Edwards +* Add log/fields helpers for keys from specification (#226) -- Dmitry Monakhov +* Improve noop impementation (#223) -- chanxuehong +* Add an extension to Tracer interface for custom go context creation (#220) -- Krzesimir Nowak +* Fix typo in comments (#222) -- meteorlxy +* Improve documentation for log.Object() to emphasize the requirement to pass immutable arguments (#219) -- 疯狂的小企鹅 +* [mock] Return ErrInvalidSpanContext if span context is not MockSpanContext (#216) -- Milad Irannejad + + +1.1.0 (2019-03-23) +------------------- + +Notable changes: +- The library is now released under Apache 2.0 license +- Use Set() instead of Add() in HTTPHeadersCarrier is functionally a breaking change (fixes issue [#159](https://github.com/opentracing/opentracing-go/issues/159)) +- 'golang.org/x/net/context' is replaced with 'context' from the standard library + +List of all changes: + +- Export StartSpanFromContextWithTracer (#214) +- Add IsGlobalTracerRegistered() to indicate if a tracer has been registered (#201) +- Use Set() instead of Add() in HTTPHeadersCarrier (#191) +- Update license to Apache 2.0 (#181) +- Replace 'golang.org/x/net/context' with 'context' (#176) +- Port of Python opentracing/harness/api_check.py to Go (#146) +- Fix race condition in MockSpan.Context() (#170) +- Add PeerHostIPv4.SetString() (#155) +- Add a Noop log field type to log to allow for optional fields (#150) + + +1.0.2 (2017-04-26) +------------------- + +- Add more semantic tags (#139) + + +1.0.1 (2017-02-06) +------------------- + +- Correct spelling in comments +- Address race in nextMockID() (#123) +- log: avoid panic marshaling nil error (#131) +- Deprecate InitGlobalTracer in favor of SetGlobalTracer (#128) +- Drop Go 1.5 that fails in Travis (#129) +- Add convenience methods Key() and Value() to log.Field +- Add convenience methods to log.Field (2 years, 6 months ago) + +1.0.0 (2016-09-26) +------------------- + +- This release implements OpenTracing Specification 1.0 (https://opentracing.io/spec) + diff --git a/vendor/github.com/opentracing/opentracing-go/LICENSE b/vendor/github.com/opentracing/opentracing-go/LICENSE new file mode 100644 index 000000000..f0027349e --- /dev/null +++ b/vendor/github.com/opentracing/opentracing-go/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2016 The OpenTracing Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/github.com/opentracing/opentracing-go/Makefile b/vendor/github.com/opentracing/opentracing-go/Makefile new file mode 100644 index 000000000..62abb63f5 --- /dev/null +++ b/vendor/github.com/opentracing/opentracing-go/Makefile @@ -0,0 +1,20 @@ +.DEFAULT_GOAL := test-and-lint + +.PHONY: test-and-lint +test-and-lint: test lint + +.PHONY: test +test: + go test -v -cover -race ./... + +.PHONY: cover +cover: + go test -v -coverprofile=coverage.txt -covermode=atomic -race ./... + +.PHONY: lint +lint: + go fmt ./... + golint ./... + @# Run again with magic to exit non-zero if golint outputs anything. + @! (golint ./... | read dummy) + go vet ./... diff --git a/vendor/github.com/opentracing/opentracing-go/README.md b/vendor/github.com/opentracing/opentracing-go/README.md new file mode 100644 index 000000000..6ef1d7c9d --- /dev/null +++ b/vendor/github.com/opentracing/opentracing-go/README.md @@ -0,0 +1,171 @@ +[![Gitter chat](http://img.shields.io/badge/gitter-join%20chat%20%E2%86%92-brightgreen.svg)](https://gitter.im/opentracing/public) [![Build Status](https://travis-ci.org/opentracing/opentracing-go.svg?branch=master)](https://travis-ci.org/opentracing/opentracing-go) [![GoDoc](https://godoc.org/github.com/opentracing/opentracing-go?status.svg)](http://godoc.org/github.com/opentracing/opentracing-go) +[![Sourcegraph Badge](https://sourcegraph.com/github.com/opentracing/opentracing-go/-/badge.svg)](https://sourcegraph.com/github.com/opentracing/opentracing-go?badge) + +# OpenTracing API for Go + +This package is a Go platform API for OpenTracing. + +## Required Reading + +In order to understand the Go platform API, one must first be familiar with the +[OpenTracing project](https://opentracing.io) and +[terminology](https://opentracing.io/specification/) more specifically. + +## API overview for those adding instrumentation + +Everyday consumers of this `opentracing` package really only need to worry +about a couple of key abstractions: the `StartSpan` function, the `Span` +interface, and binding a `Tracer` at `main()`-time. Here are code snippets +demonstrating some important use cases. + +#### Singleton initialization + +The simplest starting point is `./default_tracer.go`. As early as possible, call + +```go + import "github.com/opentracing/opentracing-go" + import ".../some_tracing_impl" + + func main() { + opentracing.SetGlobalTracer( + // tracing impl specific: + some_tracing_impl.New(...), + ) + ... + } +``` + +#### Non-Singleton initialization + +If you prefer direct control to singletons, manage ownership of the +`opentracing.Tracer` implementation explicitly. + +#### Creating a Span given an existing Go `context.Context` + +If you use `context.Context` in your application, OpenTracing's Go library will +happily rely on it for `Span` propagation. To start a new (blocking child) +`Span`, you can use `StartSpanFromContext`. + +```go + func xyz(ctx context.Context, ...) { + ... + span, ctx := opentracing.StartSpanFromContext(ctx, "operation_name") + defer span.Finish() + span.LogFields( + log.String("event", "soft error"), + log.String("type", "cache timeout"), + log.Int("waited.millis", 1500)) + ... + } +``` + +#### Starting an empty trace by creating a "root span" + +It's always possible to create a "root" `Span` with no parent or other causal +reference. + +```go + func xyz() { + ... + sp := opentracing.StartSpan("operation_name") + defer sp.Finish() + ... + } +``` + +#### Creating a (child) Span given an existing (parent) Span + +```go + func xyz(parentSpan opentracing.Span, ...) { + ... + sp := opentracing.StartSpan( + "operation_name", + opentracing.ChildOf(parentSpan.Context())) + defer sp.Finish() + ... + } +``` + +#### Serializing to the wire + +```go + func makeSomeRequest(ctx context.Context) ... { + if span := opentracing.SpanFromContext(ctx); span != nil { + httpClient := &http.Client{} + httpReq, _ := http.NewRequest("GET", "http://myservice/", nil) + + // Transmit the span's TraceContext as HTTP headers on our + // outbound request. + opentracing.GlobalTracer().Inject( + span.Context(), + opentracing.HTTPHeaders, + opentracing.HTTPHeadersCarrier(httpReq.Header)) + + resp, err := httpClient.Do(httpReq) + ... + } + ... + } +``` + +#### Deserializing from the wire + +```go + http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + var serverSpan opentracing.Span + appSpecificOperationName := ... + wireContext, err := opentracing.GlobalTracer().Extract( + opentracing.HTTPHeaders, + opentracing.HTTPHeadersCarrier(req.Header)) + if err != nil { + // Optionally record something about err here + } + + // Create the span referring to the RPC client if available. + // If wireContext == nil, a root span will be created. + serverSpan = opentracing.StartSpan( + appSpecificOperationName, + ext.RPCServerOption(wireContext)) + + defer serverSpan.Finish() + + ctx := opentracing.ContextWithSpan(context.Background(), serverSpan) + ... + } +``` + +#### Conditionally capture a field using `log.Noop` + +In some situations, you may want to dynamically decide whether or not +to log a field. For example, you may want to capture additional data, +such as a customer ID, in non-production environments: + +```go + func Customer(order *Order) log.Field { + if os.Getenv("ENVIRONMENT") == "dev" { + return log.String("customer", order.Customer.ID) + } + return log.Noop() + } +``` + +#### Goroutine-safety + +The entire public API is goroutine-safe and does not require external +synchronization. + +## API pointers for those implementing a tracing system + +Tracing system implementors may be able to reuse or copy-paste-modify the `basictracer` package, found [here](https://github.com/opentracing/basictracer-go). In particular, see `basictracer.New(...)`. + +## API compatibility + +For the time being, "mild" backwards-incompatible changes may be made without changing the major version number. As OpenTracing and `opentracing-go` mature, backwards compatibility will become more of a priority. + +## Tracer test suite + +A test suite is available in the [harness](https://godoc.org/github.com/opentracing/opentracing-go/harness) package that can assist Tracer implementors to assert that their Tracer is working correctly. + +## Licensing + +[Apache 2.0 License](./LICENSE). diff --git a/vendor/github.com/opentracing/opentracing-go/ext.go b/vendor/github.com/opentracing/opentracing-go/ext.go new file mode 100644 index 000000000..e11977ebe --- /dev/null +++ b/vendor/github.com/opentracing/opentracing-go/ext.go @@ -0,0 +1,24 @@ +package opentracing + +import ( + "context" +) + +// TracerContextWithSpanExtension is an extension interface that the +// implementation of the Tracer interface may want to implement. It +// allows to have some control over the go context when the +// ContextWithSpan is invoked. +// +// The primary purpose of this extension are adapters from opentracing +// API to some other tracing API. +type TracerContextWithSpanExtension interface { + // ContextWithSpanHook gets called by the ContextWithSpan + // function, when the Tracer implementation also implements + // this interface. It allows to put extra information into the + // context and make it available to the callers of the + // ContextWithSpan. + // + // This hook is invoked before the ContextWithSpan function + // actually puts the span into the context. + ContextWithSpanHook(ctx context.Context, span Span) context.Context +} diff --git a/vendor/github.com/opentracing/opentracing-go/globaltracer.go b/vendor/github.com/opentracing/opentracing-go/globaltracer.go new file mode 100644 index 000000000..4f7066a92 --- /dev/null +++ b/vendor/github.com/opentracing/opentracing-go/globaltracer.go @@ -0,0 +1,42 @@ +package opentracing + +type registeredTracer struct { + tracer Tracer + isRegistered bool +} + +var ( + globalTracer = registeredTracer{NoopTracer{}, false} +) + +// SetGlobalTracer sets the [singleton] opentracing.Tracer returned by +// GlobalTracer(). Those who use GlobalTracer (rather than directly manage an +// opentracing.Tracer instance) should call SetGlobalTracer as early as +// possible in main(), prior to calling the `StartSpan` global func below. +// Prior to calling `SetGlobalTracer`, any Spans started via the `StartSpan` +// (etc) globals are noops. +func SetGlobalTracer(tracer Tracer) { + globalTracer = registeredTracer{tracer, true} +} + +// GlobalTracer returns the global singleton `Tracer` implementation. +// Before `SetGlobalTracer()` is called, the `GlobalTracer()` is a noop +// implementation that drops all data handed to it. +func GlobalTracer() Tracer { + return globalTracer.tracer +} + +// StartSpan defers to `Tracer.StartSpan`. See `GlobalTracer()`. +func StartSpan(operationName string, opts ...StartSpanOption) Span { + return globalTracer.tracer.StartSpan(operationName, opts...) +} + +// InitGlobalTracer is deprecated. Please use SetGlobalTracer. +func InitGlobalTracer(tracer Tracer) { + SetGlobalTracer(tracer) +} + +// IsGlobalTracerRegistered returns a `bool` to indicate if a tracer has been globally registered +func IsGlobalTracerRegistered() bool { + return globalTracer.isRegistered +} diff --git a/vendor/github.com/opentracing/opentracing-go/gocontext.go b/vendor/github.com/opentracing/opentracing-go/gocontext.go new file mode 100644 index 000000000..1831bc9b2 --- /dev/null +++ b/vendor/github.com/opentracing/opentracing-go/gocontext.go @@ -0,0 +1,65 @@ +package opentracing + +import "context" + +type contextKey struct{} + +var activeSpanKey = contextKey{} + +// ContextWithSpan returns a new `context.Context` that holds a reference to +// the span. If span is nil, a new context without an active span is returned. +func ContextWithSpan(ctx context.Context, span Span) context.Context { + if span != nil { + if tracerWithHook, ok := span.Tracer().(TracerContextWithSpanExtension); ok { + ctx = tracerWithHook.ContextWithSpanHook(ctx, span) + } + } + return context.WithValue(ctx, activeSpanKey, span) +} + +// SpanFromContext returns the `Span` previously associated with `ctx`, or +// `nil` if no such `Span` could be found. +// +// NOTE: context.Context != SpanContext: the former is Go's intra-process +// context propagation mechanism, and the latter houses OpenTracing's per-Span +// identity and baggage information. +func SpanFromContext(ctx context.Context) Span { + val := ctx.Value(activeSpanKey) + if sp, ok := val.(Span); ok { + return sp + } + return nil +} + +// StartSpanFromContext starts and returns a Span with `operationName`, using +// any Span found within `ctx` as a ChildOfRef. If no such parent could be +// found, StartSpanFromContext creates a root (parentless) Span. +// +// The second return value is a context.Context object built around the +// returned Span. +// +// Example usage: +// +// SomeFunction(ctx context.Context, ...) { +// sp, ctx := opentracing.StartSpanFromContext(ctx, "SomeFunction") +// defer sp.Finish() +// ... +// } +func StartSpanFromContext(ctx context.Context, operationName string, opts ...StartSpanOption) (Span, context.Context) { + return StartSpanFromContextWithTracer(ctx, GlobalTracer(), operationName, opts...) +} + +// StartSpanFromContextWithTracer starts and returns a span with `operationName` +// using a span found within the context as a ChildOfRef. If that doesn't exist +// it creates a root span. It also returns a context.Context object built +// around the returned span. +// +// It's behavior is identical to StartSpanFromContext except that it takes an explicit +// tracer as opposed to using the global tracer. +func StartSpanFromContextWithTracer(ctx context.Context, tracer Tracer, operationName string, opts ...StartSpanOption) (Span, context.Context) { + if parentSpan := SpanFromContext(ctx); parentSpan != nil { + opts = append(opts, ChildOf(parentSpan.Context())) + } + span := tracer.StartSpan(operationName, opts...) + return span, ContextWithSpan(ctx, span) +} diff --git a/vendor/github.com/opentracing/opentracing-go/log/field.go b/vendor/github.com/opentracing/opentracing-go/log/field.go new file mode 100644 index 000000000..f222ded79 --- /dev/null +++ b/vendor/github.com/opentracing/opentracing-go/log/field.go @@ -0,0 +1,282 @@ +package log + +import ( + "fmt" + "math" +) + +type fieldType int + +const ( + stringType fieldType = iota + boolType + intType + int32Type + uint32Type + int64Type + uint64Type + float32Type + float64Type + errorType + objectType + lazyLoggerType + noopType +) + +// Field instances are constructed via LogBool, LogString, and so on. +// Tracing implementations may then handle them via the Field.Marshal +// method. +// +// "heavily influenced by" (i.e., partially stolen from) +// https://github.com/uber-go/zap +type Field struct { + key string + fieldType fieldType + numericVal int64 + stringVal string + interfaceVal interface{} +} + +// String adds a string-valued key:value pair to a Span.LogFields() record +func String(key, val string) Field { + return Field{ + key: key, + fieldType: stringType, + stringVal: val, + } +} + +// Bool adds a bool-valued key:value pair to a Span.LogFields() record +func Bool(key string, val bool) Field { + var numericVal int64 + if val { + numericVal = 1 + } + return Field{ + key: key, + fieldType: boolType, + numericVal: numericVal, + } +} + +// Int adds an int-valued key:value pair to a Span.LogFields() record +func Int(key string, val int) Field { + return Field{ + key: key, + fieldType: intType, + numericVal: int64(val), + } +} + +// Int32 adds an int32-valued key:value pair to a Span.LogFields() record +func Int32(key string, val int32) Field { + return Field{ + key: key, + fieldType: int32Type, + numericVal: int64(val), + } +} + +// Int64 adds an int64-valued key:value pair to a Span.LogFields() record +func Int64(key string, val int64) Field { + return Field{ + key: key, + fieldType: int64Type, + numericVal: val, + } +} + +// Uint32 adds a uint32-valued key:value pair to a Span.LogFields() record +func Uint32(key string, val uint32) Field { + return Field{ + key: key, + fieldType: uint32Type, + numericVal: int64(val), + } +} + +// Uint64 adds a uint64-valued key:value pair to a Span.LogFields() record +func Uint64(key string, val uint64) Field { + return Field{ + key: key, + fieldType: uint64Type, + numericVal: int64(val), + } +} + +// Float32 adds a float32-valued key:value pair to a Span.LogFields() record +func Float32(key string, val float32) Field { + return Field{ + key: key, + fieldType: float32Type, + numericVal: int64(math.Float32bits(val)), + } +} + +// Float64 adds a float64-valued key:value pair to a Span.LogFields() record +func Float64(key string, val float64) Field { + return Field{ + key: key, + fieldType: float64Type, + numericVal: int64(math.Float64bits(val)), + } +} + +// Error adds an error with the key "error.object" to a Span.LogFields() record +func Error(err error) Field { + return Field{ + key: "error.object", + fieldType: errorType, + interfaceVal: err, + } +} + +// Object adds an object-valued key:value pair to a Span.LogFields() record +// Please pass in an immutable object, otherwise there may be concurrency issues. +// Such as passing in the map, log.Object may result in "fatal error: concurrent map iteration and map write". +// Because span is sent asynchronously, it is possible that this map will also be modified. +func Object(key string, obj interface{}) Field { + return Field{ + key: key, + fieldType: objectType, + interfaceVal: obj, + } +} + +// Event creates a string-valued Field for span logs with key="event" and value=val. +func Event(val string) Field { + return String("event", val) +} + +// Message creates a string-valued Field for span logs with key="message" and value=val. +func Message(val string) Field { + return String("message", val) +} + +// LazyLogger allows for user-defined, late-bound logging of arbitrary data +type LazyLogger func(fv Encoder) + +// Lazy adds a LazyLogger to a Span.LogFields() record; the tracing +// implementation will call the LazyLogger function at an indefinite time in +// the future (after Lazy() returns). +func Lazy(ll LazyLogger) Field { + return Field{ + fieldType: lazyLoggerType, + interfaceVal: ll, + } +} + +// Noop creates a no-op log field that should be ignored by the tracer. +// It can be used to capture optional fields, for example those that should +// only be logged in non-production environment: +// +// func customerField(order *Order) log.Field { +// if os.Getenv("ENVIRONMENT") == "dev" { +// return log.String("customer", order.Customer.ID) +// } +// return log.Noop() +// } +// +// span.LogFields(log.String("event", "purchase"), customerField(order)) +// +func Noop() Field { + return Field{ + fieldType: noopType, + } +} + +// Encoder allows access to the contents of a Field (via a call to +// Field.Marshal). +// +// Tracer implementations typically provide an implementation of Encoder; +// OpenTracing callers typically do not need to concern themselves with it. +type Encoder interface { + EmitString(key, value string) + EmitBool(key string, value bool) + EmitInt(key string, value int) + EmitInt32(key string, value int32) + EmitInt64(key string, value int64) + EmitUint32(key string, value uint32) + EmitUint64(key string, value uint64) + EmitFloat32(key string, value float32) + EmitFloat64(key string, value float64) + EmitObject(key string, value interface{}) + EmitLazyLogger(value LazyLogger) +} + +// Marshal passes a Field instance through to the appropriate +// field-type-specific method of an Encoder. +func (lf Field) Marshal(visitor Encoder) { + switch lf.fieldType { + case stringType: + visitor.EmitString(lf.key, lf.stringVal) + case boolType: + visitor.EmitBool(lf.key, lf.numericVal != 0) + case intType: + visitor.EmitInt(lf.key, int(lf.numericVal)) + case int32Type: + visitor.EmitInt32(lf.key, int32(lf.numericVal)) + case int64Type: + visitor.EmitInt64(lf.key, int64(lf.numericVal)) + case uint32Type: + visitor.EmitUint32(lf.key, uint32(lf.numericVal)) + case uint64Type: + visitor.EmitUint64(lf.key, uint64(lf.numericVal)) + case float32Type: + visitor.EmitFloat32(lf.key, math.Float32frombits(uint32(lf.numericVal))) + case float64Type: + visitor.EmitFloat64(lf.key, math.Float64frombits(uint64(lf.numericVal))) + case errorType: + if err, ok := lf.interfaceVal.(error); ok { + visitor.EmitString(lf.key, err.Error()) + } else { + visitor.EmitString(lf.key, "") + } + case objectType: + visitor.EmitObject(lf.key, lf.interfaceVal) + case lazyLoggerType: + visitor.EmitLazyLogger(lf.interfaceVal.(LazyLogger)) + case noopType: + // intentionally left blank + } +} + +// Key returns the field's key. +func (lf Field) Key() string { + return lf.key +} + +// Value returns the field's value as interface{}. +func (lf Field) Value() interface{} { + switch lf.fieldType { + case stringType: + return lf.stringVal + case boolType: + return lf.numericVal != 0 + case intType: + return int(lf.numericVal) + case int32Type: + return int32(lf.numericVal) + case int64Type: + return int64(lf.numericVal) + case uint32Type: + return uint32(lf.numericVal) + case uint64Type: + return uint64(lf.numericVal) + case float32Type: + return math.Float32frombits(uint32(lf.numericVal)) + case float64Type: + return math.Float64frombits(uint64(lf.numericVal)) + case errorType, objectType, lazyLoggerType: + return lf.interfaceVal + case noopType: + return nil + default: + return nil + } +} + +// String returns a string representation of the key and value. +func (lf Field) String() string { + return fmt.Sprint(lf.key, ":", lf.Value()) +} diff --git a/vendor/github.com/opentracing/opentracing-go/log/util.go b/vendor/github.com/opentracing/opentracing-go/log/util.go new file mode 100644 index 000000000..d57e28aa5 --- /dev/null +++ b/vendor/github.com/opentracing/opentracing-go/log/util.go @@ -0,0 +1,61 @@ +package log + +import ( + "fmt" + "reflect" +) + +// InterleavedKVToFields converts keyValues a la Span.LogKV() to a Field slice +// a la Span.LogFields(). +func InterleavedKVToFields(keyValues ...interface{}) ([]Field, error) { + if len(keyValues)%2 != 0 { + return nil, fmt.Errorf("non-even keyValues len: %d", len(keyValues)) + } + fields := make([]Field, len(keyValues)/2) + for i := 0; i*2 < len(keyValues); i++ { + key, ok := keyValues[i*2].(string) + if !ok { + return nil, fmt.Errorf( + "non-string key (pair #%d): %T", + i, keyValues[i*2]) + } + switch typedVal := keyValues[i*2+1].(type) { + case bool: + fields[i] = Bool(key, typedVal) + case string: + fields[i] = String(key, typedVal) + case int: + fields[i] = Int(key, typedVal) + case int8: + fields[i] = Int32(key, int32(typedVal)) + case int16: + fields[i] = Int32(key, int32(typedVal)) + case int32: + fields[i] = Int32(key, typedVal) + case int64: + fields[i] = Int64(key, typedVal) + case uint: + fields[i] = Uint64(key, uint64(typedVal)) + case uint64: + fields[i] = Uint64(key, typedVal) + case uint8: + fields[i] = Uint32(key, uint32(typedVal)) + case uint16: + fields[i] = Uint32(key, uint32(typedVal)) + case uint32: + fields[i] = Uint32(key, typedVal) + case float32: + fields[i] = Float32(key, typedVal) + case float64: + fields[i] = Float64(key, typedVal) + default: + if typedVal == nil || (reflect.ValueOf(typedVal).Kind() == reflect.Ptr && reflect.ValueOf(typedVal).IsNil()) { + fields[i] = String(key, "nil") + continue + } + // When in doubt, coerce to a string + fields[i] = String(key, fmt.Sprint(typedVal)) + } + } + return fields, nil +} diff --git a/vendor/github.com/opentracing/opentracing-go/noop.go b/vendor/github.com/opentracing/opentracing-go/noop.go new file mode 100644 index 000000000..f9b680a21 --- /dev/null +++ b/vendor/github.com/opentracing/opentracing-go/noop.go @@ -0,0 +1,64 @@ +package opentracing + +import "github.com/opentracing/opentracing-go/log" + +// A NoopTracer is a trivial, minimum overhead implementation of Tracer +// for which all operations are no-ops. +// +// The primary use of this implementation is in libraries, such as RPC +// frameworks, that make tracing an optional feature controlled by the +// end user. A no-op implementation allows said libraries to use it +// as the default Tracer and to write instrumentation that does +// not need to keep checking if the tracer instance is nil. +// +// For the same reason, the NoopTracer is the default "global" tracer +// (see GlobalTracer and SetGlobalTracer functions). +// +// WARNING: NoopTracer does not support baggage propagation. +type NoopTracer struct{} + +type noopSpan struct{} +type noopSpanContext struct{} + +var ( + defaultNoopSpanContext SpanContext = noopSpanContext{} + defaultNoopSpan Span = noopSpan{} + defaultNoopTracer Tracer = NoopTracer{} +) + +const ( + emptyString = "" +) + +// noopSpanContext: +func (n noopSpanContext) ForeachBaggageItem(handler func(k, v string) bool) {} + +// noopSpan: +func (n noopSpan) Context() SpanContext { return defaultNoopSpanContext } +func (n noopSpan) SetBaggageItem(key, val string) Span { return n } +func (n noopSpan) BaggageItem(key string) string { return emptyString } +func (n noopSpan) SetTag(key string, value interface{}) Span { return n } +func (n noopSpan) LogFields(fields ...log.Field) {} +func (n noopSpan) LogKV(keyVals ...interface{}) {} +func (n noopSpan) Finish() {} +func (n noopSpan) FinishWithOptions(opts FinishOptions) {} +func (n noopSpan) SetOperationName(operationName string) Span { return n } +func (n noopSpan) Tracer() Tracer { return defaultNoopTracer } +func (n noopSpan) LogEvent(event string) {} +func (n noopSpan) LogEventWithPayload(event string, payload interface{}) {} +func (n noopSpan) Log(data LogData) {} + +// StartSpan belongs to the Tracer interface. +func (n NoopTracer) StartSpan(operationName string, opts ...StartSpanOption) Span { + return defaultNoopSpan +} + +// Inject belongs to the Tracer interface. +func (n NoopTracer) Inject(sp SpanContext, format interface{}, carrier interface{}) error { + return nil +} + +// Extract belongs to the Tracer interface. +func (n NoopTracer) Extract(format interface{}, carrier interface{}) (SpanContext, error) { + return nil, ErrSpanContextNotFound +} diff --git a/vendor/github.com/opentracing/opentracing-go/propagation.go b/vendor/github.com/opentracing/opentracing-go/propagation.go new file mode 100644 index 000000000..b0c275eb0 --- /dev/null +++ b/vendor/github.com/opentracing/opentracing-go/propagation.go @@ -0,0 +1,176 @@ +package opentracing + +import ( + "errors" + "net/http" +) + +/////////////////////////////////////////////////////////////////////////////// +// CORE PROPAGATION INTERFACES: +/////////////////////////////////////////////////////////////////////////////// + +var ( + // ErrUnsupportedFormat occurs when the `format` passed to Tracer.Inject() or + // Tracer.Extract() is not recognized by the Tracer implementation. + ErrUnsupportedFormat = errors.New("opentracing: Unknown or unsupported Inject/Extract format") + + // ErrSpanContextNotFound occurs when the `carrier` passed to + // Tracer.Extract() is valid and uncorrupted but has insufficient + // information to extract a SpanContext. + ErrSpanContextNotFound = errors.New("opentracing: SpanContext not found in Extract carrier") + + // ErrInvalidSpanContext errors occur when Tracer.Inject() is asked to + // operate on a SpanContext which it is not prepared to handle (for + // example, since it was created by a different tracer implementation). + ErrInvalidSpanContext = errors.New("opentracing: SpanContext type incompatible with tracer") + + // ErrInvalidCarrier errors occur when Tracer.Inject() or Tracer.Extract() + // implementations expect a different type of `carrier` than they are + // given. + ErrInvalidCarrier = errors.New("opentracing: Invalid Inject/Extract carrier") + + // ErrSpanContextCorrupted occurs when the `carrier` passed to + // Tracer.Extract() is of the expected type but is corrupted. + ErrSpanContextCorrupted = errors.New("opentracing: SpanContext data corrupted in Extract carrier") +) + +/////////////////////////////////////////////////////////////////////////////// +// BUILTIN PROPAGATION FORMATS: +/////////////////////////////////////////////////////////////////////////////// + +// BuiltinFormat is used to demarcate the values within package `opentracing` +// that are intended for use with the Tracer.Inject() and Tracer.Extract() +// methods. +type BuiltinFormat byte + +const ( + // Binary represents SpanContexts as opaque binary data. + // + // For Tracer.Inject(): the carrier must be an `io.Writer`. + // + // For Tracer.Extract(): the carrier must be an `io.Reader`. + Binary BuiltinFormat = iota + + // TextMap represents SpanContexts as key:value string pairs. + // + // Unlike HTTPHeaders, the TextMap format does not restrict the key or + // value character sets in any way. + // + // For Tracer.Inject(): the carrier must be a `TextMapWriter`. + // + // For Tracer.Extract(): the carrier must be a `TextMapReader`. + TextMap + + // HTTPHeaders represents SpanContexts as HTTP header string pairs. + // + // Unlike TextMap, the HTTPHeaders format requires that the keys and values + // be valid as HTTP headers as-is (i.e., character casing may be unstable + // and special characters are disallowed in keys, values should be + // URL-escaped, etc). + // + // For Tracer.Inject(): the carrier must be a `TextMapWriter`. + // + // For Tracer.Extract(): the carrier must be a `TextMapReader`. + // + // See HTTPHeadersCarrier for an implementation of both TextMapWriter + // and TextMapReader that defers to an http.Header instance for storage. + // For example, Inject(): + // + // carrier := opentracing.HTTPHeadersCarrier(httpReq.Header) + // err := span.Tracer().Inject( + // span.Context(), opentracing.HTTPHeaders, carrier) + // + // Or Extract(): + // + // carrier := opentracing.HTTPHeadersCarrier(httpReq.Header) + // clientContext, err := tracer.Extract( + // opentracing.HTTPHeaders, carrier) + // + HTTPHeaders +) + +// TextMapWriter is the Inject() carrier for the TextMap builtin format. With +// it, the caller can encode a SpanContext for propagation as entries in a map +// of unicode strings. +type TextMapWriter interface { + // Set a key:value pair to the carrier. Multiple calls to Set() for the + // same key leads to undefined behavior. + // + // NOTE: The backing store for the TextMapWriter may contain data unrelated + // to SpanContext. As such, Inject() and Extract() implementations that + // call the TextMapWriter and TextMapReader interfaces must agree on a + // prefix or other convention to distinguish their own key:value pairs. + Set(key, val string) +} + +// TextMapReader is the Extract() carrier for the TextMap builtin format. With it, +// the caller can decode a propagated SpanContext as entries in a map of +// unicode strings. +type TextMapReader interface { + // ForeachKey returns TextMap contents via repeated calls to the `handler` + // function. If any call to `handler` returns a non-nil error, ForeachKey + // terminates and returns that error. + // + // NOTE: The backing store for the TextMapReader may contain data unrelated + // to SpanContext. As such, Inject() and Extract() implementations that + // call the TextMapWriter and TextMapReader interfaces must agree on a + // prefix or other convention to distinguish their own key:value pairs. + // + // The "foreach" callback pattern reduces unnecessary copying in some cases + // and also allows implementations to hold locks while the map is read. + ForeachKey(handler func(key, val string) error) error +} + +// TextMapCarrier allows the use of regular map[string]string +// as both TextMapWriter and TextMapReader. +type TextMapCarrier map[string]string + +// ForeachKey conforms to the TextMapReader interface. +func (c TextMapCarrier) ForeachKey(handler func(key, val string) error) error { + for k, v := range c { + if err := handler(k, v); err != nil { + return err + } + } + return nil +} + +// Set implements Set() of opentracing.TextMapWriter +func (c TextMapCarrier) Set(key, val string) { + c[key] = val +} + +// HTTPHeadersCarrier satisfies both TextMapWriter and TextMapReader. +// +// Example usage for server side: +// +// carrier := opentracing.HTTPHeadersCarrier(httpReq.Header) +// clientContext, err := tracer.Extract(opentracing.HTTPHeaders, carrier) +// +// Example usage for client side: +// +// carrier := opentracing.HTTPHeadersCarrier(httpReq.Header) +// err := tracer.Inject( +// span.Context(), +// opentracing.HTTPHeaders, +// carrier) +// +type HTTPHeadersCarrier http.Header + +// Set conforms to the TextMapWriter interface. +func (c HTTPHeadersCarrier) Set(key, val string) { + h := http.Header(c) + h.Set(key, val) +} + +// ForeachKey conforms to the TextMapReader interface. +func (c HTTPHeadersCarrier) ForeachKey(handler func(key, val string) error) error { + for k, vals := range c { + for _, v := range vals { + if err := handler(k, v); err != nil { + return err + } + } + } + return nil +} diff --git a/vendor/github.com/opentracing/opentracing-go/span.go b/vendor/github.com/opentracing/opentracing-go/span.go new file mode 100644 index 000000000..0d3fb5341 --- /dev/null +++ b/vendor/github.com/opentracing/opentracing-go/span.go @@ -0,0 +1,189 @@ +package opentracing + +import ( + "time" + + "github.com/opentracing/opentracing-go/log" +) + +// SpanContext represents Span state that must propagate to descendant Spans and across process +// boundaries (e.g., a tuple). +type SpanContext interface { + // ForeachBaggageItem grants access to all baggage items stored in the + // SpanContext. + // The handler function will be called for each baggage key/value pair. + // The ordering of items is not guaranteed. + // + // The bool return value indicates if the handler wants to continue iterating + // through the rest of the baggage items; for example if the handler is trying to + // find some baggage item by pattern matching the name, it can return false + // as soon as the item is found to stop further iterations. + ForeachBaggageItem(handler func(k, v string) bool) +} + +// Span represents an active, un-finished span in the OpenTracing system. +// +// Spans are created by the Tracer interface. +type Span interface { + // Sets the end timestamp and finalizes Span state. + // + // With the exception of calls to Context() (which are always allowed), + // Finish() must be the last call made to any span instance, and to do + // otherwise leads to undefined behavior. + Finish() + // FinishWithOptions is like Finish() but with explicit control over + // timestamps and log data. + FinishWithOptions(opts FinishOptions) + + // Context() yields the SpanContext for this Span. Note that the return + // value of Context() is still valid after a call to Span.Finish(), as is + // a call to Span.Context() after a call to Span.Finish(). + Context() SpanContext + + // Sets or changes the operation name. + // + // Returns a reference to this Span for chaining. + SetOperationName(operationName string) Span + + // Adds a tag to the span. + // + // If there is a pre-existing tag set for `key`, it is overwritten. + // + // Tag values can be numeric types, strings, or bools. The behavior of + // other tag value types is undefined at the OpenTracing level. If a + // tracing system does not know how to handle a particular value type, it + // may ignore the tag, but shall not panic. + // + // Returns a reference to this Span for chaining. + SetTag(key string, value interface{}) Span + + // LogFields is an efficient and type-checked way to record key:value + // logging data about a Span, though the programming interface is a little + // more verbose than LogKV(). Here's an example: + // + // span.LogFields( + // log.String("event", "soft error"), + // log.String("type", "cache timeout"), + // log.Int("waited.millis", 1500)) + // + // Also see Span.FinishWithOptions() and FinishOptions.BulkLogData. + LogFields(fields ...log.Field) + + // LogKV is a concise, readable way to record key:value logging data about + // a Span, though unfortunately this also makes it less efficient and less + // type-safe than LogFields(). Here's an example: + // + // span.LogKV( + // "event", "soft error", + // "type", "cache timeout", + // "waited.millis", 1500) + // + // For LogKV (as opposed to LogFields()), the parameters must appear as + // key-value pairs, like + // + // span.LogKV(key1, val1, key2, val2, key3, val3, ...) + // + // The keys must all be strings. The values may be strings, numeric types, + // bools, Go error instances, or arbitrary structs. + // + // (Note to implementors: consider the log.InterleavedKVToFields() helper) + LogKV(alternatingKeyValues ...interface{}) + + // SetBaggageItem sets a key:value pair on this Span and its SpanContext + // that also propagates to descendants of this Span. + // + // SetBaggageItem() enables powerful functionality given a full-stack + // opentracing integration (e.g., arbitrary application data from a mobile + // app can make it, transparently, all the way into the depths of a storage + // system), and with it some powerful costs: use this feature with care. + // + // IMPORTANT NOTE #1: SetBaggageItem() will only propagate baggage items to + // *future* causal descendants of the associated Span. + // + // IMPORTANT NOTE #2: Use this thoughtfully and with care. Every key and + // value is copied into every local *and remote* child of the associated + // Span, and that can add up to a lot of network and cpu overhead. + // + // Returns a reference to this Span for chaining. + SetBaggageItem(restrictedKey, value string) Span + + // Gets the value for a baggage item given its key. Returns the empty string + // if the value isn't found in this Span. + BaggageItem(restrictedKey string) string + + // Provides access to the Tracer that created this Span. + Tracer() Tracer + + // Deprecated: use LogFields or LogKV + LogEvent(event string) + // Deprecated: use LogFields or LogKV + LogEventWithPayload(event string, payload interface{}) + // Deprecated: use LogFields or LogKV + Log(data LogData) +} + +// LogRecord is data associated with a single Span log. Every LogRecord +// instance must specify at least one Field. +type LogRecord struct { + Timestamp time.Time + Fields []log.Field +} + +// FinishOptions allows Span.FinishWithOptions callers to override the finish +// timestamp and provide log data via a bulk interface. +type FinishOptions struct { + // FinishTime overrides the Span's finish time, or implicitly becomes + // time.Now() if FinishTime.IsZero(). + // + // FinishTime must resolve to a timestamp that's >= the Span's StartTime + // (per StartSpanOptions). + FinishTime time.Time + + // LogRecords allows the caller to specify the contents of many LogFields() + // calls with a single slice. May be nil. + // + // None of the LogRecord.Timestamp values may be .IsZero() (i.e., they must + // be set explicitly). Also, they must be >= the Span's start timestamp and + // <= the FinishTime (or time.Now() if FinishTime.IsZero()). Otherwise the + // behavior of FinishWithOptions() is undefined. + // + // If specified, the caller hands off ownership of LogRecords at + // FinishWithOptions() invocation time. + // + // If specified, the (deprecated) BulkLogData must be nil or empty. + LogRecords []LogRecord + + // BulkLogData is DEPRECATED. + BulkLogData []LogData +} + +// LogData is DEPRECATED +type LogData struct { + Timestamp time.Time + Event string + Payload interface{} +} + +// ToLogRecord converts a deprecated LogData to a non-deprecated LogRecord +func (ld *LogData) ToLogRecord() LogRecord { + var literalTimestamp time.Time + if ld.Timestamp.IsZero() { + literalTimestamp = time.Now() + } else { + literalTimestamp = ld.Timestamp + } + rval := LogRecord{ + Timestamp: literalTimestamp, + } + if ld.Payload == nil { + rval.Fields = []log.Field{ + log.String("event", ld.Event), + } + } else { + rval.Fields = []log.Field{ + log.String("event", ld.Event), + log.Object("payload", ld.Payload), + } + } + return rval +} diff --git a/vendor/github.com/opentracing/opentracing-go/tracer.go b/vendor/github.com/opentracing/opentracing-go/tracer.go new file mode 100644 index 000000000..715f0cedf --- /dev/null +++ b/vendor/github.com/opentracing/opentracing-go/tracer.go @@ -0,0 +1,304 @@ +package opentracing + +import "time" + +// Tracer is a simple, thin interface for Span creation and SpanContext +// propagation. +type Tracer interface { + + // Create, start, and return a new Span with the given `operationName` and + // incorporate the given StartSpanOption `opts`. (Note that `opts` borrows + // from the "functional options" pattern, per + // http://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis) + // + // A Span with no SpanReference options (e.g., opentracing.ChildOf() or + // opentracing.FollowsFrom()) becomes the root of its own trace. + // + // Examples: + // + // var tracer opentracing.Tracer = ... + // + // // The root-span case: + // sp := tracer.StartSpan("GetFeed") + // + // // The vanilla child span case: + // sp := tracer.StartSpan( + // "GetFeed", + // opentracing.ChildOf(parentSpan.Context())) + // + // // All the bells and whistles: + // sp := tracer.StartSpan( + // "GetFeed", + // opentracing.ChildOf(parentSpan.Context()), + // opentracing.Tag{"user_agent", loggedReq.UserAgent}, + // opentracing.StartTime(loggedReq.Timestamp), + // ) + // + StartSpan(operationName string, opts ...StartSpanOption) Span + + // Inject() takes the `sm` SpanContext instance and injects it for + // propagation within `carrier`. The actual type of `carrier` depends on + // the value of `format`. + // + // OpenTracing defines a common set of `format` values (see BuiltinFormat), + // and each has an expected carrier type. + // + // Other packages may declare their own `format` values, much like the keys + // used by `context.Context` (see https://godoc.org/context#WithValue). + // + // Example usage (sans error handling): + // + // carrier := opentracing.HTTPHeadersCarrier(httpReq.Header) + // err := tracer.Inject( + // span.Context(), + // opentracing.HTTPHeaders, + // carrier) + // + // NOTE: All opentracing.Tracer implementations MUST support all + // BuiltinFormats. + // + // Implementations may return opentracing.ErrUnsupportedFormat if `format` + // is not supported by (or not known by) the implementation. + // + // Implementations may return opentracing.ErrInvalidCarrier or any other + // implementation-specific error if the format is supported but injection + // fails anyway. + // + // See Tracer.Extract(). + Inject(sm SpanContext, format interface{}, carrier interface{}) error + + // Extract() returns a SpanContext instance given `format` and `carrier`. + // + // OpenTracing defines a common set of `format` values (see BuiltinFormat), + // and each has an expected carrier type. + // + // Other packages may declare their own `format` values, much like the keys + // used by `context.Context` (see + // https://godoc.org/golang.org/x/net/context#WithValue). + // + // Example usage (with StartSpan): + // + // + // carrier := opentracing.HTTPHeadersCarrier(httpReq.Header) + // clientContext, err := tracer.Extract(opentracing.HTTPHeaders, carrier) + // + // // ... assuming the ultimate goal here is to resume the trace with a + // // server-side Span: + // var serverSpan opentracing.Span + // if err == nil { + // span = tracer.StartSpan( + // rpcMethodName, ext.RPCServerOption(clientContext)) + // } else { + // span = tracer.StartSpan(rpcMethodName) + // } + // + // + // NOTE: All opentracing.Tracer implementations MUST support all + // BuiltinFormats. + // + // Return values: + // - A successful Extract returns a SpanContext instance and a nil error + // - If there was simply no SpanContext to extract in `carrier`, Extract() + // returns (nil, opentracing.ErrSpanContextNotFound) + // - If `format` is unsupported or unrecognized, Extract() returns (nil, + // opentracing.ErrUnsupportedFormat) + // - If there are more fundamental problems with the `carrier` object, + // Extract() may return opentracing.ErrInvalidCarrier, + // opentracing.ErrSpanContextCorrupted, or implementation-specific + // errors. + // + // See Tracer.Inject(). + Extract(format interface{}, carrier interface{}) (SpanContext, error) +} + +// StartSpanOptions allows Tracer.StartSpan() callers and implementors a +// mechanism to override the start timestamp, specify Span References, and make +// a single Tag or multiple Tags available at Span start time. +// +// StartSpan() callers should look at the StartSpanOption interface and +// implementations available in this package. +// +// Tracer implementations can convert a slice of `StartSpanOption` instances +// into a `StartSpanOptions` struct like so: +// +// func StartSpan(opName string, opts ...opentracing.StartSpanOption) { +// sso := opentracing.StartSpanOptions{} +// for _, o := range opts { +// o.Apply(&sso) +// } +// ... +// } +// +type StartSpanOptions struct { + // Zero or more causal references to other Spans (via their SpanContext). + // If empty, start a "root" Span (i.e., start a new trace). + References []SpanReference + + // StartTime overrides the Span's start time, or implicitly becomes + // time.Now() if StartTime.IsZero(). + StartTime time.Time + + // Tags may have zero or more entries; the restrictions on map values are + // identical to those for Span.SetTag(). May be nil. + // + // If specified, the caller hands off ownership of Tags at + // StartSpan() invocation time. + Tags map[string]interface{} +} + +// StartSpanOption instances (zero or more) may be passed to Tracer.StartSpan. +// +// StartSpanOption borrows from the "functional options" pattern, per +// http://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis +type StartSpanOption interface { + Apply(*StartSpanOptions) +} + +// SpanReferenceType is an enum type describing different categories of +// relationships between two Spans. If Span-2 refers to Span-1, the +// SpanReferenceType describes Span-1 from Span-2's perspective. For example, +// ChildOfRef means that Span-1 created Span-2. +// +// NOTE: Span-1 and Span-2 do *not* necessarily depend on each other for +// completion; e.g., Span-2 may be part of a background job enqueued by Span-1, +// or Span-2 may be sitting in a distributed queue behind Span-1. +type SpanReferenceType int + +const ( + // ChildOfRef refers to a parent Span that caused *and* somehow depends + // upon the new child Span. Often (but not always), the parent Span cannot + // finish until the child Span does. + // + // An timing diagram for a ChildOfRef that's blocked on the new Span: + // + // [-Parent Span---------] + // [-Child Span----] + // + // See http://opentracing.io/spec/ + // + // See opentracing.ChildOf() + ChildOfRef SpanReferenceType = iota + + // FollowsFromRef refers to a parent Span that does not depend in any way + // on the result of the new child Span. For instance, one might use + // FollowsFromRefs to describe pipeline stages separated by queues, + // or a fire-and-forget cache insert at the tail end of a web request. + // + // A FollowsFromRef Span is part of the same logical trace as the new Span: + // i.e., the new Span is somehow caused by the work of its FollowsFromRef. + // + // All of the following could be valid timing diagrams for children that + // "FollowFrom" a parent. + // + // [-Parent Span-] [-Child Span-] + // + // + // [-Parent Span--] + // [-Child Span-] + // + // + // [-Parent Span-] + // [-Child Span-] + // + // See http://opentracing.io/spec/ + // + // See opentracing.FollowsFrom() + FollowsFromRef +) + +// SpanReference is a StartSpanOption that pairs a SpanReferenceType and a +// referenced SpanContext. See the SpanReferenceType documentation for +// supported relationships. If SpanReference is created with +// ReferencedContext==nil, it has no effect. Thus it allows for a more concise +// syntax for starting spans: +// +// sc, _ := tracer.Extract(someFormat, someCarrier) +// span := tracer.StartSpan("operation", opentracing.ChildOf(sc)) +// +// The `ChildOf(sc)` option above will not panic if sc == nil, it will just +// not add the parent span reference to the options. +type SpanReference struct { + Type SpanReferenceType + ReferencedContext SpanContext +} + +// Apply satisfies the StartSpanOption interface. +func (r SpanReference) Apply(o *StartSpanOptions) { + if r.ReferencedContext != nil { + o.References = append(o.References, r) + } +} + +// ChildOf returns a StartSpanOption pointing to a dependent parent span. +// If sc == nil, the option has no effect. +// +// See ChildOfRef, SpanReference +func ChildOf(sc SpanContext) SpanReference { + return SpanReference{ + Type: ChildOfRef, + ReferencedContext: sc, + } +} + +// FollowsFrom returns a StartSpanOption pointing to a parent Span that caused +// the child Span but does not directly depend on its result in any way. +// If sc == nil, the option has no effect. +// +// See FollowsFromRef, SpanReference +func FollowsFrom(sc SpanContext) SpanReference { + return SpanReference{ + Type: FollowsFromRef, + ReferencedContext: sc, + } +} + +// StartTime is a StartSpanOption that sets an explicit start timestamp for the +// new Span. +type StartTime time.Time + +// Apply satisfies the StartSpanOption interface. +func (t StartTime) Apply(o *StartSpanOptions) { + o.StartTime = time.Time(t) +} + +// Tags are a generic map from an arbitrary string key to an opaque value type. +// The underlying tracing system is responsible for interpreting and +// serializing the values. +type Tags map[string]interface{} + +// Apply satisfies the StartSpanOption interface. +func (t Tags) Apply(o *StartSpanOptions) { + if o.Tags == nil { + o.Tags = make(map[string]interface{}) + } + for k, v := range t { + o.Tags[k] = v + } +} + +// Tag may be passed as a StartSpanOption to add a tag to new spans, +// or its Set method may be used to apply the tag to an existing Span, +// for example: +// +// tracer.StartSpan("opName", Tag{"Key", value}) +// +// or +// +// Tag{"key", value}.Set(span) +type Tag struct { + Key string + Value interface{} +} + +// Apply satisfies the StartSpanOption interface. +func (t Tag) Apply(o *StartSpanOptions) { + if o.Tags == nil { + o.Tags = make(map[string]interface{}) + } + o.Tags[t.Key] = t.Value +} + +// Set applies the tag to an existing Span. +func (t Tag) Set(s Span) { + s.SetTag(t.Key, t.Value) +} diff --git a/vendor/github.com/segmentio/ksuid/.gitignore b/vendor/github.com/segmentio/ksuid/.gitignore new file mode 100644 index 000000000..4b7a3f38b --- /dev/null +++ b/vendor/github.com/segmentio/ksuid/.gitignore @@ -0,0 +1,31 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof +/ksuid + +# Emacs +*~ + +# govendor +/vendor/*/ diff --git a/vendor/github.com/segmentio/ksuid/LICENSE.md b/vendor/github.com/segmentio/ksuid/LICENSE.md new file mode 100644 index 000000000..aefb79318 --- /dev/null +++ b/vendor/github.com/segmentio/ksuid/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Segment.io + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/segmentio/ksuid/README.md b/vendor/github.com/segmentio/ksuid/README.md new file mode 100644 index 000000000..b23e1a6e5 --- /dev/null +++ b/vendor/github.com/segmentio/ksuid/README.md @@ -0,0 +1,234 @@ +# ksuid [![Go Report Card](https://goreportcard.com/badge/github.com/segmentio/ksuid)](https://goreportcard.com/report/github.com/segmentio/ksuid) [![GoDoc](https://godoc.org/github.com/segmentio/ksuid?status.svg)](https://godoc.org/github.com/segmentio/ksuid) [![Circle CI](https://circleci.com/gh/segmentio/ksuid.svg?style=shield)](https://circleci.com/gh/segmentio/ksuid.svg?style=shield) + +ksuid is an efficient, comprehensive, battle-tested Go library for +generating and parsing a specific kind of globally unique identifier +called a *KSUID*. This library serves as its reference implementation. + +## Install +```sh +go get -u github.com/segmentio/ksuid +``` + +## What is a KSUID? + +KSUID is for K-Sortable Unique IDentifier. It is a kind of globally +unique identifier similar to a [RFC 4122 UUID](https://en.wikipedia.org/wiki/Universally_unique_identifier), built from the ground-up to be "naturally" +sorted by generation timestamp without any special type-aware logic. + +In short, running a set of KSUIDs through the UNIX `sort` command will result +in a list ordered by generation time. + +## Why use KSUIDs? + +There are numerous methods for generating unique identifiers, so why KSUID? + +1. Naturally ordered by generation time +2. Collision-free, coordination-free, dependency-free +3. Highly portable representations + +Even if only one of these properties are important to you, KSUID is a great +choice! :) Many projects chose to use KSUIDs *just* because the text +representation is copy-and-paste friendly. + +### 1. Naturally Ordered By Generation Time + +Unlike the more ubiquitous UUIDv4, a KSUID contains a timestamp component +that allows them to be loosely sorted by generation time. This is not a strong +guarantee (an invariant) as it depends on wall clocks, but is still incredibly +useful in practice. Both the binary and text representations will sort by +creation time without any special sorting logic. + +### 2. Collision-free, Coordination-free, Dependency-free + +While RFC 4122 UUIDv1s *do* include a time component, there aren't enough +bytes of randomness to provide strong protection against collisions +(duplicates). With such a low amount of entropy, it is feasible for a +malicious party to guess generated IDs, creating a problem for systems whose +security is, implicitly or explicitly, sensitive to an adversary guessing +identifiers. + +To fit into a 64-bit number space, [Snowflake IDs](https://blog.twitter.com/2010/announcing-snowflake) +and its derivatives require coordination to avoid collisions, which +significantly increases the deployment complexity and operational burden. + +A KSUID includes 128 bits of pseudorandom data ("entropy"). This number space +is 64 times larger than the 122 bits used by the well-accepted RFC 4122 UUIDv4 +standard. The additional timestamp component can be considered "bonus entropy" +which further decreases the probability of collisions, to the point of physical +infeasibility in any practical implementation. + +### Highly Portable Representations + +The text *and* binary representations are lexicographically sortable, which +allows them to be dropped into systems which do not natively support KSUIDs +and retain their time-ordered property. + +The text representation is an alphanumeric base62 encoding, so it "fits" +anywhere alphanumeric strings are accepted. No delimiters are used, so +stringified KSUIDs won't be inadvertently truncated or tokenized when +interpreted by software that is designed for human-readable text, a common +problem for the text representation of RFC 4122 UUIDs. + +## How do KSUIDs work? + +Binary KSUIDs are 20-bytes: a 32-bit unsigned integer UTC timestamp and +a 128-bit randomly generated payload. The timestamp uses big-endian +encoding, to support lexicographic sorting. The timestamp epoch is adjusted +to March 5th, 2014, providing over 100 years of life. The payload is +generated by a cryptographically-strong pseudorandom number generator. + +The text representation is always 27 characters, encoded in alphanumeric +base62 that will lexicographically sort by timestamp. + +## High Performance + +This library is designed to be used in code paths that are performance +critical. Its code has been tuned to eliminate all non-essential +overhead. The `KSUID` type is derived from a fixed-size array, which +eliminates the additional reference chasing and allocation involved in +a variable-width type. + +The API provides an interface for use in code paths which are sensitive +to allocation. For example, the `Append` method can be used to parse the +text representation and replace the contents of a `KSUID` value +without additional heap allocation. + +All public package level "pure" functions are concurrency-safe, protected +by a global mutex. For hot loops that generate a large amount of KSUIDs +from a single Goroutine, the `Sequence` type is provided to elide the +potential contention. + +By default, out of an abundance of caution, the cryptographically-secure +PRNG is used to generate the random bits of a KSUID. This can be relaxed +in extremely performance-critical code using the included `FastRander` +type. `FastRander` uses the standard PRNG with a seed generated by the +cryptographically-secure PRNG. + +*_NOTE:_ While there is no evidence that `FastRander` will increase the +probability of a collision, it shouldn't be used in scenarios where +uniqueness is important to security, as there is an increased chance +the generated IDs can be predicted by an adversary.* + +## Battle Tested + +This code has been used in production at Segment for several years, +across a diverse array of projects. Trillions upon trillions of +KSUIDs have been generated in some of Segment's most +performance-critical, large-scale distributed systems. + +## Plays Well With Others + +Designed to be integrated with other libraries, the `KSUID` type +implements many standard library interfaces, including: + +* `Stringer` +* `database/sql.Scanner` and `database/sql/driver.Valuer` +* `encoding.BinaryMarshal` and `encoding.BinaryUnmarshal` +* `encoding.TextMarshal` and `encoding.TextUnmarshal` + (`encoding/json` friendly!) + +## Command Line Tool + +This package comes with a command-line tool `ksuid`, useful for +generating KSUIDs as well as inspecting the internal components of +existing KSUIDs. Machine-friendly output is provided for scripting +use cases. + +Given a Go build environment, it can be installed with the command: + +```sh +$ go install github.com/segmentio/ksuid/cmd/ksuid +``` + +## CLI Usage Examples + +### Generate a KSUID + +```sh +$ ksuid +0ujsswThIGTUYm2K8FjOOfXtY1K +``` + +### Generate 4 KSUIDs + +```sh +$ ksuid -n 4 +0ujsszwN8NRY24YaXiTIE2VWDTS +0ujsswThIGTUYm2K8FjOOfXtY1K +0ujssxh0cECutqzMgbtXSGnjorm +0ujsszgFvbiEr7CDgE3z8MAUPFt +``` + +### Inspect the components of a KSUID + +```sh +$ ksuid -f inspect 0ujtsYcgvSTl8PAuAdqWYSMnLOv + +REPRESENTATION: + + String: 0ujtsYcgvSTl8PAuAdqWYSMnLOv + Raw: 0669F7EFB5A1CD34B5F99D1154FB6853345C9735 + +COMPONENTS: + + Time: 2017-10-09 21:00:47 -0700 PDT + Timestamp: 107608047 + Payload: B5A1CD34B5F99D1154FB6853345C9735 +``` + +### Generate a KSUID and inspect its components + +```sh +$ ksuid -f inspect + +REPRESENTATION: + + String: 0ujzPyRiIAffKhBux4PvQdDqMHY + Raw: 066A029C73FC1AA3B2446246D6E89FCD909E8FE8 + +COMPONENTS: + + Time: 2017-10-09 21:46:20 -0700 PDT + Timestamp: 107610780 + Payload: 73FC1AA3B2446246D6E89FCD909E8FE8 + +``` + +### Inspect a KSUID with template formatted inspection output + +```sh +$ ksuid -f template -t '{{ .Time }}: {{ .Payload }}' 0ujtsYcgvSTl8PAuAdqWYSMnLOv +2017-10-09 21:00:47 -0700 PDT: B5A1CD34B5F99D1154FB6853345C9735 +``` + +### Inspect multiple KSUIDs with template formatted output + +```sh +$ ksuid -f template -t '{{ .Time }}: {{ .Payload }}' $(ksuid -n 4) +2017-10-09 21:05:37 -0700 PDT: 304102BC687E087CC3A811F21D113CCF +2017-10-09 21:05:37 -0700 PDT: EAF0B240A9BFA55E079D887120D962F0 +2017-10-09 21:05:37 -0700 PDT: DF0761769909ABB0C7BB9D66F79FC041 +2017-10-09 21:05:37 -0700 PDT: 1A8F0E3D0BDEB84A5FAD702876F46543 +``` + +### Generate KSUIDs and output JSON using template formatting + +```sh +$ ksuid -f template -t '{ "timestamp": "{{ .Timestamp }}", "payload": "{{ .Payload }}", "ksuid": "{{.String}}"}' -n 4 +{ "timestamp": "107611700", "payload": "9850EEEC191BF4FF26F99315CE43B0C8", "ksuid": "0uk1Hbc9dQ9pxyTqJ93IUrfhdGq"} +{ "timestamp": "107611700", "payload": "CC55072555316F45B8CA2D2979D3ED0A", "ksuid": "0uk1HdCJ6hUZKDgcxhpJwUl5ZEI"} +{ "timestamp": "107611700", "payload": "BA1C205D6177F0992D15EE606AE32238", "ksuid": "0uk1HcdvF0p8C20KtTfdRSB9XIm"} +{ "timestamp": "107611700", "payload": "67517BA309EA62AE7991B27BB6F2FCAC", "ksuid": "0uk1Ha7hGJ1Q9Xbnkt0yZgNwg3g"} +``` + +## Implementations for other languages + +- Python: [svix-ksuid](https://github.com/svixhq/python-ksuid/) +- Ruby: [ksuid-ruby](https://github.com/michaelherold/ksuid-ruby) +- Java: [ksuid](https://github.com/ksuid/ksuid) +- Rust: [rksuid](https://github.com/nharring/rksuid) +- dotNet: [Ksuid.Net](https://github.com/JoyMoe/Ksuid.Net) + +## License + +ksuid source code is available under an MIT [License](/LICENSE.md). diff --git a/vendor/github.com/segmentio/ksuid/base62.go b/vendor/github.com/segmentio/ksuid/base62.go new file mode 100644 index 000000000..146a41f0f --- /dev/null +++ b/vendor/github.com/segmentio/ksuid/base62.go @@ -0,0 +1,202 @@ +package ksuid + +import ( + "encoding/binary" + "errors" +) + +const ( + // lexographic ordering (based on Unicode table) is 0-9A-Za-z + base62Characters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + zeroString = "000000000000000000000000000" + offsetUppercase = 10 + offsetLowercase = 36 +) + +var ( + errShortBuffer = errors.New("the output buffer is too small to hold to decoded value") +) + +// Converts a base 62 byte into the number value that it represents. +func base62Value(digit byte) byte { + switch { + case digit >= '0' && digit <= '9': + return digit - '0' + case digit >= 'A' && digit <= 'Z': + return offsetUppercase + (digit - 'A') + default: + return offsetLowercase + (digit - 'a') + } +} + +// This function encodes the base 62 representation of the src KSUID in binary +// form into dst. +// +// In order to support a couple of optimizations the function assumes that src +// is 20 bytes long and dst is 27 bytes long. +// +// Any unused bytes in dst will be set to the padding '0' byte. +func fastEncodeBase62(dst []byte, src []byte) { + const srcBase = 4294967296 + const dstBase = 62 + + // Split src into 5 4-byte words, this is where most of the efficiency comes + // from because this is a O(N^2) algorithm, and we make N = N / 4 by working + // on 32 bits at a time. + parts := [5]uint32{ + binary.BigEndian.Uint32(src[0:4]), + binary.BigEndian.Uint32(src[4:8]), + binary.BigEndian.Uint32(src[8:12]), + binary.BigEndian.Uint32(src[12:16]), + binary.BigEndian.Uint32(src[16:20]), + } + + n := len(dst) + bp := parts[:] + bq := [5]uint32{} + + for len(bp) != 0 { + quotient := bq[:0] + remainder := uint64(0) + + for _, c := range bp { + value := uint64(c) + uint64(remainder)*srcBase + digit := value / dstBase + remainder = value % dstBase + + if len(quotient) != 0 || digit != 0 { + quotient = append(quotient, uint32(digit)) + } + } + + // Writes at the end of the destination buffer because we computed the + // lowest bits first. + n-- + dst[n] = base62Characters[remainder] + bp = quotient + } + + // Add padding at the head of the destination buffer for all bytes that were + // not set. + copy(dst[:n], zeroString) +} + +// This function appends the base 62 representation of the KSUID in src to dst, +// and returns the extended byte slice. +// The result is left-padded with '0' bytes to always append 27 bytes to the +// destination buffer. +func fastAppendEncodeBase62(dst []byte, src []byte) []byte { + dst = reserve(dst, stringEncodedLength) + n := len(dst) + fastEncodeBase62(dst[n:n+stringEncodedLength], src) + return dst[:n+stringEncodedLength] +} + +// This function decodes the base 62 representation of the src KSUID to the +// binary form into dst. +// +// In order to support a couple of optimizations the function assumes that src +// is 27 bytes long and dst is 20 bytes long. +// +// Any unused bytes in dst will be set to zero. +func fastDecodeBase62(dst []byte, src []byte) error { + const srcBase = 62 + const dstBase = 4294967296 + + // This line helps BCE (Bounds Check Elimination). + // It may be safely removed. + _ = src[26] + + parts := [27]byte{ + base62Value(src[0]), + base62Value(src[1]), + base62Value(src[2]), + base62Value(src[3]), + base62Value(src[4]), + base62Value(src[5]), + base62Value(src[6]), + base62Value(src[7]), + base62Value(src[8]), + base62Value(src[9]), + + base62Value(src[10]), + base62Value(src[11]), + base62Value(src[12]), + base62Value(src[13]), + base62Value(src[14]), + base62Value(src[15]), + base62Value(src[16]), + base62Value(src[17]), + base62Value(src[18]), + base62Value(src[19]), + + base62Value(src[20]), + base62Value(src[21]), + base62Value(src[22]), + base62Value(src[23]), + base62Value(src[24]), + base62Value(src[25]), + base62Value(src[26]), + } + + n := len(dst) + bp := parts[:] + bq := [stringEncodedLength]byte{} + + for len(bp) > 0 { + quotient := bq[:0] + remainder := uint64(0) + + for _, c := range bp { + value := uint64(c) + uint64(remainder)*srcBase + digit := value / dstBase + remainder = value % dstBase + + if len(quotient) != 0 || digit != 0 { + quotient = append(quotient, byte(digit)) + } + } + + if n < 4 { + return errShortBuffer + } + + dst[n-4] = byte(remainder >> 24) + dst[n-3] = byte(remainder >> 16) + dst[n-2] = byte(remainder >> 8) + dst[n-1] = byte(remainder) + n -= 4 + bp = quotient + } + + var zero [20]byte + copy(dst[:n], zero[:]) + return nil +} + +// This function appends the base 62 decoded version of src into dst. +func fastAppendDecodeBase62(dst []byte, src []byte) []byte { + dst = reserve(dst, byteLength) + n := len(dst) + fastDecodeBase62(dst[n:n+byteLength], src) + return dst[:n+byteLength] +} + +// Ensures that at least nbytes are available in the remaining capacity of the +// destination slice, if not, a new copy is made and returned by the function. +func reserve(dst []byte, nbytes int) []byte { + c := cap(dst) + n := len(dst) + + if avail := c - n; avail < nbytes { + c *= 2 + if (c - n) < nbytes { + c = n + nbytes + } + b := make([]byte, n, c) + copy(b, dst) + dst = b + } + + return dst +} diff --git a/vendor/github.com/segmentio/ksuid/ksuid.go b/vendor/github.com/segmentio/ksuid/ksuid.go new file mode 100644 index 000000000..dbe1f9c7f --- /dev/null +++ b/vendor/github.com/segmentio/ksuid/ksuid.go @@ -0,0 +1,352 @@ +package ksuid + +import ( + "bytes" + "crypto/rand" + "database/sql/driver" + "encoding/binary" + "fmt" + "io" + "math" + "sync" + "time" +) + +const ( + // KSUID's epoch starts more recently so that the 32-bit number space gives a + // significantly higher useful lifetime of around 136 years from March 2017. + // This number (14e8) was picked to be easy to remember. + epochStamp int64 = 1400000000 + + // Timestamp is a uint32 + timestampLengthInBytes = 4 + + // Payload is 16-bytes + payloadLengthInBytes = 16 + + // KSUIDs are 20 bytes when binary encoded + byteLength = timestampLengthInBytes + payloadLengthInBytes + + // The length of a KSUID when string (base62) encoded + stringEncodedLength = 27 + + // A string-encoded minimum value for a KSUID + minStringEncoded = "000000000000000000000000000" + + // A string-encoded maximum value for a KSUID + maxStringEncoded = "aWgEPTl1tmebfsQzFP4bxwgy80V" +) + +// KSUIDs are 20 bytes: +// 00-03 byte: uint32 BE UTC timestamp with custom epoch +// 04-19 byte: random "payload" +type KSUID [byteLength]byte + +var ( + rander = rand.Reader + randMutex = sync.Mutex{} + randBuffer = [payloadLengthInBytes]byte{} + + errSize = fmt.Errorf("Valid KSUIDs are %v bytes", byteLength) + errStrSize = fmt.Errorf("Valid encoded KSUIDs are %v characters", stringEncodedLength) + errStrValue = fmt.Errorf("Valid encoded KSUIDs are bounded by %s and %s", minStringEncoded, maxStringEncoded) + errPayloadSize = fmt.Errorf("Valid KSUID payloads are %v bytes", payloadLengthInBytes) + + // Represents a completely empty (invalid) KSUID + Nil KSUID + // Represents the highest value a KSUID can have + Max = KSUID{255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255} +) + +// Append appends the string representation of i to b, returning a slice to a +// potentially larger memory area. +func (i KSUID) Append(b []byte) []byte { + return fastAppendEncodeBase62(b, i[:]) +} + +// The timestamp portion of the ID as a Time object +func (i KSUID) Time() time.Time { + return correctedUTCTimestampToTime(i.Timestamp()) +} + +// The timestamp portion of the ID as a bare integer which is uncorrected +// for KSUID's special epoch. +func (i KSUID) Timestamp() uint32 { + return binary.BigEndian.Uint32(i[:timestampLengthInBytes]) +} + +// The 16-byte random payload without the timestamp +func (i KSUID) Payload() []byte { + return i[timestampLengthInBytes:] +} + +// String-encoded representation that can be passed through Parse() +func (i KSUID) String() string { + return string(i.Append(make([]byte, 0, stringEncodedLength))) +} + +// Raw byte representation of KSUID +func (i KSUID) Bytes() []byte { + // Safe because this is by-value + return i[:] +} + +// IsNil returns true if this is a "nil" KSUID +func (i KSUID) IsNil() bool { + return i == Nil +} + +// Get satisfies the flag.Getter interface, making it possible to use KSUIDs as +// part of of the command line options of a program. +func (i KSUID) Get() interface{} { + return i +} + +// Set satisfies the flag.Value interface, making it possible to use KSUIDs as +// part of of the command line options of a program. +func (i *KSUID) Set(s string) error { + return i.UnmarshalText([]byte(s)) +} + +func (i KSUID) MarshalText() ([]byte, error) { + return []byte(i.String()), nil +} + +func (i KSUID) MarshalBinary() ([]byte, error) { + return i.Bytes(), nil +} + +func (i *KSUID) UnmarshalText(b []byte) error { + id, err := Parse(string(b)) + if err != nil { + return err + } + *i = id + return nil +} + +func (i *KSUID) UnmarshalBinary(b []byte) error { + id, err := FromBytes(b) + if err != nil { + return err + } + *i = id + return nil +} + +// Value converts the KSUID into a SQL driver value which can be used to +// directly use the KSUID as parameter to a SQL query. +func (i KSUID) Value() (driver.Value, error) { + if i.IsNil() { + return nil, nil + } + return i.String(), nil +} + +// Scan implements the sql.Scanner interface. It supports converting from +// string, []byte, or nil into a KSUID value. Attempting to convert from +// another type will return an error. +func (i *KSUID) Scan(src interface{}) error { + switch v := src.(type) { + case nil: + return i.scan(nil) + case []byte: + return i.scan(v) + case string: + return i.scan([]byte(v)) + default: + return fmt.Errorf("Scan: unable to scan type %T into KSUID", v) + } +} + +func (i *KSUID) scan(b []byte) error { + switch len(b) { + case 0: + *i = Nil + return nil + case byteLength: + return i.UnmarshalBinary(b) + case stringEncodedLength: + return i.UnmarshalText(b) + default: + return errSize + } +} + +// Parse decodes a string-encoded representation of a KSUID object +func Parse(s string) (KSUID, error) { + if len(s) != stringEncodedLength { + return Nil, errStrSize + } + + src := [stringEncodedLength]byte{} + dst := [byteLength]byte{} + + copy(src[:], s[:]) + + if err := fastDecodeBase62(dst[:], src[:]); err != nil { + return Nil, errStrValue + } + + return FromBytes(dst[:]) +} + +func timeToCorrectedUTCTimestamp(t time.Time) uint32 { + return uint32(t.Unix() - epochStamp) +} + +func correctedUTCTimestampToTime(ts uint32) time.Time { + return time.Unix(int64(ts)+epochStamp, 0) +} + +// Generates a new KSUID. In the strange case that random bytes +// can't be read, it will panic. +func New() KSUID { + ksuid, err := NewRandom() + if err != nil { + panic(fmt.Sprintf("Couldn't generate KSUID, inconceivable! error: %v", err)) + } + return ksuid +} + +// Generates a new KSUID +func NewRandom() (ksuid KSUID, err error) { + return NewRandomWithTime(time.Now()) +} + +func NewRandomWithTime(t time.Time) (ksuid KSUID, err error) { + // Go's default random number generators are not safe for concurrent use by + // multiple goroutines, the use of the rander and randBuffer are explicitly + // synchronized here. + randMutex.Lock() + + _, err = io.ReadAtLeast(rander, randBuffer[:], len(randBuffer)) + copy(ksuid[timestampLengthInBytes:], randBuffer[:]) + + randMutex.Unlock() + + if err != nil { + ksuid = Nil // don't leak random bytes on error + return + } + + ts := timeToCorrectedUTCTimestamp(t) + binary.BigEndian.PutUint32(ksuid[:timestampLengthInBytes], ts) + return +} + +// Constructs a KSUID from constituent parts +func FromParts(t time.Time, payload []byte) (KSUID, error) { + if len(payload) != payloadLengthInBytes { + return Nil, errPayloadSize + } + + var ksuid KSUID + + ts := timeToCorrectedUTCTimestamp(t) + binary.BigEndian.PutUint32(ksuid[:timestampLengthInBytes], ts) + + copy(ksuid[timestampLengthInBytes:], payload) + + return ksuid, nil +} + +// Constructs a KSUID from a 20-byte binary representation +func FromBytes(b []byte) (KSUID, error) { + var ksuid KSUID + + if len(b) != byteLength { + return Nil, errSize + } + + copy(ksuid[:], b) + return ksuid, nil +} + +// Sets the global source of random bytes for KSUID generation. This +// should probably only be set once globally. While this is technically +// thread-safe as in it won't cause corruption, there's no guarantee +// on ordering. +func SetRand(r io.Reader) { + if r == nil { + rander = rand.Reader + return + } + rander = r +} + +// Implements comparison for KSUID type +func Compare(a, b KSUID) int { + return bytes.Compare(a[:], b[:]) +} + +// Sorts the given slice of KSUIDs +func Sort(ids []KSUID) { + quickSort(ids, 0, len(ids)-1) +} + +// IsSorted checks whether a slice of KSUIDs is sorted +func IsSorted(ids []KSUID) bool { + if len(ids) != 0 { + min := ids[0] + for _, id := range ids[1:] { + if bytes.Compare(min[:], id[:]) > 0 { + return false + } + min = id + } + } + return true +} + +func quickSort(a []KSUID, lo int, hi int) { + if lo < hi { + pivot := a[hi] + i := lo - 1 + + for j, n := lo, hi; j != n; j++ { + if bytes.Compare(a[j][:], pivot[:]) < 0 { + i++ + a[i], a[j] = a[j], a[i] + } + } + + i++ + if bytes.Compare(a[hi][:], a[i][:]) < 0 { + a[i], a[hi] = a[hi], a[i] + } + + quickSort(a, lo, i-1) + quickSort(a, i+1, hi) + } +} + +// Next returns the next KSUID after id. +func (id KSUID) Next() KSUID { + zero := makeUint128(0, 0) + + t := id.Timestamp() + u := uint128Payload(id) + v := add128(u, makeUint128(0, 1)) + + if v == zero { // overflow + t++ + } + + return v.ksuid(t) +} + +// Prev returns the previoud KSUID before id. +func (id KSUID) Prev() KSUID { + max := makeUint128(math.MaxUint64, math.MaxUint64) + + t := id.Timestamp() + u := uint128Payload(id) + v := sub128(u, makeUint128(0, 1)) + + if v == max { // overflow + t-- + } + + return v.ksuid(t) +} diff --git a/vendor/github.com/segmentio/ksuid/rand.go b/vendor/github.com/segmentio/ksuid/rand.go new file mode 100644 index 000000000..66edbd4d8 --- /dev/null +++ b/vendor/github.com/segmentio/ksuid/rand.go @@ -0,0 +1,55 @@ +package ksuid + +import ( + cryptoRand "crypto/rand" + "encoding/binary" + "io" + "math/rand" +) + +// FastRander is an io.Reader that uses math/rand and is optimized for +// generating 16 bytes KSUID payloads. It is intended to be used as a +// performance improvements for programs that have no need for +// cryptographically secure KSUIDs and are generating a lot of them. +var FastRander = newRBG() + +func newRBG() io.Reader { + r, err := newRandomBitsGenerator() + if err != nil { + panic(err) + } + return r +} + +func newRandomBitsGenerator() (r io.Reader, err error) { + var seed int64 + + if seed, err = readCryptoRandomSeed(); err != nil { + return + } + + r = &randSourceReader{source: rand.NewSource(seed).(rand.Source64)} + return +} + +func readCryptoRandomSeed() (seed int64, err error) { + var b [8]byte + + if _, err = io.ReadFull(cryptoRand.Reader, b[:]); err != nil { + return + } + + seed = int64(binary.LittleEndian.Uint64(b[:])) + return +} + +type randSourceReader struct { + source rand.Source64 +} + +func (r *randSourceReader) Read(b []byte) (int, error) { + // optimized for generating 16 bytes payloads + binary.LittleEndian.PutUint64(b[:8], r.source.Uint64()) + binary.LittleEndian.PutUint64(b[8:], r.source.Uint64()) + return 16, nil +} diff --git a/vendor/github.com/segmentio/ksuid/sequence.go b/vendor/github.com/segmentio/ksuid/sequence.go new file mode 100644 index 000000000..9f1c33a0c --- /dev/null +++ b/vendor/github.com/segmentio/ksuid/sequence.go @@ -0,0 +1,55 @@ +package ksuid + +import ( + "encoding/binary" + "errors" + "math" +) + +// Sequence is a KSUID generator which produces a sequence of ordered KSUIDs +// from a seed. +// +// Up to 65536 KSUIDs can be generated by for a single seed. +// +// A typical usage of a Sequence looks like this: +// +// seq := ksuid.Sequence{ +// Seed: ksuid.New(), +// } +// id, err := seq.Next() +// +// Sequence values are not safe to use concurrently from multiple goroutines. +type Sequence struct { + // The seed is used as base for the KSUID generator, all generated KSUIDs + // share the same leading 18 bytes of the seed. + Seed KSUID + count uint32 // uint32 for overflow, only 2 bytes are used +} + +// Next produces the next KSUID in the sequence, or returns an error if the +// sequence has been exhausted. +func (seq *Sequence) Next() (KSUID, error) { + id := seq.Seed // copy + count := seq.count + if count > math.MaxUint16 { + return Nil, errors.New("too many IDs were generated") + } + seq.count++ + return withSequenceNumber(id, uint16(count)), nil +} + +// Bounds returns the inclusive min and max bounds of the KSUIDs that may be +// generated by the sequence. If all ids have been generated already then the +// returned min value is equal to the max. +func (seq *Sequence) Bounds() (min KSUID, max KSUID) { + count := seq.count + if count > math.MaxUint16 { + count = math.MaxUint16 + } + return withSequenceNumber(seq.Seed, uint16(count)), withSequenceNumber(seq.Seed, math.MaxUint16) +} + +func withSequenceNumber(id KSUID, n uint16) KSUID { + binary.BigEndian.PutUint16(id[len(id)-2:], n) + return id +} diff --git a/vendor/github.com/segmentio/ksuid/set.go b/vendor/github.com/segmentio/ksuid/set.go new file mode 100644 index 000000000..a6b0e6582 --- /dev/null +++ b/vendor/github.com/segmentio/ksuid/set.go @@ -0,0 +1,343 @@ +package ksuid + +import ( + "bytes" + "encoding/binary" +) + +// CompressedSet is an immutable data type which stores a set of KSUIDs. +type CompressedSet []byte + +// Iter returns an iterator that produces all KSUIDs in the set. +func (set CompressedSet) Iter() CompressedSetIter { + return CompressedSetIter{ + content: []byte(set), + } +} + +// String satisfies the fmt.Stringer interface, returns a human-readable string +// representation of the set. +func (set CompressedSet) String() string { + b := bytes.Buffer{} + b.WriteByte('[') + set.writeTo(&b) + b.WriteByte(']') + return b.String() +} + +// String satisfies the fmt.GoStringer interface, returns a Go representation of +// the set. +func (set CompressedSet) GoString() string { + b := bytes.Buffer{} + b.WriteString("ksuid.CompressedSet{") + set.writeTo(&b) + b.WriteByte('}') + return b.String() +} + +func (set CompressedSet) writeTo(b *bytes.Buffer) { + a := [27]byte{} + + for i, it := 0, set.Iter(); it.Next(); i++ { + if i != 0 { + b.WriteString(", ") + } + b.WriteByte('"') + it.KSUID.Append(a[:0]) + b.Write(a[:]) + b.WriteByte('"') + } +} + +// Compress creates and returns a compressed set of KSUIDs from the list given +// as arguments. +func Compress(ids ...KSUID) CompressedSet { + c := 1 + byteLength + (len(ids) / 5) + b := make([]byte, 0, c) + return AppendCompressed(b, ids...) +} + +// AppendCompressed uses the given byte slice as pre-allocated storage space to +// build a KSUID set. +// +// Note that the set uses a compression technique to store the KSUIDs, so the +// resuling length is not 20 x len(ids). The rule of thumb here is for the given +// byte slice to reserve the amount of memory that the application would be OK +// to waste. +func AppendCompressed(set []byte, ids ...KSUID) CompressedSet { + if len(ids) != 0 { + if !IsSorted(ids) { + Sort(ids) + } + one := makeUint128(0, 1) + + // The first KSUID is always written to the set, this is the starting + // point for all deltas. + set = append(set, byte(rawKSUID)) + set = append(set, ids[0][:]...) + + timestamp := ids[0].Timestamp() + lastKSUID := ids[0] + lastValue := uint128Payload(ids[0]) + + for i := 1; i != len(ids); i++ { + id := ids[i] + + if id == lastKSUID { + continue + } + + t := id.Timestamp() + v := uint128Payload(id) + + if t != timestamp { + d := t - timestamp + n := varintLength32(d) + + set = append(set, timeDelta|byte(n)) + set = appendVarint32(set, d, n) + set = append(set, id[timestampLengthInBytes:]...) + + timestamp = t + } else { + d := sub128(v, lastValue) + + if d != one { + n := varintLength128(d) + + set = append(set, payloadDelta|byte(n)) + set = appendVarint128(set, d, n) + } else { + l, c := rangeLength(ids[i+1:], t, id, v) + m := uint64(l + 1) + n := varintLength64(m) + + set = append(set, payloadRange|byte(n)) + set = appendVarint64(set, m, n) + + i += c + id = ids[i] + v = uint128Payload(id) + } + } + + lastKSUID = id + lastValue = v + } + } + return CompressedSet(set) +} + +func rangeLength(ids []KSUID, timestamp uint32, lastKSUID KSUID, lastValue uint128) (length int, count int) { + one := makeUint128(0, 1) + + for i := range ids { + id := ids[i] + + if id == lastKSUID { + continue + } + + if id.Timestamp() != timestamp { + count = i + return + } + + v := uint128Payload(id) + + if sub128(v, lastValue) != one { + count = i + return + } + + lastKSUID = id + lastValue = v + length++ + } + + count = len(ids) + return +} + +func appendVarint128(b []byte, v uint128, n int) []byte { + c := v.bytes() + return append(b, c[len(c)-n:]...) +} + +func appendVarint64(b []byte, v uint64, n int) []byte { + c := [8]byte{} + binary.BigEndian.PutUint64(c[:], v) + return append(b, c[len(c)-n:]...) +} + +func appendVarint32(b []byte, v uint32, n int) []byte { + c := [4]byte{} + binary.BigEndian.PutUint32(c[:], v) + return append(b, c[len(c)-n:]...) +} + +func varint128(b []byte) uint128 { + a := [16]byte{} + copy(a[16-len(b):], b) + return makeUint128FromPayload(a[:]) +} + +func varint64(b []byte) uint64 { + a := [8]byte{} + copy(a[8-len(b):], b) + return binary.BigEndian.Uint64(a[:]) +} + +func varint32(b []byte) uint32 { + a := [4]byte{} + copy(a[4-len(b):], b) + return binary.BigEndian.Uint32(a[:]) +} + +func varintLength128(v uint128) int { + if v[1] != 0 { + return 8 + varintLength64(v[1]) + } + return varintLength64(v[0]) +} + +func varintLength64(v uint64) int { + switch { + case (v & 0xFFFFFFFFFFFFFF00) == 0: + return 1 + case (v & 0xFFFFFFFFFFFF0000) == 0: + return 2 + case (v & 0xFFFFFFFFFF000000) == 0: + return 3 + case (v & 0xFFFFFFFF00000000) == 0: + return 4 + case (v & 0xFFFFFF0000000000) == 0: + return 5 + case (v & 0xFFFF000000000000) == 0: + return 6 + case (v & 0xFF00000000000000) == 0: + return 7 + default: + return 8 + } +} + +func varintLength32(v uint32) int { + switch { + case (v & 0xFFFFFF00) == 0: + return 1 + case (v & 0xFFFF0000) == 0: + return 2 + case (v & 0xFF000000) == 0: + return 3 + default: + return 4 + } +} + +const ( + rawKSUID = 0 + timeDelta = (1 << 6) + payloadDelta = (1 << 7) + payloadRange = (1 << 6) | (1 << 7) +) + +// CompressedSetIter is an iterator type returned by Set.Iter to produce the +// list of KSUIDs stored in a set. +// +// Here's is how the iterator type is commonly used: +// +// for it := set.Iter(); it.Next(); { +// id := it.KSUID +// // ... +// } +// +// CompressedSetIter values are not safe to use concurrently from multiple +// goroutines. +type CompressedSetIter struct { + // KSUID is modified by calls to the Next method to hold the KSUID loaded + // by the iterator. + KSUID KSUID + + content []byte + offset int + + seqlength uint64 + timestamp uint32 + lastValue uint128 +} + +// Next moves the iterator forward, returning true if there a KSUID was found, +// or false if the iterator as reached the end of the set it was created from. +func (it *CompressedSetIter) Next() bool { + if it.seqlength != 0 { + value := incr128(it.lastValue) + it.KSUID = value.ksuid(it.timestamp) + it.seqlength-- + it.lastValue = value + return true + } + + if it.offset == len(it.content) { + return false + } + + b := it.content[it.offset] + it.offset++ + + const mask = rawKSUID | timeDelta | payloadDelta | payloadRange + tag := int(b) & mask + cnt := int(b) & ^mask + + switch tag { + case rawKSUID: + off0 := it.offset + off1 := off0 + byteLength + + copy(it.KSUID[:], it.content[off0:off1]) + + it.offset = off1 + it.timestamp = it.KSUID.Timestamp() + it.lastValue = uint128Payload(it.KSUID) + + case timeDelta: + off0 := it.offset + off1 := off0 + cnt + off2 := off1 + payloadLengthInBytes + + it.timestamp += varint32(it.content[off0:off1]) + + binary.BigEndian.PutUint32(it.KSUID[:timestampLengthInBytes], it.timestamp) + copy(it.KSUID[timestampLengthInBytes:], it.content[off1:off2]) + + it.offset = off2 + it.lastValue = uint128Payload(it.KSUID) + + case payloadDelta: + off0 := it.offset + off1 := off0 + cnt + + delta := varint128(it.content[off0:off1]) + value := add128(it.lastValue, delta) + + it.KSUID = value.ksuid(it.timestamp) + it.offset = off1 + it.lastValue = value + + case payloadRange: + off0 := it.offset + off1 := off0 + cnt + + value := incr128(it.lastValue) + it.KSUID = value.ksuid(it.timestamp) + it.seqlength = varint64(it.content[off0:off1]) + it.offset = off1 + it.seqlength-- + it.lastValue = value + + default: + panic("KSUID set iterator is reading malformed data") + } + + return true +} diff --git a/vendor/github.com/segmentio/ksuid/uint128.go b/vendor/github.com/segmentio/ksuid/uint128.go new file mode 100644 index 000000000..b934489ce --- /dev/null +++ b/vendor/github.com/segmentio/ksuid/uint128.go @@ -0,0 +1,141 @@ +package ksuid + +import "fmt" + +// uint128 represents an unsigned 128 bits little endian integer. +type uint128 [2]uint64 + +func uint128Payload(ksuid KSUID) uint128 { + return makeUint128FromPayload(ksuid[timestampLengthInBytes:]) +} + +func makeUint128(high uint64, low uint64) uint128 { + return uint128{low, high} +} + +func makeUint128FromPayload(payload []byte) uint128 { + return uint128{ + // low + uint64(payload[8])<<56 | + uint64(payload[9])<<48 | + uint64(payload[10])<<40 | + uint64(payload[11])<<32 | + uint64(payload[12])<<24 | + uint64(payload[13])<<16 | + uint64(payload[14])<<8 | + uint64(payload[15]), + // high + uint64(payload[0])<<56 | + uint64(payload[1])<<48 | + uint64(payload[2])<<40 | + uint64(payload[3])<<32 | + uint64(payload[4])<<24 | + uint64(payload[5])<<16 | + uint64(payload[6])<<8 | + uint64(payload[7]), + } +} + +func (v uint128) ksuid(timestamp uint32) KSUID { + return KSUID{ + // time + byte(timestamp >> 24), + byte(timestamp >> 16), + byte(timestamp >> 8), + byte(timestamp), + + // high + byte(v[1] >> 56), + byte(v[1] >> 48), + byte(v[1] >> 40), + byte(v[1] >> 32), + byte(v[1] >> 24), + byte(v[1] >> 16), + byte(v[1] >> 8), + byte(v[1]), + + // low + byte(v[0] >> 56), + byte(v[0] >> 48), + byte(v[0] >> 40), + byte(v[0] >> 32), + byte(v[0] >> 24), + byte(v[0] >> 16), + byte(v[0] >> 8), + byte(v[0]), + } +} + +func (v uint128) bytes() [16]byte { + return [16]byte{ + // high + byte(v[1] >> 56), + byte(v[1] >> 48), + byte(v[1] >> 40), + byte(v[1] >> 32), + byte(v[1] >> 24), + byte(v[1] >> 16), + byte(v[1] >> 8), + byte(v[1]), + + // low + byte(v[0] >> 56), + byte(v[0] >> 48), + byte(v[0] >> 40), + byte(v[0] >> 32), + byte(v[0] >> 24), + byte(v[0] >> 16), + byte(v[0] >> 8), + byte(v[0]), + } +} + +func (v uint128) String() string { + return fmt.Sprintf("0x%016X%016X", v[0], v[1]) +} + +const wordBitSize = 64 + +func cmp128(x, y uint128) int { + if x[1] < y[1] { + return -1 + } + if x[1] > y[1] { + return 1 + } + if x[0] < y[0] { + return -1 + } + if x[0] > y[0] { + return 1 + } + return 0 +} + +func add128(x, y uint128) (z uint128) { + x0 := x[0] + y0 := y[0] + z0 := x0 + y0 + z[0] = z0 + + c := (x0&y0 | (x0|y0)&^z0) >> (wordBitSize - 1) + + z[1] = x[1] + y[1] + c + return +} + +func sub128(x, y uint128) (z uint128) { + x0 := x[0] + y0 := y[0] + z0 := x0 - y0 + z[0] = z0 + + c := (y0&^x0 | (y0|^x0)&z0) >> (wordBitSize - 1) + + z[1] = x[1] - y[1] - c + return +} + +func incr128(x uint128) uint128 { + return add128(x, uint128{1, 0}) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 5836d0282..812836526 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -14,6 +14,10 @@ github.com/IBM/sarama # github.com/Joker/jade v1.1.3 ## explicit; go 1.14 github.com/Joker/jade +# github.com/Nerzal/gocloak/v13 v13.8.0 +## explicit; go 1.18 +github.com/Nerzal/gocloak/v13 +github.com/Nerzal/gocloak/v13/pkg/jwx # github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06 ## explicit; go 1.17 github.com/Shopify/goreferrer @@ -228,6 +232,9 @@ github.com/go-playground/universal-translator # github.com/go-playground/validator/v10 v10.16.0 ## explicit; go 1.18 github.com/go-playground/validator/v10 +# github.com/go-resty/resty/v2 v2.11.0 +## explicit; go 1.16 +github.com/go-resty/resty/v2 # github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 ## explicit; go 1.13 github.com/go-task/slim-sprig @@ -517,6 +524,10 @@ github.com/onsi/gomega/matchers/support/goraph/edge github.com/onsi/gomega/matchers/support/goraph/node github.com/onsi/gomega/matchers/support/goraph/util github.com/onsi/gomega/types +# github.com/opentracing/opentracing-go v1.2.0 +## explicit; go 1.14 +github.com/opentracing/opentracing-go +github.com/opentracing/opentracing-go/log # github.com/pelletier/go-toml/v2 v2.1.0 ## explicit; go 1.16 github.com/pelletier/go-toml/v2 @@ -570,6 +581,9 @@ github.com/russross/blackfriday/v2 # github.com/schollz/closestmatch v2.1.0+incompatible ## explicit github.com/schollz/closestmatch +# github.com/segmentio/ksuid v1.0.4 +## explicit; go 1.12 +github.com/segmentio/ksuid # github.com/sirupsen/logrus v1.9.3 ## explicit; go 1.13 github.com/sirupsen/logrus From 76a6bb557c88e266aa9381586425baa17ef45ba3 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 6 Feb 2024 17:11:50 -0800 Subject: [PATCH 02/62] Cleanup. --- user/internal_user.go | 58 -------------------- user/kcclient.go | 121 +----------------------------------------- 2 files changed, 2 insertions(+), 177 deletions(-) diff --git a/user/internal_user.go b/user/internal_user.go index 2823d9e4b..60aad815c 100644 --- a/user/internal_user.go +++ b/user/internal_user.go @@ -777,61 +777,3 @@ func ToExternalUser(user *InternalUser) *ExternalUser { EmailVerified: emailVerified, } } - -// func (u *User) DeepClone() *User { -// clonedUser := &User{ -// Id: u.Id, -// Username: u.Username, -// TermsAccepted: u.TermsAccepted, -// EmailVerified: u.EmailVerified, -// PwHash: u.PwHash, -// Hash: u.Hash, -// IsMigrated: u.IsMigrated, -// } -// if u.Emails != nil { -// clonedUser.Emails = make([]string, len(u.Emails)) -// copy(clonedUser.Emails, u.Emails) -// } -// if u.Roles != nil { -// clonedUser.Roles = make([]string, len(u.Roles)) -// copy(clonedUser.Roles, u.Roles) -// } -// // if u.Private != nil { -// // clonedUser.Private = make(map[string]*IdHashPair) -// // for k, v := range u.Private { -// // clonedUser.Private[k] = &IdHashPair{Id: v.Id, Hash: v.Hash} -// // } -// // } -// return clonedUser -// } - -// func (u *User) ToKeycloakUser() *KeycloakUser { -// keycloakUser := &KeycloakUser{ -// ID: u.Id, -// Username: strings.ToLower(u.Username), -// Email: strings.ToLower(u.Email()), -// Enabled: u.IsEnabled(), -// EmailVerified: u.EmailVerified, -// Roles: u.Roles, -// Attributes: KeycloakUserAttributes{}, -// } -// if len(keycloakUser.Roles) == 0 { -// keycloakUser.Roles = []string{RolePatient} -// } -// if !u.IsMigrated && u.PwHash == "" && !u.HasRole(RoleCustodialAccount) { -// keycloakUser.Roles = append(keycloakUser.Roles, RoleCustodialAccount) -// } -// if termsAccepted, err := TimestampToUnixString(u.TermsAccepted); err == nil { -// keycloakUser.Attributes.TermsAcceptedDate = []string{termsAccepted} -// } - -// return keycloakUser -// } - -// func (u *User) IsEnabled() bool { -// if u.IsMigrated { -// return u.Enabled -// } else { -// return u.PwHash != "" && !u.IsDeleted() -// } -// } diff --git a/user/kcclient.go b/user/kcclient.go index efff1434c..b911f3602 100644 --- a/user/kcclient.go +++ b/user/kcclient.go @@ -1,3 +1,4 @@ +// Temp place to put the keycloak client package user import ( @@ -197,35 +198,13 @@ func (c *KeycloakClient) GetUserByEmail(ctx context.Context, email string) (*goc return c.GetUserById(ctx, *users[0].ID) } + func (c *KeycloakClient) UpdateUser(ctx context.Context, user *gocloak.User) error { token, err := c.getAdminToken(ctx) if err != nil { return err } - // gocloakUser := gocloak.User{ - // ID: &user.ID, - // Username: &user.Username, - // Enabled: &user.Enabled, - // EmailVerified: &user.EmailVerified, - // FirstName: &user.FirstName, - // LastName: &user.LastName, - // Email: &user.Email, - // } - - // attrs := map[string][]string{} - // if len(user.Attributes.TermsAcceptedDate) > 0 { - // attrs["terms_and_conditions"] = user.Attributes.TermsAcceptedDate - // } - // if user.Attributes.Profile != nil { - // maps.Copy(attrs, user.Attributes.Profile.ToAttributes()) - // } - - // if len(attrs) > 0 { - // gocloakUser.Attributes = &attrs - // } - - // if err := c.keycloak.UpdateUser(ctx, token.AccessToken, c.cfg.Realm, gocloakUser); err != nil { if err := c.keycloak.UpdateUser(ctx, token.AccessToken, c.cfg.Realm, *user); err != nil { return err } @@ -235,43 +214,6 @@ func (c *KeycloakClient) UpdateUser(ctx context.Context, user *gocloak.User) err return nil } -// func (c *KeycloakClient) UpdateUser(ctx context.Context, user *KeycloakUser) error { -// token, err := c.getAdminToken(ctx) -// if err != nil { -// return err -// } - -// gocloakUser := gocloak.User{ -// ID: &user.ID, -// Username: &user.Username, -// Enabled: &user.Enabled, -// EmailVerified: &user.EmailVerified, -// FirstName: &user.FirstName, -// LastName: &user.LastName, -// Email: &user.Email, -// } - -// attrs := map[string][]string{} -// if len(user.Attributes.TermsAcceptedDate) > 0 { -// attrs["terms_and_conditions"] = user.Attributes.TermsAcceptedDate -// } -// if user.Attributes.Profile != nil { -// maps.Copy(attrs, user.Attributes.Profile.ToAttributes()) -// } - -// if len(attrs) > 0 { -// gocloakUser.Attributes = &attrs -// } - -// if err := c.keycloak.UpdateUser(ctx, token.AccessToken, c.cfg.Realm, gocloakUser); err != nil { -// return err -// } -// if err := c.updateRolesForUser(ctx, user); err != nil { -// return err -// } -// return nil -// } - func (c *KeycloakClient) UpdateUserPassword(ctx context.Context, id, password string) error { token, err := c.getAdminToken(ctx) if err != nil { @@ -294,25 +236,6 @@ func (c *KeycloakClient) CreateUser(ctx context.Context, user *gocloak.User) (*g return nil, err } - // model := gocloak.User{ - // Username: &user.Username, - // Email: &user.Email, - // EmailVerified: &user.EmailVerified, - // Enabled: &user.Enabled, - // RealmRoles: &user.Roles, - // } - - // attrs := map[string][]string{} - // if len(user.Attributes.TermsAcceptedDate) > 0 { - // attrs["terms_and_conditions"] = user.Attributes.TermsAcceptedDate - // } - // if user.Attributes.Profile != nil { - // maps.Copy(attrs, user.Attributes.Profile.ToAttributes()) - // } - // if len(attrs) > 0 { - // model.Attributes = &attrs - // } - userID, err := c.keycloak.CreateUser(ctx, token.AccessToken, c.cfg.Realm, *user) if err != nil { var gerr *gocloak.APIError @@ -329,46 +252,6 @@ func (c *KeycloakClient) CreateUser(ctx context.Context, user *gocloak.User) (*g return c.GetUserById(ctx, userID) } -// func (c *KeycloakClient) CreateUser(ctx context.Context, user *KeycloakUser) (*KeycloakUser, error) { -// token, err := c.getAdminToken(ctx) -// if err != nil { -// return nil, err -// } - -// model := gocloak.User{ -// Username: &user.Username, -// Email: &user.Email, -// EmailVerified: &user.EmailVerified, -// Enabled: &user.Enabled, -// RealmRoles: &user.Roles, -// } - -// attrs := map[string][]string{} -// if len(user.Attributes.TermsAcceptedDate) > 0 { -// attrs["terms_and_conditions"] = user.Attributes.TermsAcceptedDate -// } -// if user.Attributes.Profile != nil { -// maps.Copy(attrs, user.Attributes.Profile.ToAttributes()) -// } -// if len(attrs) > 0 { -// model.Attributes = &attrs -// } - -// user.ID, err = c.keycloak.CreateUser(ctx, token.AccessToken, c.cfg.Realm, model) -// if err != nil { -// if e, ok := err.(*gocloak.APIError); ok && e.Code == http.StatusConflict { -// err = ErrUserConflict -// } -// return nil, err -// } - -// if err := c.updateRolesForUser(ctx, user); err != nil { -// return nil, err -// } - -// return c.GetUserById(ctx, user.ID) -// } - func (c *KeycloakClient) FindUsersWithIds(ctx context.Context, ids []string) (users []*gocloak.User, err error) { const errMessage = "could not retrieve users by ids" From 0b9f170fdc1b283e67d86f15a0c09d1816cb9ca0 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 12 Feb 2024 10:19:34 -0800 Subject: [PATCH 03/62] Add /v1/profiles/:userId route. --- auth/service/api/v1/profile.go | 65 +++++ auth/service/service.go | 2 + auth/service/service/service.go | 21 ++ user/{internal_user.go => full_user.go} | 120 ++------- user/{kcclient.go => keycloak_client.go} | 295 ++++++++++++++++------- user/keycloak_user_accessor.go | 225 +++++++++++++++++ user/user_accessor.go | 68 ++++++ 7 files changed, 612 insertions(+), 184 deletions(-) create mode 100644 auth/service/api/v1/profile.go rename user/{internal_user.go => full_user.go} (83%) rename user/{kcclient.go => keycloak_client.go} (54%) create mode 100644 user/keycloak_user_accessor.go create mode 100644 user/user_accessor.go diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go new file mode 100644 index 000000000..431598675 --- /dev/null +++ b/auth/service/api/v1/profile.go @@ -0,0 +1,65 @@ +package v1 + +import ( + stdErrs "errors" + "net/http" + + "github.com/ant0ine/go-json-rest/rest" + + "github.com/tidepool-org/platform/request" + "github.com/tidepool-org/platform/service/api" + "github.com/tidepool-org/platform/user" +) + +func (r *Router) ProfileRoutes() []*rest.Route { + return []*rest.Route{ + rest.Get("/v1/profiles/:userId", api.RequireUser(r.GetProfile)), + rest.Put("/v1/profiles/:userId", api.RequireUser(r.UpdateProfile)), + } +} + +func (r *Router) GetProfile(res rest.ResponseWriter, req *rest.Request) { + responder := request.MustNewResponder(res, req) + ctx := req.Context() + details := request.GetAuthDetails(ctx) + userID := req.PathParam("userId") + if !details.IsService() && details.UserID() != userID { + responder.Empty(http.StatusNotFound) + return + } + + user, err := r.UserAccessor().FindUserById(ctx, userID) + if err != nil { + responder.Error(http.StatusBadRequest, err) + return + } + + responder.Data(http.StatusOK, user.Profile) +} + +func (r *Router) UpdateProfile(res rest.ResponseWriter, req *rest.Request) { + responder := request.MustNewResponder(res, req) + ctx := req.Context() + details := request.GetAuthDetails(ctx) + userID := req.PathParam("userId") + if !details.IsService() && details.UserID() != userID { + responder.Empty(http.StatusNotFound) + return + } + + profile := &user.UserProfile{} + if err := request.DecodeRequestBody(req.Request, profile); err != nil { + responder.Error(http.StatusBadRequest, err) + return + } + err := r.UserAccessor().UpdateUserProfile(ctx, userID, profile) + if stdErrs.Is(err, user.ErrUserNotFound) { + responder.Empty(http.StatusNotFound) + return + } + if err != nil { + responder.InternalServerError(err) + return + } + responder.Empty(http.StatusOK) +} diff --git a/auth/service/service.go b/auth/service/service.go index e6b88b2c3..33c4c4318 100644 --- a/auth/service/service.go +++ b/auth/service/service.go @@ -4,6 +4,7 @@ import ( "context" "github.com/tidepool-org/platform/apple" + "github.com/tidepool-org/platform/user" confirmationClient "github.com/tidepool-org/hydrophone/client" @@ -18,6 +19,7 @@ type Service interface { Domain() string AuthStore() store.Store + UserAccessor() user.UserAccessor ProviderFactory() provider.Factory diff --git a/auth/service/service/service.go b/auth/service/service/service.go index abc501197..c5a4e3830 100644 --- a/auth/service/service/service.go +++ b/auth/service/service/service.go @@ -9,6 +9,7 @@ import ( "github.com/tidepool-org/platform/apple" "github.com/tidepool-org/platform/auth" + "github.com/tidepool-org/platform/user" eventsCommon "github.com/tidepool-org/go-common/events" @@ -56,6 +57,7 @@ type Service struct { authClient *Client userEventsHandler events.Runner deviceCheck apple.DeviceCheck + userAccessor user.UserAccessor } func New() *Service { @@ -108,6 +110,9 @@ func (s *Service) Initialize(provider application.Provider) error { if err := s.initializeDeviceCheck(); err != nil { return err } + if err := s.initializeUserAccessor(); err != nil { + return err + } return s.initializeUserEventsHandler() } @@ -152,6 +157,10 @@ func (s *Service) DeviceCheck() apple.DeviceCheck { return s.deviceCheck } +func (s *Service) UserAccessor() user.UserAccessor { + return s.userAccessor +} + func (s *Service) Status(ctx context.Context) *service.Status { return &service.Status{ Version: s.VersionReporter().Long(), @@ -416,6 +425,18 @@ func (s *Service) initializeUserEventsHandler() error { return nil } +func (s *Service) initializeUserAccessor() error { + s.Logger().Debug("Initializing user accessor") + + config := &user.KeycloakConfig{} + if err := config.FromEnv(); err != nil { + return err + } + s.userAccessor = user.NewKeycloakUserAccessor(config) + + return nil +} + func (s *Service) initializeDeviceCheck() error { s.Logger().Debug("Initializing device check") diff --git a/user/internal_user.go b/user/full_user.go similarity index 83% rename from user/internal_user.go rename to user/full_user.go index 60aad815c..e3b6ca411 100644 --- a/user/internal_user.go +++ b/user/full_user.go @@ -5,15 +5,12 @@ import ( "errors" "fmt" "io" - "maps" "math/rand" "regexp" "strconv" "strings" "time" - "github.com/Nerzal/gocloak/v13" - "github.com/tidepool-org/platform/pointer" ) @@ -40,7 +37,11 @@ var validRoles = map[string]struct{}{ var custodialAccountRoles = []string{RoleCustodialAccount, RolePatient} -type InternalUser struct { +// FullUser is the rull representation of a user. It is a +// temporary type until I can figure out how much the +// existing user.User type can be extended to include these +// fields +type FullUser struct { Id string `json:"userid,omitempty" bson:"userid,omitempty"` // map userid to id Username string `json:"username,omitempty" bson:"username,omitempty"` Emails []string `json:"emails,omitempty" bson:"emails,omitempty"` @@ -65,7 +66,7 @@ type InternalUser struct { LastName string `json:"lastName,omitempty"` } -// ExternalUser is the user returned to services. +// ExternalUser is the user returned to public services. type ExternalUser struct { // same attributes as original shoreline Api.asSerializableUser ID *string `json:"userid,omitempty"` @@ -339,14 +340,14 @@ func ParseNewUserDetails(reader io.Reader) (*NewUserDetails, error) { } } -func NewUser(details *NewUserDetails, salt string) (user *InternalUser, err error) { +func NewUser(details *NewUserDetails, salt string) (user *FullUser, err error) { if details == nil { return nil, errors.New("New user details is nil") } else if err := details.Validate(); err != nil { return nil, err } - user = &InternalUser{Username: *details.Username, Emails: details.Emails, Roles: details.Roles} + user = &FullUser{Username: *details.Username, Emails: details.Emails, Roles: details.Roles} if user.Id, err = generateUniqueHash([]string{*details.Username, *details.Password}, 10); err != nil { return nil, errors.New("User: error generating id") @@ -417,7 +418,7 @@ func ParseNewCustodialUserDetails(reader io.Reader) (*NewCustodialUserDetails, e } } -func NewCustodialUser(details *NewCustodialUserDetails, salt string) (user *InternalUser, err error) { +func NewCustodialUser(details *NewCustodialUserDetails, salt string) (user *FullUser, err error) { if details == nil { return nil, errors.New("New custodial user details is nil") } else if err := details.Validate(); err != nil { @@ -429,7 +430,7 @@ func NewCustodialUser(details *NewCustodialUserDetails, salt string) (user *Inte username = *details.Username } - user = &InternalUser{ + user = &FullUser{ Username: username, Emails: details.Emails, } @@ -571,86 +572,11 @@ func ParseUpdateUserDetails(reader io.Reader) (*UpdateUserDetails, error) { } } -func NewUserFromKeycloakUser(keycloakUser *gocloak.User) *InternalUser { - attributes := map[string][]string{} - if keycloakUser.Attributes != nil { - attributes = *keycloakUser.Attributes - } - termsAcceptedDate := "" - if len(attributes[termsAcceptedAttribute]) > 0 { - if ts, err := UnixStringToTimestamp(attributes[termsAcceptedAttribute][0]); err == nil { - termsAcceptedDate = ts - } - } - - user := &InternalUser{ - Id: pointer.ToString(keycloakUser.ID), - Username: pointer.ToString(keycloakUser.Username), - Roles: pointer.ToStringArray(keycloakUser.RealmRoles), - TermsAccepted: termsAcceptedDate, - EmailVerified: pointer.ToBool(keycloakUser.EmailVerified), - IsMigrated: true, - Enabled: pointer.ToBool(keycloakUser.Enabled), - } - - if keycloakUser.Email != nil { - user.Emails = []string{*keycloakUser.Email} - } - // All non-custodial users have a password and it's important to set the hash to a non-empty value. - // When users are serialized by this service, the payload contains a flag `passwordExists` that - // is computed based on the presence of a password hash in the user struct. This flag is used by - // other services (e.g. hydrophone) to determine whether the user is custodial or not. - if !user.IsCustodialAccount() { - user.PwHash = "true" - } - - return user -} - -func NewKeycloakUser(gocloakUser *gocloak.User) *InternalUser { - if gocloakUser == nil { - return nil - } - var emails []string - if gocloakUser.Email != nil { - emails = append(emails, pointer.ToString(gocloakUser.Email)) - } - user := &InternalUser{ - Id: pointer.ToString(gocloakUser.ID), - Username: pointer.ToString(gocloakUser.Username), - FirstName: pointer.ToString(gocloakUser.FirstName), - LastName: pointer.ToString(gocloakUser.LastName), - Emails: emails, - EmailVerified: pointer.ToBool(gocloakUser.EmailVerified), - Enabled: pointer.ToBool(gocloakUser.Enabled), - } - if gocloakUser.Attributes != nil { - attrs := maps.Clone(*gocloakUser.Attributes) - if ts, ok := attrs[termsAcceptedAttribute]; ok && len(ts) > 0 { - user.TermsAccepted = ts[0] - } - if prof, ok := profileFromAttributes(attrs); ok { - user.Profile = prof - } - } - - if gocloakUser.RealmRoles != nil { - user.Roles = *gocloakUser.RealmRoles - } - - return user -} - -func (u *InternalUser) Email() string { +func (u *FullUser) Email() string { return strings.ToLower(u.Username) } -func (u *InternalUser) DeepClone() *InternalUser { - panic("todo - needed? only used in mongostore") - return nil -} - -func (u *InternalUser) HasRole(role string) bool { +func (u *FullUser) HasRole(role string) bool { for _, userRole := range u.Roles { if userRole == role { return true @@ -660,37 +586,37 @@ func (u *InternalUser) HasRole(role string) bool { } // IsClinic returns true if the user is legacy clinic Account -func (u *InternalUser) IsClinic() bool { +func (u *FullUser) IsClinic() bool { return u.HasRole(RoleClinic) } -func (u *InternalUser) IsCustodialAccount() bool { +func (u *FullUser) IsCustodialAccount() bool { return u.HasRole(RoleCustodialAccount) } // IsClinician returns true if the user is a clinician -func (u *InternalUser) IsClinician() bool { +func (u *FullUser) IsClinician() bool { return u.HasRole(RoleClinician) } -func (u *InternalUser) AreTermsAccepted() bool { +func (u *FullUser) AreTermsAccepted() bool { _, err := TimestampToUnixString(u.TermsAccepted) return err == nil } -func (u *InternalUser) IsEnabled() bool { +func (u *FullUser) IsEnabled() bool { if u.IsMigrated { return u.Enabled } return u.PwHash != "" && !u.IsDeleted() } -func (u *InternalUser) IsDeleted() bool { +func (u *FullUser) IsDeleted() bool { // mdb only? return u.DeletedTime != "" } -func (u *InternalUser) HashPassword(pw, salt string) error { +func (u *FullUser) HashPassword(pw, salt string) error { if passwordHash, err := GeneratePasswordHash(u.Id, pw, salt); err != nil { return err } else { @@ -699,7 +625,7 @@ func (u *InternalUser) HashPassword(pw, salt string) error { } } -func (u *InternalUser) PasswordsMatch(pw, salt string) bool { +func (u *FullUser) PasswordsMatch(pw, salt string) bool { if u.PwHash == "" || pw == "" { return false } else if pwMatch, err := GeneratePasswordHash(u.Id, pw, salt); err != nil { @@ -709,7 +635,7 @@ func (u *InternalUser) PasswordsMatch(pw, salt string) bool { } } -func (u *InternalUser) IsEmailVerified(secret string) bool { +func (u *FullUser) IsEmailVerified(secret string) bool { if secret != "" { if strings.Contains(u.Username, secret) { return true @@ -723,7 +649,7 @@ func (u *InternalUser) IsEmailVerified(secret string) bool { return u.EmailVerified } -func ToMigrationUser(u *InternalUser) *MigrationUser { +func ToMigrationUser(u *FullUser) *MigrationUser { migratedUser := &MigrationUser{ ID: u.Id, Username: strings.ToLower(u.Username), @@ -743,7 +669,7 @@ func ToMigrationUser(u *InternalUser) *MigrationUser { return migratedUser } -func ToExternalUser(user *InternalUser) *ExternalUser { +func ToExternalUser(user *FullUser) *ExternalUser { var id *string if len(user.Id) > 0 { id = &user.Id diff --git a/user/kcclient.go b/user/keycloak_client.go similarity index 54% rename from user/kcclient.go rename to user/keycloak_client.go index b911f3602..350d8c706 100644 --- a/user/kcclient.go +++ b/user/keycloak_client.go @@ -1,10 +1,9 @@ -// Temp place to put the keycloak client package user import ( "context" - "errors" "fmt" + "maps" "net/http" "strings" "time" @@ -22,48 +21,10 @@ const ( tokenPrefix = "kc" tokenPartsSeparator = ":" masterRealm = "master" - serverRole = "backend_service" termsAcceptedAttribute = "terms_and_conditions" - - TimestampFormat = "2006-01-02T15:04:05-07:00" ) -var shorelineManagedRoles = map[string]struct{}{"patient": {}, "clinic": {}, "clinician": {}, "custodial_account": {}} - -var ErrUserNotFound = errors.New("user not found") -var ErrUserConflict = errors.New("user already exists") - -type TokenIntrospectionResult struct { - Active bool `json:"active"` - Subject string `json:"sub"` - EmailVerified bool `json:"email_verified"` - ExpiresAt int64 `json:"eat"` - RealmAccess RealmAccess `json:"realm_access"` - IdentityProvider string `json:"identityProvider"` -} - -type AccessTokenCustomClaims struct { - jwx.Claims - IdentityProvider string `json:"identity_provider,omitempty"` -} - -type RealmAccess struct { - Roles []string `json:"roles"` -} - -func (t *TokenIntrospectionResult) IsServerToken() bool { - if len(t.RealmAccess.Roles) > 0 { - for _, role := range t.RealmAccess.Roles { - if role == serverRole { - return true - } - } - } - - return false -} - -type Config struct { +type KeycloakConfig struct { ClientID string `envconfig:"TIDEPOOL_KEYCLOAK_CLIENT_ID" required:"true"` ClientSecret string `envconfig:"TIDEPOOL_KEYCLOAK_CLIENT_SECRET" required:"true"` LongLivedClientID string `envconfig:"TIDEPOOL_KEYCLOAK_LONG_LIVED_CLIENT_ID" required:"true"` @@ -76,33 +37,51 @@ type Config struct { AdminPassword string `envconfig:"TIDEPOOL_KEYCLOAK_ADMIN_PASSWORD" required:"true"` } -func (c *Config) FromEnv() error { +func (c *KeycloakConfig) FromEnv() error { return envconfig.Process("", c) } -type KeycloakClient struct { - cfg *Config +// keycloakUser is an intermediate user representation from FullModel to gocloak's model - though is this actually needed? Can it be removed entirely? +type keycloakUser struct { + ID string `json:"id"` + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` + Enabled bool `json:"enabled,omitempty"` + EmailVerified bool `json:"emailVerified,omitempty"` + Roles []string `json:"roles,omitempty"` + Attributes keycloakUserAttributes `json:"attributes"` +} + +type keycloakUserAttributes struct { + TermsAcceptedDate []string `json:"terms_and_conditions,omitempty"` + Profile *UserProfile `json:"profile"` +} + +type keycloakClient struct { + cfg *KeycloakConfig adminToken *oauth2.Token adminTokenRefreshExpires time.Time keycloak *gocloak.GoCloak } -func NewKeycloakClient(config *Config) *KeycloakClient { - return &KeycloakClient{ +func newKCClient(config *KeycloakConfig) *keycloakClient { + return &keycloakClient{ cfg: config, keycloak: gocloak.NewClient(config.BaseUrl), } } -func (c *KeycloakClient) Login(ctx context.Context, username, password string) (*oauth2.Token, error) { +func (c *keycloakClient) Login(ctx context.Context, username, password string) (*oauth2.Token, error) { return c.doLogin(ctx, c.cfg.ClientID, c.cfg.ClientSecret, username, password) } -func (c *KeycloakClient) LoginLongLived(ctx context.Context, username, password string) (*oauth2.Token, error) { +func (c *keycloakClient) LoginLongLived(ctx context.Context, username, password string) (*oauth2.Token, error) { return c.doLogin(ctx, c.cfg.LongLivedClientID, c.cfg.LongLivedClientSecret, username, password) } -func (c *KeycloakClient) doLogin(ctx context.Context, clientId, clientSecret, username, password string) (*oauth2.Token, error) { +func (c *keycloakClient) doLogin(ctx context.Context, clientId, clientSecret, username, password string) (*oauth2.Token, error) { jwt, err := c.keycloak.Login( ctx, clientId, @@ -117,15 +96,16 @@ func (c *KeycloakClient) doLogin(ctx context.Context, clientId, clientSecret, us return c.jwtToAccessToken(jwt), nil } -func (c *KeycloakClient) GetBackendServiceToken(ctx context.Context) (*oauth2.Token, error) { +func (c *keycloakClient) GetBackendServiceToken(ctx context.Context) (*oauth2.Token, error) { jwt, err := c.keycloak.LoginClient(ctx, c.cfg.BackendClientID, c.cfg.BackendClientSecret, c.cfg.Realm) + fmt.Println("GetBackendServiceToken LoginClient", c.cfg.BackendClientID, c.cfg.BackendClientSecret, c.cfg.Realm) if err != nil { return nil, err } return c.jwtToAccessToken(jwt), nil } -func (c *KeycloakClient) jwtToAccessToken(jwt *gocloak.JWT) *oauth2.Token { +func (c *keycloakClient) jwtToAccessToken(jwt *gocloak.JWT) *oauth2.Token { if jwt == nil { return nil } @@ -139,7 +119,7 @@ func (c *KeycloakClient) jwtToAccessToken(jwt *gocloak.JWT) *oauth2.Token { }) } -func (c *KeycloakClient) RevokeToken(ctx context.Context, token oauth2.Token) error { +func (c *keycloakClient) RevokeToken(ctx context.Context, token oauth2.Token) error { clientId, clientSecret := c.getClientAndSecretFromToken(ctx, token) return c.keycloak.Logout( ctx, @@ -150,7 +130,7 @@ func (c *KeycloakClient) RevokeToken(ctx context.Context, token oauth2.Token) er ) } -func (c *KeycloakClient) RefreshToken(ctx context.Context, token oauth2.Token) (*oauth2.Token, error) { +func (c *keycloakClient) RefreshToken(ctx context.Context, token oauth2.Token) (*oauth2.Token, error) { clientId, clientSecret := c.getClientAndSecretFromToken(ctx, token) jwt, err := c.keycloak.RefreshToken( @@ -166,7 +146,7 @@ func (c *KeycloakClient) RefreshToken(ctx context.Context, token oauth2.Token) ( return c.jwtToAccessToken(jwt), nil } -func (c *KeycloakClient) GetUserById(ctx context.Context, id string) (*gocloak.User, error) { +func (c *keycloakClient) GetUserById(ctx context.Context, id string) (*keycloakUser, error) { if id == "" { return nil, nil } @@ -179,7 +159,7 @@ func (c *KeycloakClient) GetUserById(ctx context.Context, id string) (*gocloak.U return users[0], nil } -func (c *KeycloakClient) GetUserByEmail(ctx context.Context, email string) (*gocloak.User, error) { +func (c *keycloakClient) GetUserByEmail(ctx context.Context, email string) (*keycloakUser, error) { if email == "" { return nil, nil } @@ -199,13 +179,32 @@ func (c *KeycloakClient) GetUserByEmail(ctx context.Context, email string) (*goc return c.GetUserById(ctx, *users[0].ID) } -func (c *KeycloakClient) UpdateUser(ctx context.Context, user *gocloak.User) error { +func (c *keycloakClient) UpdateUser(ctx context.Context, user *keycloakUser) error { token, err := c.getAdminToken(ctx) if err != nil { return err } - if err := c.keycloak.UpdateUser(ctx, token.AccessToken, c.cfg.Realm, *user); err != nil { + gocloakUser := gocloak.User{ + ID: &user.ID, + Username: &user.Username, + Enabled: &user.Enabled, + EmailVerified: &user.EmailVerified, + FirstName: &user.FirstName, + LastName: &user.LastName, + Email: &user.Email, + } + + attrs := map[string][]string{ + termsAcceptedAttribute: user.Attributes.TermsAcceptedDate, + } + if user.Attributes.Profile != nil { + profileAttrs := user.Attributes.Profile.ToAttributes() + maps.Copy(attrs, profileAttrs) + } + + gocloakUser.Attributes = &attrs + if err := c.keycloak.UpdateUser(ctx, token.AccessToken, c.cfg.Realm, gocloakUser); err != nil { return err } if err := c.updateRolesForUser(ctx, user); err != nil { @@ -214,7 +213,19 @@ func (c *KeycloakClient) UpdateUser(ctx context.Context, user *gocloak.User) err return nil } -func (c *KeycloakClient) UpdateUserPassword(ctx context.Context, id, password string) error { +func (c *keycloakClient) UpdateUserProfile(ctx context.Context, id string, p *UserProfile) error { + user, err := c.GetUserById(ctx, id) + if err != nil { + return err + } + if user == nil { + return ErrUserNotFound + } + user.Attributes.Profile = p + return c.UpdateUser(ctx, user) +} + +func (c *keycloakClient) UpdateUserPassword(ctx context.Context, id, password string) error { token, err := c.getAdminToken(ctx) if err != nil { return err @@ -230,16 +241,30 @@ func (c *KeycloakClient) UpdateUserPassword(ctx context.Context, id, password st ) } -func (c *KeycloakClient) CreateUser(ctx context.Context, user *gocloak.User) (*gocloak.User, error) { +func (c *keycloakClient) CreateUser(ctx context.Context, user *keycloakUser) (*keycloakUser, error) { token, err := c.getAdminToken(ctx) if err != nil { return nil, err } - userID, err := c.keycloak.CreateUser(ctx, token.AccessToken, c.cfg.Realm, *user) + model := gocloak.User{ + Username: &user.Username, + Email: &user.Email, + EmailVerified: &user.EmailVerified, + Enabled: &user.Enabled, + RealmRoles: &user.Roles, + } + + if len(user.Attributes.TermsAcceptedDate) > 0 { + attrs := map[string][]string{ + termsAcceptedAttribute: user.Attributes.TermsAcceptedDate, + } + model.Attributes = &attrs + } + + user.ID, err = c.keycloak.CreateUser(ctx, token.AccessToken, c.cfg.Realm, model) if err != nil { - var gerr *gocloak.APIError - if errors.As(err, &gerr) && gerr.Code == http.StatusConflict { + if e, ok := err.(*gocloak.APIError); ok && e.Code == http.StatusConflict { err = ErrUserConflict } return nil, err @@ -249,10 +274,10 @@ func (c *KeycloakClient) CreateUser(ctx context.Context, user *gocloak.User) (*g return nil, err } - return c.GetUserById(ctx, userID) + return c.GetUserById(ctx, user.ID) } -func (c *KeycloakClient) FindUsersWithIds(ctx context.Context, ids []string) (users []*gocloak.User, err error) { +func (c *keycloakClient) FindUsersWithIds(ctx context.Context, ids []string) (users []*keycloakUser, err error) { const errMessage = "could not retrieve users by ids" token, err := c.getAdminToken(ctx) @@ -275,10 +300,15 @@ func (c *KeycloakClient) FindUsersWithIds(ctx context.Context, ids []string) (us return } - return res, nil + users = make([]*keycloakUser, len(res)) + for i, u := range res { + users[i] = newKeycloakUser(u) + } + + return } -func (c *KeycloakClient) IntrospectToken(ctx context.Context, token oauth2.Token) (*TokenIntrospectionResult, error) { +func (c *keycloakClient) IntrospectToken(ctx context.Context, token oauth2.Token) (*TokenIntrospectionResult, error) { clientId, clientSecret := c.getClientAndSecretFromToken(ctx, token) rtr, err := c.keycloak.RetrospectToken( @@ -318,7 +348,7 @@ func (c *KeycloakClient) IntrospectToken(ctx context.Context, token oauth2.Token return result, nil } -func (c *KeycloakClient) DeleteUser(ctx context.Context, id string) error { +func (c *keycloakClient) DeleteUser(ctx context.Context, id string) error { token, err := c.getAdminToken(ctx) if err != nil { return err @@ -332,7 +362,7 @@ func (c *KeycloakClient) DeleteUser(ctx context.Context, id string) error { return err } -func (c *KeycloakClient) DeleteUserSessions(ctx context.Context, id string) error { +func (c *keycloakClient) DeleteUserSessions(ctx context.Context, id string) error { token, err := c.getAdminToken(ctx) if err != nil { return err @@ -347,12 +377,12 @@ func (c *KeycloakClient) DeleteUserSessions(ctx context.Context, id string) erro return err } -func (c *KeycloakClient) getRealmURL(realm string, path ...string) string { +func (c *keycloakClient) getRealmURL(realm string, path ...string) string { path = append([]string{c.cfg.BaseUrl, "realms", realm}, path...) return strings.Join(path, "/") } -func (c *KeycloakClient) getAdminToken(ctx context.Context) (*oauth2.Token, error) { +func (c *keycloakClient) getAdminToken(ctx context.Context) (*oauth2.Token, error) { var err error if c.adminTokenIsExpired() { err = c.loginAsAdmin(ctx) @@ -361,7 +391,7 @@ func (c *KeycloakClient) getAdminToken(ctx context.Context) (*oauth2.Token, erro return c.adminToken, err } -func (c *KeycloakClient) loginAsAdmin(ctx context.Context) error { +func (c *keycloakClient) loginAsAdmin(ctx context.Context) error { jwt, err := c.keycloak.LoginAdmin( ctx, c.cfg.AdminUsername, @@ -377,11 +407,11 @@ func (c *KeycloakClient) loginAsAdmin(ctx context.Context) error { return nil } -func (c *KeycloakClient) adminTokenIsExpired() bool { +func (c *keycloakClient) adminTokenIsExpired() bool { return c.adminToken == nil || time.Now().After(c.adminTokenRefreshExpires) } -func (c *KeycloakClient) updateRolesForUser(ctx context.Context, user *gocloak.User) error { +func (c *keycloakClient) updateRolesForUser(ctx context.Context, user *keycloakUser) error { token, err := c.getAdminToken(ctx) if err != nil { return err @@ -393,7 +423,7 @@ func (c *KeycloakClient) updateRolesForUser(ctx context.Context, user *gocloak.U if err != nil { return err } - currentUserRoles, err := c.keycloak.GetRealmRolesByUserID(ctx, token.AccessToken, c.cfg.Realm, *user.ID) + currentUserRoles, err := c.keycloak.GetRealmRolesByUserID(ctx, token.AccessToken, c.cfg.Realm, user.ID) if err != nil { return err } @@ -402,8 +432,8 @@ func (c *KeycloakClient) updateRolesForUser(ctx context.Context, user *gocloak.U var rolesToDelete []gocloak.Role targetRoles := make(map[string]struct{}) - if user.RealmRoles != nil { - for _, targetRoleName := range *user.RealmRoles { + if len(user.Roles) > 0 { + for _, targetRoleName := range user.Roles { targetRoles[targetRoleName] = struct{}{} } } @@ -415,26 +445,28 @@ func (c *KeycloakClient) updateRolesForUser(ctx context.Context, user *gocloak.U } } - for _, currentRole := range currentUserRoles { - if currentRole == nil || currentRole.Name == nil || *currentRole.Name == "" { - continue - } + if len(currentUserRoles) > 0 { + for _, currentRole := range currentUserRoles { + if currentRole == nil || currentRole.Name == nil || *currentRole.Name == "" { + continue + } - if _, ok := targetRoles[*currentRole.Name]; !ok { - // Only remove roles managed by shoreline - if _, ok := shorelineManagedRoles[*currentRole.Name]; ok { - rolesToDelete = append(rolesToDelete, *currentRole) + if _, ok := targetRoles[*currentRole.Name]; !ok { + // Only remove roles managed by shoreline + if _, ok := shorelineManagedRoles[*currentRole.Name]; ok { + rolesToDelete = append(rolesToDelete, *currentRole) + } } } } if len(rolesToAdd) > 0 { - if err = c.keycloak.AddRealmRoleToUser(ctx, token.AccessToken, c.cfg.Realm, *user.ID, rolesToAdd); err != nil { + if err = c.keycloak.AddRealmRoleToUser(ctx, token.AccessToken, c.cfg.Realm, user.ID, rolesToAdd); err != nil { return err } } if len(rolesToDelete) > 0 { - if err = c.keycloak.DeleteRealmRoleFromUser(ctx, token.AccessToken, c.cfg.Realm, *user.ID, rolesToDelete); err != nil { + if err = c.keycloak.DeleteRealmRoleFromUser(ctx, token.AccessToken, c.cfg.Realm, user.ID, rolesToDelete); err != nil { return err } } @@ -442,7 +474,7 @@ func (c *KeycloakClient) updateRolesForUser(ctx context.Context, user *gocloak.U return nil } -func (c *KeycloakClient) getClientAndSecretFromToken(ctx context.Context, token oauth2.Token) (string, string) { +func (c *keycloakClient) getClientAndSecretFromToken(ctx context.Context, token oauth2.Token) (string, string) { clientId := c.cfg.ClientID clientSecret := c.cfg.ClientSecret @@ -462,6 +494,95 @@ func (c *KeycloakClient) getClientAndSecretFromToken(ctx context.Context, token return clientId, clientSecret } +func newKeycloakUser(gocloakUser *gocloak.User) *keycloakUser { + if gocloakUser == nil { + return nil + } + + user := &keycloakUser{ + ID: pointer.ToString(gocloakUser.ID), + Username: pointer.ToString(gocloakUser.Username), + FirstName: pointer.ToString(gocloakUser.FirstName), + LastName: pointer.ToString(gocloakUser.LastName), + Email: pointer.ToString(gocloakUser.Email), + EmailVerified: pointer.ToBool(gocloakUser.EmailVerified), + Enabled: pointer.ToBool(gocloakUser.Enabled), + } + if gocloakUser.Attributes != nil { + attrs := *gocloakUser.Attributes + if ts, ok := attrs[termsAcceptedAttribute]; ok { + user.Attributes.TermsAcceptedDate = ts + } + if prof, ok := profileFromAttributes(attrs); ok { + user.Attributes.Profile = prof + } + } + + if gocloakUser.RealmRoles != nil { + user.Roles = *gocloakUser.RealmRoles + } + + return user +} + +func newUserFromKeycloakUser(keycloakUser *keycloakUser) *FullUser { + termsAcceptedDate := "" + attrs := keycloakUser.Attributes + if len(attrs.TermsAcceptedDate) > 0 { + if ts, err := UnixStringToTimestamp(attrs.TermsAcceptedDate[0]); err == nil { + termsAcceptedDate = ts + } + } + + user := &FullUser{ + Id: keycloakUser.ID, + Username: keycloakUser.Username, + Emails: []string{keycloakUser.Email}, + Roles: keycloakUser.Roles, + TermsAccepted: termsAcceptedDate, + EmailVerified: keycloakUser.EmailVerified, + IsMigrated: true, + Enabled: keycloakUser.Enabled, + Profile: attrs.Profile, + } + + // All non-custodial users have a password and it's important to set the hash to a non-empty value. + // When users are serialized by this service, the payload contains a flag `passwordExists` that + // is computed based on the presence of a password hash in the user struct. This flag is used by + // other services (e.g. hydrophone) to determine whether the user is custodial or not. + if !user.IsCustodialAccount() { + user.PwHash = "true" + } + + return user +} + +func userToKeycloakUser(u *FullUser) *keycloakUser { + keycloakUser := &keycloakUser{ + ID: u.Id, + Username: strings.ToLower(u.Username), + Email: strings.ToLower(u.Email()), + Enabled: u.IsEnabled(), + EmailVerified: u.EmailVerified, + Roles: u.Roles, + Attributes: keycloakUserAttributes{}, + } + if len(keycloakUser.Roles) == 0 { + keycloakUser.Roles = []string{RolePatient} + } + if !u.IsMigrated && u.PwHash == "" && !u.HasRole(RoleCustodialAccount) { + keycloakUser.Roles = append(keycloakUser.Roles, RoleCustodialAccount) + } + if termsAccepted, err := TimestampToUnixString(u.TermsAccepted); err == nil { + keycloakUser.Attributes.TermsAcceptedDate = []string{termsAccepted} + } + if u.Profile != nil { + keycloakUser.Attributes.Profile = u.Profile + } + + return keycloakUser +} + func getRealmRoleByName(realmRoles []*gocloak.Role, name string) *gocloak.Role { for _, realmRole := range realmRoles { if realmRole.Name != nil && *realmRole.Name == name { diff --git a/user/keycloak_user_accessor.go b/user/keycloak_user_accessor.go new file mode 100644 index 000000000..47921f105 --- /dev/null +++ b/user/keycloak_user_accessor.go @@ -0,0 +1,225 @@ +package user + +import ( + "context" + "errors" + "fmt" + "time" + + "golang.org/x/oauth2" +) + +type keycloakUserAccessor struct { + cfg *KeycloakConfig + adminToken *oauth2.Token + adminTokenRefreshExpires time.Time + keycloakClient *keycloakClient +} + +func NewKeycloakUserAccessor(config *KeycloakConfig) *keycloakUserAccessor { + newKCClient(config) + return &keycloakUserAccessor{ + cfg: config, + keycloakClient: newKCClient(config), + } +} + +func (m *keycloakUserAccessor) CreateUser(ctx context.Context, details *NewUserDetails) (*FullUser, error) { + keycloakUser := &keycloakUser{ + Enabled: details.Password != nil && *details.Password != "", + EmailVerified: details.EmailVerified, + } + + if keycloakUser.EmailVerified { + // Automatically set terms accepted date when email is verified (i.e. internal usage only). + termsAccepted := fmt.Sprintf("%v", time.Now().Unix()) + keycloakUser.Attributes = keycloakUserAttributes{ + TermsAcceptedDate: []string{termsAccepted}, + } + } + + // Users without roles should be treated as patients to prevent keycloak from displaying + // the role selection dialog + if len(details.Roles) == 0 { + details.Roles = []string{RolePatient} + } + if details.Username != nil { + keycloakUser.Username = *details.Username + } + if len(details.Emails) > 0 { + keycloakUser.Email = details.Emails[0] + } + keycloakUser.Roles = details.Roles + + keycloakUser, err := m.keycloakClient.CreateUser(ctx, keycloakUser) + if errors.Is(err, ErrUserConflict) { + return nil, ErrUserConflict + } + if err != nil { + return nil, err + } + + user := newUserFromKeycloakUser(keycloakUser) + + // Unclaimed custodial account should not be allowed to have a password + if !user.IsCustodialAccount() { + if err = m.keycloakClient.UpdateUserPassword(ctx, keycloakUser.ID, *details.Password); err != nil { + return nil, err + } + } + + return user, nil +} + +func (m *keycloakUserAccessor) UpdateUser(ctx context.Context, user *FullUser, details *UpdateUserDetails) (*FullUser, error) { + emails := append([]string{}, details.Emails...) + if details.Username != nil { + emails = append(emails, *details.Username) + } + if err := m.assertEmailsUnique(ctx, user.Id, emails); err != nil { + return nil, err + } + + if user.IsMigrated { + return m.updateKeycloakUser(ctx, user, details) + } + // expected all users to be migrated(?) + return nil, ErrUserNotMigrated +} + +func (m *keycloakUserAccessor) assertEmailsUnique(ctx context.Context, userId string, emails []string) error { + // for _, email := range emails { + // users, err := m.fallback.FindUsers(&User{ + // Username: email, + // Emails: emails, + // }) + // if err != nil { + // return err + // } + // for _, user := range users { + // if user.Id != userId { + // return ErrEmailConflict + // } + // } + // } + + for _, email := range emails { + user, err := m.keycloakClient.GetUserByEmail(ctx, email) + if err != nil { + return err + } + if user != nil && user.ID != userId { + return ErrEmailConflict + } + } + return nil +} + +func (m *keycloakUserAccessor) updateKeycloakUser(ctx context.Context, user *FullUser, details *UpdateUserDetails) (*FullUser, error) { + keycloakUser := userToKeycloakUser(user) + if details.Roles != nil { + keycloakUser.Roles = details.Roles + } + if details.Password != nil && len(*details.Password) > 0 { + if err := m.keycloakClient.UpdateUserPassword(ctx, user.Id, *details.Password); err != nil { + return nil, err + } + // Remove the custodial role after the password has been set + newRoles := make([]string, 0) + for _, role := range keycloakUser.Roles { + if role != RoleCustodialAccount { + newRoles = append(newRoles, role) + } + } + keycloakUser.Roles = newRoles + keycloakUser.Enabled = true + } + if details.Username != nil { + keycloakUser.Username = *details.Username + } + if details.Emails != nil && len(details.Emails) > 0 { + keycloakUser.Email = details.Emails[0] + } + if details.EmailVerified != nil { + keycloakUser.EmailVerified = *details.EmailVerified + } + if details.TermsAccepted != nil && IsValidTimestamp(*details.TermsAccepted) { + if ts, err := TimestampToUnixString(*details.TermsAccepted); err == nil { + keycloakUser.Attributes.TermsAcceptedDate = []string{ts} + } + } + + err := m.keycloakClient.UpdateUser(ctx, keycloakUser) + if err != nil { + return nil, err + } + + updated, err := m.keycloakClient.GetUserById(ctx, keycloakUser.ID) + if err != nil { + return nil, err + } + + return newUserFromKeycloakUser(updated), nil +} + +func (m *keycloakUserAccessor) FindUser(ctx context.Context, user *FullUser) (*FullUser, error) { + var keycloakUser *keycloakUser + var err error + + if IsValidUserID(user.Id) { + keycloakUser, err = m.keycloakClient.GetUserById(ctx, user.Id) + } else { + email := "" + if user.Emails != nil && len(user.Emails) > 0 { + email = user.Emails[0] + } + keycloakUser, err = m.keycloakClient.GetUserByEmail(ctx, email) + } + + if err != nil && err != ErrUserNotFound { + return nil, err + } else if err == nil && keycloakUser != nil { + return newUserFromKeycloakUser(keycloakUser), nil + } + // expected all users to already be migrated(?) + return nil, ErrUserNotMigrated +} + +func (m *keycloakUserAccessor) FindUserById(ctx context.Context, id string) (*FullUser, error) { + if !IsValidUserID(id) { + return nil, ErrUserNotFound + } + + keycloakUser, err := m.keycloakClient.GetUserById(ctx, id) + if err != nil { + return nil, err + } + if keycloakUser == nil { + return nil, ErrUserNotFound + } + return newUserFromKeycloakUser(keycloakUser), nil +} + +func (m *keycloakUserAccessor) FindUsersWithIds(ctx context.Context, ids []string) (users []*FullUser, err error) { + keycloakUsers, err := m.keycloakClient.FindUsersWithIds(ctx, ids) + if err != nil { + return users, err + } + + for _, user := range keycloakUsers { + users = append(users, newUserFromKeycloakUser(user)) + } + return users, nil +} + +func (m *keycloakUserAccessor) RemoveUser(ctx context.Context, user *FullUser) error { + return m.keycloakClient.DeleteUser(ctx, user.Id) +} + +func (m *keycloakUserAccessor) RemoveTokensForUser(ctx context.Context, userId string) error { + return m.keycloakClient.DeleteUserSessions(ctx, userId) +} + +func (m *keycloakUserAccessor) UpdateUserProfile(ctx context.Context, userId string, p *UserProfile) error { + return m.keycloakClient.UpdateUserProfile(ctx, userId, p) +} diff --git a/user/user_accessor.go b/user/user_accessor.go new file mode 100644 index 000000000..cc6f4993e --- /dev/null +++ b/user/user_accessor.go @@ -0,0 +1,68 @@ +package user + +import ( + "context" + "errors" + + "github.com/Nerzal/gocloak/v13/pkg/jwx" +) + +const ( + serverRole = "backend_service" + + TimestampFormat = "2006-01-02T15:04:05-07:00" +) + +var ( + shorelineManagedRoles = map[string]struct{}{"patient": {}, "clinic": {}, "clinician": {}, "custodial_account": {}} + + ErrUserNotFound = errors.New("user not found") + ErrUserConflict = errors.New("user already exists") + ErrEmailConflict = errors.New("email already exists") + ErrUserNotMigrated = errors.New("user has not been migrated") +) + +// UserAccessor is the interface that can retrieve users. +// It is the equivalent of shoreline's shoreline's Storage +// interface, but for now will only retrieve user +// information. +type UserAccessor interface { + CreateUser(ctx context.Context, details *NewUserDetails) (*FullUser, error) + UpdateUser(ctx context.Context, user *FullUser, details *UpdateUserDetails) (*FullUser, error) + FindUser(ctx context.Context, user *FullUser) (*FullUser, error) + FindUserById(ctx context.Context, id string) (*FullUser, error) + FindUsersWithIds(ctx context.Context, ids []string) ([]*FullUser, error) + RemoveUser(ctx context.Context, user *FullUser) error + RemoveTokensForUser(ctx context.Context, userId string) error + UpdateUserProfile(ctx context.Context, id string, p *UserProfile) error +} + +type TokenIntrospectionResult struct { + Active bool `json:"active"` + Subject string `json:"sub"` + EmailVerified bool `json:"email_verified"` + ExpiresAt int64 `json:"eat"` + RealmAccess RealmAccess `json:"realm_access"` + IdentityProvider string `json:"identityProvider"` +} + +type AccessTokenCustomClaims struct { + jwx.Claims + IdentityProvider string `json:"identity_provider,omitempty"` +} + +type RealmAccess struct { + Roles []string `json:"roles"` +} + +func (t *TokenIntrospectionResult) IsServerToken() bool { + if len(t.RealmAccess.Roles) > 0 { + for _, role := range t.RealmAccess.Roles { + if role == serverRole { + return true + } + } + } + + return false +} From a93f797b9c9a3ac4ac6973ff2340b2070b108539 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 12 Feb 2024 10:22:59 -0800 Subject: [PATCH 04/62] Renaming. --- user/keycloak_client.go | 2 +- user/keycloak_user_accessor.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/user/keycloak_client.go b/user/keycloak_client.go index 350d8c706..4368854ec 100644 --- a/user/keycloak_client.go +++ b/user/keycloak_client.go @@ -66,7 +66,7 @@ type keycloakClient struct { keycloak *gocloak.GoCloak } -func newKCClient(config *KeycloakConfig) *keycloakClient { +func newKeycloakClient(config *KeycloakConfig) *keycloakClient { return &keycloakClient{ cfg: config, keycloak: gocloak.NewClient(config.BaseUrl), diff --git a/user/keycloak_user_accessor.go b/user/keycloak_user_accessor.go index 47921f105..90d660fe1 100644 --- a/user/keycloak_user_accessor.go +++ b/user/keycloak_user_accessor.go @@ -17,10 +17,10 @@ type keycloakUserAccessor struct { } func NewKeycloakUserAccessor(config *KeycloakConfig) *keycloakUserAccessor { - newKCClient(config) + newKeycloakClient(config) return &keycloakUserAccessor{ cfg: config, - keycloakClient: newKCClient(config), + keycloakClient: newKeycloakClient(config), } } From 5dbd8ac2072b5c33a36f3c6c2aa521e560d94eb0 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 12 Feb 2024 10:25:55 -0800 Subject: [PATCH 05/62] Add the route. --- auth/service/api/v1/router.go | 1 + 1 file changed, 1 insertion(+) diff --git a/auth/service/api/v1/router.go b/auth/service/api/v1/router.go index 408f085a9..e973bb6ae 100644 --- a/auth/service/api/v1/router.go +++ b/auth/service/api/v1/router.go @@ -27,6 +27,7 @@ func (r *Router) Routes() []*rest.Route { r.ProviderSessionsRoutes(), r.RestrictedTokensRoutes(), r.DeviceCheckRoutes(), + r.ProfileRoutes(), } acc := make([]*rest.Route, 0) for _, r := range routes { From 6a9014c4ffa468d7e143e9d99db976da19b2cdd6 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 12 Feb 2024 10:29:22 -0800 Subject: [PATCH 06/62] Fix build. --- user/keycloak_client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user/keycloak_client.go b/user/keycloak_client.go index 4368854ec..9a74febdf 100644 --- a/user/keycloak_client.go +++ b/user/keycloak_client.go @@ -438,7 +438,7 @@ func (c *keycloakClient) updateRolesForUser(ctx context.Context, user *keycloakU } } - for targetRoleName, _ := range targetRoles { + for targetRoleName := range targetRoles { realmRole := getRealmRoleByName(realmRoles, targetRoleName) if realmRole != nil { rolesToAdd = append(rolesToAdd, *realmRole) From ed826a9f9e7509355f71d41ea496891a96c81038 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 12 Feb 2024 10:48:35 -0800 Subject: [PATCH 07/62] Fix test. --- auth/service/test/service.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/auth/service/test/service.go b/auth/service/test/service.go index e029704c5..0972cfa7e 100644 --- a/auth/service/test/service.go +++ b/auth/service/test/service.go @@ -4,6 +4,7 @@ import ( "context" "github.com/tidepool-org/platform/apple" + "github.com/tidepool-org/platform/user" "github.com/onsi/gomega" @@ -79,6 +80,10 @@ func (s *Service) DeviceCheck() apple.DeviceCheck { return nil } +func (s *Service) UserAccessor() user.UserAccessor { + return nil +} + func (s *Service) Status(ctx context.Context) *service.Status { s.StatusInvocations++ From 2a49fb0f51a35d09adf87d24b6ae618b48d4a9a9 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 14 Feb 2024 08:22:09 -0800 Subject: [PATCH 08/62] Use permissions like in seagull. --- auth/service/api/v1/permission.go | 35 +++++++++++++++++++++++++++++++ auth/service/api/v1/profile.go | 34 +++++++++++++++++++++++------- auth/service/service.go | 2 ++ auth/service/service/service.go | 28 +++++++++++++++++++++++++ auth/service/test/service.go | 5 +++++ permission/client/client.go | 26 +++++++++++++++++++++++ permission/permission.go | 3 +++ user/keycloak_client.go | 12 +++++++++++ user/keycloak_user_accessor.go | 4 ++++ user/profile.go | 6 ++++-- user/user_accessor.go | 1 + 11 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 auth/service/api/v1/permission.go diff --git a/auth/service/api/v1/permission.go b/auth/service/api/v1/permission.go new file mode 100644 index 000000000..394b017ee --- /dev/null +++ b/auth/service/api/v1/permission.go @@ -0,0 +1,35 @@ +package v1 + +import ( + "net/http" + + "github.com/ant0ine/go-json-rest/rest" + + "github.com/tidepool-org/platform/request" + "github.com/tidepool-org/platform/service/api" +) + +// requireUserHasCustodian aborts with an error if a a request isn't +// authenticated as a user and the user does not have custodian access to the +// user with the id defined in the url param targetParamUserID +func (r *Router) requireUserHasCustodian(targetParamUserID string, handlerFunc rest.HandlerFunc) rest.HandlerFunc { + fn := func(res rest.ResponseWriter, req *rest.Request) { + if handlerFunc != nil && res != nil && req != nil { + targetUserID := req.PathParam(targetParamUserID) + responder := request.MustNewResponder(res, req) + ctx := req.Context() + details := request.GetAuthDetails(ctx) + hasPerms, err := r.PermissionsClient().HasCustodianPermissions(ctx, details.UserID(), targetUserID) + if err != nil { + responder.InternalServerError(err) + return + } + if !hasPerms { + responder.Empty(http.StatusForbidden) + return + } + handlerFunc(res, req) + } + } + return api.RequireUser(fn) +} diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index 431598675..7961fb3cf 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -14,7 +14,8 @@ import ( func (r *Router) ProfileRoutes() []*rest.Route { return []*rest.Route{ rest.Get("/v1/profiles/:userId", api.RequireUser(r.GetProfile)), - rest.Put("/v1/profiles/:userId", api.RequireUser(r.UpdateProfile)), + rest.Put("/v1/profiles/:userId", r.requireUserHasCustodian("userId", r.UpdateProfile)), + rest.Delete("/v1/profiles/:userId", r.requireUserHasCustodian("userId", r.DeleteProfile)), } } @@ -23,8 +24,13 @@ func (r *Router) GetProfile(res rest.ResponseWriter, req *rest.Request) { ctx := req.Context() details := request.GetAuthDetails(ctx) userID := req.PathParam("userId") - if !details.IsService() && details.UserID() != userID { - responder.Empty(http.StatusNotFound) + hasPerms, err := r.PermissionsClient().HasMembershipRelationship(ctx, details.UserID(), userID) + if err != nil { + responder.InternalServerError(err) + return + } + if !hasPerms { + responder.Empty(http.StatusForbidden) return } @@ -40,12 +46,7 @@ func (r *Router) GetProfile(res rest.ResponseWriter, req *rest.Request) { func (r *Router) UpdateProfile(res rest.ResponseWriter, req *rest.Request) { responder := request.MustNewResponder(res, req) ctx := req.Context() - details := request.GetAuthDetails(ctx) userID := req.PathParam("userId") - if !details.IsService() && details.UserID() != userID { - responder.Empty(http.StatusNotFound) - return - } profile := &user.UserProfile{} if err := request.DecodeRequestBody(req.Request, profile); err != nil { @@ -63,3 +64,20 @@ func (r *Router) UpdateProfile(res rest.ResponseWriter, req *rest.Request) { } responder.Empty(http.StatusOK) } + +func (r *Router) DeleteProfile(res rest.ResponseWriter, req *rest.Request) { + responder := request.MustNewResponder(res, req) + ctx := req.Context() + userID := req.PathParam("userId") + + err := r.UserAccessor().DeleteUserProfile(ctx, userID) + if stdErrs.Is(err, user.ErrUserNotFound) { + responder.Empty(http.StatusNotFound) + return + } + if err != nil { + responder.InternalServerError(err) + return + } + responder.Empty(http.StatusOK) +} diff --git a/auth/service/service.go b/auth/service/service.go index 33c4c4318..64d0abefe 100644 --- a/auth/service/service.go +++ b/auth/service/service.go @@ -9,6 +9,7 @@ import ( confirmationClient "github.com/tidepool-org/hydrophone/client" "github.com/tidepool-org/platform/auth/store" + permission "github.com/tidepool-org/platform/permission" "github.com/tidepool-org/platform/provider" "github.com/tidepool-org/platform/service" "github.com/tidepool-org/platform/task" @@ -20,6 +21,7 @@ type Service interface { Domain() string AuthStore() store.Store UserAccessor() user.UserAccessor + PermissionsClient() permission.Client ProviderFactory() provider.Factory diff --git a/auth/service/service/service.go b/auth/service/service/service.go index c5a4e3830..036607351 100644 --- a/auth/service/service/service.go +++ b/auth/service/service/service.go @@ -29,6 +29,8 @@ import ( "github.com/tidepool-org/platform/errors" "github.com/tidepool-org/platform/events" logInternal "github.com/tidepool-org/platform/log" + "github.com/tidepool-org/platform/permission" + permissionClient "github.com/tidepool-org/platform/permission/client" "github.com/tidepool-org/platform/platform" "github.com/tidepool-org/platform/provider" providerFactory "github.com/tidepool-org/platform/provider/factory" @@ -58,6 +60,7 @@ type Service struct { userEventsHandler events.Runner deviceCheck apple.DeviceCheck userAccessor user.UserAccessor + permsClient *permissionClient.Client } func New() *Service { @@ -113,6 +116,9 @@ func (s *Service) Initialize(provider application.Provider) error { if err := s.initializeUserAccessor(); err != nil { return err } + if err := s.initializePermissionsClient(); err != nil { + return err + } return s.initializeUserEventsHandler() } @@ -161,6 +167,9 @@ func (s *Service) UserAccessor() user.UserAccessor { return s.userAccessor } +func (s *Service) PermissionsClient() permission.Client { + return s.permsClient +} func (s *Service) Status(ctx context.Context) *service.Status { return &service.Status{ Version: s.VersionReporter().Long(), @@ -334,6 +343,25 @@ func (s *Service) initializeTaskClient() error { return nil } +func (s *Service) initializePermissionsClient() error { + s.Logger().Debug("Loading permission client config") + + cfg := platform.NewConfig() + cfg.UserAgent = s.UserAgent() + reporter := s.ConfigReporter().WithScopes("permission", "client") + loader := platform.NewConfigReporterLoader(reporter) + if err := cfg.Load(loader); err != nil { + return errors.Wrap(err, "unable to load permission client config") + } + + permsClient, err := permissionClient.New(cfg, platform.AuthorizeAsService) + if err != nil { + return errors.Wrap(err, "unable to create permission client") + } + s.permsClient = permsClient + return nil +} + func (s *Service) terminateTaskClient() { if s.taskClient != nil { s.Logger().Debug("Destroying task client") diff --git a/auth/service/test/service.go b/auth/service/test/service.go index 0972cfa7e..61f06b943 100644 --- a/auth/service/test/service.go +++ b/auth/service/test/service.go @@ -13,6 +13,7 @@ import ( "github.com/tidepool-org/platform/auth/service" "github.com/tidepool-org/platform/auth/store" authStoreTest "github.com/tidepool-org/platform/auth/store/test" + "github.com/tidepool-org/platform/permission" "github.com/tidepool-org/platform/provider" providerTest "github.com/tidepool-org/platform/provider/test" serviceTest "github.com/tidepool-org/platform/service/test" @@ -84,6 +85,10 @@ func (s *Service) UserAccessor() user.UserAccessor { return nil } +func (s *Service) PermissionsClient() permission.Client { + return nil +} + func (s *Service) Status(ctx context.Context) *service.Status { s.StatusInvocations++ diff --git a/permission/client/client.go b/permission/client/client.go index 5145e0ba2..a24c4257c 100644 --- a/permission/client/client.go +++ b/permission/client/client.go @@ -46,3 +46,29 @@ func (c *Client) GetUserPermissions(ctx context.Context, requestUserID string, t return permission.FixOwnerPermissions(result), nil } + +func (c *Client) HasMembershipRelationship(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) { + fromTo, err := c.GetUserPermissions(ctx, granteeUserID, grantorUserID) + if err != nil { + return false, err + } + if len(fromTo) > 0 { + return true, nil + } + toFrom, err := c.GetUserPermissions(ctx, grantorUserID, granteeUserID) + if err != nil { + return false, err + } + if len(toFrom) > 0 { + return true, nil + } + return false, nil +} + +func (c *Client) HasCustodianPermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) { + perms, err := c.GetUserPermissions(ctx, granteeUserID, grantorUserID) + if err != nil { + return false, err + } + return len(perms[permission.Custodian]) > 0, nil +} diff --git a/permission/permission.go b/permission/permission.go index 1d5bac961..2dff14bbc 100644 --- a/permission/permission.go +++ b/permission/permission.go @@ -17,6 +17,9 @@ const ( type Client interface { GetUserPermissions(ctx context.Context, requestUserID string, targetUserID string) (Permissions, error) + // Not sure whether to put these methods in platform/permission or go-common/clients + HasMembershipRelationship(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) + HasCustodianPermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) } func FixOwnerPermissions(permissions Permissions) Permissions { diff --git a/user/keycloak_client.go b/user/keycloak_client.go index 9a74febdf..246491a28 100644 --- a/user/keycloak_client.go +++ b/user/keycloak_client.go @@ -225,6 +225,18 @@ func (c *keycloakClient) UpdateUserProfile(ctx context.Context, id string, p *Us return c.UpdateUser(ctx, user) } +func (c *keycloakClient) DeleteUserProfile(ctx context.Context, id string) error { + user, err := c.GetUserById(ctx, id) + if err != nil { + return err + } + if user == nil { + return ErrUserNotFound + } + user.Attributes.Profile = nil + return c.UpdateUser(ctx, user) +} + func (c *keycloakClient) UpdateUserPassword(ctx context.Context, id, password string) error { token, err := c.getAdminToken(ctx) if err != nil { diff --git a/user/keycloak_user_accessor.go b/user/keycloak_user_accessor.go index 90d660fe1..9877eafd4 100644 --- a/user/keycloak_user_accessor.go +++ b/user/keycloak_user_accessor.go @@ -223,3 +223,7 @@ func (m *keycloakUserAccessor) RemoveTokensForUser(ctx context.Context, userId s func (m *keycloakUserAccessor) UpdateUserProfile(ctx context.Context, userId string, p *UserProfile) error { return m.keycloakClient.UpdateUserProfile(ctx, userId, p) } + +func (m *keycloakUserAccessor) DeleteUserProfile(ctx context.Context, userId string) error { + return m.keycloakClient.DeleteUserProfile(ctx, userId) +} diff --git a/user/profile.go b/user/profile.go index e338a99d8..f95bebf76 100644 --- a/user/profile.go +++ b/user/profile.go @@ -28,7 +28,9 @@ type ClinicProfile struct { func (u *UserProfile) ToAttributes() map[string][]string { attributes := map[string][]string{} - addAttribute(attributes, "profile.fullName", u.FullName) + if u.FullName != "" { + addAttribute(attributes, "profile.fullName", u.FullName) + } if u.Patient != nil { patient := u.Patient addAttribute(attributes, "profile.patient.birthday", patient.Birthday) @@ -72,7 +74,7 @@ func profileFromAttributes(attributes map[string][]string) (profile *UserProfile u.Clinic = clinic } - if u.Clinic == nil && u.Patient == nil { + if u.Clinic == nil && u.Patient == nil && u.FullName == "" { return nil, false } return u, true diff --git a/user/user_accessor.go b/user/user_accessor.go index cc6f4993e..669c49357 100644 --- a/user/user_accessor.go +++ b/user/user_accessor.go @@ -35,6 +35,7 @@ type UserAccessor interface { RemoveUser(ctx context.Context, user *FullUser) error RemoveTokensForUser(ctx context.Context, userId string) error UpdateUserProfile(ctx context.Context, id string, p *UserProfile) error + DeleteUserProfile(ctx context.Context, id string) error } type TokenIntrospectionResult struct { From b1fdbd32754c5114e59ddd6f0ca8ad3e6b5013a7 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Thu, 15 Feb 2024 09:14:44 -0800 Subject: [PATCH 09/62] Validate the profile. --- auth/service/api/v1/profile.go | 5 ++++ user/profile.go | 52 ++++++++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index 7961fb3cf..e2bdb576b 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -8,6 +8,7 @@ import ( "github.com/tidepool-org/platform/request" "github.com/tidepool-org/platform/service/api" + structValidator "github.com/tidepool-org/platform/structure/validator" "github.com/tidepool-org/platform/user" ) @@ -53,6 +54,10 @@ func (r *Router) UpdateProfile(res rest.ResponseWriter, req *rest.Request) { responder.Error(http.StatusBadRequest, err) return } + if err := structValidator.New().Validate(profile); err != nil { + responder.Error(http.StatusBadRequest, err) + return + } err := r.UserAccessor().UpdateUserProfile(ctx, userID, profile) if stdErrs.Is(err, user.ErrUserNotFound) { responder.Empty(http.StatusNotFound) diff --git a/user/profile.go b/user/profile.go index f95bebf76..663588353 100644 --- a/user/profile.go +++ b/user/profile.go @@ -2,8 +2,20 @@ package user import ( "slices" + "time" + + "github.com/tidepool-org/platform/structure" +) + +const ( + maxAboutLength = 256 + maxNameLength = 256 ) +// Date is a string of type YYYY-mm-dd, the reason this isn't just a type definition +// of a time.Time is to ignore timezones when marshaling. +type Date string + type UserProfile struct { FullName string `json:"fullName"` Patient *PatientProfile `json:"patient,omitempty"` @@ -11,8 +23,8 @@ type UserProfile struct { } type PatientProfile struct { - Birthday string `json:"birthday"` - DiagnosisDate string `json:"diagnosisDate"` + Birthday Date `json:"birthday"` + DiagnosisDate Date `json:"diagnosisDate"` DiagnosisType string `json:"diagnosisType"` TargetDevices []string `json:"targetDevices"` TargetTimezone string `json:"targetTimezone"` @@ -33,8 +45,8 @@ func (u *UserProfile) ToAttributes() map[string][]string { } if u.Patient != nil { patient := u.Patient - addAttribute(attributes, "profile.patient.birthday", patient.Birthday) - addAttribute(attributes, "profile.patient.diagnosisDate", patient.DiagnosisDate) + addAttribute(attributes, "profile.patient.birthday", string(patient.Birthday)) + addAttribute(attributes, "profile.patient.diagnosisDate", string(patient.DiagnosisDate)) addAttribute(attributes, "profile.patient.diagnosisType", patient.DiagnosisType) addAttributes(attributes, "profile.patient.targetDevices", patient.TargetDevices...) addAttribute(attributes, "profile.patient.targetTimezone", patient.TargetTimezone) @@ -57,8 +69,8 @@ func profileFromAttributes(attributes map[string][]string) (profile *UserProfile if containsAnyAttributeKeys(attributes, "profile.patient.birthday", "profile.patient.diagnosisDate", "profile.patient.diagnosisType", "profile.patient.targetDevices", "profile.patient.targetTimezone", "profile.patient.about") { patient := &PatientProfile{} - patient.Birthday = getAttribute(attributes, "profile.patient.birthday") - patient.DiagnosisDate = getAttribute(attributes, "profile.patient.diagnosisDate") + patient.Birthday = Date(getAttribute(attributes, "profile.patient.birthday")) + patient.DiagnosisDate = Date(getAttribute(attributes, "profile.patient.diagnosisDate")) patient.DiagnosisType = getAttribute(attributes, "profile.patient.diagnosisType") patient.TargetDevices = getAttributes(attributes, "profile.patient.targetDevices") patient.TargetTimezone = getAttribute(attributes, "profile.patient.targetTimezone") @@ -125,3 +137,31 @@ func containsAnyAttributeKeys(attributes map[string][]string, keys ...string) bo } return false } + +func (d Date) Validate(v structure.Validator) { + if d == "" { + return + } + str := string(d) + v.String("date", &str).AsTime(time.DateOnly) +} + +func (p *PatientProfile) Validate(v structure.Validator) { + p.Birthday.Validate(v.WithReference("birthday")) + p.DiagnosisDate.Validate(v.WithReference("diagnosisDate")) + v.String("about", &p.About).LengthLessThanOrEqualTo(maxAboutLength) +} + +func (p *UserProfile) Validate(v structure.Validator) { + if p.Patient != nil { + p.Patient.Validate(v.WithReference("patient")) + } + if p.Clinic != nil { + p.Clinic.Validate(v.WithReference("clinic")) + } + v.String("fullName", &p.FullName).LengthLessThanOrEqualTo(maxNameLength) +} + +func (p *ClinicProfile) Validate(v structure.Validator) { + v.String("name", &p.Name).NotEmpty().LengthLessThanOrEqualTo(maxNameLength) +} From 73cb0265cf131b6ecd6fe88bb355138f8b1816b4 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 20 Feb 2024 18:07:45 -0800 Subject: [PATCH 10/62] HasWritePermissions. --- auth/service/api/v1/permission.go | 32 +++++++++++++++++++++++++++++++ auth/service/api/v1/profile.go | 5 +++-- permission/client/client.go | 12 ++++++++++++ permission/permission.go | 1 + 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/auth/service/api/v1/permission.go b/auth/service/api/v1/permission.go index 394b017ee..e393aefee 100644 --- a/auth/service/api/v1/permission.go +++ b/auth/service/api/v1/permission.go @@ -33,3 +33,35 @@ func (r *Router) requireUserHasCustodian(targetParamUserID string, handlerFunc r } return api.RequireUser(fn) } + +// requireWriteAccess aborts with an error if the request isn't a server request +// or the authenticated user doesn't have access to the user id in the url param, +// targetParamUserID +func (r *Router) requireWriteAccess(targetParamUserID string, handlerFunc rest.HandlerFunc) rest.HandlerFunc { + return func(res rest.ResponseWriter, req *rest.Request) { + if handlerFunc != nil && res != nil && req != nil { + targetUserID := req.PathParam(targetParamUserID) + responder := request.MustNewResponder(res, req) + ctx := req.Context() + details := request.GetAuthDetails(ctx) + if details == nil { + responder.Empty(http.StatusUnauthorized) + return + } + if details.IsService() { + handlerFunc(res, req) + return + } + hasPerms, err := r.PermissionsClient().HasWritePermissions(ctx, details.UserID(), targetUserID) + if err != nil { + responder.InternalServerError(err) + return + } + if !hasPerms { + responder.Empty(http.StatusForbidden) + return + } + handlerFunc(res, req) + } + } +} diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index e2bdb576b..46df152c5 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -15,8 +15,9 @@ import ( func (r *Router) ProfileRoutes() []*rest.Route { return []*rest.Route{ rest.Get("/v1/profiles/:userId", api.RequireUser(r.GetProfile)), - rest.Put("/v1/profiles/:userId", r.requireUserHasCustodian("userId", r.UpdateProfile)), - rest.Delete("/v1/profiles/:userId", r.requireUserHasCustodian("userId", r.DeleteProfile)), + // The following modification routes required custodian access in seagull, but I'm not sure that's quite right - it seems it should be if the user can modify the userId. + rest.Put("/v1/profiles/:userId", r.requireWriteAccess("userId", r.UpdateProfile)), + rest.Delete("/v1/profiles/:userId", r.requireWriteAccess("userId", r.DeleteProfile)), } } diff --git a/permission/client/client.go b/permission/client/client.go index a24c4257c..86cba3968 100644 --- a/permission/client/client.go +++ b/permission/client/client.go @@ -72,3 +72,15 @@ func (c *Client) HasCustodianPermissions(ctx context.Context, granteeUserID, gra } return len(perms[permission.Custodian]) > 0, nil } + +func (c *Client) HasWritePermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) { + if granteeUserID != "" && granteeUserID == grantorUserID { + return true, nil + } + perms, err := c.GetUserPermissions(ctx, granteeUserID, grantorUserID) + if err != nil { + return false, err + } + return len(perms[permission.Custodian]) > 0 || len(perms[permission.Write]) > 0 || len(perms[permission.Owner]) > 0, nil + +} diff --git a/permission/permission.go b/permission/permission.go index 2dff14bbc..ca7ede42f 100644 --- a/permission/permission.go +++ b/permission/permission.go @@ -20,6 +20,7 @@ type Client interface { // Not sure whether to put these methods in platform/permission or go-common/clients HasMembershipRelationship(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) HasCustodianPermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) + HasWritePermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) } func FixOwnerPermissions(permissions Permissions) Permissions { From 7d3288137c38d63fd806f80e862b6ee75b3fd235 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Sun, 17 Mar 2024 16:27:53 -0700 Subject: [PATCH 11/62] Rename profile routes to be consistent w/ existing ones. --- auth/service/api/v1/profile.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index 46df152c5..b91efeb9d 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -14,10 +14,10 @@ import ( func (r *Router) ProfileRoutes() []*rest.Route { return []*rest.Route{ - rest.Get("/v1/profiles/:userId", api.RequireUser(r.GetProfile)), + rest.Get("/v1/users/:userId/profile", api.RequireUser(r.GetProfile)), // The following modification routes required custodian access in seagull, but I'm not sure that's quite right - it seems it should be if the user can modify the userId. - rest.Put("/v1/profiles/:userId", r.requireWriteAccess("userId", r.UpdateProfile)), - rest.Delete("/v1/profiles/:userId", r.requireWriteAccess("userId", r.DeleteProfile)), + rest.Put("/v1/users/:userId/profile", r.requireWriteAccess("userId", r.UpdateProfile)), + rest.Delete("/v1/users/:userId/profile", r.requireWriteAccess("userId", r.DeleteProfile)), } } From 5d56462b10e164be69463fdde714e96dfe595df3 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 27 Mar 2024 15:39:12 -0700 Subject: [PATCH 12/62] Use snakecase attributes for now but don't flatten yet as blip is still sending as `{"patient":{"about": "..."}}`. --- user/profile.go | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/user/profile.go b/user/profile.go index 663588353..54caa16b9 100644 --- a/user/profile.go +++ b/user/profile.go @@ -41,23 +41,23 @@ func (u *UserProfile) ToAttributes() map[string][]string { attributes := map[string][]string{} if u.FullName != "" { - addAttribute(attributes, "profile.fullName", u.FullName) + addAttribute(attributes, "profile_full_name", u.FullName) } if u.Patient != nil { patient := u.Patient - addAttribute(attributes, "profile.patient.birthday", string(patient.Birthday)) - addAttribute(attributes, "profile.patient.diagnosisDate", string(patient.DiagnosisDate)) - addAttribute(attributes, "profile.patient.diagnosisType", patient.DiagnosisType) - addAttributes(attributes, "profile.patient.targetDevices", patient.TargetDevices...) - addAttribute(attributes, "profile.patient.targetTimezone", patient.TargetTimezone) - addAttribute(attributes, "profile.patient.about", patient.About) + addAttribute(attributes, "profile_patient_birthday", string(patient.Birthday)) + addAttribute(attributes, "profile_patient_diagnosis_date", string(patient.DiagnosisDate)) + addAttribute(attributes, "profile_patient_diagnosis_type", patient.DiagnosisType) + addAttributes(attributes, "profile_patient_target_devices", patient.TargetDevices...) + addAttribute(attributes, "profile_patient_target_timezone", patient.TargetTimezone) + addAttribute(attributes, "profile_patient_about", patient.About) } if u.Clinic != nil { clinic := u.Clinic - addAttribute(attributes, "profile.clinic.name", clinic.Name) - addAttributes(attributes, "profile.clinic.role", clinic.Role...) - addAttribute(attributes, "profile.clinic.telephone", clinic.Telephone) + addAttribute(attributes, "profile_clinic_name", clinic.Name) + addAttributes(attributes, "profile_clinic_role", clinic.Role...) + addAttribute(attributes, "profile_clinic_telephone", clinic.Telephone) } return attributes @@ -65,24 +65,24 @@ func (u *UserProfile) ToAttributes() map[string][]string { func profileFromAttributes(attributes map[string][]string) (profile *UserProfile, ok bool) { u := &UserProfile{} - u.FullName = getAttribute(attributes, "profile.fullName") + u.FullName = getAttribute(attributes, "profile_full_name") - if containsAnyAttributeKeys(attributes, "profile.patient.birthday", "profile.patient.diagnosisDate", "profile.patient.diagnosisType", "profile.patient.targetDevices", "profile.patient.targetTimezone", "profile.patient.about") { + if containsAnyAttributeKeys(attributes, "profile_patient_birthday", "profile_patient_diagnosis_date", "profile_patient_diagnosis_type", "profile_patient_target_devices", "profile_patient_target_timezone", "profile_patient_about") { patient := &PatientProfile{} - patient.Birthday = Date(getAttribute(attributes, "profile.patient.birthday")) - patient.DiagnosisDate = Date(getAttribute(attributes, "profile.patient.diagnosisDate")) - patient.DiagnosisType = getAttribute(attributes, "profile.patient.diagnosisType") - patient.TargetDevices = getAttributes(attributes, "profile.patient.targetDevices") - patient.TargetTimezone = getAttribute(attributes, "profile.patient.targetTimezone") - patient.About = getAttribute(attributes, "profile.patient.about") + patient.Birthday = Date(getAttribute(attributes, "profile_patient_birthday")) + patient.DiagnosisDate = Date(getAttribute(attributes, "profile_patient_diagnosis_date")) + patient.DiagnosisType = getAttribute(attributes, "profile_patient_diagnosis_type") + patient.TargetDevices = getAttributes(attributes, "profile_patient_target_devices") + patient.TargetTimezone = getAttribute(attributes, "profile_patient_target_timezone") + patient.About = getAttribute(attributes, "profile_patient_about") u.Patient = patient } - if containsAnyAttributeKeys(attributes, "profile.clinic.name", "profile.clinic.role", "profile.clinic.telephone") { + if containsAnyAttributeKeys(attributes, "profile_clinic_name", "profile_clinic_role", "profile_clinic_telephone") { clinic := &ClinicProfile{} - clinic.Name = getAttribute(attributes, "profile.clinic.name") - clinic.Role = getAttributes(attributes, "profile.clinic.role") - clinic.Telephone = getAttribute(attributes, "profile.clinic.telephone") + clinic.Name = getAttribute(attributes, "profile_clinic_name") + clinic.Role = getAttributes(attributes, "profile_clinic_role") + clinic.Telephone = getAttribute(attributes, "profile_clinic_telephone") u.Clinic = clinic } From 82db89b86d74f18087349062027b98258a830d45 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 1 Apr 2024 07:40:23 -0700 Subject: [PATCH 13/62] Have a LegacyUserProfile to support seagull requests. --- auth/service/api/v1/profile.go | 52 +++++++++++- user/profile.go | 140 ++++++++++++++++++++------------- 2 files changed, 137 insertions(+), 55 deletions(-) diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index b91efeb9d..46727df71 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -15,8 +15,10 @@ import ( func (r *Router) ProfileRoutes() []*rest.Route { return []*rest.Route{ rest.Get("/v1/users/:userId/profile", api.RequireUser(r.GetProfile)), + rest.Get("/v1/users/:userId/legacy_profile", api.RequireUser(r.GetLegacyProfile)), // The following modification routes required custodian access in seagull, but I'm not sure that's quite right - it seems it should be if the user can modify the userId. rest.Put("/v1/users/:userId/profile", r.requireWriteAccess("userId", r.UpdateProfile)), + rest.Put("/v1/users/:userId/legacy_profile", r.requireWriteAccess("userId", r.UpdateLegacyProfile)), rest.Delete("/v1/users/:userId/profile", r.requireWriteAccess("userId", r.DeleteProfile)), } } @@ -41,20 +43,68 @@ func (r *Router) GetProfile(res rest.ResponseWriter, req *rest.Request) { responder.Error(http.StatusBadRequest, err) return } + if user == nil || user.Profile == nil { + responder.Empty(http.StatusNotFound) + return + } responder.Data(http.StatusOK, user.Profile) } -func (r *Router) UpdateProfile(res rest.ResponseWriter, req *rest.Request) { +func (r *Router) GetLegacyProfile(res rest.ResponseWriter, req *rest.Request) { responder := request.MustNewResponder(res, req) ctx := req.Context() + details := request.GetAuthDetails(ctx) userID := req.PathParam("userId") + hasPerms, err := r.PermissionsClient().HasMembershipRelationship(ctx, details.UserID(), userID) + if err != nil { + responder.InternalServerError(err) + return + } + if !hasPerms { + responder.Empty(http.StatusForbidden) + return + } + + user, err := r.UserAccessor().FindUserById(ctx, userID) + if err != nil { + responder.Error(http.StatusBadRequest, err) + return + } + if user == nil || user.Profile == nil { + responder.Empty(http.StatusNotFound) + return + } + + responder.Data(http.StatusOK, user.Profile.ToLegacyProfile()) +} + +func (r *Router) UpdateProfile(res rest.ResponseWriter, req *rest.Request) { + responder := request.MustNewResponder(res, req) profile := &user.UserProfile{} if err := request.DecodeRequestBody(req.Request, profile); err != nil { responder.Error(http.StatusBadRequest, err) return } + r.updateProfile(res, req, profile) +} + +func (r *Router) UpdateLegacyProfile(res rest.ResponseWriter, req *rest.Request) { + responder := request.MustNewResponder(res, req) + + profile := &user.LegacyUserProfile{} + if err := request.DecodeRequestBody(req.Request, profile); err != nil { + responder.Error(http.StatusBadRequest, err) + return + } + r.updateProfile(res, req, profile.ToUserProfile()) +} + +func (r *Router) updateProfile(res rest.ResponseWriter, req *rest.Request, profile *user.UserProfile) { + responder := request.MustNewResponder(res, req) + ctx := req.Context() + userID := req.PathParam("userId") if err := structValidator.New().Validate(profile); err != nil { responder.Error(http.StatusBadRequest, err) return diff --git a/user/profile.go b/user/profile.go index 54caa16b9..094964e47 100644 --- a/user/profile.go +++ b/user/profile.go @@ -16,7 +16,44 @@ const ( // of a time.Time is to ignore timezones when marshaling. type Date string +// UserProfile represents the user modifiable attributes of a user. It is named +// somewhat redundantly as UserProfile instead of Profile because there already +// exists a type Profile in this package. type UserProfile struct { + FullName string `json:"fullName"` + Birthday Date `json:"birthday"` + DiagnosisDate Date `json:"diagnosisDate"` + DiagnosisType string `json:"diagnosisType"` + TargetDevices []string `json:"targetDevices"` + TargetTimezone string `json:"targetTimezone"` + About string `json:"about"` +} + +func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { + return &LegacyUserProfile{ + FullName: up.FullName, + Patient: &PatientProfile{ + Birthday: up.Birthday, + DiagnosisDate: up.DiagnosisDate, + TargetDevices: up.TargetDevices, + TargetTimezone: up.TargetTimezone, + About: up.About, + }, + } +} + +func (p *LegacyUserProfile) ToUserProfile() *UserProfile { + return &UserProfile{ + FullName: p.FullName, + Birthday: p.Patient.Birthday, + DiagnosisDate: p.Patient.DiagnosisDate, + TargetDevices: p.Patient.TargetDevices, + TargetTimezone: p.Patient.TargetTimezone, + About: p.Patient.About, + } +} + +type LegacyUserProfile struct { FullName string `json:"fullName"` Patient *PatientProfile `json:"patient,omitempty"` Clinic *ClinicProfile `json:"clinic,omitempty"` @@ -37,59 +74,64 @@ type ClinicProfile struct { Telephone string `json:"telephone"` } -func (u *UserProfile) ToAttributes() map[string][]string { +func (up *UserProfile) ToAttributes() map[string][]string { attributes := map[string][]string{} - if u.FullName != "" { - addAttribute(attributes, "profile_full_name", u.FullName) + if up.FullName != "" { + addAttribute(attributes, "profile_full_name", up.FullName) } - if u.Patient != nil { - patient := u.Patient - addAttribute(attributes, "profile_patient_birthday", string(patient.Birthday)) - addAttribute(attributes, "profile_patient_diagnosis_date", string(patient.DiagnosisDate)) - addAttribute(attributes, "profile_patient_diagnosis_type", patient.DiagnosisType) - addAttributes(attributes, "profile_patient_target_devices", patient.TargetDevices...) - addAttribute(attributes, "profile_patient_target_timezone", patient.TargetTimezone) - addAttribute(attributes, "profile_patient_about", patient.About) + if string(up.Birthday) != "" { + addAttribute(attributes, "profile_birthday", string(up.Birthday)) } - - if u.Clinic != nil { - clinic := u.Clinic - addAttribute(attributes, "profile_clinic_name", clinic.Name) - addAttributes(attributes, "profile_clinic_role", clinic.Role...) - addAttribute(attributes, "profile_clinic_telephone", clinic.Telephone) + if string(up.DiagnosisDate) != "" { + addAttribute(attributes, "profile_diagnosis_date", string(up.DiagnosisDate)) + } + if up.DiagnosisType != "" { + addAttribute(attributes, "profile_diagnosis_type", up.DiagnosisType) + } + addAttributes(attributes, "profile_target_devices", up.TargetDevices...) + if up.TargetTimezone != "" { + addAttribute(attributes, "profile_target_timezone", up.TargetTimezone) + } + if up.About != "" { + addAttribute(attributes, "profile_about", up.About) } return attributes } func profileFromAttributes(attributes map[string][]string) (profile *UserProfile, ok bool) { - u := &UserProfile{} - u.FullName = getAttribute(attributes, "profile_full_name") - - if containsAnyAttributeKeys(attributes, "profile_patient_birthday", "profile_patient_diagnosis_date", "profile_patient_diagnosis_type", "profile_patient_target_devices", "profile_patient_target_timezone", "profile_patient_about") { - patient := &PatientProfile{} - patient.Birthday = Date(getAttribute(attributes, "profile_patient_birthday")) - patient.DiagnosisDate = Date(getAttribute(attributes, "profile_patient_diagnosis_date")) - patient.DiagnosisType = getAttribute(attributes, "profile_patient_diagnosis_type") - patient.TargetDevices = getAttributes(attributes, "profile_patient_target_devices") - patient.TargetTimezone = getAttribute(attributes, "profile_patient_target_timezone") - patient.About = getAttribute(attributes, "profile_patient_about") - u.Patient = patient + up := &UserProfile{} + if val := getAttribute(attributes, "profile_full_name"); val != "" { + up.FullName = val + ok = true } - - if containsAnyAttributeKeys(attributes, "profile_clinic_name", "profile_clinic_role", "profile_clinic_telephone") { - clinic := &ClinicProfile{} - clinic.Name = getAttribute(attributes, "profile_clinic_name") - clinic.Role = getAttributes(attributes, "profile_clinic_role") - clinic.Telephone = getAttribute(attributes, "profile_clinic_telephone") - u.Clinic = clinic + if val := getAttribute(attributes, "profile_birthday"); val != "" { + up.Birthday = Date(val) + ok = true } - - if u.Clinic == nil && u.Patient == nil && u.FullName == "" { - return nil, false + if val := getAttribute(attributes, "profile_diagnosis_date"); val != "" { + up.DiagnosisDate = Date(val) + ok = true + } + if val := getAttribute(attributes, "profile_diagnosis_type"); val != "" { + up.DiagnosisType = val + ok = true + } + if vals := getAttributes(attributes, "profile_target_devices"); len(vals) > 0 { + up.TargetDevices = vals + ok = true } - return u, true + if val := getAttribute(attributes, "profile_target_timezone"); val != "" { + up.TargetTimezone = val + ok = true + } + if val := getAttribute(attributes, "profile_about"); val != "" { + up.About = val + ok = true + } + + return up, ok } func addAttribute(attributes map[string][]string, attribute, value string) (ok bool) { @@ -146,20 +188,10 @@ func (d Date) Validate(v structure.Validator) { v.String("date", &str).AsTime(time.DateOnly) } -func (p *PatientProfile) Validate(v structure.Validator) { - p.Birthday.Validate(v.WithReference("birthday")) - p.DiagnosisDate.Validate(v.WithReference("diagnosisDate")) - v.String("about", &p.About).LengthLessThanOrEqualTo(maxAboutLength) -} - -func (p *UserProfile) Validate(v structure.Validator) { - if p.Patient != nil { - p.Patient.Validate(v.WithReference("patient")) - } - if p.Clinic != nil { - p.Clinic.Validate(v.WithReference("clinic")) - } - v.String("fullName", &p.FullName).LengthLessThanOrEqualTo(maxNameLength) +func (up *UserProfile) Validate(v structure.Validator) { + up.Birthday.Validate(v.WithReference("birthday")) + up.DiagnosisDate.Validate(v.WithReference("diagnosisDate")) + v.String("fullName", &up.FullName).LengthLessThanOrEqualTo(maxNameLength) } func (p *ClinicProfile) Validate(v structure.Validator) { From 9e9f798d9520e98e3673f0047caf751057c6b530 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 1 Apr 2024 08:01:12 -0700 Subject: [PATCH 14/62] Change leagcy profile routes for simpler proxying in routetable. --- auth/service/api/v1/profile.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index 46727df71..f1cf10ea2 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -15,10 +15,10 @@ import ( func (r *Router) ProfileRoutes() []*rest.Route { return []*rest.Route{ rest.Get("/v1/users/:userId/profile", api.RequireUser(r.GetProfile)), - rest.Get("/v1/users/:userId/legacy_profile", api.RequireUser(r.GetLegacyProfile)), + rest.Get("/v1/users/legacy/:userId/profile", api.RequireUser(r.GetLegacyProfile)), // The following modification routes required custodian access in seagull, but I'm not sure that's quite right - it seems it should be if the user can modify the userId. rest.Put("/v1/users/:userId/profile", r.requireWriteAccess("userId", r.UpdateProfile)), - rest.Put("/v1/users/:userId/legacy_profile", r.requireWriteAccess("userId", r.UpdateLegacyProfile)), + rest.Put("/v1/users/legacy/:userId/profile", r.requireWriteAccess("userId", r.UpdateLegacyProfile)), rest.Delete("/v1/users/:userId/profile", r.requireWriteAccess("userId", r.DeleteProfile)), } } From 650b693358e1098e2b5b269051711d46344fc2c9 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 1 Apr 2024 08:03:18 -0700 Subject: [PATCH 15/62] Add legacy delete route. --- auth/service/api/v1/profile.go | 1 + 1 file changed, 1 insertion(+) diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index f1cf10ea2..64f847fe1 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -20,6 +20,7 @@ func (r *Router) ProfileRoutes() []*rest.Route { rest.Put("/v1/users/:userId/profile", r.requireWriteAccess("userId", r.UpdateProfile)), rest.Put("/v1/users/legacy/:userId/profile", r.requireWriteAccess("userId", r.UpdateLegacyProfile)), rest.Delete("/v1/users/:userId/profile", r.requireWriteAccess("userId", r.DeleteProfile)), + rest.Delete("/v1/users/legacy/:userId/profile", r.requireWriteAccess("userId", r.DeleteProfile)), } } From 523b23e8f5708511170e7262de6c2144da414518 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 1 Apr 2024 11:54:26 -0700 Subject: [PATCH 16/62] Move keycloak client and keycloak user_accessor to own package. --- auth/service/service/service.go | 5 +++-- user/{keycloak_client.go => keycloak/client.go} | 0 .../{keycloak_user_accessor.go => keycloak/user_accessor.go} | 0 user/profile.go | 2 +- user/user_accessor.go | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) rename user/{keycloak_client.go => keycloak/client.go} (100%) rename user/{keycloak_user_accessor.go => keycloak/user_accessor.go} (100%) diff --git a/auth/service/service/service.go b/auth/service/service/service.go index 036607351..7635e68a7 100644 --- a/auth/service/service/service.go +++ b/auth/service/service/service.go @@ -10,6 +10,7 @@ import ( "github.com/tidepool-org/platform/apple" "github.com/tidepool-org/platform/auth" "github.com/tidepool-org/platform/user" + "github.com/tidepool-org/platform/user/keycloak" eventsCommon "github.com/tidepool-org/go-common/events" @@ -456,11 +457,11 @@ func (s *Service) initializeUserEventsHandler() error { func (s *Service) initializeUserAccessor() error { s.Logger().Debug("Initializing user accessor") - config := &user.KeycloakConfig{} + config := &keycloak.KeycloakConfig{} if err := config.FromEnv(); err != nil { return err } - s.userAccessor = user.NewKeycloakUserAccessor(config) + s.userAccessor = keycloak.NewKeycloakUserAccessor(config) return nil } diff --git a/user/keycloak_client.go b/user/keycloak/client.go similarity index 100% rename from user/keycloak_client.go rename to user/keycloak/client.go diff --git a/user/keycloak_user_accessor.go b/user/keycloak/user_accessor.go similarity index 100% rename from user/keycloak_user_accessor.go rename to user/keycloak/user_accessor.go diff --git a/user/profile.go b/user/profile.go index 094964e47..9f54850be 100644 --- a/user/profile.go +++ b/user/profile.go @@ -100,7 +100,7 @@ func (up *UserProfile) ToAttributes() map[string][]string { return attributes } -func profileFromAttributes(attributes map[string][]string) (profile *UserProfile, ok bool) { +func ProfileFromAttributes(attributes map[string][]string) (profile *UserProfile, ok bool) { up := &UserProfile{} if val := getAttribute(attributes, "profile_full_name"); val != "" { up.FullName = val diff --git a/user/user_accessor.go b/user/user_accessor.go index 669c49357..68ba01360 100644 --- a/user/user_accessor.go +++ b/user/user_accessor.go @@ -14,7 +14,7 @@ const ( ) var ( - shorelineManagedRoles = map[string]struct{}{"patient": {}, "clinic": {}, "clinician": {}, "custodial_account": {}} + ShorelineManagedRoles = map[string]struct{}{"patient": {}, "clinic": {}, "clinician": {}, "custodial_account": {}} ErrUserNotFound = errors.New("user not found") ErrUserConflict = errors.New("user already exists") From d17ab19ed9e4513e56a070ebffd986a91beedd2f Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 1 Apr 2024 11:57:12 -0700 Subject: [PATCH 17/62] Rename to package keycloak. --- user/keycloak/client.go | 43 +++++++++++++++--------------- user/keycloak/user_accessor.go | 48 ++++++++++++++++++---------------- 2 files changed, 47 insertions(+), 44 deletions(-) diff --git a/user/keycloak/client.go b/user/keycloak/client.go index 246491a28..4b80f657b 100644 --- a/user/keycloak/client.go +++ b/user/keycloak/client.go @@ -1,4 +1,4 @@ -package user +package keycloak import ( "context" @@ -15,6 +15,7 @@ import ( "golang.org/x/oauth2" "github.com/tidepool-org/platform/pointer" + userLib "github.com/tidepool-org/platform/user" ) const ( @@ -55,8 +56,8 @@ type keycloakUser struct { } type keycloakUserAttributes struct { - TermsAcceptedDate []string `json:"terms_and_conditions,omitempty"` - Profile *UserProfile `json:"profile"` + TermsAcceptedDate []string `json:"terms_and_conditions,omitempty"` + Profile *userLib.UserProfile `json:"profile"` } type keycloakClient struct { @@ -213,13 +214,13 @@ func (c *keycloakClient) UpdateUser(ctx context.Context, user *keycloakUser) err return nil } -func (c *keycloakClient) UpdateUserProfile(ctx context.Context, id string, p *UserProfile) error { +func (c *keycloakClient) UpdateUserProfile(ctx context.Context, id string, p *userLib.UserProfile) error { user, err := c.GetUserById(ctx, id) if err != nil { return err } if user == nil { - return ErrUserNotFound + return userLib.ErrUserNotFound } user.Attributes.Profile = p return c.UpdateUser(ctx, user) @@ -231,7 +232,7 @@ func (c *keycloakClient) DeleteUserProfile(ctx context.Context, id string) error return err } if user == nil { - return ErrUserNotFound + return userLib.ErrUserNotFound } user.Attributes.Profile = nil return c.UpdateUser(ctx, user) @@ -277,7 +278,7 @@ func (c *keycloakClient) CreateUser(ctx context.Context, user *keycloakUser) (*k user.ID, err = c.keycloak.CreateUser(ctx, token.AccessToken, c.cfg.Realm, model) if err != nil { if e, ok := err.(*gocloak.APIError); ok && e.Code == http.StatusConflict { - err = ErrUserConflict + err = userLib.ErrUserConflict } return nil, err } @@ -320,7 +321,7 @@ func (c *keycloakClient) FindUsersWithIds(ctx context.Context, ids []string) (us return } -func (c *keycloakClient) IntrospectToken(ctx context.Context, token oauth2.Token) (*TokenIntrospectionResult, error) { +func (c *keycloakClient) IntrospectToken(ctx context.Context, token oauth2.Token) (*userLib.TokenIntrospectionResult, error) { clientId, clientSecret := c.getClientAndSecretFromToken(ctx, token) rtr, err := c.keycloak.RetrospectToken( @@ -334,11 +335,11 @@ func (c *keycloakClient) IntrospectToken(ctx context.Context, token oauth2.Token return nil, err } - result := &TokenIntrospectionResult{ + result := &userLib.TokenIntrospectionResult{ Active: pointer.ToBool(rtr.Active), } if result.Active { - customClaims := &AccessTokenCustomClaims{} + customClaims := &userLib.AccessTokenCustomClaims{} _, err := c.keycloak.DecodeAccessTokenCustomClaims( ctx, token.AccessToken, @@ -351,7 +352,7 @@ func (c *keycloakClient) IntrospectToken(ctx context.Context, token oauth2.Token result.Subject = customClaims.Subject result.EmailVerified = customClaims.EmailVerified result.ExpiresAt = customClaims.ExpiresAt - result.RealmAccess = RealmAccess{ + result.RealmAccess = userLib.RealmAccess{ Roles: customClaims.RealmAccess.Roles, } result.IdentityProvider = customClaims.IdentityProvider @@ -465,7 +466,7 @@ func (c *keycloakClient) updateRolesForUser(ctx context.Context, user *keycloakU if _, ok := targetRoles[*currentRole.Name]; !ok { // Only remove roles managed by shoreline - if _, ok := shorelineManagedRoles[*currentRole.Name]; ok { + if _, ok := userLib.ShorelineManagedRoles[*currentRole.Name]; ok { rolesToDelete = append(rolesToDelete, *currentRole) } } @@ -525,7 +526,7 @@ func newKeycloakUser(gocloakUser *gocloak.User) *keycloakUser { if ts, ok := attrs[termsAcceptedAttribute]; ok { user.Attributes.TermsAcceptedDate = ts } - if prof, ok := profileFromAttributes(attrs); ok { + if prof, ok := userLib.ProfileFromAttributes(attrs); ok { user.Attributes.Profile = prof } } @@ -537,16 +538,16 @@ func newKeycloakUser(gocloakUser *gocloak.User) *keycloakUser { return user } -func newUserFromKeycloakUser(keycloakUser *keycloakUser) *FullUser { +func newUserFromKeycloakUser(keycloakUser *keycloakUser) *userLib.FullUser { termsAcceptedDate := "" attrs := keycloakUser.Attributes if len(attrs.TermsAcceptedDate) > 0 { - if ts, err := UnixStringToTimestamp(attrs.TermsAcceptedDate[0]); err == nil { + if ts, err := userLib.UnixStringToTimestamp(attrs.TermsAcceptedDate[0]); err == nil { termsAcceptedDate = ts } } - user := &FullUser{ + user := &userLib.FullUser{ Id: keycloakUser.ID, Username: keycloakUser.Username, Emails: []string{keycloakUser.Email}, @@ -569,7 +570,7 @@ func newUserFromKeycloakUser(keycloakUser *keycloakUser) *FullUser { return user } -func userToKeycloakUser(u *FullUser) *keycloakUser { +func userToKeycloakUser(u *userLib.FullUser) *keycloakUser { keycloakUser := &keycloakUser{ ID: u.Id, Username: strings.ToLower(u.Username), @@ -580,12 +581,12 @@ func userToKeycloakUser(u *FullUser) *keycloakUser { Attributes: keycloakUserAttributes{}, } if len(keycloakUser.Roles) == 0 { - keycloakUser.Roles = []string{RolePatient} + keycloakUser.Roles = []string{userLib.RolePatient} } - if !u.IsMigrated && u.PwHash == "" && !u.HasRole(RoleCustodialAccount) { - keycloakUser.Roles = append(keycloakUser.Roles, RoleCustodialAccount) + if !u.IsMigrated && u.PwHash == "" && !u.HasRole(userLib.RoleCustodialAccount) { + keycloakUser.Roles = append(keycloakUser.Roles, userLib.RoleCustodialAccount) } - if termsAccepted, err := TimestampToUnixString(u.TermsAccepted); err == nil { + if termsAccepted, err := userLib.TimestampToUnixString(u.TermsAccepted); err == nil { keycloakUser.Attributes.TermsAcceptedDate = []string{termsAccepted} } if u.Profile != nil { diff --git a/user/keycloak/user_accessor.go b/user/keycloak/user_accessor.go index 9877eafd4..7954b53fd 100644 --- a/user/keycloak/user_accessor.go +++ b/user/keycloak/user_accessor.go @@ -1,4 +1,4 @@ -package user +package keycloak import ( "context" @@ -7,6 +7,8 @@ import ( "time" "golang.org/x/oauth2" + + userLib "github.com/tidepool-org/platform/user" ) type keycloakUserAccessor struct { @@ -24,7 +26,7 @@ func NewKeycloakUserAccessor(config *KeycloakConfig) *keycloakUserAccessor { } } -func (m *keycloakUserAccessor) CreateUser(ctx context.Context, details *NewUserDetails) (*FullUser, error) { +func (m *keycloakUserAccessor) CreateUser(ctx context.Context, details *userLib.NewUserDetails) (*userLib.FullUser, error) { keycloakUser := &keycloakUser{ Enabled: details.Password != nil && *details.Password != "", EmailVerified: details.EmailVerified, @@ -41,7 +43,7 @@ func (m *keycloakUserAccessor) CreateUser(ctx context.Context, details *NewUserD // Users without roles should be treated as patients to prevent keycloak from displaying // the role selection dialog if len(details.Roles) == 0 { - details.Roles = []string{RolePatient} + details.Roles = []string{userLib.RolePatient} } if details.Username != nil { keycloakUser.Username = *details.Username @@ -52,8 +54,8 @@ func (m *keycloakUserAccessor) CreateUser(ctx context.Context, details *NewUserD keycloakUser.Roles = details.Roles keycloakUser, err := m.keycloakClient.CreateUser(ctx, keycloakUser) - if errors.Is(err, ErrUserConflict) { - return nil, ErrUserConflict + if errors.Is(err, userLib.ErrUserConflict) { + return nil, userLib.ErrUserConflict } if err != nil { return nil, err @@ -71,7 +73,7 @@ func (m *keycloakUserAccessor) CreateUser(ctx context.Context, details *NewUserD return user, nil } -func (m *keycloakUserAccessor) UpdateUser(ctx context.Context, user *FullUser, details *UpdateUserDetails) (*FullUser, error) { +func (m *keycloakUserAccessor) UpdateUser(ctx context.Context, user *userLib.FullUser, details *userLib.UpdateUserDetails) (*userLib.FullUser, error) { emails := append([]string{}, details.Emails...) if details.Username != nil { emails = append(emails, *details.Username) @@ -84,7 +86,7 @@ func (m *keycloakUserAccessor) UpdateUser(ctx context.Context, user *FullUser, d return m.updateKeycloakUser(ctx, user, details) } // expected all users to be migrated(?) - return nil, ErrUserNotMigrated + return nil, userLib.ErrUserNotMigrated } func (m *keycloakUserAccessor) assertEmailsUnique(ctx context.Context, userId string, emails []string) error { @@ -109,13 +111,13 @@ func (m *keycloakUserAccessor) assertEmailsUnique(ctx context.Context, userId st return err } if user != nil && user.ID != userId { - return ErrEmailConflict + return userLib.ErrEmailConflict } } return nil } -func (m *keycloakUserAccessor) updateKeycloakUser(ctx context.Context, user *FullUser, details *UpdateUserDetails) (*FullUser, error) { +func (m *keycloakUserAccessor) updateKeycloakUser(ctx context.Context, user *userLib.FullUser, details *userLib.UpdateUserDetails) (*userLib.FullUser, error) { keycloakUser := userToKeycloakUser(user) if details.Roles != nil { keycloakUser.Roles = details.Roles @@ -127,7 +129,7 @@ func (m *keycloakUserAccessor) updateKeycloakUser(ctx context.Context, user *Ful // Remove the custodial role after the password has been set newRoles := make([]string, 0) for _, role := range keycloakUser.Roles { - if role != RoleCustodialAccount { + if role != userLib.RoleCustodialAccount { newRoles = append(newRoles, role) } } @@ -143,8 +145,8 @@ func (m *keycloakUserAccessor) updateKeycloakUser(ctx context.Context, user *Ful if details.EmailVerified != nil { keycloakUser.EmailVerified = *details.EmailVerified } - if details.TermsAccepted != nil && IsValidTimestamp(*details.TermsAccepted) { - if ts, err := TimestampToUnixString(*details.TermsAccepted); err == nil { + if details.TermsAccepted != nil && userLib.IsValidTimestamp(*details.TermsAccepted) { + if ts, err := userLib.TimestampToUnixString(*details.TermsAccepted); err == nil { keycloakUser.Attributes.TermsAcceptedDate = []string{ts} } } @@ -162,11 +164,11 @@ func (m *keycloakUserAccessor) updateKeycloakUser(ctx context.Context, user *Ful return newUserFromKeycloakUser(updated), nil } -func (m *keycloakUserAccessor) FindUser(ctx context.Context, user *FullUser) (*FullUser, error) { +func (m *keycloakUserAccessor) FindUser(ctx context.Context, user *userLib.FullUser) (*userLib.FullUser, error) { var keycloakUser *keycloakUser var err error - if IsValidUserID(user.Id) { + if userLib.IsValidUserID(user.Id) { keycloakUser, err = m.keycloakClient.GetUserById(ctx, user.Id) } else { email := "" @@ -176,18 +178,18 @@ func (m *keycloakUserAccessor) FindUser(ctx context.Context, user *FullUser) (*F keycloakUser, err = m.keycloakClient.GetUserByEmail(ctx, email) } - if err != nil && err != ErrUserNotFound { + if err != nil && err != userLib.ErrUserNotFound { return nil, err } else if err == nil && keycloakUser != nil { return newUserFromKeycloakUser(keycloakUser), nil } // expected all users to already be migrated(?) - return nil, ErrUserNotMigrated + return nil, userLib.ErrUserNotMigrated } -func (m *keycloakUserAccessor) FindUserById(ctx context.Context, id string) (*FullUser, error) { - if !IsValidUserID(id) { - return nil, ErrUserNotFound +func (m *keycloakUserAccessor) FindUserById(ctx context.Context, id string) (*userLib.FullUser, error) { + if !userLib.IsValidUserID(id) { + return nil, userLib.ErrUserNotFound } keycloakUser, err := m.keycloakClient.GetUserById(ctx, id) @@ -195,12 +197,12 @@ func (m *keycloakUserAccessor) FindUserById(ctx context.Context, id string) (*Fu return nil, err } if keycloakUser == nil { - return nil, ErrUserNotFound + return nil, userLib.ErrUserNotFound } return newUserFromKeycloakUser(keycloakUser), nil } -func (m *keycloakUserAccessor) FindUsersWithIds(ctx context.Context, ids []string) (users []*FullUser, err error) { +func (m *keycloakUserAccessor) FindUsersWithIds(ctx context.Context, ids []string) (users []*userLib.FullUser, err error) { keycloakUsers, err := m.keycloakClient.FindUsersWithIds(ctx, ids) if err != nil { return users, err @@ -212,7 +214,7 @@ func (m *keycloakUserAccessor) FindUsersWithIds(ctx context.Context, ids []strin return users, nil } -func (m *keycloakUserAccessor) RemoveUser(ctx context.Context, user *FullUser) error { +func (m *keycloakUserAccessor) RemoveUser(ctx context.Context, user *userLib.FullUser) error { return m.keycloakClient.DeleteUser(ctx, user.Id) } @@ -220,7 +222,7 @@ func (m *keycloakUserAccessor) RemoveTokensForUser(ctx context.Context, userId s return m.keycloakClient.DeleteUserSessions(ctx, userId) } -func (m *keycloakUserAccessor) UpdateUserProfile(ctx context.Context, userId string, p *UserProfile) error { +func (m *keycloakUserAccessor) UpdateUserProfile(ctx context.Context, userId string, p *userLib.UserProfile) error { return m.keycloakClient.UpdateUserProfile(ctx, userId, p) } From e468cbb16e7f4f41bfaad86cfc2fd8f2ffc60b7d Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 1 Apr 2024 13:27:03 -0700 Subject: [PATCH 18/62] Add user profile config for keycloak 24+. --- user/keycloak/user_profile_config.go | 93 ++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 user/keycloak/user_profile_config.go diff --git a/user/keycloak/user_profile_config.go b/user/keycloak/user_profile_config.go new file mode 100644 index 000000000..9108be579 --- /dev/null +++ b/user/keycloak/user_profile_config.go @@ -0,0 +1,93 @@ +package keycloak + +import ( + "context" + "strings" + + "github.com/Nerzal/gocloak/v13" +) + +type LengthValidator struct { + Max int `json:"max"` +} + +type DoubleValidator struct { + Min *string `json:"min,omitempty"` + Max *string `json:"max,omitempty"` +} + +type EmailValidator struct { + MaxLocalLength string `json:"max-local-length"` +} + +type IntegerValidator struct { + Min *string `json:"min,omitempty"` + Max *string `json:"max,omitempty"` +} + +// PatternValidator is a regex pattern validator +type PatternValidator struct { + Pattern string `json:"pattern"` + ErrorMessage string `json:"error-message"` +} + +type OptionsValidator struct { + Options []string `json:"options"` +} + +type UPAttributeValidations struct { + Double *DoubleValidator `json:"double,omitempty"` + Length *LengthValidator `json:"length,omitempty"` + Integer *IntegerValidator `json:"integer,omitempty"` + Options *OptionsValidator `json:"options,omitempty"` + Pattern *PatternValidator `json:"pattern,omitempty"` + Email *EmailValidator `json:"email,omitempty"` +} + +type UPAttributePermissions struct { + Edit *[]string `json:"edit,omitempty"` + View *[]string `json:"view,omitempty"` +} + +// UPAttribute is a single attribute definition for a User Profile. +type UPAttribute struct { + Name *string `json:"name,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + Required *bool `json:"required,omitempty"` + MultiValued *bool `json:"multivalued,omitempty"` + Validations *UPAttributeValidations `json:"validations,omitempty"` + Permissions *UPAttributePermissions `json:"permissions,omitempty"` + Group *string `json:"group,omitempty"` + Annotations *map[string]string `json:"annotations,omitempty"` // might not be omitted? looks empty object if not set +} + +// UPConfig represents the Keycloak "schema" for the User Profile that is +// shared across an entire Keycloak realm - see +// https://www.keycloak.org/docs-api/24.0.1/rest-api/index.html#UPConfig +type UPConfig struct { + Attributes []UPAttribute `json:"attributes"` +} + +func (c *keycloakClient) getAdminRealmURL(realm string, path ...string) string { + path = append([]string{c.cfg.BaseUrl, "admin", "realms", realm}, path...) + return strings.Join(path, "/") +} + +func (c *keycloakClient) SetUserProfileConfig(ctx context.Context, config *UPConfig) error { + token, err := c.getAdminToken(ctx) + if err != nil { + return err + } + + var res map[string]any + var errorResponse gocloak.HTTPErrorResponse + response, err := c.keycloak.RestyClient().R(). + SetContext(ctx). + SetError(&errorResponse). + SetAuthToken(token.AccessToken). + SetBody(config). + SetResult(&res). + Put(c.getAdminRealmURL(c.cfg.Realm, "users", "profile")) + + return checkForError(response, err, "unable to set User Profile Config") +} From 36570c46270e6254366816aa37ff9e1f27fa66c4 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 2 Apr 2024 08:27:17 -0700 Subject: [PATCH 19/62] Remove user profile config as that's handled in TF. --- user/keycloak/user_profile_config.go | 93 ---------------------------- 1 file changed, 93 deletions(-) delete mode 100644 user/keycloak/user_profile_config.go diff --git a/user/keycloak/user_profile_config.go b/user/keycloak/user_profile_config.go deleted file mode 100644 index 9108be579..000000000 --- a/user/keycloak/user_profile_config.go +++ /dev/null @@ -1,93 +0,0 @@ -package keycloak - -import ( - "context" - "strings" - - "github.com/Nerzal/gocloak/v13" -) - -type LengthValidator struct { - Max int `json:"max"` -} - -type DoubleValidator struct { - Min *string `json:"min,omitempty"` - Max *string `json:"max,omitempty"` -} - -type EmailValidator struct { - MaxLocalLength string `json:"max-local-length"` -} - -type IntegerValidator struct { - Min *string `json:"min,omitempty"` - Max *string `json:"max,omitempty"` -} - -// PatternValidator is a regex pattern validator -type PatternValidator struct { - Pattern string `json:"pattern"` - ErrorMessage string `json:"error-message"` -} - -type OptionsValidator struct { - Options []string `json:"options"` -} - -type UPAttributeValidations struct { - Double *DoubleValidator `json:"double,omitempty"` - Length *LengthValidator `json:"length,omitempty"` - Integer *IntegerValidator `json:"integer,omitempty"` - Options *OptionsValidator `json:"options,omitempty"` - Pattern *PatternValidator `json:"pattern,omitempty"` - Email *EmailValidator `json:"email,omitempty"` -} - -type UPAttributePermissions struct { - Edit *[]string `json:"edit,omitempty"` - View *[]string `json:"view,omitempty"` -} - -// UPAttribute is a single attribute definition for a User Profile. -type UPAttribute struct { - Name *string `json:"name,omitempty"` - DisplayName *string `json:"displayName,omitempty"` - Required *bool `json:"required,omitempty"` - MultiValued *bool `json:"multivalued,omitempty"` - Validations *UPAttributeValidations `json:"validations,omitempty"` - Permissions *UPAttributePermissions `json:"permissions,omitempty"` - Group *string `json:"group,omitempty"` - Annotations *map[string]string `json:"annotations,omitempty"` // might not be omitted? looks empty object if not set -} - -// UPConfig represents the Keycloak "schema" for the User Profile that is -// shared across an entire Keycloak realm - see -// https://www.keycloak.org/docs-api/24.0.1/rest-api/index.html#UPConfig -type UPConfig struct { - Attributes []UPAttribute `json:"attributes"` -} - -func (c *keycloakClient) getAdminRealmURL(realm string, path ...string) string { - path = append([]string{c.cfg.BaseUrl, "admin", "realms", realm}, path...) - return strings.Join(path, "/") -} - -func (c *keycloakClient) SetUserProfileConfig(ctx context.Context, config *UPConfig) error { - token, err := c.getAdminToken(ctx) - if err != nil { - return err - } - - var res map[string]any - var errorResponse gocloak.HTTPErrorResponse - response, err := c.keycloak.RestyClient().R(). - SetContext(ctx). - SetError(&errorResponse). - SetAuthToken(token.AccessToken). - SetBody(config). - SetResult(&res). - Put(c.getAdminRealmURL(c.cfg.Realm, "users", "profile")) - - return checkForError(response, err, "unable to set User Profile Config") -} From deb91a70f3ce4ac73f32b8a1245fdd5de7df7d68 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 2 Apr 2024 08:52:59 -0700 Subject: [PATCH 20/62] Add custodian field to profile. --- user/profile.go | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/user/profile.go b/user/profile.go index 9f54850be..4d9ff5848 100644 --- a/user/profile.go +++ b/user/profile.go @@ -20,13 +20,18 @@ type Date string // somewhat redundantly as UserProfile instead of Profile because there already // exists a type Profile in this package. type UserProfile struct { - FullName string `json:"fullName"` - Birthday Date `json:"birthday"` - DiagnosisDate Date `json:"diagnosisDate"` - DiagnosisType string `json:"diagnosisType"` - TargetDevices []string `json:"targetDevices"` - TargetTimezone string `json:"targetTimezone"` - About string `json:"about"` + FullName string `json:"fullName"` + Birthday Date `json:"birthday"` + DiagnosisDate Date `json:"diagnosisDate"` + DiagnosisType string `json:"diagnosisType"` + TargetDevices []string `json:"targetDevices"` + TargetTimezone string `json:"targetTimezone"` + About string `json:"about"` + Custodian *Custodian `json:"custodian,omitempty"` +} + +type Custodian struct { + FullName string `json:"fullName"` } func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { @@ -80,6 +85,9 @@ func (up *UserProfile) ToAttributes() map[string][]string { if up.FullName != "" { addAttribute(attributes, "profile_full_name", up.FullName) } + if up.Custodian != nil && up.Custodian.FullName != "" { + addAttribute(attributes, "profile_custodian_full_name", up.Custodian.FullName) + } if string(up.Birthday) != "" { addAttribute(attributes, "profile_birthday", string(up.Birthday)) } @@ -106,6 +114,12 @@ func ProfileFromAttributes(attributes map[string][]string) (profile *UserProfile up.FullName = val ok = true } + if val := getAttribute(attributes, "profile_custodian_full_name"); val != "" { + up.Custodian = &Custodian{ + FullName: val, + } + ok = true + } if val := getAttribute(attributes, "profile_birthday"); val != "" { up.Birthday = Date(val) ok = true From 4f40f4bf8f0f757b197b0cc023dcd0e677e9ee1b Mon Sep 17 00:00:00 2001 From: lostlevels Date: Thu, 11 Apr 2024 13:30:19 -0700 Subject: [PATCH 21/62] Allow services to retrieve user profile. --- auth/service/api/v1/profile.go | 42 +++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index 64f847fe1..14ec56356 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -14,8 +14,8 @@ import ( func (r *Router) ProfileRoutes() []*rest.Route { return []*rest.Route{ - rest.Get("/v1/users/:userId/profile", api.RequireUser(r.GetProfile)), - rest.Get("/v1/users/legacy/:userId/profile", api.RequireUser(r.GetLegacyProfile)), + rest.Get("/v1/users/:userId/profile", api.RequireAuth(r.GetProfile)), + rest.Get("/v1/users/legacy/:userId/profile", api.RequireAuth(r.GetLegacyProfile)), // The following modification routes required custodian access in seagull, but I'm not sure that's quite right - it seems it should be if the user can modify the userId. rest.Put("/v1/users/:userId/profile", r.requireWriteAccess("userId", r.UpdateProfile)), rest.Put("/v1/users/legacy/:userId/profile", r.requireWriteAccess("userId", r.UpdateLegacyProfile)), @@ -29,14 +29,17 @@ func (r *Router) GetProfile(res rest.ResponseWriter, req *rest.Request) { ctx := req.Context() details := request.GetAuthDetails(ctx) userID := req.PathParam("userId") - hasPerms, err := r.PermissionsClient().HasMembershipRelationship(ctx, details.UserID(), userID) - if err != nil { - responder.InternalServerError(err) - return - } - if !hasPerms { - responder.Empty(http.StatusForbidden) - return + + if details.IsUser() { + hasPerms, err := r.PermissionsClient().HasMembershipRelationship(ctx, details.UserID(), userID) + if err != nil { + responder.InternalServerError(err) + return + } + if !hasPerms { + responder.Empty(http.StatusForbidden) + return + } } user, err := r.UserAccessor().FindUserById(ctx, userID) @@ -57,14 +60,17 @@ func (r *Router) GetLegacyProfile(res rest.ResponseWriter, req *rest.Request) { ctx := req.Context() details := request.GetAuthDetails(ctx) userID := req.PathParam("userId") - hasPerms, err := r.PermissionsClient().HasMembershipRelationship(ctx, details.UserID(), userID) - if err != nil { - responder.InternalServerError(err) - return - } - if !hasPerms { - responder.Empty(http.StatusForbidden) - return + + if details.IsUser() { + hasPerms, err := r.PermissionsClient().HasMembershipRelationship(ctx, details.UserID(), userID) + if err != nil { + responder.InternalServerError(err) + return + } + if !hasPerms { + responder.Empty(http.StatusForbidden) + return + } } user, err := r.UserAccessor().FindUserById(ctx, userID) From 49c2809af32ad93c64f7c956f2a4f6d41d1f5d3e Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 16 Apr 2024 15:00:54 -0700 Subject: [PATCH 22/62] Use "dummy" attribute "profile_has_custodian" for easier keycloak search. Fix profile to legacy seagull mapping. --- user/profile.go | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/user/profile.go b/user/profile.go index 4d9ff5848..9408bc1c4 100644 --- a/user/profile.go +++ b/user/profile.go @@ -35,9 +35,10 @@ type Custodian struct { } func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { - return &LegacyUserProfile{ + legacyProfile := &LegacyUserProfile{ FullName: up.FullName, Patient: &PatientProfile{ + FullName: up.FullName, Birthday: up.Birthday, DiagnosisDate: up.DiagnosisDate, TargetDevices: up.TargetDevices, @@ -45,17 +46,28 @@ func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { About: up.About, }, } + if up.Custodian != nil { + legacyProfile.FullName = up.Custodian.FullName + } + return legacyProfile } func (p *LegacyUserProfile) ToUserProfile() *UserProfile { - return &UserProfile{ - FullName: p.FullName, - Birthday: p.Patient.Birthday, - DiagnosisDate: p.Patient.DiagnosisDate, - TargetDevices: p.Patient.TargetDevices, - TargetTimezone: p.Patient.TargetTimezone, - About: p.Patient.About, + up := &UserProfile{ + FullName: p.FullName, } + if p.Patient != nil { + up.FullName = p.Patient.FullName + up.Custodian = &Custodian{ + FullName: p.FullName, + } + up.Birthday = p.Patient.Birthday + up.DiagnosisDate = p.Patient.DiagnosisDate + up.TargetDevices = p.Patient.TargetDevices + up.TargetTimezone = p.Patient.TargetTimezone + up.About = p.Patient.About + } + return up } type LegacyUserProfile struct { @@ -65,6 +77,7 @@ type LegacyUserProfile struct { } type PatientProfile struct { + FullName string `json:"fullName"` Birthday Date `json:"birthday"` DiagnosisDate Date `json:"diagnosisDate"` DiagnosisType string `json:"diagnosisType"` @@ -79,6 +92,8 @@ type ClinicProfile struct { Telephone string `json:"telephone"` } +// TODO: may or may not have the "profile_" prefix in the keycloak attribute name as it is somewhat redundant. + func (up *UserProfile) ToAttributes() map[string][]string { attributes := map[string][]string{} @@ -87,6 +102,9 @@ func (up *UserProfile) ToAttributes() map[string][]string { } if up.Custodian != nil && up.Custodian.FullName != "" { addAttribute(attributes, "profile_custodian_full_name", up.Custodian.FullName) + // The "profile_has_custodian" attribute is only added so that filtering on users is simpler via the keycloak API - because + // there is a way to filter by custom attribute values but not by the presence of one. + addAttribute(attributes, "profile_has_custodian", "true") } if string(up.Birthday) != "" { addAttribute(attributes, "profile_birthday", string(up.Birthday)) From b0b6163076d19c8696a2528727d4fec9b0d0339a Mon Sep 17 00:00:00 2001 From: lostlevels Date: Fri, 19 Apr 2024 12:33:04 -0700 Subject: [PATCH 23/62] patient.fullName is only set for fake children. --- user/profile.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/user/profile.go b/user/profile.go index 9408bc1c4..64a6d19c2 100644 --- a/user/profile.go +++ b/user/profile.go @@ -38,7 +38,6 @@ func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { legacyProfile := &LegacyUserProfile{ FullName: up.FullName, Patient: &PatientProfile{ - FullName: up.FullName, Birthday: up.Birthday, DiagnosisDate: up.DiagnosisDate, TargetDevices: up.TargetDevices, @@ -46,8 +45,10 @@ func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { About: up.About, }, } + // only custodiaL fake child accounts have Patient.FullName set if up.Custodian != nil { legacyProfile.FullName = up.Custodian.FullName + legacyProfile.Patient.FullName = up.FullName } return legacyProfile } @@ -58,8 +59,12 @@ func (p *LegacyUserProfile) ToUserProfile() *UserProfile { } if p.Patient != nil { up.FullName = p.Patient.FullName - up.Custodian = &Custodian{ - FullName: p.FullName, + // Only users with isOtherPerson set has a patient.fullName field set so + // they have a custodian. + if p.Patient.FullName != "" { + up.Custodian = &Custodian{ + FullName: p.FullName, + } } up.Birthday = p.Patient.Birthday up.DiagnosisDate = p.Patient.DiagnosisDate @@ -70,6 +75,7 @@ func (p *LegacyUserProfile) ToUserProfile() *UserProfile { return up } +// LegacyUserProfile represents the old seagull format for a profile. type LegacyUserProfile struct { FullName string `json:"fullName"` Patient *PatientProfile `json:"patient,omitempty"` @@ -77,7 +83,7 @@ type LegacyUserProfile struct { } type PatientProfile struct { - FullName string `json:"fullName"` + FullName string `json:"fullName,omitempty"` // This is only non-empty if the user is also a fake child (has the patient.isOtherPerson field set) Birthday Date `json:"birthday"` DiagnosisDate Date `json:"diagnosisDate"` DiagnosisType string `json:"diagnosisType"` From 97ce75c4297489d827768a930bf682bf7e9e042f Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 22 Apr 2024 20:33:47 -0700 Subject: [PATCH 24/62] Remove "profile_" prefix from profile keycloak attributes. Add "isOtherPerson" to legacy response. --- user/profile.go | 45 ++++++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/user/profile.go b/user/profile.go index 64a6d19c2..f2c641a25 100644 --- a/user/profile.go +++ b/user/profile.go @@ -49,6 +49,7 @@ func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { if up.Custodian != nil { legacyProfile.FullName = up.Custodian.FullName legacyProfile.Patient.FullName = up.FullName + legacyProfile.Patient.IsOtherPerson = true } return legacyProfile } @@ -61,10 +62,13 @@ func (p *LegacyUserProfile) ToUserProfile() *UserProfile { up.FullName = p.Patient.FullName // Only users with isOtherPerson set has a patient.fullName field set so // they have a custodian. - if p.Patient.FullName != "" { + if p.Patient.FullName != "" || p.Patient.IsOtherPerson { up.Custodian = &Custodian{ FullName: p.FullName, } + if up.Custodian.FullName == "" { + up.Custodian.FullName = p.FullName + } } up.Birthday = p.Patient.Birthday up.DiagnosisDate = p.Patient.DiagnosisDate @@ -90,6 +94,7 @@ type PatientProfile struct { TargetDevices []string `json:"targetDevices"` TargetTimezone string `json:"targetTimezone"` About string `json:"about"` + IsOtherPerson bool `json:"isOtherPerson,omitempty"` } type ClinicProfile struct { @@ -98,35 +103,33 @@ type ClinicProfile struct { Telephone string `json:"telephone"` } -// TODO: may or may not have the "profile_" prefix in the keycloak attribute name as it is somewhat redundant. - func (up *UserProfile) ToAttributes() map[string][]string { attributes := map[string][]string{} if up.FullName != "" { - addAttribute(attributes, "profile_full_name", up.FullName) + addAttribute(attributes, "full_name", up.FullName) } if up.Custodian != nil && up.Custodian.FullName != "" { - addAttribute(attributes, "profile_custodian_full_name", up.Custodian.FullName) - // The "profile_has_custodian" attribute is only added so that filtering on users is simpler via the keycloak API - because + addAttribute(attributes, "custodian_full_name", up.Custodian.FullName) + // The "has_custodian" attribute is only added so that filtering on users is simpler via the keycloak API - because // there is a way to filter by custom attribute values but not by the presence of one. - addAttribute(attributes, "profile_has_custodian", "true") + addAttribute(attributes, "has_custodian", "true") } if string(up.Birthday) != "" { - addAttribute(attributes, "profile_birthday", string(up.Birthday)) + addAttribute(attributes, "birthday", string(up.Birthday)) } if string(up.DiagnosisDate) != "" { - addAttribute(attributes, "profile_diagnosis_date", string(up.DiagnosisDate)) + addAttribute(attributes, "diagnosis_date", string(up.DiagnosisDate)) } if up.DiagnosisType != "" { - addAttribute(attributes, "profile_diagnosis_type", up.DiagnosisType) + addAttribute(attributes, "diagnosis_type", up.DiagnosisType) } - addAttributes(attributes, "profile_target_devices", up.TargetDevices...) + addAttributes(attributes, "target_devices", up.TargetDevices...) if up.TargetTimezone != "" { - addAttribute(attributes, "profile_target_timezone", up.TargetTimezone) + addAttribute(attributes, "target_timezone", up.TargetTimezone) } if up.About != "" { - addAttribute(attributes, "profile_about", up.About) + addAttribute(attributes, "about", up.About) } return attributes @@ -134,37 +137,37 @@ func (up *UserProfile) ToAttributes() map[string][]string { func ProfileFromAttributes(attributes map[string][]string) (profile *UserProfile, ok bool) { up := &UserProfile{} - if val := getAttribute(attributes, "profile_full_name"); val != "" { + if val := getAttribute(attributes, "full_name"); val != "" { up.FullName = val ok = true } - if val := getAttribute(attributes, "profile_custodian_full_name"); val != "" { + if val := getAttribute(attributes, "custodian_full_name"); val != "" { up.Custodian = &Custodian{ FullName: val, } ok = true } - if val := getAttribute(attributes, "profile_birthday"); val != "" { + if val := getAttribute(attributes, "birthday"); val != "" { up.Birthday = Date(val) ok = true } - if val := getAttribute(attributes, "profile_diagnosis_date"); val != "" { + if val := getAttribute(attributes, "diagnosis_date"); val != "" { up.DiagnosisDate = Date(val) ok = true } - if val := getAttribute(attributes, "profile_diagnosis_type"); val != "" { + if val := getAttribute(attributes, "diagnosis_type"); val != "" { up.DiagnosisType = val ok = true } - if vals := getAttributes(attributes, "profile_target_devices"); len(vals) > 0 { + if vals := getAttributes(attributes, "target_devices"); len(vals) > 0 { up.TargetDevices = vals ok = true } - if val := getAttribute(attributes, "profile_target_timezone"); val != "" { + if val := getAttribute(attributes, "target_timezone"); val != "" { up.TargetTimezone = val ok = true } - if val := getAttribute(attributes, "profile_about"); val != "" { + if val := getAttribute(attributes, "about"); val != "" { up.About = val ok = true } From cdb875c72beadaa0406c88a45e305f66281d92ed Mon Sep 17 00:00:00 2001 From: lostlevels Date: Sun, 28 Apr 2024 19:19:38 -0700 Subject: [PATCH 25/62] Use right json. --- user/profile.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user/profile.go b/user/profile.go index f2c641a25..7d6d22521 100644 --- a/user/profile.go +++ b/user/profile.go @@ -98,7 +98,7 @@ type PatientProfile struct { } type ClinicProfile struct { - Name string `json:"diagnosisDate"` + Name string `json:"name"` Role []string `json:"role"` Telephone string `json:"telephone"` } From 62a936865fc2af923174d3ae7aad61466523a159 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 30 Apr 2024 14:33:18 -0700 Subject: [PATCH 26/62] Delete unused shoreline code. Move user.FullUser into user.User. --- auth/service/api/v1/profile.go | 2 + permission/permission.go | 1 - user/full_user.go | 705 --------------------------------- user/keycloak/client.go | 98 +---- user/keycloak/user_accessor.go | 159 +------- user/timeutil.go | 30 ++ user/user.go | 108 +++++ user/user_accessor.go | 10 +- 8 files changed, 166 insertions(+), 947 deletions(-) delete mode 100644 user/full_user.go create mode 100644 user/timeutil.go diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index 14ec56356..3faa8da73 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -19,6 +19,8 @@ func (r *Router) ProfileRoutes() []*rest.Route { // The following modification routes required custodian access in seagull, but I'm not sure that's quite right - it seems it should be if the user can modify the userId. rest.Put("/v1/users/:userId/profile", r.requireWriteAccess("userId", r.UpdateProfile)), rest.Put("/v1/users/legacy/:userId/profile", r.requireWriteAccess("userId", r.UpdateLegacyProfile)), + rest.Post("/v1/users/:userId/profile", r.requireWriteAccess("userId", r.UpdateProfile)), + rest.Post("/v1/users/legacy/:userId/profile", r.requireWriteAccess("userId", r.UpdateLegacyProfile)), rest.Delete("/v1/users/:userId/profile", r.requireWriteAccess("userId", r.DeleteProfile)), rest.Delete("/v1/users/legacy/:userId/profile", r.requireWriteAccess("userId", r.DeleteProfile)), } diff --git a/permission/permission.go b/permission/permission.go index ca7ede42f..80e23a56b 100644 --- a/permission/permission.go +++ b/permission/permission.go @@ -17,7 +17,6 @@ const ( type Client interface { GetUserPermissions(ctx context.Context, requestUserID string, targetUserID string) (Permissions, error) - // Not sure whether to put these methods in platform/permission or go-common/clients HasMembershipRelationship(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) HasCustodianPermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) HasWritePermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) diff --git a/user/full_user.go b/user/full_user.go deleted file mode 100644 index e3b6ca411..000000000 --- a/user/full_user.go +++ /dev/null @@ -1,705 +0,0 @@ -package user - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "math/rand" - "regexp" - "strconv" - "strings" - "time" - - "github.com/tidepool-org/platform/pointer" -) - -const ( - custodialEmailFormat = "unclaimed-custodial-automation+%020d@tidepool.org" - RoleClinic = "clinic" - RoleClinician = "clinician" - RoleCustodialAccount = "custodial_account" - RoleMigratedClinic = "migrated_clinic" - RolePatient = "patient" - RoleBrokered = "brokered" -) - -var custodialAccountRegexp = regexp.MustCompile("unclaimed-custodial-automation\\+\\d+@tidepool\\.org") - -var validRoles = map[string]struct{}{ - RoleBrokered: {}, - RoleClinic: {}, - RoleClinician: {}, - RoleCustodialAccount: {}, - RoleMigratedClinic: {}, - RolePatient: {}, -} - -var custodialAccountRoles = []string{RoleCustodialAccount, RolePatient} - -// FullUser is the rull representation of a user. It is a -// temporary type until I can figure out how much the -// existing user.User type can be extended to include these -// fields -type FullUser struct { - Id string `json:"userid,omitempty" bson:"userid,omitempty"` // map userid to id - Username string `json:"username,omitempty" bson:"username,omitempty"` - Emails []string `json:"emails,omitempty" bson:"emails,omitempty"` - Roles []string `json:"roles,omitempty" bson:"roles,omitempty"` - TermsAccepted string `json:"termsAccepted,omitempty" bson:"termsAccepted,omitempty"` - EmailVerified bool `json:"emailVerified" bson:"authenticated"` //tag is name `authenticated` for historical reasons - PwHash string `json:"-" bson:"pwhash,omitempty"` - Hash string `json:"-" bson:"userhash,omitempty"` - // Private map[string]*IdHashPair `json:"-" bson:"private"` - IsMigrated bool `json:"-" bson:"-"` - IsUnclaimedCustodial bool `json:"-" bson:"-"` - Enabled bool `json:"-" bson:"-"` - CreatedTime string `json:"createdTime,omitempty" bson:"createdTime,omitempty"` - CreatedUserID string `json:"createdUserId,omitempty" bson:"createdUserId,omitempty"` - ModifiedTime string `json:"modifiedTime,omitempty" bson:"modifiedTime,omitempty"` - ModifiedUserID string `json:"modifiedUserId,omitempty" bson:"modifiedUserId,omitempty"` - DeletedTime string `json:"deletedTime,omitempty" bson:"deletedTime,omitempty"` - DeletedUserID string `json:"deletedUserId,omitempty" bson:"deletedUserId,omitempty"` - Attributes map[string][]string `json:"-"` - Profile *UserProfile `json:"-"` - FirstName string `json:"firstName,omitempty"` - LastName string `json:"lastName,omitempty"` -} - -// ExternalUser is the user returned to public services. -type ExternalUser struct { - // same attributes as original shoreline Api.asSerializableUser - ID *string `json:"userid,omitempty"` - Username *string `json:"username,omitempty"` - Emails *[]string `json:"emails,omitempty"` - EmailVerified *bool `json:"emailVerified,omitempty"` - Roles *[]string `json:"roles,omitempty"` - TermsAccepted *bool `json:"terms_accepted"` -} - -// MigrationUser is a User that conforms to the structure -// expected by the keycloak-user-migration keycloak plugin -type MigrationUser struct { - ID string `json:"id"` - Username string `json:"username,omitempty"` - Email string `json:"email,omitempty"` - FirstName string `json:"firstName,omitempty"` - LastName string `json:"lastName,omitempty"` - Enabled bool `json:"enabled,omitempty"` - EmailVerified bool `json:"emailVerified,omitempty"` - Roles []string `json:"roles,omitempty"` - Attributes struct { - TermsAcceptedDate []string `json:"terms_and_conditions,omitempty"` - } `json:"attributes"` -} - -/* - * Incoming user details used to create or update a `User` - */ -type NewUserDetails struct { - Username *string - Emails []string - Password *string - Roles []string - EmailVerified bool -} - -type NewCustodialUserDetails struct { - Username *string - Emails []string -} - -type UpdateUserDetails struct { - Username *string - Emails []string - Password *string - HashedPassword *string - Roles []string - TermsAccepted *string - EmailVerified *bool -} - -type Profile struct { - FullName string `json:"fullName"` -} - -var ( - User_error_details_missing = errors.New("User details are missing") - User_error_username_missing = errors.New("Username is missing") - User_error_username_invalid = errors.New("Username is invalid") - User_error_emails_missing = errors.New("Emails are missing") - User_error_emails_invalid = errors.New("Emails are invalid") - User_error_password_missing = errors.New("Password is missing") - User_error_password_invalid = errors.New("Password is invalid") - User_error_roles_invalid = errors.New("Roles are invalid") - User_error_terms_accepted_invalid = errors.New("Terms accepted is invalid") - User_error_email_verified_invalid = errors.New("Email verified is invalid") -) - -func ExtractBool(data map[string]interface{}, key string) (*bool, bool) { - if raw, ok := data[key]; !ok { - return nil, true - } else if extractedBool, ok := raw.(bool); !ok { - return nil, false - } else { - return &extractedBool, true - } -} - -func ExtractString(data map[string]interface{}, key string) (*string, bool) { - if raw, ok := data[key]; !ok { - return nil, true - } else if extractedString, ok := raw.(string); !ok { - return nil, false - } else { - return &extractedString, true - } -} - -func ExtractArray(data map[string]interface{}, key string) ([]interface{}, bool) { - if raw, ok := data[key]; !ok { - return nil, true - } else if extractedArray, ok := raw.([]interface{}); !ok { - return nil, false - } else if len(extractedArray) == 0 { - return []interface{}{}, true - } else { - return extractedArray, true - } -} - -func ExtractStringArray(data map[string]interface{}, key string) ([]string, bool) { - if rawArray, ok := ExtractArray(data, key); !ok { - return nil, false - } else if rawArray == nil { - return nil, true - } else { - extractedStringArray := make([]string, 0) - for _, raw := range rawArray { - if extractedString, ok := raw.(string); !ok { - return nil, false - } else { - extractedStringArray = append(extractedStringArray, extractedString) - } - } - return extractedStringArray, true - } -} - -func ExtractStringMap(data map[string]interface{}, key string) (map[string]interface{}, bool) { - if raw, ok := data[key]; !ok { - return nil, true - } else if extractedMap, ok := raw.(map[string]interface{}); !ok { - return nil, false - } else if len(extractedMap) == 0 { - return map[string]interface{}{}, true - } else { - return extractedMap, true - } -} - -func IsValidEmail(email string) bool { - ok, _ := regexp.MatchString(`\A(?i)([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z`, email) - return ok -} - -func IsValidPassword(password string) bool { - ok, _ := regexp.MatchString(`\A\S{8,72}\z`, password) - return ok -} - -func IsValidRole(role string) bool { - _, ok := validRoles[role] - return ok -} - -func IsValidDate(date string) bool { - _, err := time.Parse("2006-01-02", date) - return err == nil -} - -func ParseAndValidateDateParam(date string) (time.Time, error) { - if date == "" { - return time.Time{}, nil - } - - return time.Parse("2006-01-02", date) -} - -func IsValidTimestamp(timestamp string) bool { - _, err := ParseTimestamp(timestamp) - return err == nil -} - -func ParseTimestamp(timestamp string) (time.Time, error) { - return time.Parse(TimestampFormat, timestamp) -} - -func TimestampToUnixString(timestamp string) (unix string, err error) { - parsed, err := ParseTimestamp(timestamp) - if err != nil { - return - } - unix = fmt.Sprintf("%v", parsed.Unix()) - return -} - -func UnixStringToTimestamp(unixString string) (timestamp string, err error) { - i, err := strconv.ParseInt(unixString, 10, 64) - if err != nil { - return - } - t := time.Unix(i, 0) - timestamp = t.Format(TimestampFormat) - return -} - -func IsValidUserID(id string) bool { - ok, _ := regexp.MatchString(`^([a-fA-F0-9]{10})$|^([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})$`, id) - return ok -} - -func (details *NewUserDetails) ExtractFromJSON(reader io.Reader) error { - if reader == nil { - return User_error_details_missing - } - - var decoded map[string]interface{} - if err := json.NewDecoder(reader).Decode(&decoded); err != nil { - return err - } - - var ( - username *string - emails []string - password *string - roles []string - ok bool - ) - - if username, ok = ExtractString(decoded, "username"); !ok { - return User_error_username_invalid - } - if emails, ok = ExtractStringArray(decoded, "emails"); !ok { - return User_error_emails_invalid - } - if password, ok = ExtractString(decoded, "password"); !ok { - return User_error_password_invalid - } - if roles, ok = ExtractStringArray(decoded, "roles"); !ok { - return User_error_roles_invalid - } - - details.Username = username - details.Emails = emails - details.Password = password - details.Roles = roles - return nil -} - -func (details *NewUserDetails) Validate() error { - if details.Username == nil { - return User_error_username_missing - } else if !IsValidEmail(*details.Username) { - return User_error_username_invalid - } - - if len(details.Emails) == 0 { - return User_error_emails_missing - } else { - for _, email := range details.Emails { - if !IsValidEmail(email) { - return User_error_emails_invalid - } - } - } - - if details.Password == nil { - return User_error_password_missing - } else if !IsValidPassword(*details.Password) { - return User_error_password_invalid - } - - if details.Roles != nil { - for _, role := range details.Roles { - if !IsValidRole(role) { - return User_error_roles_invalid - } - } - } - - return nil -} - -func ParseNewUserDetails(reader io.Reader) (*NewUserDetails, error) { - details := &NewUserDetails{} - if err := details.ExtractFromJSON(reader); err != nil { - return nil, err - } else { - return details, nil - } -} - -func NewUser(details *NewUserDetails, salt string) (user *FullUser, err error) { - if details == nil { - return nil, errors.New("New user details is nil") - } else if err := details.Validate(); err != nil { - return nil, err - } - - user = &FullUser{Username: *details.Username, Emails: details.Emails, Roles: details.Roles} - - if user.Id, err = generateUniqueHash([]string{*details.Username, *details.Password}, 10); err != nil { - return nil, errors.New("User: error generating id") - } - if user.Hash, err = generateUniqueHash([]string{*details.Username, *details.Password, user.Id}, 24); err != nil { - return nil, errors.New("User: error generating hash") - } - - if err = user.HashPassword(*details.Password, salt); err != nil { - return nil, errors.New("User: error generating password hash") - } - - return user, nil -} - -func (details *NewCustodialUserDetails) ExtractFromJSON(reader io.Reader) error { - if reader == nil { - return User_error_details_missing - } - - var decoded map[string]interface{} - if err := json.NewDecoder(reader).Decode(&decoded); err != nil { - return err - } - - var ( - username *string - emails []string - ok bool - ) - - if username, ok = ExtractString(decoded, "username"); !ok { - return User_error_username_invalid - } - if emails, ok = ExtractStringArray(decoded, "emails"); !ok { - return User_error_emails_invalid - } - - details.Username = username - details.Emails = emails - return nil -} - -func (details *NewCustodialUserDetails) Validate() error { - if details.Username != nil { - if !IsValidEmail(*details.Username) { - return User_error_username_invalid - } - } - - if details.Emails != nil { - for _, email := range details.Emails { - if !IsValidEmail(email) { - return User_error_emails_invalid - } - } - } - - return nil -} - -func ParseNewCustodialUserDetails(reader io.Reader) (*NewCustodialUserDetails, error) { - details := &NewCustodialUserDetails{} - if err := details.ExtractFromJSON(reader); err != nil { - return nil, err - } else { - return details, nil - } -} - -func NewCustodialUser(details *NewCustodialUserDetails, salt string) (user *FullUser, err error) { - if details == nil { - return nil, errors.New("New custodial user details is nil") - } else if err := details.Validate(); err != nil { - return nil, err - } - - var username string - if details.Username != nil { - username = *details.Username - } - - user = &FullUser{ - Username: username, - Emails: details.Emails, - } - - id, err := generateUniqueHash([]string{username}, 10) - if err != nil { - return nil, errors.New("User: error generating id") - } - if user.Hash, err = generateUniqueHash([]string{username, id}, 24); err != nil { - return nil, errors.New("User: error generating hash") - } - - return user, nil -} - -func NewUserDetailsFromCustodialUserDetails(details *NewCustodialUserDetails) (*NewUserDetails, error) { - var email string - if len(details.Emails) > 0 { - email = details.Emails[0] - } else if details.Username != nil { - email = *details.Username - } else { - email = GenerateTemporaryCustodialEmail() - } - - return &NewUserDetails{ - Username: &email, - Emails: []string{email}, - Roles: custodialAccountRoles, - }, nil -} - -func GenerateTemporaryCustodialEmail() string { - random := rand.Uint64() - return fmt.Sprintf(custodialEmailFormat, random) -} - -func IsTemporaryCustodialEmail(email string) bool { - return custodialAccountRegexp.MatchString(email) -} - -func (details *UpdateUserDetails) ExtractFromJSON(reader io.Reader) error { - if reader == nil { - return User_error_details_missing - } - - var decoded map[string]interface{} - if err := json.NewDecoder(reader).Decode(&decoded); err != nil { - return err - } - - var ( - username *string - emails []string - password *string - roles []string - termsAccepted *string - emailVerified *bool - ok bool - ) - - decoded, ok = ExtractStringMap(decoded, "updates") - if !ok || decoded == nil { - return User_error_details_missing - } - - if username, ok = ExtractString(decoded, "username"); !ok { - return User_error_username_invalid - } - if emails, ok = ExtractStringArray(decoded, "emails"); !ok { - return User_error_emails_invalid - } - if password, ok = ExtractString(decoded, "password"); !ok { - return User_error_password_invalid - } - if roles, ok = ExtractStringArray(decoded, "roles"); !ok { - return User_error_roles_invalid - } - if termsAccepted, ok = ExtractString(decoded, "termsAccepted"); !ok { - return User_error_terms_accepted_invalid - } - if emailVerified, ok = ExtractBool(decoded, "emailVerified"); !ok { - return User_error_email_verified_invalid - } - - details.Username = username - details.Emails = emails - details.Password = password - details.Roles = roles - details.TermsAccepted = termsAccepted - details.EmailVerified = emailVerified - return nil -} - -func (details *UpdateUserDetails) Validate() error { - if details.Username != nil { - if !IsValidEmail(*details.Username) { - return User_error_username_invalid - } - } - - if details.Emails != nil { - for _, email := range details.Emails { - if !IsValidEmail(email) { - return User_error_emails_invalid - } - } - } - - if details.Password != nil { - if !IsValidPassword(*details.Password) { - return User_error_password_invalid - } - } - - if details.Roles != nil { - for _, role := range details.Roles { - if !IsValidRole(role) { - return User_error_roles_invalid - } - } - } - - if details.TermsAccepted != nil { - if !IsValidTimestamp(*details.TermsAccepted) { - return User_error_terms_accepted_invalid - } - } - - return nil -} - -func ParseUpdateUserDetails(reader io.Reader) (*UpdateUserDetails, error) { - details := &UpdateUserDetails{} - if err := details.ExtractFromJSON(reader); err != nil { - return nil, err - } else { - return details, nil - } -} - -func (u *FullUser) Email() string { - return strings.ToLower(u.Username) -} - -func (u *FullUser) HasRole(role string) bool { - for _, userRole := range u.Roles { - if userRole == role { - return true - } - } - return false -} - -// IsClinic returns true if the user is legacy clinic Account -func (u *FullUser) IsClinic() bool { - return u.HasRole(RoleClinic) -} - -func (u *FullUser) IsCustodialAccount() bool { - return u.HasRole(RoleCustodialAccount) -} - -// IsClinician returns true if the user is a clinician -func (u *FullUser) IsClinician() bool { - return u.HasRole(RoleClinician) -} - -func (u *FullUser) AreTermsAccepted() bool { - _, err := TimestampToUnixString(u.TermsAccepted) - return err == nil -} - -func (u *FullUser) IsEnabled() bool { - if u.IsMigrated { - return u.Enabled - } - return u.PwHash != "" && !u.IsDeleted() -} - -func (u *FullUser) IsDeleted() bool { - // mdb only? - return u.DeletedTime != "" -} - -func (u *FullUser) HashPassword(pw, salt string) error { - if passwordHash, err := GeneratePasswordHash(u.Id, pw, salt); err != nil { - return err - } else { - u.PwHash = passwordHash - return nil - } -} - -func (u *FullUser) PasswordsMatch(pw, salt string) bool { - if u.PwHash == "" || pw == "" { - return false - } else if pwMatch, err := GeneratePasswordHash(u.Id, pw, salt); err != nil { - return false - } else { - return u.PwHash == pwMatch - } -} - -func (u *FullUser) IsEmailVerified(secret string) bool { - if secret != "" { - if strings.Contains(u.Username, secret) { - return true - } - for i := range u.Emails { - if strings.Contains(u.Emails[i], secret) { - return true - } - } - } - return u.EmailVerified -} - -func ToMigrationUser(u *FullUser) *MigrationUser { - migratedUser := &MigrationUser{ - ID: u.Id, - Username: strings.ToLower(u.Username), - Email: strings.ToLower(u.Email()), - Roles: u.Roles, - } - if len(migratedUser.Roles) == 0 { - migratedUser.Roles = []string{RolePatient} - } - if !u.IsMigrated && u.PwHash == "" && !u.HasRole(RoleCustodialAccount) { - migratedUser.Roles = append(migratedUser.Roles, RoleCustodialAccount) - } - if termsAccepted, err := TimestampToUnixString(u.TermsAccepted); err == nil { - migratedUser.Attributes.TermsAcceptedDate = []string{termsAccepted} - } - - return migratedUser -} - -func ToExternalUser(user *FullUser) *ExternalUser { - var id *string - if len(user.Id) > 0 { - id = &user.Id - } - var emails *[]string - if len(user.Emails) == 1 && !IsTemporaryCustodialEmail(user.Emails[0]) { - emails = &user.Emails - } - var username *string - if len(user.Username) > 0 && !IsTemporaryCustodialEmail(user.Username) { - username = &user.Username - } - var roles *[]string - if len(user.Roles) > 0 { - roles = &user.Roles - } - var emailVerified *bool - if len(user.Username) > 0 || len(user.Emails) > 0 { - emailVerified = &user.EmailVerified - } - var termsAccepted *bool - if user.AreTermsAccepted() { - termsAccepted = pointer.FromBool(true) - } - return &ExternalUser{ - ID: id, - Emails: emails, - Username: username, - Roles: roles, - TermsAccepted: termsAccepted, - EmailVerified: emailVerified, - } -} diff --git a/user/keycloak/client.go b/user/keycloak/client.go index 4b80f657b..23c95954d 100644 --- a/user/keycloak/client.go +++ b/user/keycloak/client.go @@ -238,58 +238,6 @@ func (c *keycloakClient) DeleteUserProfile(ctx context.Context, id string) error return c.UpdateUser(ctx, user) } -func (c *keycloakClient) UpdateUserPassword(ctx context.Context, id, password string) error { - token, err := c.getAdminToken(ctx) - if err != nil { - return err - } - - return c.keycloak.SetPassword( - ctx, - token.AccessToken, - id, - c.cfg.Realm, - password, - false, - ) -} - -func (c *keycloakClient) CreateUser(ctx context.Context, user *keycloakUser) (*keycloakUser, error) { - token, err := c.getAdminToken(ctx) - if err != nil { - return nil, err - } - - model := gocloak.User{ - Username: &user.Username, - Email: &user.Email, - EmailVerified: &user.EmailVerified, - Enabled: &user.Enabled, - RealmRoles: &user.Roles, - } - - if len(user.Attributes.TermsAcceptedDate) > 0 { - attrs := map[string][]string{ - termsAcceptedAttribute: user.Attributes.TermsAcceptedDate, - } - model.Attributes = &attrs - } - - user.ID, err = c.keycloak.CreateUser(ctx, token.AccessToken, c.cfg.Realm, model) - if err != nil { - if e, ok := err.(*gocloak.APIError); ok && e.Code == http.StatusConflict { - err = userLib.ErrUserConflict - } - return nil, err - } - - if err := c.updateRolesForUser(ctx, user); err != nil { - return nil, err - } - - return c.GetUserById(ctx, user.ID) -} - func (c *keycloakClient) FindUsersWithIds(ctx context.Context, ids []string) (users []*keycloakUser, err error) { const errMessage = "could not retrieve users by ids" @@ -361,20 +309,6 @@ func (c *keycloakClient) IntrospectToken(ctx context.Context, token oauth2.Token return result, nil } -func (c *keycloakClient) DeleteUser(ctx context.Context, id string) error { - token, err := c.getAdminToken(ctx) - if err != nil { - return err - } - - if err := c.keycloak.DeleteUser(ctx, token.AccessToken, c.cfg.Realm, id); err != nil { - if aErr, ok := err.(*gocloak.APIError); ok && aErr.Code == http.StatusNotFound { - return nil - } - } - return err -} - func (c *keycloakClient) DeleteUserSessions(ctx context.Context, id string) error { token, err := c.getAdminToken(ctx) if err != nil { @@ -538,22 +472,22 @@ func newKeycloakUser(gocloakUser *gocloak.User) *keycloakUser { return user } -func newUserFromKeycloakUser(keycloakUser *keycloakUser) *userLib.FullUser { - termsAcceptedDate := "" +func newUserFromKeycloakUser(keycloakUser *keycloakUser) *userLib.User { + var termsAcceptedDate *string attrs := keycloakUser.Attributes if len(attrs.TermsAcceptedDate) > 0 { if ts, err := userLib.UnixStringToTimestamp(attrs.TermsAcceptedDate[0]); err == nil { - termsAcceptedDate = ts + termsAcceptedDate = &ts } } - user := &userLib.FullUser{ - Id: keycloakUser.ID, - Username: keycloakUser.Username, + user := &userLib.User{ + UserID: pointer.FromString(keycloakUser.ID), + Username: pointer.FromString(keycloakUser.Username), Emails: []string{keycloakUser.Email}, - Roles: keycloakUser.Roles, + Roles: pointer.FromStringArray(keycloakUser.Roles), TermsAccepted: termsAcceptedDate, - EmailVerified: keycloakUser.EmailVerified, + EmailVerified: pointer.FromBool(keycloakUser.EmailVerified), IsMigrated: true, Enabled: keycloakUser.Enabled, Profile: attrs.Profile, @@ -570,14 +504,14 @@ func newUserFromKeycloakUser(keycloakUser *keycloakUser) *userLib.FullUser { return user } -func userToKeycloakUser(u *userLib.FullUser) *keycloakUser { +func userToKeycloakUser(u *userLib.User) *keycloakUser { keycloakUser := &keycloakUser{ - ID: u.Id, - Username: strings.ToLower(u.Username), + ID: pointer.ToString(u.UserID), + Username: strings.ToLower(pointer.ToString(u.Username)), Email: strings.ToLower(u.Email()), Enabled: u.IsEnabled(), - EmailVerified: u.EmailVerified, - Roles: u.Roles, + EmailVerified: pointer.ToBool(u.EmailVerified), + Roles: pointer.ToStringArray(u.Roles), Attributes: keycloakUserAttributes{}, } if len(keycloakUser.Roles) == 0 { @@ -586,8 +520,10 @@ func userToKeycloakUser(u *userLib.FullUser) *keycloakUser { if !u.IsMigrated && u.PwHash == "" && !u.HasRole(userLib.RoleCustodialAccount) { keycloakUser.Roles = append(keycloakUser.Roles, userLib.RoleCustodialAccount) } - if termsAccepted, err := userLib.TimestampToUnixString(u.TermsAccepted); err == nil { - keycloakUser.Attributes.TermsAcceptedDate = []string{termsAccepted} + if u.TermsAccepted != nil { + if termsAccepted, err := userLib.TimestampToUnixString(*u.TermsAccepted); err == nil { + keycloakUser.Attributes.TermsAcceptedDate = []string{termsAccepted} + } } if u.Profile != nil { keycloakUser.Attributes.Profile = u.Profile diff --git a/user/keycloak/user_accessor.go b/user/keycloak/user_accessor.go index 7954b53fd..dbf41799f 100644 --- a/user/keycloak/user_accessor.go +++ b/user/keycloak/user_accessor.go @@ -2,12 +2,11 @@ package keycloak import ( "context" - "errors" - "fmt" "time" "golang.org/x/oauth2" + "github.com/tidepool-org/platform/pointer" userLib "github.com/tidepool-org/platform/user" ) @@ -26,150 +25,12 @@ func NewKeycloakUserAccessor(config *KeycloakConfig) *keycloakUserAccessor { } } -func (m *keycloakUserAccessor) CreateUser(ctx context.Context, details *userLib.NewUserDetails) (*userLib.FullUser, error) { - keycloakUser := &keycloakUser{ - Enabled: details.Password != nil && *details.Password != "", - EmailVerified: details.EmailVerified, - } - - if keycloakUser.EmailVerified { - // Automatically set terms accepted date when email is verified (i.e. internal usage only). - termsAccepted := fmt.Sprintf("%v", time.Now().Unix()) - keycloakUser.Attributes = keycloakUserAttributes{ - TermsAcceptedDate: []string{termsAccepted}, - } - } - - // Users without roles should be treated as patients to prevent keycloak from displaying - // the role selection dialog - if len(details.Roles) == 0 { - details.Roles = []string{userLib.RolePatient} - } - if details.Username != nil { - keycloakUser.Username = *details.Username - } - if len(details.Emails) > 0 { - keycloakUser.Email = details.Emails[0] - } - keycloakUser.Roles = details.Roles - - keycloakUser, err := m.keycloakClient.CreateUser(ctx, keycloakUser) - if errors.Is(err, userLib.ErrUserConflict) { - return nil, userLib.ErrUserConflict - } - if err != nil { - return nil, err - } - - user := newUserFromKeycloakUser(keycloakUser) - - // Unclaimed custodial account should not be allowed to have a password - if !user.IsCustodialAccount() { - if err = m.keycloakClient.UpdateUserPassword(ctx, keycloakUser.ID, *details.Password); err != nil { - return nil, err - } - } - - return user, nil -} - -func (m *keycloakUserAccessor) UpdateUser(ctx context.Context, user *userLib.FullUser, details *userLib.UpdateUserDetails) (*userLib.FullUser, error) { - emails := append([]string{}, details.Emails...) - if details.Username != nil { - emails = append(emails, *details.Username) - } - if err := m.assertEmailsUnique(ctx, user.Id, emails); err != nil { - return nil, err - } - - if user.IsMigrated { - return m.updateKeycloakUser(ctx, user, details) - } - // expected all users to be migrated(?) - return nil, userLib.ErrUserNotMigrated -} - -func (m *keycloakUserAccessor) assertEmailsUnique(ctx context.Context, userId string, emails []string) error { - // for _, email := range emails { - // users, err := m.fallback.FindUsers(&User{ - // Username: email, - // Emails: emails, - // }) - // if err != nil { - // return err - // } - // for _, user := range users { - // if user.Id != userId { - // return ErrEmailConflict - // } - // } - // } - - for _, email := range emails { - user, err := m.keycloakClient.GetUserByEmail(ctx, email) - if err != nil { - return err - } - if user != nil && user.ID != userId { - return userLib.ErrEmailConflict - } - } - return nil -} - -func (m *keycloakUserAccessor) updateKeycloakUser(ctx context.Context, user *userLib.FullUser, details *userLib.UpdateUserDetails) (*userLib.FullUser, error) { - keycloakUser := userToKeycloakUser(user) - if details.Roles != nil { - keycloakUser.Roles = details.Roles - } - if details.Password != nil && len(*details.Password) > 0 { - if err := m.keycloakClient.UpdateUserPassword(ctx, user.Id, *details.Password); err != nil { - return nil, err - } - // Remove the custodial role after the password has been set - newRoles := make([]string, 0) - for _, role := range keycloakUser.Roles { - if role != userLib.RoleCustodialAccount { - newRoles = append(newRoles, role) - } - } - keycloakUser.Roles = newRoles - keycloakUser.Enabled = true - } - if details.Username != nil { - keycloakUser.Username = *details.Username - } - if details.Emails != nil && len(details.Emails) > 0 { - keycloakUser.Email = details.Emails[0] - } - if details.EmailVerified != nil { - keycloakUser.EmailVerified = *details.EmailVerified - } - if details.TermsAccepted != nil && userLib.IsValidTimestamp(*details.TermsAccepted) { - if ts, err := userLib.TimestampToUnixString(*details.TermsAccepted); err == nil { - keycloakUser.Attributes.TermsAcceptedDate = []string{ts} - } - } - - err := m.keycloakClient.UpdateUser(ctx, keycloakUser) - if err != nil { - return nil, err - } - - updated, err := m.keycloakClient.GetUserById(ctx, keycloakUser.ID) - if err != nil { - return nil, err - } - - return newUserFromKeycloakUser(updated), nil -} - -func (m *keycloakUserAccessor) FindUser(ctx context.Context, user *userLib.FullUser) (*userLib.FullUser, error) { +func (m *keycloakUserAccessor) FindUser(ctx context.Context, user *userLib.User) (*userLib.User, error) { var keycloakUser *keycloakUser var err error - if userLib.IsValidUserID(user.Id) { - keycloakUser, err = m.keycloakClient.GetUserById(ctx, user.Id) + if userLib.IsValidUserID(pointer.ToString(user.UserID)) { + keycloakUser, err = m.keycloakClient.GetUserById(ctx, pointer.ToString(user.UserID)) } else { email := "" if user.Emails != nil && len(user.Emails) > 0 { @@ -187,7 +48,7 @@ func (m *keycloakUserAccessor) FindUser(ctx context.Context, user *userLib.FullU return nil, userLib.ErrUserNotMigrated } -func (m *keycloakUserAccessor) FindUserById(ctx context.Context, id string) (*userLib.FullUser, error) { +func (m *keycloakUserAccessor) FindUserById(ctx context.Context, id string) (*userLib.User, error) { if !userLib.IsValidUserID(id) { return nil, userLib.ErrUserNotFound } @@ -202,7 +63,7 @@ func (m *keycloakUserAccessor) FindUserById(ctx context.Context, id string) (*us return newUserFromKeycloakUser(keycloakUser), nil } -func (m *keycloakUserAccessor) FindUsersWithIds(ctx context.Context, ids []string) (users []*userLib.FullUser, err error) { +func (m *keycloakUserAccessor) FindUsersWithIds(ctx context.Context, ids []string) (users []*userLib.User, err error) { keycloakUsers, err := m.keycloakClient.FindUsersWithIds(ctx, ids) if err != nil { return users, err @@ -214,14 +75,6 @@ func (m *keycloakUserAccessor) FindUsersWithIds(ctx context.Context, ids []strin return users, nil } -func (m *keycloakUserAccessor) RemoveUser(ctx context.Context, user *userLib.FullUser) error { - return m.keycloakClient.DeleteUser(ctx, user.Id) -} - -func (m *keycloakUserAccessor) RemoveTokensForUser(ctx context.Context, userId string) error { - return m.keycloakClient.DeleteUserSessions(ctx, userId) -} - func (m *keycloakUserAccessor) UpdateUserProfile(ctx context.Context, userId string, p *userLib.UserProfile) error { return m.keycloakClient.UpdateUserProfile(ctx, userId, p) } diff --git a/user/timeutil.go b/user/timeutil.go new file mode 100644 index 000000000..a0e6cf510 --- /dev/null +++ b/user/timeutil.go @@ -0,0 +1,30 @@ +package user + +import ( + "fmt" + "strconv" + "time" +) + +func ParseTimestamp(timestamp string) (time.Time, error) { + return time.Parse(TimestampFormat, timestamp) +} + +func TimestampToUnixString(timestamp string) (unix string, err error) { + parsed, err := ParseTimestamp(timestamp) + if err != nil { + return + } + unix = fmt.Sprintf("%v", parsed.Unix()) + return +} + +func UnixStringToTimestamp(unixString string) (timestamp string, err error) { + i, err := strconv.ParseInt(unixString, 10, 64) + if err != nil { + return + } + t := time.Unix(i, 0) + timestamp = t.Format(TimestampFormat) + return +} diff --git a/user/user.go b/user/user.go index 4171257d3..a033844ec 100644 --- a/user/user.go +++ b/user/user.go @@ -3,14 +3,25 @@ package user import ( "context" "regexp" + "strings" "time" "github.com/tidepool-org/platform/id" + "github.com/tidepool-org/platform/pointer" "github.com/tidepool-org/platform/request" "github.com/tidepool-org/platform/structure" structureValidator "github.com/tidepool-org/platform/structure/validator" ) +const ( + RoleClinic = "clinic" + RoleClinician = "clinician" + RoleCustodialAccount = "custodial_account" + RoleMigratedClinic = "migrated_clinic" + RolePatient = "patient" + RoleBrokered = "brokered" +) + func Roles() []string { return []string{ RoleClinic, @@ -27,6 +38,24 @@ type User struct { EmailVerified *bool `json:"emailVerified,omitempty" bson:"emailVerified,omitempty"` TermsAccepted *string `json:"termsAccepted,omitempty" bson:"termsAccepted,omitempty"` Roles *[]string `json:"roles,omitempty" bson:"roles,omitempty"` + Emails []string `json:"emails,omitempty" bson:"emails,omitempty"` + // EmailVerified bool `json:"emailVerified" bson:"authenticated"` //tag is name `authenticated` for historical reasons + PwHash string `json:"-" bson:"pwhash,omitempty"` + Hash string `json:"-" bson:"userhash,omitempty"` + // Private map[string]*IdHashPair `json:"-" bson:"private"` + IsMigrated bool `json:"-" bson:"-"` + IsUnclaimedCustodial bool `json:"-" bson:"-"` + Enabled bool `json:"-" bson:"-"` + CreatedTime string `json:"createdTime,omitempty" bson:"createdTime,omitempty"` + CreatedUserID string `json:"createdUserId,omitempty" bson:"createdUserId,omitempty"` + ModifiedTime string `json:"modifiedTime,omitempty" bson:"modifiedTime,omitempty"` + ModifiedUserID string `json:"modifiedUserId,omitempty" bson:"modifiedUserId,omitempty"` + DeletedTime string `json:"deletedTime,omitempty" bson:"deletedTime,omitempty"` + DeletedUserID string `json:"deletedUserId,omitempty" bson:"deletedUserId,omitempty"` + Attributes map[string][]string `json:"-"` + Profile *UserProfile `json:"-"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` } func (u *User) Parse(parser structure.ObjectParser) { @@ -74,6 +103,80 @@ func (u *User) Sanitize(details request.AuthDetails) error { return nil } +func (u *User) Email() string { + if u.Username != nil { + return strings.ToLower(*u.Username) + } + return "" +} + +// IsClinic returns true if the user is legacy clinic Account +func (u *User) IsClinic() bool { + return u.HasRole(RoleClinic) +} + +func (u *User) IsCustodialAccount() bool { + return u.HasRole(RoleCustodialAccount) +} + +// IsClinician returns true if the user is a clinician +func (u *User) IsClinician() bool { + return u.HasRole(RoleClinician) +} + +func (u *User) AreTermsAccepted() bool { + if u.TermsAccepted == nil { + return false + } + _, err := TimestampToUnixString(*u.TermsAccepted) + return err == nil +} + +func (u *User) IsEnabled() bool { + if u.IsMigrated { + return u.Enabled + } + return u.PwHash != "" && !u.IsDeleted() +} + +func (u *User) IsDeleted() bool { + // mdb only? + return u.DeletedTime != "" +} + +func (u *User) HashPassword(pw, salt string) error { + if passwordHash, err := GeneratePasswordHash(*u.UserID, pw, salt); err != nil { + return err + } else { + u.PwHash = passwordHash + return nil + } +} + +func (u *User) PasswordsMatch(pw, salt string) bool { + if u.PwHash == "" || pw == "" { + return false + } else if pwMatch, err := GeneratePasswordHash(*u.UserID, pw, salt); err != nil { + return false + } else { + return u.PwHash == pwMatch + } +} + +func (u *User) IsEmailVerified(secret string) bool { + if secret != "" { + if u.Username != nil && strings.Contains(*u.Username, secret) { + return true + } + for i := range u.Emails { + if strings.Contains(u.Emails[i], secret) { + return true + } + } + } + return pointer.ToBool(u.EmailVerified) +} + type UserArray []*User func (u UserArray) Sanitize(details request.AuthDetails) error { @@ -107,3 +210,8 @@ func ValidateID(value string) error { } var idExpression = regexp.MustCompile(`^([0-9a-f]{10}|[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12})$`) + +func IsValidUserID(id string) bool { + ok, _ := regexp.MatchString(`^([a-fA-F0-9]{10})$|^([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})$`, id) + return ok +} diff --git a/user/user_accessor.go b/user/user_accessor.go index 68ba01360..a7c2e6d30 100644 --- a/user/user_accessor.go +++ b/user/user_accessor.go @@ -27,13 +27,9 @@ var ( // interface, but for now will only retrieve user // information. type UserAccessor interface { - CreateUser(ctx context.Context, details *NewUserDetails) (*FullUser, error) - UpdateUser(ctx context.Context, user *FullUser, details *UpdateUserDetails) (*FullUser, error) - FindUser(ctx context.Context, user *FullUser) (*FullUser, error) - FindUserById(ctx context.Context, id string) (*FullUser, error) - FindUsersWithIds(ctx context.Context, ids []string) ([]*FullUser, error) - RemoveUser(ctx context.Context, user *FullUser) error - RemoveTokensForUser(ctx context.Context, userId string) error + FindUser(ctx context.Context, user *User) (*User, error) + FindUserById(ctx context.Context, id string) (*User, error) + FindUsersWithIds(ctx context.Context, ids []string) ([]*User, error) UpdateUserProfile(ctx context.Context, id string, p *UserProfile) error DeleteUserProfile(ctx context.Context, id string) error } From 5efb6eef04143392440a282534b0733e99a81d4d Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 30 Apr 2024 14:36:54 -0700 Subject: [PATCH 27/62] Remove unused hasher code. --- user/hasher.go | 57 -------------------------------------------------- user/user.go | 34 ------------------------------ 2 files changed, 91 deletions(-) delete mode 100644 user/hasher.go diff --git a/user/hasher.go b/user/hasher.go deleted file mode 100644 index 1c2b81e3f..000000000 --- a/user/hasher.go +++ /dev/null @@ -1,57 +0,0 @@ -package user - -import ( - "crypto/rand" - "crypto/sha1" - "crypto/sha256" - "encoding/hex" - "errors" - "math/big" - "strconv" - "time" -) - -func generateUniqueHash(strings []string, length int) (string, error) { - - if len(strings) > 0 && length > 0 { - - hash := sha256.New() - - for i := range strings { - hash.Write([]byte(strings[i])) - } - - max := big.NewInt(9999999999) - //add some randomness - n, err := rand.Int(rand.Reader, max) - - if err != nil { - return "", err - } - hash.Write([]byte(n.String())) - //and use unix nano - hash.Write([]byte(strconv.FormatInt(time.Now().UnixNano(), 10))) - hashString := hex.EncodeToString(hash.Sum(nil)) - return string([]rune(hashString)[0:length]), nil - } - - return "", errors.New("both strings and length are required") - -} - -func GeneratePasswordHash(id, pw, salt string) (string, error) { - - if salt == "" || id == "" { - return "", errors.New("id and salt are required") - } - - hash := sha1.New() - if pw != "" { - hash.Write([]byte(pw)) - } - hash.Write([]byte(salt)) - hash.Write([]byte(id)) - pwHash := hex.EncodeToString(hash.Sum(nil)) - - return pwHash, nil -} diff --git a/user/user.go b/user/user.go index a033844ec..f27515e83 100644 --- a/user/user.go +++ b/user/user.go @@ -7,7 +7,6 @@ import ( "time" "github.com/tidepool-org/platform/id" - "github.com/tidepool-org/platform/pointer" "github.com/tidepool-org/platform/request" "github.com/tidepool-org/platform/structure" structureValidator "github.com/tidepool-org/platform/structure/validator" @@ -144,39 +143,6 @@ func (u *User) IsDeleted() bool { return u.DeletedTime != "" } -func (u *User) HashPassword(pw, salt string) error { - if passwordHash, err := GeneratePasswordHash(*u.UserID, pw, salt); err != nil { - return err - } else { - u.PwHash = passwordHash - return nil - } -} - -func (u *User) PasswordsMatch(pw, salt string) bool { - if u.PwHash == "" || pw == "" { - return false - } else if pwMatch, err := GeneratePasswordHash(*u.UserID, pw, salt); err != nil { - return false - } else { - return u.PwHash == pwMatch - } -} - -func (u *User) IsEmailVerified(secret string) bool { - if secret != "" { - if u.Username != nil && strings.Contains(*u.Username, secret) { - return true - } - for i := range u.Emails { - if strings.Contains(u.Emails[i], secret) { - return true - } - } - } - return pointer.ToBool(u.EmailVerified) -} - type UserArray []*User func (u UserArray) Sanitize(details request.AuthDetails) error { From 4670d48cf240ddbba15562c564fb8bb42b3438e6 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 30 Apr 2024 14:37:25 -0700 Subject: [PATCH 28/62] Remove unused fields. --- user/user.go | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/user/user.go b/user/user.go index f27515e83..061e4b607 100644 --- a/user/user.go +++ b/user/user.go @@ -32,16 +32,14 @@ type Client interface { } type User struct { - UserID *string `json:"userid,omitempty" bson:"userid,omitempty"` - Username *string `json:"username,omitempty" bson:"username,omitempty"` - EmailVerified *bool `json:"emailVerified,omitempty" bson:"emailVerified,omitempty"` - TermsAccepted *string `json:"termsAccepted,omitempty" bson:"termsAccepted,omitempty"` - Roles *[]string `json:"roles,omitempty" bson:"roles,omitempty"` - Emails []string `json:"emails,omitempty" bson:"emails,omitempty"` - // EmailVerified bool `json:"emailVerified" bson:"authenticated"` //tag is name `authenticated` for historical reasons - PwHash string `json:"-" bson:"pwhash,omitempty"` - Hash string `json:"-" bson:"userhash,omitempty"` - // Private map[string]*IdHashPair `json:"-" bson:"private"` + UserID *string `json:"userid,omitempty" bson:"userid,omitempty"` + Username *string `json:"username,omitempty" bson:"username,omitempty"` + EmailVerified *bool `json:"emailVerified,omitempty" bson:"emailVerified,omitempty"` + TermsAccepted *string `json:"termsAccepted,omitempty" bson:"termsAccepted,omitempty"` + Roles *[]string `json:"roles,omitempty" bson:"roles,omitempty"` + Emails []string `json:"emails,omitempty" bson:"emails,omitempty"` + PwHash string `json:"-" bson:"pwhash,omitempty"` + Hash string `json:"-" bson:"userhash,omitempty"` IsMigrated bool `json:"-" bson:"-"` IsUnclaimedCustodial bool `json:"-" bson:"-"` Enabled bool `json:"-" bson:"-"` From 992ea7180da2c848abcb9467a901a12ab604bbb3 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 30 Apr 2024 15:09:41 -0700 Subject: [PATCH 29/62] Add MRN attribute. --- user/profile.go | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/user/profile.go b/user/profile.go index 7d6d22521..a4a5c7ae6 100644 --- a/user/profile.go +++ b/user/profile.go @@ -27,6 +27,7 @@ type UserProfile struct { TargetDevices []string `json:"targetDevices"` TargetTimezone string `json:"targetTimezone"` About string `json:"about"` + MRN string `json:"mrn"` Custodian *Custodian `json:"custodian,omitempty"` } @@ -37,12 +38,13 @@ type Custodian struct { func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { legacyProfile := &LegacyUserProfile{ FullName: up.FullName, - Patient: &PatientProfile{ + Patient: &LegacyPatientProfile{ Birthday: up.Birthday, DiagnosisDate: up.DiagnosisDate, TargetDevices: up.TargetDevices, TargetTimezone: up.TargetTimezone, About: up.About, + MRN: up.MRN, }, } // only custodiaL fake child accounts have Patient.FullName set @@ -75,18 +77,19 @@ func (p *LegacyUserProfile) ToUserProfile() *UserProfile { up.TargetDevices = p.Patient.TargetDevices up.TargetTimezone = p.Patient.TargetTimezone up.About = p.Patient.About + up.MRN = p.Patient.MRN } return up } // LegacyUserProfile represents the old seagull format for a profile. type LegacyUserProfile struct { - FullName string `json:"fullName"` - Patient *PatientProfile `json:"patient,omitempty"` - Clinic *ClinicProfile `json:"clinic,omitempty"` + FullName string `json:"fullName"` + Patient *LegacyPatientProfile `json:"patient,omitempty"` + Clinic *ClinicProfile `json:"clinic,omitempty"` } -type PatientProfile struct { +type LegacyPatientProfile struct { FullName string `json:"fullName,omitempty"` // This is only non-empty if the user is also a fake child (has the patient.isOtherPerson field set) Birthday Date `json:"birthday"` DiagnosisDate Date `json:"diagnosisDate"` @@ -95,6 +98,7 @@ type PatientProfile struct { TargetTimezone string `json:"targetTimezone"` About string `json:"about"` IsOtherPerson bool `json:"isOtherPerson,omitempty"` + MRN string `json:"mrn"` } type ClinicProfile struct { @@ -131,6 +135,9 @@ func (up *UserProfile) ToAttributes() map[string][]string { if up.About != "" { addAttribute(attributes, "about", up.About) } + if up.MRN != "" { + addAttribute(attributes, "mrn", up.MRN) + } return attributes } @@ -171,6 +178,10 @@ func ProfileFromAttributes(attributes map[string][]string) (profile *UserProfile up.About = val ok = true } + if val := getAttribute(attributes, "mrn"); val != "" { + up.MRN = val + ok = true + } return up, ok } From 904e27687d5dc4224219922fd0943c11a73a34f5 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 1 May 2024 08:56:21 -0700 Subject: [PATCH 30/62] Copy amoeba's permissions with regards to membership and custodian. --- auth/service/api/v1/permission.go | 63 +++++++++++++++++++++++++++---- auth/service/api/v1/profile.go | 17 ++++----- permission/client/client.go | 15 ++++++-- 3 files changed, 76 insertions(+), 19 deletions(-) diff --git a/auth/service/api/v1/permission.go b/auth/service/api/v1/permission.go index e393aefee..d02661ee6 100644 --- a/auth/service/api/v1/permission.go +++ b/auth/service/api/v1/permission.go @@ -6,19 +6,32 @@ import ( "github.com/ant0ine/go-json-rest/rest" "github.com/tidepool-org/platform/request" - "github.com/tidepool-org/platform/service/api" ) -// requireUserHasCustodian aborts with an error if a a request isn't -// authenticated as a user and the user does not have custodian access to the -// user with the id defined in the url param targetParamUserID -func (r *Router) requireUserHasCustodian(targetParamUserID string, handlerFunc rest.HandlerFunc) rest.HandlerFunc { - fn := func(res rest.ResponseWriter, req *rest.Request) { +// requireCustodian aborts with an error if the user associated w/ the +// request doesn't have custodian access to the user with the id defined in the +// url param targetParamUserID. +// +// This mimics the logic of amoeba's requireCustodian access. This means a +// user has access to the target user if any of the following is true: +// - The is a service call (AuthDetails.IsService() == true) +// - The requester and target are the same - AuthDetails.UserID == targetParamUserID +// - The requester has explicit permissions to access targetParamUserID +func (r *Router) requireCustodian(targetParamUserID string, handlerFunc rest.HandlerFunc) rest.HandlerFunc { + return func(res rest.ResponseWriter, req *rest.Request) { if handlerFunc != nil && res != nil && req != nil { targetUserID := req.PathParam(targetParamUserID) responder := request.MustNewResponder(res, req) ctx := req.Context() details := request.GetAuthDetails(ctx) + if details == nil { + request.MustNewResponder(res, req).Error(http.StatusUnauthorized, request.ErrorUnauthenticated()) + return + } + if details.IsService() || details.UserID() == targetUserID { + handlerFunc(res, req) + return + } hasPerms, err := r.PermissionsClient().HasCustodianPermissions(ctx, details.UserID(), targetUserID) if err != nil { responder.InternalServerError(err) @@ -31,7 +44,43 @@ func (r *Router) requireUserHasCustodian(targetParamUserID string, handlerFunc r handlerFunc(res, req) } } - return api.RequireUser(fn) +} + +// requireMembership proceeds if the user with the id specified in the URL +// paramter targetParamUserID has some association with the user in the current +// request - the "requester". This mimics amoeba's requireMembership function. +// +// This proceeds if any of the following are true: +// - The is a service call (AuthDetails.IsService() == true) +// - The requester and target are the same - AuthDetails.UserID == targetParamUserID +// - The requester has any permissions to targetParamUserID OR targetParamUserID has permissions to the requester. +func (r *Router) requireMembership(targetParamUserID string, handlerFunc rest.HandlerFunc) rest.HandlerFunc { + return func(res rest.ResponseWriter, req *rest.Request) { + if handlerFunc != nil && res != nil && req != nil { + targetUserID := req.PathParam(targetParamUserID) + responder := request.MustNewResponder(res, req) + ctx := req.Context() + details := request.GetAuthDetails(ctx) + if details == nil { + request.MustNewResponder(res, req).Error(http.StatusUnauthorized, request.ErrorUnauthenticated()) + return + } + if details.IsService() || details.UserID() == targetUserID { + handlerFunc(res, req) + return + } + hasPerms, err := r.PermissionsClient().HasMembershipRelationship(ctx, details.UserID(), targetUserID) + if err != nil { + responder.InternalServerError(err) + return + } + if !hasPerms { + responder.Empty(http.StatusForbidden) + return + } + handlerFunc(res, req) + } + } } // requireWriteAccess aborts with an error if the request isn't a server request diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index 3faa8da73..5cfc6028d 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -7,22 +7,21 @@ import ( "github.com/ant0ine/go-json-rest/rest" "github.com/tidepool-org/platform/request" - "github.com/tidepool-org/platform/service/api" structValidator "github.com/tidepool-org/platform/structure/validator" "github.com/tidepool-org/platform/user" ) func (r *Router) ProfileRoutes() []*rest.Route { return []*rest.Route{ - rest.Get("/v1/users/:userId/profile", api.RequireAuth(r.GetProfile)), - rest.Get("/v1/users/legacy/:userId/profile", api.RequireAuth(r.GetLegacyProfile)), + rest.Get("/v1/users/:userId/profile", r.requireMembership("userId", r.GetProfile)), + rest.Get("/v1/users/legacy/:userId/profile", r.requireMembership("userId", r.GetLegacyProfile)), // The following modification routes required custodian access in seagull, but I'm not sure that's quite right - it seems it should be if the user can modify the userId. - rest.Put("/v1/users/:userId/profile", r.requireWriteAccess("userId", r.UpdateProfile)), - rest.Put("/v1/users/legacy/:userId/profile", r.requireWriteAccess("userId", r.UpdateLegacyProfile)), - rest.Post("/v1/users/:userId/profile", r.requireWriteAccess("userId", r.UpdateProfile)), - rest.Post("/v1/users/legacy/:userId/profile", r.requireWriteAccess("userId", r.UpdateLegacyProfile)), - rest.Delete("/v1/users/:userId/profile", r.requireWriteAccess("userId", r.DeleteProfile)), - rest.Delete("/v1/users/legacy/:userId/profile", r.requireWriteAccess("userId", r.DeleteProfile)), + rest.Put("/v1/users/:userId/profile", r.requireCustodian("userId", r.UpdateProfile)), + rest.Put("/v1/users/legacy/:userId/profile", r.requireCustodian("userId", r.UpdateLegacyProfile)), + rest.Post("/v1/users/:userId/profile", r.requireCustodian("userId", r.UpdateProfile)), + rest.Post("/v1/users/legacy/:userId/profile", r.requireCustodian("userId", r.UpdateLegacyProfile)), + rest.Delete("/v1/users/:userId/profile", r.requireCustodian("userId", r.DeleteProfile)), + rest.Delete("/v1/users/legacy/:userId/profile", r.requireCustodian("userId", r.DeleteProfile)), } } diff --git a/permission/client/client.go b/permission/client/client.go index 86cba3968..e0f518156 100644 --- a/permission/client/client.go +++ b/permission/client/client.go @@ -70,7 +70,8 @@ func (c *Client) HasCustodianPermissions(ctx context.Context, granteeUserID, gra if err != nil { return false, err } - return len(perms[permission.Custodian]) > 0, nil + _, ok := perms[permission.Custodian] + return ok, nil } func (c *Client) HasWritePermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) { @@ -81,6 +82,14 @@ func (c *Client) HasWritePermissions(ctx context.Context, granteeUserID, grantor if err != nil { return false, err } - return len(perms[permission.Custodian]) > 0 || len(perms[permission.Write]) > 0 || len(perms[permission.Owner]) > 0, nil - + if _, ok := perms[permission.Custodian]; ok { + return true, nil + } + if _, ok := perms[permission.Write]; ok { + return true, nil + } + if _, ok := perms[permission.Owner]; ok { + return true, nil + } + return false, nil } From 41a1faec9ff2e66a793b46604356d2ac05b0c128 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 1 May 2024 09:16:18 -0700 Subject: [PATCH 31/62] Remove check from route since part of middleware now. --- auth/service/api/v1/profile.go | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index 5cfc6028d..5828f3b91 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -28,21 +28,8 @@ func (r *Router) ProfileRoutes() []*rest.Route { func (r *Router) GetProfile(res rest.ResponseWriter, req *rest.Request) { responder := request.MustNewResponder(res, req) ctx := req.Context() - details := request.GetAuthDetails(ctx) userID := req.PathParam("userId") - if details.IsUser() { - hasPerms, err := r.PermissionsClient().HasMembershipRelationship(ctx, details.UserID(), userID) - if err != nil { - responder.InternalServerError(err) - return - } - if !hasPerms { - responder.Empty(http.StatusForbidden) - return - } - } - user, err := r.UserAccessor().FindUserById(ctx, userID) if err != nil { responder.Error(http.StatusBadRequest, err) @@ -59,21 +46,8 @@ func (r *Router) GetProfile(res rest.ResponseWriter, req *rest.Request) { func (r *Router) GetLegacyProfile(res rest.ResponseWriter, req *rest.Request) { responder := request.MustNewResponder(res, req) ctx := req.Context() - details := request.GetAuthDetails(ctx) userID := req.PathParam("userId") - if details.IsUser() { - hasPerms, err := r.PermissionsClient().HasMembershipRelationship(ctx, details.UserID(), userID) - if err != nil { - responder.InternalServerError(err) - return - } - if !hasPerms { - responder.Empty(http.StatusForbidden) - return - } - } - user, err := r.UserAccessor().FindUserById(ctx, userID) if err != nil { responder.Error(http.StatusBadRequest, err) From 8e5598fee4b86e2c6a6f4002ea04874c4078ea1f Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 29 May 2024 09:23:52 -0700 Subject: [PATCH 32/62] Remove unneeded comment. --- auth/service/api/v1/profile.go | 1 - 1 file changed, 1 deletion(-) diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index 5828f3b91..895fc9974 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -15,7 +15,6 @@ func (r *Router) ProfileRoutes() []*rest.Route { return []*rest.Route{ rest.Get("/v1/users/:userId/profile", r.requireMembership("userId", r.GetProfile)), rest.Get("/v1/users/legacy/:userId/profile", r.requireMembership("userId", r.GetLegacyProfile)), - // The following modification routes required custodian access in seagull, but I'm not sure that's quite right - it seems it should be if the user can modify the userId. rest.Put("/v1/users/:userId/profile", r.requireCustodian("userId", r.UpdateProfile)), rest.Put("/v1/users/legacy/:userId/profile", r.requireCustodian("userId", r.UpdateLegacyProfile)), rest.Post("/v1/users/:userId/profile", r.requireCustodian("userId", r.UpdateProfile)), From d3f829aef7f87bf2a3297b2131707f3c933665ed Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 5 Jun 2024 10:28:15 -0700 Subject: [PATCH 33/62] Add GroupsForUser as a prelude to some seagull / gatekeeper functionality. --- permission/client/client.go | 22 ++++++++++++++++++++++ permission/permission.go | 9 +++++++++ 2 files changed, 31 insertions(+) diff --git a/permission/client/client.go b/permission/client/client.go index e0f518156..413f18886 100644 --- a/permission/client/client.go +++ b/permission/client/client.go @@ -47,6 +47,28 @@ func (c *Client) GetUserPermissions(ctx context.Context, requestUserID string, t return permission.FixOwnerPermissions(result), nil } +// GroupsForUser returns what users have shared permissions with the user with an id of granteeUserID. +// The GroupedPermissions are keyed by the id of the user who shared their permissions with granteeUserID. +func (c *Client) GroupsForUser(ctx context.Context, granteeUserID string) (permission.GroupedPermissions, error) { + if ctx == nil { + return nil, errors.New("context is missing") + } + if granteeUserID == "" { + return nil, errors.New("user id is missing") + } + + url := c.client.ConstructURL("access", "groups", granteeUserID) + result := permission.GroupedPermissions{} + if err := c.client.RequestData(ctx, "GET", url, nil, nil, &result); err != nil { + if request.IsErrorResourceNotFound(err) { + return nil, request.ErrorUnauthorized() + } + return nil, err + } + + return permission.FixGroupedOwnerPermissions(result), nil +} + func (c *Client) HasMembershipRelationship(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) { fromTo, err := c.GetUserPermissions(ctx, granteeUserID, grantorUserID) if err != nil { diff --git a/permission/permission.go b/permission/permission.go index 80e23a56b..3f5e3352b 100644 --- a/permission/permission.go +++ b/permission/permission.go @@ -6,6 +6,7 @@ import ( type Permission map[string]interface{} type Permissions map[string]Permission +type GroupedPermissions map[string]Permissions const ( Follow = "follow" @@ -17,11 +18,19 @@ const ( type Client interface { GetUserPermissions(ctx context.Context, requestUserID string, targetUserID string) (Permissions, error) + GroupsForUser(ctx context.Context, granteeUserID string) (GroupedPermissions, error) HasMembershipRelationship(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) HasCustodianPermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) HasWritePermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) } +func FixGroupedOwnerPermissions(groupPermissions GroupedPermissions) GroupedPermissions { + for key, perms := range groupPermissions { + groupPermissions[key] = FixOwnerPermissions(perms) + } + return groupPermissions +} + func FixOwnerPermissions(permissions Permissions) Permissions { if ownerPermission, ok := permissions[Owner]; ok { if _, ok = permissions[Write]; !ok { From 942e2109e5c73c8e3dbc6871b7a374b1b4b4194c Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 5 Jun 2024 10:41:52 -0700 Subject: [PATCH 34/62] Commence "old" seagull routes that retrieves from the seagull collection as migration will take some time and during that period, old profile may be created / read / modified. These fallback functions and objects can be removed when all profiles are fully migrated. --- .../mongo/fallback_user_profile_repository.go | 111 ++++++++++++++++++ store/structured/mongo/config.go | 12 +- user/fallback_user_accessor.go | 60 ++++++++++ user/keycloak/user_accessor.go | 11 ++ user/legacy_raw_seagull_profile.go | 46 ++++++++ user/profile.go | 41 ++++++- user/user_accessor.go | 29 ++++- 7 files changed, 300 insertions(+), 10 deletions(-) create mode 100644 auth/store/mongo/fallback_user_profile_repository.go create mode 100644 user/fallback_user_accessor.go create mode 100644 user/legacy_raw_seagull_profile.go diff --git a/auth/store/mongo/fallback_user_profile_repository.go b/auth/store/mongo/fallback_user_profile_repository.go new file mode 100644 index 000000000..8fb7dfc1c --- /dev/null +++ b/auth/store/mongo/fallback_user_profile_repository.go @@ -0,0 +1,111 @@ +package mongo + +import ( + "context" + "encoding/json" + stdErrors "errors" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + + "github.com/tidepool-org/platform/errors" + storeStructuredMongo "github.com/tidepool-org/platform/store/structured/mongo" + structureValidator "github.com/tidepool-org/platform/structure/validator" + "github.com/tidepool-org/platform/user" +) + +type FallbackUserProfileRepository struct { + *storeStructuredMongo.Repository +} + +func (p *FallbackUserProfileRepository) EnsureIndexes() error { + return nil +} + +func (p *FallbackUserProfileRepository) FindUserProfile(ctx context.Context, userID string) (*user.LegacyUserProfile, error) { + if ctx == nil { + return nil, errors.New("context is missing") + } + if userID == "" { + return nil, errors.New("user id is missing") + } + selector := bson.M{ + "userId": userID, + } + var profile user.LegacyUserProfile + if err := p.FindOne(ctx, selector).Decode(&profile); err != nil { + if stdErrors.Is(err, mongo.ErrNoDocuments) { + return nil, user.ErrUserProfileNotFound + } + return nil, err + } + return &profile, nil + +} + +func (p *FallbackUserProfileRepository) UpdateUserProfile(ctx context.Context, userID string, profile *user.LegacyUserProfile) error { + if ctx == nil { + return errors.New("context is missing") + } + if userID == "" { + return errors.New("user id is missing") + } + if err := structureValidator.New().Validate(profile); err != nil { + return err + } + var doc user.LegacyRawSeagullProfile + // The original seagull code just had a JSONified string as the value + // so we have to Unmarshal that to a an actual object, add any + // updates to the profile, and then Marshal it back to JSON to store + // in the database. + opts := options.FindOne().SetProjection(bson.M{"_id": 0, "value": 1}) + selector := bson.M{"userId": userID} + err := p.FindOne(ctx, selector, opts).Decode(&doc) + + // A user can have no profile set - see seagull/lib/routes/seagullApi.js `if (err.statusCode == 404 && addIfNotThere)` + var noDocument bool + if stdErrors.Is(err, mongo.ErrNoDocuments) { + noDocument = true + } + if err != nil && !noDocument { + return err + } + // Since the legacy seagull is actually a stringified JSON object + // we need to JSON parse it so we can then add the profile as a regular object, then restrigify it + value := make(map[string]any) + if !noDocument { + if err := json.Unmarshal([]byte(doc.Value), &value); err != nil { + return err + } + } + value["profile"] = profile + valueRaw, err := json.Marshal(value) + if err != nil { + return err + } + uselector := bson.M{"userId": userID} + update := bson.M{ + "$set": bson.M{ + "value": string(valueRaw), + }, + } + uopts := options.Update().SetUpsert(true) + _, err = p.UpdateOne(ctx, uselector, update, uopts) + return err +} + +func (p *FallbackUserProfileRepository) DeleteUserProfile(ctx context.Context, userID string) error { + if ctx == nil { + return errors.New("context is missing") + } + if userID == "" { + return errors.New("user id is missing") + } + + _, err := p.DeleteOne(ctx, bson.M{"userId": userID}) + if err != nil { + return errors.Wrap(err, "unable to delete user profile") + } + return nil +} diff --git a/store/structured/mongo/config.go b/store/structured/mongo/config.go index 72bdf0803..59d5eb1f7 100644 --- a/store/structured/mongo/config.go +++ b/store/structured/mongo/config.go @@ -26,6 +26,12 @@ func LoadConfig() (*Config, error) { return cfg, err } +func LoadConfigPrefix(prefix string) (*Config, error) { + cfg := NewConfig() + err := cfg.LoadPrefix(prefix) + return cfg, err +} + // Config describe parameters need to make a connection to a Mongo database type Config struct { Scheme string `json:"scheme" envconfig:"TIDEPOOL_STORE_SCHEME"` @@ -72,7 +78,11 @@ func (c *Config) AsConnectionString() string { } func (c *Config) Load() error { - return envconfig.Process("", c) + return c.LoadPrefix("") +} + +func (c *Config) LoadPrefix(prefix string) error { + return envconfig.Process(prefix, c) } func (c *Config) SetDatabaseFromReporter(configReporter platformConfig.Reporter) error { diff --git a/user/fallback_user_accessor.go b/user/fallback_user_accessor.go new file mode 100644 index 000000000..a46595b14 --- /dev/null +++ b/user/fallback_user_accessor.go @@ -0,0 +1,60 @@ +package user + +import ( + "context" + "errors" +) + +// FallbackLegacyUserAccessor acts as an intermediary between seagulls +// profile and the new keycloak profile. This is because prior and during +// migration, some profiles may be still in seagull. As such, +// FallbackLegacyUserAccessor will first try to retrieve first. TODO: +// once all profiles are migrated, we can use UserProfileAccessor +// directly and get rid of this. +type FallbackLegacyUserAccessor struct { + legacy LegacyUserProfileAccessor + accessor UserProfileAccessor +} + +func (f *FallbackLegacyUserAccessor) FindUserProfile(ctx context.Context, id string) (*LegacyUserProfile, error) { + seagullProfile, err := f.legacy.FindUserProfile(ctx, id) + if err != nil && !errors.Is(err, ErrUserProfileNotFound) { + return nil, err + } + if seagullProfile != nil && !seagullProfile.Migrated { + return seagullProfile, nil + } + profile, err := f.accessor.FindUserProfile(ctx, id) + if err != nil { + return nil, err + } + if profile == nil { + return nil, ErrUserProfileNotFound + } + return profile.ToLegacyProfile(), nil +} + +func (f *FallbackLegacyUserAccessor) UpdateUserProfile(ctx context.Context, id string, p *LegacyUserProfile) error { + seagullProfile, err := f.legacy.FindUserProfile(ctx, id) + if err != nil && !errors.Is(err, ErrUserProfileNotFound) { + return err + } + // An unmigrated profile should be returned until the profile has been migrated + if seagullProfile != nil && !seagullProfile.Migrated { + return f.legacy.UpdateUserProfile(ctx, id, p) + } + profile := p.ToUserProfile() + return f.accessor.UpdateUserProfile(ctx, id, profile) +} + +func (f *FallbackLegacyUserAccessor) DeleteUserProfile(ctx context.Context, id string) error { + seagullProfile, err := f.legacy.FindUserProfile(ctx, id) + if err != nil && !errors.Is(err, ErrUserProfileNotFound) { + return err + } + if seagullProfile != nil && !seagullProfile.Migrated { + return f.legacy.DeleteUserProfile(ctx, id) + } + return f.accessor.DeleteUserProfile(ctx, id) + +} diff --git a/user/keycloak/user_accessor.go b/user/keycloak/user_accessor.go index dbf41799f..0e5532525 100644 --- a/user/keycloak/user_accessor.go +++ b/user/keycloak/user_accessor.go @@ -63,6 +63,17 @@ func (m *keycloakUserAccessor) FindUserById(ctx context.Context, id string) (*us return newUserFromKeycloakUser(keycloakUser), nil } +func (m *keycloakUserAccessor) FindUserProfile(ctx context.Context, id string) (*userLib.UserProfile, error) { + user, err := m.FindUserById(ctx, id) + if err != nil { + return nil, err + } + if user == nil { + return nil, userLib.ErrUserProfileNotFound + } + return user.Profile, nil +} + func (m *keycloakUserAccessor) FindUsersWithIds(ctx context.Context, ids []string) (users []*userLib.User, err error) { keycloakUsers, err := m.keycloakClient.FindUsersWithIds(ctx, ids) if err != nil { diff --git a/user/legacy_raw_seagull_profile.go b/user/legacy_raw_seagull_profile.go new file mode 100644 index 000000000..b01bccf38 --- /dev/null +++ b/user/legacy_raw_seagull_profile.go @@ -0,0 +1,46 @@ +package user + +import ( + "encoding/json" + "time" +) + +// LegacyRawSeagullProfile is database model representation of the legacy seagull collection object. The value is a raw stringified JSON blob. +// TODO: delete once all profiles are migrated over +type LegacyRawSeagullProfile struct { + UserID string `bson:"userId"` + Value string `bson:"value"` + // The presence of migrationStart in the seagull collection + // means migration of this profile is in progress. + MigrationStart *time.Time `bson:"migrationStart,omitempty"` + // The presence of migrationEnd means the profile is fully migrated and all reads / writes to a user profile should go through keycloak + MigrationEnd *time.Time `bson:"migrationEnd,omitempty"` +} + +// ToLegacyProfile returns an object that is suitable as a JSON response - ie, the profile is not just a stringified JSON blob. +func (up *LegacyRawSeagullProfile) ToLegacyProfile() (*LegacyUserProfile, error) { + var value map[string]any + if err := json.Unmarshal([]byte(up.Value), &value); err != nil { + return nil, err + } + // Unfortunately since the profile is embedded within the raw string, we will need Marshal and Unmarshal to our actual LegacyUserProfile object. + profileRaw, ok := value["profile"].(map[string]any) + if !ok { + return nil, ErrUserProfileNotFound + } + var legacyProfile LegacyUserProfile + if err := marshalThenUnmarshal(profileRaw, &legacyProfile); err != nil { + return nil, err + } + return &legacyProfile, nil +} + +// marshalThenUnmarshal marshal's src into JSON, then Unmarshals +// that JSON into dst. This is needed if we only need to marshal part of a map[string]any object into dst +func marshalThenUnmarshal(src, dst any) error { + bytes, err := json.Marshal(src) + if err != nil { + return err + } + return json.Unmarshal(bytes, dst) +} diff --git a/user/profile.go b/user/profile.go index a4a5c7ae6..8296162c8 100644 --- a/user/profile.go +++ b/user/profile.go @@ -12,6 +12,18 @@ const ( maxNameLength = 256 ) +var ( + diabetesTypes = []string{ + "type1", + "type2", + "gestational", + "lada", + "other", + "prediabetes", + "mody", + } +) + // Date is a string of type YYYY-mm-dd, the reason this isn't just a type definition // of a time.Time is to ignore timezones when marshaling. type Date string @@ -46,6 +58,7 @@ func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { About: up.About, MRN: up.MRN, }, + Migrated: true, // If we have a non legacy UserProfile, then that means the legacy version has been migrated from seagull } // only custodiaL fake child accounts have Patient.FullName set if up.Custodian != nil { @@ -84,9 +97,11 @@ func (p *LegacyUserProfile) ToUserProfile() *UserProfile { // LegacyUserProfile represents the old seagull format for a profile. type LegacyUserProfile struct { - FullName string `json:"fullName"` - Patient *LegacyPatientProfile `json:"patient,omitempty"` - Clinic *ClinicProfile `json:"clinic,omitempty"` + FullName string `json:"fullName"` + Patient *LegacyPatientProfile `json:"patient,omitempty"` + Clinic *ClinicProfile `json:"clinic,omitempty"` + Migrated bool `json:"-"` + MigrationInProgress bool `json:"-"` } type LegacyPatientProfile struct { @@ -244,8 +259,28 @@ func (up *UserProfile) Validate(v structure.Validator) { up.Birthday.Validate(v.WithReference("birthday")) up.DiagnosisDate.Validate(v.WithReference("diagnosisDate")) v.String("fullName", &up.FullName).LengthLessThanOrEqualTo(maxNameLength) + if up.DiagnosisType != "" { + v.String("diagnosisType", &up.DiagnosisType).OneOf(diabetesTypes...) + } } func (p *ClinicProfile) Validate(v structure.Validator) { + // TODO: confirm: can be empty v.String("name", &p.Name).NotEmpty().LengthLessThanOrEqualTo(maxNameLength) } + +func (up *LegacyUserProfile) Validate(v structure.Validator) { + if up.Patient != nil { + up.Patient.Validate(v.WithReference("patient")) + } + v.String("fullName", &up.FullName).LengthLessThanOrEqualTo(maxNameLength) +} + +func (pp *LegacyPatientProfile) Validate(v structure.Validator) { + pp.Birthday.Validate(v.WithReference("birthday")) + pp.DiagnosisDate.Validate(v.WithReference("diagnosisDate")) + v.String("fullName", &pp.FullName).LengthLessThanOrEqualTo(maxNameLength) + if pp.DiagnosisType != "" { + v.String("diagnosisType", &pp.DiagnosisType).OneOf(diabetesTypes...) + } +} diff --git a/user/user_accessor.go b/user/user_accessor.go index a7c2e6d30..e348c8b0d 100644 --- a/user/user_accessor.go +++ b/user/user_accessor.go @@ -16,22 +16,39 @@ const ( var ( ShorelineManagedRoles = map[string]struct{}{"patient": {}, "clinic": {}, "clinician": {}, "custodial_account": {}} - ErrUserNotFound = errors.New("user not found") - ErrUserConflict = errors.New("user already exists") - ErrEmailConflict = errors.New("email already exists") - ErrUserNotMigrated = errors.New("user has not been migrated") + ErrUserNotFound = errors.New("user not found") + ErrUserProfileNotFound = errors.New("profile not found") + ErrUserConflict = errors.New("user already exists") + ErrEmailConflict = errors.New("email already exists") + ErrUserNotMigrated = errors.New("user has not been migrated") + // ErrUserProfileMigrationInProgress means a specific user profile is + // currently being migrated so the client should ideally wait and + // retry their operation again - the migration for a single user + // should be no longer than a few seconds. + ErrUserProfileMigrationInProgress = errors.New("user migration is in progress") ) +type LegacyUserProfileAccessor interface { + FindUserProfile(ctx context.Context, id string) (*LegacyUserProfile, error) + UpdateUserProfile(ctx context.Context, id string, p *LegacyUserProfile) error + DeleteUserProfile(ctx context.Context, id string) error +} + +type UserProfileAccessor interface { + FindUserProfile(ctx context.Context, id string) (*UserProfile, error) + UpdateUserProfile(ctx context.Context, id string, p *UserProfile) error + DeleteUserProfile(ctx context.Context, id string) error +} + // UserAccessor is the interface that can retrieve users. // It is the equivalent of shoreline's shoreline's Storage // interface, but for now will only retrieve user // information. type UserAccessor interface { + UserProfileAccessor FindUser(ctx context.Context, user *User) (*User, error) FindUserById(ctx context.Context, id string) (*User, error) FindUsersWithIds(ctx context.Context, ids []string) ([]*User, error) - UpdateUserProfile(ctx context.Context, id string, p *UserProfile) error - DeleteUserProfile(ctx context.Context, id string) error } type TokenIntrospectionResult struct { From 97d74dff2884d25e41a7a7e5dd5ec9053f0f20fd Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 5 Jun 2024 14:10:01 -0700 Subject: [PATCH 35/62] Update migration status. --- user/fallback_user_accessor.go | 6 +++--- user/legacy_raw_seagull_profile.go | 20 ++++++++++++++++---- user/profile.go | 17 +++++++++++------ 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/user/fallback_user_accessor.go b/user/fallback_user_accessor.go index a46595b14..a76c1caff 100644 --- a/user/fallback_user_accessor.go +++ b/user/fallback_user_accessor.go @@ -21,7 +21,7 @@ func (f *FallbackLegacyUserAccessor) FindUserProfile(ctx context.Context, id str if err != nil && !errors.Is(err, ErrUserProfileNotFound) { return nil, err } - if seagullProfile != nil && !seagullProfile.Migrated { + if seagullProfile != nil && seagullProfile.MigrationStatus == migrationUnmigrated { return seagullProfile, nil } profile, err := f.accessor.FindUserProfile(ctx, id) @@ -40,7 +40,7 @@ func (f *FallbackLegacyUserAccessor) UpdateUserProfile(ctx context.Context, id s return err } // An unmigrated profile should be returned until the profile has been migrated - if seagullProfile != nil && !seagullProfile.Migrated { + if seagullProfile != nil && seagullProfile.MigrationStatus == migrationUnmigrated { return f.legacy.UpdateUserProfile(ctx, id, p) } profile := p.ToUserProfile() @@ -52,7 +52,7 @@ func (f *FallbackLegacyUserAccessor) DeleteUserProfile(ctx context.Context, id s if err != nil && !errors.Is(err, ErrUserProfileNotFound) { return err } - if seagullProfile != nil && !seagullProfile.Migrated { + if seagullProfile != nil && seagullProfile.MigrationStatus == migrationUnmigrated { return f.legacy.DeleteUserProfile(ctx, id) } return f.accessor.DeleteUserProfile(ctx, id) diff --git a/user/legacy_raw_seagull_profile.go b/user/legacy_raw_seagull_profile.go index b01bccf38..a88873cee 100644 --- a/user/legacy_raw_seagull_profile.go +++ b/user/legacy_raw_seagull_profile.go @@ -10,11 +10,15 @@ import ( type LegacyRawSeagullProfile struct { UserID string `bson:"userId"` Value string `bson:"value"` - // The presence of migrationStart in the seagull collection - // means migration of this profile is in progress. - MigrationStart *time.Time `bson:"migrationStart,omitempty"` + + // The presence of these various migration markers indicate the migration + // status of a seagull profile into keycloak. A non nil MigrationStart and + // nil MigrationEnd indicates an inprogress migration UNLESS MigrationError + // is non empty, in which migration should be reattempted. + MigrationStart *time.Time `bson:"_migrationStart,omitempty"` // The presence of migrationEnd means the profile is fully migrated and all reads / writes to a user profile should go through keycloak - MigrationEnd *time.Time `bson:"migrationEnd,omitempty"` + MigrationEnd *time.Time `bson:"_migrationEnd,omitempty"` + MigrationError string `bson:"_migrationError,omitempty"` } // ToLegacyProfile returns an object that is suitable as a JSON response - ie, the profile is not just a stringified JSON blob. @@ -32,6 +36,14 @@ func (up *LegacyRawSeagullProfile) ToLegacyProfile() (*LegacyUserProfile, error) if err := marshalThenUnmarshal(profileRaw, &legacyProfile); err != nil { return nil, err } + + legacyProfile.MigrationStatus = migrationUnmigrated + if up.MigrationStart != nil && up.MigrationEnd != nil { + legacyProfile.MigrationStatus = migrationCompleted + } + if up.MigrationStart != nil && up.MigrationEnd == nil && up.MigrationError == "" { + legacyProfile.MigrationStatus = migrationInProgress + } return &legacyProfile, nil } diff --git a/user/profile.go b/user/profile.go index 8296162c8..79a53703b 100644 --- a/user/profile.go +++ b/user/profile.go @@ -7,7 +7,13 @@ import ( "github.com/tidepool-org/platform/structure" ) +type migrationStatus int + const ( + migrationUnmigrated migrationStatus = iota + migrationCompleted + migrationInProgress + maxAboutLength = 256 maxNameLength = 256 ) @@ -58,7 +64,7 @@ func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { About: up.About, MRN: up.MRN, }, - Migrated: true, // If we have a non legacy UserProfile, then that means the legacy version has been migrated from seagull + MigrationStatus: migrationCompleted, // If we have a non legacy UserProfile, then that means the legacy version has been migrated from seagull (or it never existed which is equivalent for the new user profile purposes) } // only custodiaL fake child accounts have Patient.FullName set if up.Custodian != nil { @@ -97,11 +103,10 @@ func (p *LegacyUserProfile) ToUserProfile() *UserProfile { // LegacyUserProfile represents the old seagull format for a profile. type LegacyUserProfile struct { - FullName string `json:"fullName"` - Patient *LegacyPatientProfile `json:"patient,omitempty"` - Clinic *ClinicProfile `json:"clinic,omitempty"` - Migrated bool `json:"-"` - MigrationInProgress bool `json:"-"` + FullName string `json:"fullName"` + Patient *LegacyPatientProfile `json:"patient,omitempty"` + Clinic *ClinicProfile `json:"clinic,omitempty"` + MigrationStatus migrationStatus `json:"-"` } type LegacyPatientProfile struct { From 6721784e2722e8b8ac29829573de57416d3e874e Mon Sep 17 00:00:00 2001 From: lostlevels Date: Thu, 6 Jun 2024 21:12:52 -0700 Subject: [PATCH 36/62] Make sure seagull.value field is preserved properly during updates and reads. --- .../mongo/fallback_user_profile_repository.go | 39 ++++------- user/legacy_raw_seagull_profile.go | 68 ++++++++++++++----- 2 files changed, 64 insertions(+), 43 deletions(-) diff --git a/auth/store/mongo/fallback_user_profile_repository.go b/auth/store/mongo/fallback_user_profile_repository.go index 8fb7dfc1c..b555d8042 100644 --- a/auth/store/mongo/fallback_user_profile_repository.go +++ b/auth/store/mongo/fallback_user_profile_repository.go @@ -2,7 +2,6 @@ package mongo import ( "context" - "encoding/json" stdErrors "errors" "go.mongodb.org/mongo-driver/bson" @@ -33,15 +32,15 @@ func (p *FallbackUserProfileRepository) FindUserProfile(ctx context.Context, use selector := bson.M{ "userId": userID, } - var profile user.LegacyUserProfile - if err := p.FindOne(ctx, selector).Decode(&profile); err != nil { + var doc user.LegacySeagullDocument + if err := p.FindOne(ctx, selector).Decode(&doc); err != nil { if stdErrors.Is(err, mongo.ErrNoDocuments) { return nil, user.ErrUserProfileNotFound } return nil, err } - return &profile, nil + return doc.ToLegacyProfile() } func (p *FallbackUserProfileRepository) UpdateUserProfile(ctx context.Context, userID string, profile *user.LegacyUserProfile) error { @@ -54,40 +53,26 @@ func (p *FallbackUserProfileRepository) UpdateUserProfile(ctx context.Context, u if err := structureValidator.New().Validate(profile); err != nil { return err } - var doc user.LegacyRawSeagullProfile - // The original seagull code just had a JSONified string as the value - // so we have to Unmarshal that to a an actual object, add any - // updates to the profile, and then Marshal it back to JSON to store - // in the database. - opts := options.FindOne().SetProjection(bson.M{"_id": 0, "value": 1}) + var doc user.LegacySeagullDocument selector := bson.M{"userId": userID} - err := p.FindOne(ctx, selector, opts).Decode(&doc) + err := p.FindOne(ctx, selector).Decode(&doc) // A user can have no profile set - see seagull/lib/routes/seagullApi.js `if (err.statusCode == 404 && addIfNotThere)` - var noDocument bool - if stdErrors.Is(err, mongo.ErrNoDocuments) { - noDocument = true - } - if err != nil && !noDocument { + if err != nil && !stdErrors.Is(err, mongo.ErrNoDocuments) { return err } - // Since the legacy seagull is actually a stringified JSON object - // we need to JSON parse it so we can then add the profile as a regular object, then restrigify it - value := make(map[string]any) - if !noDocument { - if err := json.Unmarshal([]byte(doc.Value), &value); err != nil { - return err - } - } - value["profile"] = profile - valueRaw, err := json.Marshal(value) + + // This will create a new value even if doc.Value is empty + updatedValueRaw, err := user.AddProfileToSeagullValue(doc.Value, profile) if err != nil { return err } + uselector := bson.M{"userId": userID} update := bson.M{ "$set": bson.M{ - "value": string(valueRaw), + "value": updatedValueRaw, + "userId": userID, // Set because of possible upsert }, } uopts := options.Update().SetUpsert(true) diff --git a/user/legacy_raw_seagull_profile.go b/user/legacy_raw_seagull_profile.go index a88873cee..ba1d1bd3d 100644 --- a/user/legacy_raw_seagull_profile.go +++ b/user/legacy_raw_seagull_profile.go @@ -5,9 +5,10 @@ import ( "time" ) -// LegacyRawSeagullProfile is database model representation of the legacy seagull collection object. The value is a raw stringified JSON blob. -// TODO: delete once all profiles are migrated over -type LegacyRawSeagullProfile struct { +// LegacySeagullDocument is the database model representation of the legacy +// seagull collection object. The value is a raw stringified JSON blob. TODO: +// delete once all profiles are migrated over +type LegacySeagullDocument struct { UserID string `bson:"userId"` Value string `bson:"value"` @@ -17,39 +18,74 @@ type LegacyRawSeagullProfile struct { // is non empty, in which migration should be reattempted. MigrationStart *time.Time `bson:"_migrationStart,omitempty"` // The presence of migrationEnd means the profile is fully migrated and all reads / writes to a user profile should go through keycloak - MigrationEnd *time.Time `bson:"_migrationEnd,omitempty"` - MigrationError string `bson:"_migrationError,omitempty"` + MigrationEnd *time.Time `bson:"_migrationEnd,omitempty"` + MigrationError string `bson:"_migrationError,omitempty"` + MigrationErrorTime *time.Time `bson:"_migrationErrorTime,omitempty"` } // ToLegacyProfile returns an object that is suitable as a JSON response - ie, the profile is not just a stringified JSON blob. -func (up *LegacyRawSeagullProfile) ToLegacyProfile() (*LegacyUserProfile, error) { - var value map[string]any - if err := json.Unmarshal([]byte(up.Value), &value); err != nil { +func (doc *LegacySeagullDocument) ToLegacyProfile() (*LegacyUserProfile, error) { + valueObj, err := extractSeagullValue(doc.Value) + if err != nil { return nil, err } - // Unfortunately since the profile is embedded within the raw string, we will need Marshal and Unmarshal to our actual LegacyUserProfile object. - profileRaw, ok := value["profile"].(map[string]any) + // Unfortunately since the profile is embedded within the raw string and unmarshaled to a map[string]any, we will need Marshal and Unmarshal to our actual LegacyUserProfile object. + profileRaw, ok := valueObj["profile"].(map[string]any) if !ok { return nil, ErrUserProfileNotFound } var legacyProfile LegacyUserProfile - if err := marshalThenUnmarshal(profileRaw, &legacyProfile); err != nil { + if err := MarshalThenUnmarshal(profileRaw, &legacyProfile); err != nil { return nil, err } legacyProfile.MigrationStatus = migrationUnmigrated - if up.MigrationStart != nil && up.MigrationEnd != nil { + if doc.MigrationStart != nil && doc.MigrationEnd != nil { legacyProfile.MigrationStatus = migrationCompleted } - if up.MigrationStart != nil && up.MigrationEnd == nil && up.MigrationError == "" { + if doc.MigrationStart != nil && doc.MigrationEnd == nil && doc.MigrationError == "" { legacyProfile.MigrationStatus = migrationInProgress } return &legacyProfile, nil } -// marshalThenUnmarshal marshal's src into JSON, then Unmarshals -// that JSON into dst. This is needed if we only need to marshal part of a map[string]any object into dst -func marshalThenUnmarshal(src, dst any) error { +// extractSeagullValue unmarshals the jsonified string field "value" in the +// seagull collection to a map[string]any - the reason the fields aren't +// explicitly defined is because there is / was no defined schema at the +// time for seagull, so we should preserve these fields. +func extractSeagullValue(valueRaw string) (valueAsMap map[string]any, err error) { + var value map[string]any + if err := json.Unmarshal([]byte(valueRaw), &value); err != nil { + return nil, err + } + return value, nil +} + +// AddProfileToSeagullValue takes a legacy profile and adds it to an +// existing valueObj (the unmarshaled "value" of the seagull +// collection"), then returns the marshaled version of it. It returns +// this new object as a raw string to be compatible with the seagull +// collection. This is done to preserve any non profile fields that were +// stored in the "value" field - TODO: is this even necessary? was any +// non profile info just junk? TODO confirm +func AddProfileToSeagullValue(valueRaw string, profile *LegacyUserProfile) (updatedValueRaw string, err error) { + valueObj, err := extractSeagullValue(valueRaw) + // If there was an error, just make a new field "value" value. + if err != nil { + valueObj = map[string]any{} + } + valueObj["profile"] = profile + bytes, err := json.Marshal(valueObj) + if err != nil { + return "", err + } + return string(bytes), nil +} + +// MarshalThenUnmarshal marshal's src into JSON, then Unmarshals that +// JSON into dst. This is useful if src has some fields fields common to +// dst but are defined explicitly or in the same way. +func MarshalThenUnmarshal(src, dst any) error { bytes, err := json.Marshal(src) if err != nil { return err From 1354fa92f6206c59bdefaea1a08a2eb37547aac7 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 11 Jun 2024 13:00:00 -0700 Subject: [PATCH 37/62] Use fallback profile accessor to check for profile first in seagull. --- auth/service/api/v1/profile.go | 84 +++++++++++++------ auth/service/service.go | 1 + auth/service/service/service.go | 58 ++++++++++--- .../mongo/fallback_user_profile_repository.go | 21 ++++- user/fallback_user_accessor.go | 18 ++-- user/user_accessor.go | 2 +- 6 files changed, 136 insertions(+), 48 deletions(-) diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index 895fc9974..c8ed1f8dd 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -1,7 +1,8 @@ package v1 import ( - stdErrs "errors" + "context" + "errors" "net/http" "github.com/ant0ine/go-json-rest/rest" @@ -24,40 +25,51 @@ func (r *Router) ProfileRoutes() []*rest.Route { } } +func (r *Router) getProfile(ctx context.Context, userID string) (*user.UserProfile, error) { + // Until seagull migration is complete use UserProfileAccessor() to get a profile instead of the profile within the user itself. + profile, err := r.UserProfileAccessor().FindUserProfile(ctx, userID) + if err != nil { + return nil, err + } + if profile == nil { + return nil, user.ErrUserProfileNotFound + } + // Once seagull migration is compelte, we can return + // the profile attached to the user directly via person.Profile + // through r.UserAccessor().FindUserProfile + return profile, nil +} + func (r *Router) GetProfile(res rest.ResponseWriter, req *rest.Request) { responder := request.MustNewResponder(res, req) ctx := req.Context() userID := req.PathParam("userId") - - user, err := r.UserAccessor().FindUserById(ctx, userID) - if err != nil { - responder.Error(http.StatusBadRequest, err) + if r.handledUserNotExists(ctx, responder, userID) { return } - if user == nil || user.Profile == nil { - responder.Empty(http.StatusNotFound) + + profile, err := r.getProfile(ctx, userID) + if err != nil { + r.handleProfileErr(responder, err) return } - - responder.Data(http.StatusOK, user.Profile) + responder.Data(http.StatusOK, profile) } func (r *Router) GetLegacyProfile(res rest.ResponseWriter, req *rest.Request) { responder := request.MustNewResponder(res, req) ctx := req.Context() userID := req.PathParam("userId") - - user, err := r.UserAccessor().FindUserById(ctx, userID) - if err != nil { - responder.Error(http.StatusBadRequest, err) + if r.handledUserNotExists(ctx, responder, userID) { return } - if user == nil || user.Profile == nil { - responder.Empty(http.StatusNotFound) + + profile, err := r.getProfile(ctx, userID) + if err != nil { + r.handleProfileErr(responder, err) return } - - responder.Data(http.StatusOK, user.Profile.ToLegacyProfile()) + responder.Data(http.StatusOK, profile.ToLegacyProfile()) } func (r *Router) UpdateProfile(res rest.ResponseWriter, req *rest.Request) { @@ -90,13 +102,12 @@ func (r *Router) updateProfile(res rest.ResponseWriter, req *rest.Request, profi responder.Error(http.StatusBadRequest, err) return } - err := r.UserAccessor().UpdateUserProfile(ctx, userID, profile) - if stdErrs.Is(err, user.ErrUserNotFound) { - responder.Empty(http.StatusNotFound) + if r.handledUserNotExists(ctx, responder, userID) { return } - if err != nil { - responder.InternalServerError(err) + // Once seagull migration is complete, we can use r.UserAccessor().UpdateUserProfile. + if err := r.UserProfileAccessor().UpdateUserProfile(ctx, userID, profile); err != nil { + r.handleProfileErr(responder, err) return } responder.Empty(http.StatusOK) @@ -107,14 +118,33 @@ func (r *Router) DeleteProfile(res rest.ResponseWriter, req *rest.Request) { ctx := req.Context() userID := req.PathParam("userId") - err := r.UserAccessor().DeleteUserProfile(ctx, userID) - if stdErrs.Is(err, user.ErrUserNotFound) { + err := r.UserProfileAccessor().DeleteUserProfile(ctx, userID) + if err != nil { + r.handleProfileErr(responder, err) + return + } + responder.Empty(http.StatusOK) +} + +func (r *Router) handleProfileErr(responder *request.Responder, err error) { + switch { + case errors.Is(err, user.ErrUserNotFound), errors.Is(err, user.ErrUserProfileNotFound): responder.Empty(http.StatusNotFound) return + default: + responder.InternalServerError(err) } +} + +func (r *Router) handledUserNotExists(ctx context.Context, responder *request.Responder, userID string) (handled bool) { + person, err := r.UserAccessor().FindUserById(ctx, userID) if err != nil { - responder.InternalServerError(err) - return + r.handleProfileErr(responder, err) + return true } - responder.Empty(http.StatusOK) + if person == nil { + responder.Empty(http.StatusNotFound) + return true + } + return false } diff --git a/auth/service/service.go b/auth/service/service.go index 64d0abefe..ca60a3151 100644 --- a/auth/service/service.go +++ b/auth/service/service.go @@ -21,6 +21,7 @@ type Service interface { Domain() string AuthStore() store.Store UserAccessor() user.UserAccessor + UserProfileAccessor() user.UserProfileAccessor // UserProfileAccessor is separate from UserAccessor while the seagull migration is in progress because the user returned from UserAccessor is the keycloak user and their profile may not have been migrated yet PermissionsClient() permission.Client ProviderFactory() provider.Factory diff --git a/auth/service/service/service.go b/auth/service/service/service.go index 7635e68a7..8150b7f98 100644 --- a/auth/service/service/service.go +++ b/auth/service/service/service.go @@ -51,17 +51,18 @@ func (c *confirmationClientConfig) Load() error { type Service struct { *serviceService.Service - domain string - authStore *authMongo.Store - dataSourceClient *dataSourceClient.Client - confirmationClient confirmationClient.ClientWithResponsesInterface - taskClient task.Client - providerFactory provider.Factory - authClient *Client - userEventsHandler events.Runner - deviceCheck apple.DeviceCheck - userAccessor user.UserAccessor - permsClient *permissionClient.Client + domain string + authStore *authMongo.Store + dataSourceClient *dataSourceClient.Client + confirmationClient confirmationClient.ClientWithResponsesInterface + taskClient task.Client + providerFactory provider.Factory + authClient *Client + userEventsHandler events.Runner + deviceCheck apple.DeviceCheck + userAccessor user.UserAccessor + userProfileAccessor user.UserProfileAccessor + permsClient *permissionClient.Client } func New() *Service { @@ -117,6 +118,9 @@ func (s *Service) Initialize(provider application.Provider) error { if err := s.initializeUserAccessor(); err != nil { return err } + if err := s.initializeUserProfileAccessor(s.userAccessor); err != nil { + return err + } if err := s.initializePermissionsClient(); err != nil { return err } @@ -168,6 +172,10 @@ func (s *Service) UserAccessor() user.UserAccessor { return s.userAccessor } +func (s *Service) UserProfileAccessor() user.UserProfileAccessor { + return s.userProfileAccessor +} + func (s *Service) PermissionsClient() permission.Client { return s.permsClient } @@ -466,6 +474,34 @@ func (s *Service) initializeUserAccessor() error { return nil } +func (s *Service) initializeUserProfileAccessor(userAccessor user.UserAccessor) error { + s.Logger().Debug("Initializing user profile accessor") + + if userAccessor == nil { + return errors.New("empty user accessor passed to initializeUserProfileAccessor") + } + cfg := storeStructuredMongo.NewConfig() + // Note the "SEAGULL" prefix, this is so that the regular env vars + // for mongo access such as TIDEPOOL_STORE_SCHEME are + // SEAGULL_TIDEPOOL_STORE_SCHEME so as to not conflict with existing + // TIDEPOOL_STORE_SCHEME values. This is done instead of using a + // seagull client as seagull will eventually be removed so no sense + // in keeping it around. + if err := cfg.LoadPrefix("SEAGULL"); err != nil { + return errors.Wrap(err, "unable to load seagull profile accessor config") + } + + s.Logger().Debug("creating legacy seagull profile accessor") + + repo, err := authMongo.NewFallbackUserProfileRepository(cfg) + if err != nil { + return errors.Wrap(err, "unable to create fallback user profile repository") + } + + s.userProfileAccessor = user.NewFallbackLegacyUserAccessor(repo, userAccessor) + return nil +} + func (s *Service) initializeDeviceCheck() error { s.Logger().Debug("Initializing device check") diff --git a/auth/store/mongo/fallback_user_profile_repository.go b/auth/store/mongo/fallback_user_profile_repository.go index b555d8042..d2a21141f 100644 --- a/auth/store/mongo/fallback_user_profile_repository.go +++ b/auth/store/mongo/fallback_user_profile_repository.go @@ -18,6 +18,20 @@ type FallbackUserProfileRepository struct { *storeStructuredMongo.Repository } +func NewFallbackUserProfileRepository(c *storeStructuredMongo.Config) (*FallbackUserProfileRepository, error) { + if c == nil { + return nil, errors.New("config is missing") + } + + store, err := storeStructuredMongo.NewStore(c) + if err != nil { + return nil, err + } + return &FallbackUserProfileRepository{ + store.GetRepository("seagull"), + }, nil +} + func (p *FallbackUserProfileRepository) EnsureIndexes() error { return nil } @@ -43,14 +57,15 @@ func (p *FallbackUserProfileRepository) FindUserProfile(ctx context.Context, use return doc.ToLegacyProfile() } -func (p *FallbackUserProfileRepository) UpdateUserProfile(ctx context.Context, userID string, profile *user.LegacyUserProfile) error { +func (p *FallbackUserProfileRepository) UpdateUserProfile(ctx context.Context, userID string, profile *user.UserProfile) error { if ctx == nil { return errors.New("context is missing") } if userID == "" { return errors.New("user id is missing") } - if err := structureValidator.New().Validate(profile); err != nil { + legacyProfile := profile.ToLegacyProfile() + if err := structureValidator.New().Validate(legacyProfile); err != nil { return err } var doc user.LegacySeagullDocument @@ -63,7 +78,7 @@ func (p *FallbackUserProfileRepository) UpdateUserProfile(ctx context.Context, u } // This will create a new value even if doc.Value is empty - updatedValueRaw, err := user.AddProfileToSeagullValue(doc.Value, profile) + updatedValueRaw, err := user.AddProfileToSeagullValue(doc.Value, legacyProfile) if err != nil { return err } diff --git a/user/fallback_user_accessor.go b/user/fallback_user_accessor.go index a76c1caff..e5850a41e 100644 --- a/user/fallback_user_accessor.go +++ b/user/fallback_user_accessor.go @@ -16,13 +16,20 @@ type FallbackLegacyUserAccessor struct { accessor UserProfileAccessor } -func (f *FallbackLegacyUserAccessor) FindUserProfile(ctx context.Context, id string) (*LegacyUserProfile, error) { +func NewFallbackLegacyUserAccessor(legacy LegacyUserProfileAccessor, accessor UserProfileAccessor) *FallbackLegacyUserAccessor { + return &FallbackLegacyUserAccessor{ + legacy: legacy, + accessor: accessor, + } +} + +func (f *FallbackLegacyUserAccessor) FindUserProfile(ctx context.Context, id string) (*UserProfile, error) { seagullProfile, err := f.legacy.FindUserProfile(ctx, id) if err != nil && !errors.Is(err, ErrUserProfileNotFound) { return nil, err } if seagullProfile != nil && seagullProfile.MigrationStatus == migrationUnmigrated { - return seagullProfile, nil + return seagullProfile.ToUserProfile(), nil } profile, err := f.accessor.FindUserProfile(ctx, id) if err != nil { @@ -31,19 +38,18 @@ func (f *FallbackLegacyUserAccessor) FindUserProfile(ctx context.Context, id str if profile == nil { return nil, ErrUserProfileNotFound } - return profile.ToLegacyProfile(), nil + return profile, nil } -func (f *FallbackLegacyUserAccessor) UpdateUserProfile(ctx context.Context, id string, p *LegacyUserProfile) error { +func (f *FallbackLegacyUserAccessor) UpdateUserProfile(ctx context.Context, id string, profile *UserProfile) error { seagullProfile, err := f.legacy.FindUserProfile(ctx, id) if err != nil && !errors.Is(err, ErrUserProfileNotFound) { return err } // An unmigrated profile should be returned until the profile has been migrated if seagullProfile != nil && seagullProfile.MigrationStatus == migrationUnmigrated { - return f.legacy.UpdateUserProfile(ctx, id, p) + return f.legacy.UpdateUserProfile(ctx, id, profile) } - profile := p.ToUserProfile() return f.accessor.UpdateUserProfile(ctx, id, profile) } diff --git a/user/user_accessor.go b/user/user_accessor.go index e348c8b0d..ffab4c474 100644 --- a/user/user_accessor.go +++ b/user/user_accessor.go @@ -30,7 +30,7 @@ var ( type LegacyUserProfileAccessor interface { FindUserProfile(ctx context.Context, id string) (*LegacyUserProfile, error) - UpdateUserProfile(ctx context.Context, id string, p *LegacyUserProfile) error + UpdateUserProfile(ctx context.Context, id string, p *UserProfile) error DeleteUserProfile(ctx context.Context, id string) error } From 3195e7b0bf33214e3f9eb4ca8ab30ee046e3b81c Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 11 Jun 2024 13:03:08 -0700 Subject: [PATCH 38/62] Rename repository for clarity of purpose. --- auth/service/service/service.go | 2 +- ...y.go => legacy_seagull_profile_repository.go} | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) rename auth/store/mongo/{fallback_user_profile_repository.go => legacy_seagull_profile_repository.go} (76%) diff --git a/auth/service/service/service.go b/auth/service/service/service.go index 8150b7f98..b421c6af8 100644 --- a/auth/service/service/service.go +++ b/auth/service/service/service.go @@ -493,7 +493,7 @@ func (s *Service) initializeUserProfileAccessor(userAccessor user.UserAccessor) s.Logger().Debug("creating legacy seagull profile accessor") - repo, err := authMongo.NewFallbackUserProfileRepository(cfg) + repo, err := authMongo.NewLegacySeagullProfileRepository(cfg) if err != nil { return errors.Wrap(err, "unable to create fallback user profile repository") } diff --git a/auth/store/mongo/fallback_user_profile_repository.go b/auth/store/mongo/legacy_seagull_profile_repository.go similarity index 76% rename from auth/store/mongo/fallback_user_profile_repository.go rename to auth/store/mongo/legacy_seagull_profile_repository.go index d2a21141f..e31828a8e 100644 --- a/auth/store/mongo/fallback_user_profile_repository.go +++ b/auth/store/mongo/legacy_seagull_profile_repository.go @@ -14,11 +14,13 @@ import ( "github.com/tidepool-org/platform/user" ) -type FallbackUserProfileRepository struct { +// LegacySeagullProfileRepository accesses legacy seagull profiles while the +// seagll migration to keycloak is in progress. +type LegacySeagullProfileRepository struct { *storeStructuredMongo.Repository } -func NewFallbackUserProfileRepository(c *storeStructuredMongo.Config) (*FallbackUserProfileRepository, error) { +func NewLegacySeagullProfileRepository(c *storeStructuredMongo.Config) (*LegacySeagullProfileRepository, error) { if c == nil { return nil, errors.New("config is missing") } @@ -27,16 +29,16 @@ func NewFallbackUserProfileRepository(c *storeStructuredMongo.Config) (*Fallback if err != nil { return nil, err } - return &FallbackUserProfileRepository{ + return &LegacySeagullProfileRepository{ store.GetRepository("seagull"), }, nil } -func (p *FallbackUserProfileRepository) EnsureIndexes() error { +func (p *LegacySeagullProfileRepository) EnsureIndexes() error { return nil } -func (p *FallbackUserProfileRepository) FindUserProfile(ctx context.Context, userID string) (*user.LegacyUserProfile, error) { +func (p *LegacySeagullProfileRepository) FindUserProfile(ctx context.Context, userID string) (*user.LegacyUserProfile, error) { if ctx == nil { return nil, errors.New("context is missing") } @@ -57,7 +59,7 @@ func (p *FallbackUserProfileRepository) FindUserProfile(ctx context.Context, use return doc.ToLegacyProfile() } -func (p *FallbackUserProfileRepository) UpdateUserProfile(ctx context.Context, userID string, profile *user.UserProfile) error { +func (p *LegacySeagullProfileRepository) UpdateUserProfile(ctx context.Context, userID string, profile *user.UserProfile) error { if ctx == nil { return errors.New("context is missing") } @@ -95,7 +97,7 @@ func (p *FallbackUserProfileRepository) UpdateUserProfile(ctx context.Context, u return err } -func (p *FallbackUserProfileRepository) DeleteUserProfile(ctx context.Context, userID string) error { +func (p *LegacySeagullProfileRepository) DeleteUserProfile(ctx context.Context, userID string) error { if ctx == nil { return errors.New("context is missing") } From e993dd1b1dcbb9b6848c28a307f0baab3dc339a6 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 19 Jun 2024 11:19:34 -0700 Subject: [PATCH 39/62] Bump gocloak. --- go.mod | 11 +- go.sum | 19 +- user/keycloak/client.go | 2 +- .../github.com/Nerzal/gocloak/v13/client.go | 42 ++- .../github.com/Nerzal/gocloak/v13/models.go | 31 +- .../Nerzal/gocloak/v13/pkg/jwx/jwx.go | 2 +- .../Nerzal/gocloak/v13/pkg/jwx/models.go | 4 +- .../github.com/golang-jwt/jwt/v5/.gitignore | 4 + vendor/github.com/golang-jwt/jwt/v5/LICENSE | 9 + .../golang-jwt/jwt/v5/MIGRATION_GUIDE.md | 185 +++++++++++ vendor/github.com/golang-jwt/jwt/v5/README.md | 167 ++++++++++ .../github.com/golang-jwt/jwt/v5/SECURITY.md | 19 ++ .../golang-jwt/jwt/v5/VERSION_HISTORY.md | 137 ++++++++ vendor/github.com/golang-jwt/jwt/v5/claims.go | 16 + vendor/github.com/golang-jwt/jwt/v5/doc.go | 4 + vendor/github.com/golang-jwt/jwt/v5/ecdsa.go | 134 ++++++++ .../golang-jwt/jwt/v5/ecdsa_utils.go | 69 ++++ .../github.com/golang-jwt/jwt/v5/ed25519.go | 80 +++++ .../golang-jwt/jwt/v5/ed25519_utils.go | 64 ++++ vendor/github.com/golang-jwt/jwt/v5/errors.go | 49 +++ .../golang-jwt/jwt/v5/errors_go1_20.go | 47 +++ .../golang-jwt/jwt/v5/errors_go_other.go | 78 +++++ vendor/github.com/golang-jwt/jwt/v5/hmac.go | 104 ++++++ .../golang-jwt/jwt/v5/map_claims.go | 109 +++++++ vendor/github.com/golang-jwt/jwt/v5/none.go | 50 +++ vendor/github.com/golang-jwt/jwt/v5/parser.go | 215 +++++++++++++ .../golang-jwt/jwt/v5/parser_option.go | 120 +++++++ .../golang-jwt/jwt/v5/registered_claims.go | 63 ++++ vendor/github.com/golang-jwt/jwt/v5/rsa.go | 93 ++++++ .../github.com/golang-jwt/jwt/v5/rsa_pss.go | 135 ++++++++ .../github.com/golang-jwt/jwt/v5/rsa_utils.go | 107 +++++++ .../golang-jwt/jwt/v5/signing_method.go | 49 +++ .../golang-jwt/jwt/v5/staticcheck.conf | 1 + vendor/github.com/golang-jwt/jwt/v5/token.go | 86 +++++ .../golang-jwt/jwt/v5/token_option.go | 5 + vendor/github.com/golang-jwt/jwt/v5/types.go | 150 +++++++++ .../github.com/golang-jwt/jwt/v5/validator.go | 301 ++++++++++++++++++ .../golang.org/x/crypto/acme/version_go112.go | 1 - .../x/crypto/sha3/hashes_generic.go | 1 - vendor/golang.org/x/crypto/sha3/keccakf.go | 1 - .../golang.org/x/crypto/sha3/keccakf_amd64.go | 1 - .../golang.org/x/crypto/sha3/keccakf_amd64.s | 5 +- vendor/golang.org/x/crypto/sha3/register.go | 1 - vendor/golang.org/x/crypto/sha3/sha3_s390x.go | 1 - vendor/golang.org/x/crypto/sha3/sha3_s390x.s | 1 - .../golang.org/x/crypto/sha3/shake_generic.go | 1 - vendor/golang.org/x/crypto/sha3/xor.go | 1 - .../golang.org/x/crypto/sha3/xor_unaligned.go | 2 - vendor/golang.org/x/sys/unix/fcntl.go | 2 +- vendor/golang.org/x/sys/unix/ioctl_linux.go | 5 + vendor/golang.org/x/sys/unix/mkerrors.sh | 3 +- vendor/golang.org/x/sys/unix/syscall_bsd.go | 2 +- vendor/golang.org/x/sys/unix/syscall_linux.go | 28 +- .../golang.org/x/sys/unix/syscall_openbsd.go | 14 + .../golang.org/x/sys/unix/syscall_solaris.go | 2 +- .../x/sys/unix/syscall_zos_s390x.go | 2 +- vendor/golang.org/x/sys/unix/zerrors_linux.go | 2 +- .../golang.org/x/sys/unix/zsyscall_linux.go | 15 + .../x/sys/unix/zsyscall_openbsd_386.go | 26 ++ .../x/sys/unix/zsyscall_openbsd_386.s | 5 + .../x/sys/unix/zsyscall_openbsd_amd64.go | 26 ++ .../x/sys/unix/zsyscall_openbsd_amd64.s | 5 + .../x/sys/unix/zsyscall_openbsd_arm.go | 26 ++ .../x/sys/unix/zsyscall_openbsd_arm.s | 5 + .../x/sys/unix/zsyscall_openbsd_arm64.go | 26 ++ .../x/sys/unix/zsyscall_openbsd_arm64.s | 5 + .../x/sys/unix/zsyscall_openbsd_mips64.go | 26 ++ .../x/sys/unix/zsyscall_openbsd_mips64.s | 5 + .../x/sys/unix/zsyscall_openbsd_ppc64.go | 26 ++ .../x/sys/unix/zsyscall_openbsd_ppc64.s | 6 + .../x/sys/unix/zsyscall_openbsd_riscv64.go | 26 ++ .../x/sys/unix/zsyscall_openbsd_riscv64.s | 5 + vendor/golang.org/x/sys/unix/ztypes_linux.go | 32 ++ .../x/sys/windows/syscall_windows.go | 2 + .../x/sys/windows/zsyscall_windows.go | 19 ++ vendor/golang.org/x/term/term_unix.go | 1 - vendor/golang.org/x/term/term_unix_bsd.go | 1 - vendor/golang.org/x/term/term_unix_other.go | 1 - vendor/golang.org/x/term/term_unsupported.go | 1 - vendor/modules.txt | 15 +- 80 files changed, 3066 insertions(+), 76 deletions(-) create mode 100644 vendor/github.com/golang-jwt/jwt/v5/.gitignore create mode 100644 vendor/github.com/golang-jwt/jwt/v5/LICENSE create mode 100644 vendor/github.com/golang-jwt/jwt/v5/MIGRATION_GUIDE.md create mode 100644 vendor/github.com/golang-jwt/jwt/v5/README.md create mode 100644 vendor/github.com/golang-jwt/jwt/v5/SECURITY.md create mode 100644 vendor/github.com/golang-jwt/jwt/v5/VERSION_HISTORY.md create mode 100644 vendor/github.com/golang-jwt/jwt/v5/claims.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/doc.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/ecdsa.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/ecdsa_utils.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/ed25519.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/ed25519_utils.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/errors.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/errors_go1_20.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/errors_go_other.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/hmac.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/map_claims.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/none.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/parser.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/parser_option.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/registered_claims.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/rsa.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/rsa_pss.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/rsa_utils.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/signing_method.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/staticcheck.conf create mode 100644 vendor/github.com/golang-jwt/jwt/v5/token.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/token_option.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/types.go create mode 100644 vendor/github.com/golang-jwt/jwt/v5/validator.go diff --git a/go.mod b/go.mod index 0778d2f87..8df3eb725 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,13 @@ toolchain go1.21.0 require ( github.com/IBM/sarama v1.42.1 - github.com/Nerzal/gocloak/v13 v13.8.0 + github.com/Nerzal/gocloak/v13 v13.9.0 github.com/ant0ine/go-json-rest v3.3.2+incompatible github.com/aws/aws-sdk-go v1.47.4 github.com/blang/semver v3.5.1+incompatible github.com/deckarep/golang-set/v2 v2.3.1 github.com/githubnemo/CompileDaemon v1.4.0 + github.com/go-resty/resty/v2 v2.11.0 github.com/golang-jwt/jwt/v4 v4.5.0 github.com/golang/mock v1.6.0 github.com/google/uuid v1.4.0 @@ -30,7 +31,7 @@ require ( github.com/urfave/cli v1.22.14 go.mongodb.org/mongo-driver v1.12.1 go.uber.org/fx v1.20.1 - golang.org/x/crypto v0.14.0 + golang.org/x/crypto v0.17.0 golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 golang.org/x/oauth2 v0.13.0 golang.org/x/sync v0.5.0 @@ -76,9 +77,9 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.16.0 // indirect - github.com/go-resty/resty/v2 v2.11.0 // indirect github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v5 v5.0.0 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386 // indirect @@ -155,8 +156,8 @@ require ( golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/mod v0.14.0 // indirect golang.org/x/net v0.17.0 // indirect - golang.org/x/sys v0.14.0 // indirect - golang.org/x/term v0.13.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/term v0.15.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.4.0 // indirect google.golang.org/appengine v1.6.8 // indirect diff --git a/go.sum b/go.sum index bc739f6df..99d9f0fd7 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ github.com/Joker/hpp v1.0.0 h1:65+iuJYdRXv/XyN62C1uEmmOx3432rNG/rKlX6V7Kkc= github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY= github.com/Joker/jade v1.1.3 h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk= github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM= -github.com/Nerzal/gocloak/v13 v13.8.0 h1:7s9cK8X3vy8OIic+pG4POE9vGy02tSHkMhvWXv0P2m8= -github.com/Nerzal/gocloak/v13 v13.8.0/go.mod h1:rRBtEdh5N0+JlZZEsrfZcB2sRMZWbgSxI2EIv9jpJp4= +github.com/Nerzal/gocloak/v13 v13.9.0 h1:YWsJsdM5b0yhM2Ba3MLydiOlujkBry4TtdzfIzSVZhw= +github.com/Nerzal/gocloak/v13 v13.9.0/go.mod h1:YYuDcXZ7K2zKECyVP7pPqjKxx2AzYSpKDj8d6GuyM10= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06 h1:KkH3I3sJuOLP3TjA/dfr4NAY8bghDwnXiU7cTKxQqo0= github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06/go.mod h1:7erjKLwalezA0k99cWs5L11HWOAPNjdUZ6RxH1BXbbM= @@ -105,8 +105,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE= github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= -github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY= -github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I= github.com/go-resty/resty/v2 v2.11.0 h1:i7jMfNOJYMp69lq7qozJP+bjgzfAzeOhuGlyDrqxT/8= github.com/go-resty/resty/v2 v2.11.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= @@ -117,6 +115,8 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= @@ -393,8 +393,9 @@ golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/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/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= @@ -413,7 +414,6 @@ golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLL 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-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 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= @@ -451,14 +451,15 @@ 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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= -golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/user/keycloak/client.go b/user/keycloak/client.go index 23c95954d..e1fdaec85 100644 --- a/user/keycloak/client.go +++ b/user/keycloak/client.go @@ -299,7 +299,7 @@ func (c *keycloakClient) IntrospectToken(ctx context.Context, token oauth2.Token } result.Subject = customClaims.Subject result.EmailVerified = customClaims.EmailVerified - result.ExpiresAt = customClaims.ExpiresAt + result.ExpiresAt = customClaims.ExpiresAt.Unix() result.RealmAccess = userLib.RealmAccess{ Roles: customClaims.RealmAccess.Roles, } diff --git a/vendor/github.com/Nerzal/gocloak/v13/client.go b/vendor/github.com/Nerzal/gocloak/v13/client.go index 9f06b215b..ccff45fc0 100644 --- a/vendor/github.com/Nerzal/gocloak/v13/client.go +++ b/vendor/github.com/Nerzal/gocloak/v13/client.go @@ -13,7 +13,7 @@ import ( "time" "github.com/go-resty/resty/v2" - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" "github.com/opentracing/opentracing-go" "github.com/pkg/errors" "github.com/segmentio/ksuid" @@ -508,7 +508,6 @@ func (g *GoCloak) GetRequestingPartyPermissions(ctx context.Context, token, real if err := checkForError(resp, err, errMessage); err != nil { return nil, err } - return &res, nil } @@ -1456,7 +1455,7 @@ func (g *GoCloak) GetClientOfflineSessions(ctx context.Context, token, realm, id var res []*UserSessionRepresentation queryParams := map[string]string{} - if params != nil && len(params) > 0 { + if len(params) > 0 { var err error queryParams, err = GetQueryParams(params[0]) @@ -1483,7 +1482,7 @@ func (g *GoCloak) GetClientUserSessions(ctx context.Context, token, realm, idOfC var res []*UserSessionRepresentation queryParams := map[string]string{} - if params != nil && len(params) > 0 { + if len(params) > 0 { var err error queryParams, err = GetQueryParams(params[0]) @@ -4304,3 +4303,38 @@ func (g *GoCloak) RevokeToken(ctx context.Context, realm, clientID, clientSecret return checkForError(resp, err, errMessage) } + +// UpdateUsersManagementPermissions updates the management permissions for users +func (g *GoCloak) UpdateUsersManagementPermissions(ctx context.Context, accessToken, realm string, managementPermissions ManagementPermissionRepresentation) (*ManagementPermissionRepresentation, error) { + const errMessage = "could not update users management permissions" + + var result ManagementPermissionRepresentation + + resp, err := g.GetRequestWithBearerAuth(ctx, accessToken). + SetResult(&result). + SetBody(managementPermissions). + Put(g.getAdminRealmURL(realm, "users-management-permissions")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} + +// GetUsersManagementPermissions returns the management permissions for users +func (g *GoCloak) GetUsersManagementPermissions(ctx context.Context, accessToken, realm string) (*ManagementPermissionRepresentation, error) { + const errMessage = "could not get users management permissions" + + var result ManagementPermissionRepresentation + + resp, err := g.GetRequestWithBearerAuth(ctx, accessToken). + SetResult(&result). + Get(g.getAdminRealmURL(realm, "users-management-permissions")) + + if err := checkForError(resp, err, errMessage); err != nil { + return nil, err + } + + return &result, nil +} diff --git a/vendor/github.com/Nerzal/gocloak/v13/models.go b/vendor/github.com/Nerzal/gocloak/v13/models.go index ee0512178..a093b2334 100644 --- a/vendor/github.com/Nerzal/gocloak/v13/models.go +++ b/vendor/github.com/Nerzal/gocloak/v13/models.go @@ -5,7 +5,7 @@ import ( "encoding/json" "strings" - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" ) // GetQueryParams converts the struct to map[string]string @@ -441,6 +441,7 @@ type ProtocolMappersConfig struct { ClaimValue *string `json:"claim.value,omitempty"` JSONTypeLabel *string `json:"jsonType.label,omitempty"` Multivalued *string `json:"multivalued,omitempty"` + AggregateAttrs *string `json:"aggregate.attrs,omitempty"` UsermodelClientRoleMappingClientID *string `json:"usermodel.clientRoleMapping.clientId,omitempty"` IncludedClientAudience *string `json:"included.client.audience,omitempty"` FullPath *string `json:"full.path,omitempty"` @@ -964,18 +965,20 @@ func (t *TokenOptions) FormData() map[string]string { // RequestingPartyTokenOptions represents the options to obtain a requesting party token type RequestingPartyTokenOptions struct { - GrantType *string `json:"grant_type,omitempty"` - Ticket *string `json:"ticket,omitempty"` - ClaimToken *string `json:"claim_token,omitempty"` - ClaimTokenFormat *string `json:"claim_token_format,omitempty"` - RPT *string `json:"rpt,omitempty"` - Permissions *[]string `json:"-"` - Audience *string `json:"audience,omitempty"` - ResponseIncludeResourceName *bool `json:"response_include_resource_name,string,omitempty"` - ResponsePermissionsLimit *uint32 `json:"response_permissions_limit,omitempty"` - SubmitRequest *bool `json:"submit_request,string,omitempty"` - ResponseMode *string `json:"response_mode,omitempty"` - SubjectToken *string `json:"subject_token,omitempty"` + GrantType *string `json:"grant_type,omitempty"` + Ticket *string `json:"ticket,omitempty"` + ClaimToken *string `json:"claim_token,omitempty"` + ClaimTokenFormat *string `json:"claim_token_format,omitempty"` + RPT *string `json:"rpt,omitempty"` + Permissions *[]string `json:"-"` + PermissionResourceFormat *string `json:"permission_resource_format,omitempty"` + PermissionResourceMatchingURI *bool `json:"permission_resource_matching_uri,string,omitempty"` + Audience *string `json:"audience,omitempty"` + ResponseIncludeResourceName *bool `json:"response_include_resource_name,string,omitempty"` + ResponsePermissionsLimit *uint32 `json:"response_permissions_limit,omitempty"` + SubmitRequest *bool `json:"submit_request,string,omitempty"` + ResponseMode *string `json:"response_mode,omitempty"` + SubjectToken *string `json:"subject_token,omitempty"` } // FormData returns a map of options to be used in SetFormData function @@ -1258,7 +1261,7 @@ type PermissionTicketRepresentation struct { AZP *string `json:"azp,omitempty"` Claims *map[string][]string `json:"claims,omitempty"` Permissions *[]PermissionTicketPermissionRepresentation `json:"permissions,omitempty"` - jwt.StandardClaims + jwt.RegisteredClaims } // PermissionTicketPermissionRepresentation represents the individual permissions in a permission ticket diff --git a/vendor/github.com/Nerzal/gocloak/v13/pkg/jwx/jwx.go b/vendor/github.com/Nerzal/gocloak/v13/pkg/jwx/jwx.go index ccf000f56..8eeefada2 100644 --- a/vendor/github.com/Nerzal/gocloak/v13/pkg/jwx/jwx.go +++ b/vendor/github.com/Nerzal/gocloak/v13/pkg/jwx/jwx.go @@ -12,7 +12,7 @@ import ( "math/big" "strings" - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" "github.com/pkg/errors" ) diff --git a/vendor/github.com/Nerzal/gocloak/v13/pkg/jwx/models.go b/vendor/github.com/Nerzal/gocloak/v13/pkg/jwx/models.go index 48b7449a9..c5ccfb7d7 100644 --- a/vendor/github.com/Nerzal/gocloak/v13/pkg/jwx/models.go +++ b/vendor/github.com/Nerzal/gocloak/v13/pkg/jwx/models.go @@ -1,6 +1,6 @@ package jwx -import jwt "github.com/golang-jwt/jwt/v4" +import jwt "github.com/golang-jwt/jwt/v5" // DecodedAccessTokenHeader is the decoded header from the access token type DecodedAccessTokenHeader struct { @@ -11,7 +11,7 @@ type DecodedAccessTokenHeader struct { // Claims served by keycloak inside the accessToken type Claims struct { - jwt.StandardClaims + jwt.RegisteredClaims Typ string `json:"typ,omitempty"` Azp string `json:"azp,omitempty"` AuthTime int `json:"auth_time,omitempty"` diff --git a/vendor/github.com/golang-jwt/jwt/v5/.gitignore b/vendor/github.com/golang-jwt/jwt/v5/.gitignore new file mode 100644 index 000000000..09573e016 --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +bin +.idea/ + diff --git a/vendor/github.com/golang-jwt/jwt/v5/LICENSE b/vendor/github.com/golang-jwt/jwt/v5/LICENSE new file mode 100644 index 000000000..35dbc2520 --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/LICENSE @@ -0,0 +1,9 @@ +Copyright (c) 2012 Dave Grijalva +Copyright (c) 2021 golang-jwt maintainers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/vendor/github.com/golang-jwt/jwt/v5/MIGRATION_GUIDE.md b/vendor/github.com/golang-jwt/jwt/v5/MIGRATION_GUIDE.md new file mode 100644 index 000000000..6ad1c22bb --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/MIGRATION_GUIDE.md @@ -0,0 +1,185 @@ +# Migration Guide (v5.0.0) + +Version `v5` contains a major rework of core functionalities in the `jwt-go` +library. This includes support for several validation options as well as a +re-design of the `Claims` interface. Lastly, we reworked how errors work under +the hood, which should provide a better overall developer experience. + +Starting from [v5.0.0](https://github.com/golang-jwt/jwt/releases/tag/v5.0.0), +the import path will be: + + "github.com/golang-jwt/jwt/v5" + +For most users, changing the import path *should* suffice. However, since we +intentionally changed and cleaned some of the public API, existing programs +might need to be updated. The following sections describe significant changes +and corresponding updates for existing programs. + +## Parsing and Validation Options + +Under the hood, a new `validator` struct takes care of validating the claims. A +long awaited feature has been the option to fine-tune the validation of tokens. +This is now possible with several `ParserOption` functions that can be appended +to most `Parse` functions, such as `ParseWithClaims`. The most important options +and changes are: + * Added `WithLeeway` to support specifying the leeway that is allowed when + validating time-based claims, such as `exp` or `nbf`. + * Changed default behavior to not check the `iat` claim. Usage of this claim + is OPTIONAL according to the JWT RFC. The claim itself is also purely + informational according to the RFC, so a strict validation failure is not + recommended. If you want to check for sensible values in these claims, + please use the `WithIssuedAt` parser option. + * Added `WithAudience`, `WithSubject` and `WithIssuer` to support checking for + expected `aud`, `sub` and `iss`. + * Added `WithStrictDecoding` and `WithPaddingAllowed` options to allow + previously global settings to enable base64 strict encoding and the parsing + of base64 strings with padding. The latter is strictly speaking against the + standard, but unfortunately some of the major identity providers issue some + of these incorrect tokens. Both options are disabled by default. + +## Changes to the `Claims` interface + +### Complete Restructuring + +Previously, the claims interface was satisfied with an implementation of a +`Valid() error` function. This had several issues: + * The different claim types (struct claims, map claims, etc.) then contained + similar (but not 100 % identical) code of how this validation was done. This + lead to a lot of (almost) duplicate code and was hard to maintain + * It was not really semantically close to what a "claim" (or a set of claims) + really is; which is a list of defined key/value pairs with a certain + semantic meaning. + +Since all the validation functionality is now extracted into the validator, all +`VerifyXXX` and `Valid` functions have been removed from the `Claims` interface. +Instead, the interface now represents a list of getters to retrieve values with +a specific meaning. This allows us to completely decouple the validation logic +with the underlying storage representation of the claim, which could be a +struct, a map or even something stored in a database. + +```go +type Claims interface { + GetExpirationTime() (*NumericDate, error) + GetIssuedAt() (*NumericDate, error) + GetNotBefore() (*NumericDate, error) + GetIssuer() (string, error) + GetSubject() (string, error) + GetAudience() (ClaimStrings, error) +} +``` + +### Supported Claim Types and Removal of `StandardClaims` + +The two standard claim types supported by this library, `MapClaims` and +`RegisteredClaims` both implement the necessary functions of this interface. The +old `StandardClaims` struct, which has already been deprecated in `v4` is now +removed. + +Users using custom claims, in most cases, will not experience any changes in the +behavior as long as they embedded `RegisteredClaims`. If they created a new +claim type from scratch, they now need to implemented the proper getter +functions. + +### Migrating Application Specific Logic of the old `Valid` + +Previously, users could override the `Valid` method in a custom claim, for +example to extend the validation with application-specific claims. However, this +was always very dangerous, since once could easily disable the standard +validation and signature checking. + +In order to avoid that, while still supporting the use-case, a new +`ClaimsValidator` interface has been introduced. This interface consists of the +`Validate() error` function. If the validator sees, that a `Claims` struct +implements this interface, the errors returned to the `Validate` function will +be *appended* to the regular standard validation. It is not possible to disable +the standard validation anymore (even only by accident). + +Usage examples can be found in [example_test.go](./example_test.go), to build +claims structs like the following. + +```go +// MyCustomClaims includes all registered claims, plus Foo. +type MyCustomClaims struct { + Foo string `json:"foo"` + jwt.RegisteredClaims +} + +// Validate can be used to execute additional application-specific claims +// validation. +func (m MyCustomClaims) Validate() error { + if m.Foo != "bar" { + return errors.New("must be foobar") + } + + return nil +} +``` + +## Changes to the `Token` and `Parser` struct + +The previously global functions `DecodeSegment` and `EncodeSegment` were moved +to the `Parser` and `Token` struct respectively. This will allow us in the +future to configure the behavior of these two based on options supplied on the +parser or the token (creation). This also removes two previously global +variables and moves them to parser options `WithStrictDecoding` and +`WithPaddingAllowed`. + +In order to do that, we had to adjust the way signing methods work. Previously +they were given a base64 encoded signature in `Verify` and were expected to +return a base64 encoded version of the signature in `Sign`, both as a `string`. +However, this made it necessary to have `DecodeSegment` and `EncodeSegment` +global and was a less than perfect design because we were repeating +encoding/decoding steps for all signing methods. Now, `Sign` and `Verify` +operate on a decoded signature as a `[]byte`, which feels more natural for a +cryptographic operation anyway. Lastly, `Parse` and `SignedString` take care of +the final encoding/decoding part. + +In addition to that, we also changed the `Signature` field on `Token` from a +`string` to `[]byte` and this is also now populated with the decoded form. This +is also more consistent, because the other parts of the JWT, mainly `Header` and +`Claims` were already stored in decoded form in `Token`. Only the signature was +stored in base64 encoded form, which was redundant with the information in the +`Raw` field, which contains the complete token as base64. + +```go +type Token struct { + Raw string // Raw contains the raw token + Method SigningMethod // Method is the signing method used or to be used + Header map[string]interface{} // Header is the first segment of the token in decoded form + Claims Claims // Claims is the second segment of the token in decoded form + Signature []byte // Signature is the third segment of the token in decoded form + Valid bool // Valid specifies if the token is valid +} +``` + +Most (if not all) of these changes should not impact the normal usage of this +library. Only users directly accessing the `Signature` field as well as +developers of custom signing methods should be affected. + +# Migration Guide (v4.0.0) + +Starting from [v4.0.0](https://github.com/golang-jwt/jwt/releases/tag/v4.0.0), +the import path will be: + + "github.com/golang-jwt/jwt/v4" + +The `/v4` version will be backwards compatible with existing `v3.x.y` tags in +this repo, as well as `github.com/dgrijalva/jwt-go`. For most users this should +be a drop-in replacement, if you're having troubles migrating, please open an +issue. + +You can replace all occurrences of `github.com/dgrijalva/jwt-go` or +`github.com/golang-jwt/jwt` with `github.com/golang-jwt/jwt/v5`, either manually +or by using tools such as `sed` or `gofmt`. + +And then you'd typically run: + +``` +go get github.com/golang-jwt/jwt/v4 +go mod tidy +``` + +# Older releases (before v3.2.0) + +The original migration guide for older releases can be found at +https://github.com/dgrijalva/jwt-go/blob/master/MIGRATION_GUIDE.md. diff --git a/vendor/github.com/golang-jwt/jwt/v5/README.md b/vendor/github.com/golang-jwt/jwt/v5/README.md new file mode 100644 index 000000000..964598a31 --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/README.md @@ -0,0 +1,167 @@ +# jwt-go + +[![build](https://github.com/golang-jwt/jwt/actions/workflows/build.yml/badge.svg)](https://github.com/golang-jwt/jwt/actions/workflows/build.yml) +[![Go +Reference](https://pkg.go.dev/badge/github.com/golang-jwt/jwt/v5.svg)](https://pkg.go.dev/github.com/golang-jwt/jwt/v5) +[![Coverage Status](https://coveralls.io/repos/github/golang-jwt/jwt/badge.svg?branch=main)](https://coveralls.io/github/golang-jwt/jwt?branch=main) + +A [go](http://www.golang.org) (or 'golang' for search engine friendliness) +implementation of [JSON Web +Tokens](https://datatracker.ietf.org/doc/html/rfc7519). + +Starting with [v4.0.0](https://github.com/golang-jwt/jwt/releases/tag/v4.0.0) +this project adds Go module support, but maintains backwards compatibility with +older `v3.x.y` tags and upstream `github.com/dgrijalva/jwt-go`. See the +[`MIGRATION_GUIDE.md`](./MIGRATION_GUIDE.md) for more information. Version +v5.0.0 introduces major improvements to the validation of tokens, but is not +entirely backwards compatible. + +> After the original author of the library suggested migrating the maintenance +> of `jwt-go`, a dedicated team of open source maintainers decided to clone the +> existing library into this repository. See +> [dgrijalva/jwt-go#462](https://github.com/dgrijalva/jwt-go/issues/462) for a +> detailed discussion on this topic. + + +**SECURITY NOTICE:** Some older versions of Go have a security issue in the +crypto/elliptic. Recommendation is to upgrade to at least 1.15 See issue +[dgrijalva/jwt-go#216](https://github.com/dgrijalva/jwt-go/issues/216) for more +detail. + +**SECURITY NOTICE:** It's important that you [validate the `alg` presented is +what you +expect](https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/). +This library attempts to make it easy to do the right thing by requiring key +types match the expected alg, but you should take the extra step to verify it in +your usage. See the examples provided. + +### Supported Go versions + +Our support of Go versions is aligned with Go's [version release +policy](https://golang.org/doc/devel/release#policy). So we will support a major +version of Go until there are two newer major releases. We no longer support +building jwt-go with unsupported Go versions, as these contain security +vulnerabilities which will not be fixed. + +## What the heck is a JWT? + +JWT.io has [a great introduction](https://jwt.io/introduction) to JSON Web +Tokens. + +In short, it's a signed JSON object that does something useful (for example, +authentication). It's commonly used for `Bearer` tokens in Oauth 2. A token is +made of three parts, separated by `.`'s. The first two parts are JSON objects, +that have been [base64url](https://datatracker.ietf.org/doc/html/rfc4648) +encoded. The last part is the signature, encoded the same way. + +The first part is called the header. It contains the necessary information for +verifying the last part, the signature. For example, which encryption method +was used for signing and what key was used. + +The part in the middle is the interesting bit. It's called the Claims and +contains the actual stuff you care about. Refer to [RFC +7519](https://datatracker.ietf.org/doc/html/rfc7519) for information about +reserved keys and the proper way to add your own. + +## What's in the box? + +This library supports the parsing and verification as well as the generation and +signing of JWTs. Current supported signing algorithms are HMAC SHA, RSA, +RSA-PSS, and ECDSA, though hooks are present for adding your own. + +## Installation Guidelines + +1. To install the jwt package, you first need to have + [Go](https://go.dev/doc/install) installed, then you can use the command + below to add `jwt-go` as a dependency in your Go program. + +```sh +go get -u github.com/golang-jwt/jwt/v5 +``` + +2. Import it in your code: + +```go +import "github.com/golang-jwt/jwt/v5" +``` + +## Usage + +A detailed usage guide, including how to sign and verify tokens can be found on +our [documentation website](https://golang-jwt.github.io/jwt/usage/create/). + +## Examples + +See [the project documentation](https://pkg.go.dev/github.com/golang-jwt/jwt/v5) +for examples of usage: + +* [Simple example of parsing and validating a + token](https://pkg.go.dev/github.com/golang-jwt/jwt/v5#example-Parse-Hmac) +* [Simple example of building and signing a + token](https://pkg.go.dev/github.com/golang-jwt/jwt/v5#example-New-Hmac) +* [Directory of + Examples](https://pkg.go.dev/github.com/golang-jwt/jwt/v5#pkg-examples) + +## Compliance + +This library was last reviewed to comply with [RFC +7519](https://datatracker.ietf.org/doc/html/rfc7519) dated May 2015 with a few +notable differences: + +* In order to protect against accidental use of [Unsecured + JWTs](https://datatracker.ietf.org/doc/html/rfc7519#section-6), tokens using + `alg=none` will only be accepted if the constant + `jwt.UnsafeAllowNoneSignatureType` is provided as the key. + +## Project Status & Versioning + +This library is considered production ready. Feedback and feature requests are +appreciated. The API should be considered stable. There should be very few +backwards-incompatible changes outside of major version updates (and only with +good reason). + +This project uses [Semantic Versioning 2.0.0](http://semver.org). Accepted pull +requests will land on `main`. Periodically, versions will be tagged from +`main`. You can find all the releases on [the project releases +page](https://github.com/golang-jwt/jwt/releases). + +**BREAKING CHANGES:*** A full list of breaking changes is available in +`VERSION_HISTORY.md`. See `MIGRATION_GUIDE.md` for more information on updating +your code. + +## Extensions + +This library publishes all the necessary components for adding your own signing +methods or key functions. Simply implement the `SigningMethod` interface and +register a factory method using `RegisterSigningMethod` or provide a +`jwt.Keyfunc`. + +A common use case would be integrating with different 3rd party signature +providers, like key management services from various cloud providers or Hardware +Security Modules (HSMs) or to implement additional standards. + +| Extension | Purpose | Repo | +| --------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------ | +| GCP | Integrates with multiple Google Cloud Platform signing tools (AppEngine, IAM API, Cloud KMS) | https://github.com/someone1/gcp-jwt-go | +| AWS | Integrates with AWS Key Management Service, KMS | https://github.com/matelang/jwt-go-aws-kms | +| JWKS | Provides support for JWKS ([RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517)) as a `jwt.Keyfunc` | https://github.com/MicahParks/keyfunc | + +*Disclaimer*: Unless otherwise specified, these integrations are maintained by +third parties and should not be considered as a primary offer by any of the +mentioned cloud providers + +## More + +Go package documentation can be found [on +pkg.go.dev](https://pkg.go.dev/github.com/golang-jwt/jwt/v5). Additional +documentation can be found on [our project +page](https://golang-jwt.github.io/jwt/). + +The command line utility included in this project (cmd/jwt) provides a +straightforward example of token creation and parsing as well as a useful tool +for debugging your own integration. You'll also find several implementation +examples in the documentation. + +[golang-jwt](https://github.com/orgs/golang-jwt) incorporates a modified version +of the JWT logo, which is distributed under the terms of the [MIT +License](https://github.com/jsonwebtoken/jsonwebtoken.github.io/blob/master/LICENSE.txt). diff --git a/vendor/github.com/golang-jwt/jwt/v5/SECURITY.md b/vendor/github.com/golang-jwt/jwt/v5/SECURITY.md new file mode 100644 index 000000000..b08402c34 --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/SECURITY.md @@ -0,0 +1,19 @@ +# Security Policy + +## Supported Versions + +As of February 2022 (and until this document is updated), the latest version `v4` is supported. + +## Reporting a Vulnerability + +If you think you found a vulnerability, and even if you are not sure, please report it to jwt-go-security@googlegroups.com or one of the other [golang-jwt maintainers](https://github.com/orgs/golang-jwt/people). Please try be explicit, describe steps to reproduce the security issue with code example(s). + +You will receive a response within a timely manner. If the issue is confirmed, we will do our best to release a patch as soon as possible given the complexity of the problem. + +## Public Discussions + +Please avoid publicly discussing a potential security vulnerability. + +Let's take this offline and find a solution first, this limits the potential impact as much as possible. + +We appreciate your help! diff --git a/vendor/github.com/golang-jwt/jwt/v5/VERSION_HISTORY.md b/vendor/github.com/golang-jwt/jwt/v5/VERSION_HISTORY.md new file mode 100644 index 000000000..b5039e49c --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/VERSION_HISTORY.md @@ -0,0 +1,137 @@ +# `jwt-go` Version History + +The following version history is kept for historic purposes. To retrieve the current changes of each version, please refer to the change-log of the specific release versions on https://github.com/golang-jwt/jwt/releases. + +## 4.0.0 + +* Introduces support for Go modules. The `v4` version will be backwards compatible with `v3.x.y`. + +## 3.2.2 + +* Starting from this release, we are adopting the policy to support the most 2 recent versions of Go currently available. By the time of this release, this is Go 1.15 and 1.16 ([#28](https://github.com/golang-jwt/jwt/pull/28)). +* Fixed a potential issue that could occur when the verification of `exp`, `iat` or `nbf` was not required and contained invalid contents, i.e. non-numeric/date. Thanks for @thaJeztah for making us aware of that and @giorgos-f3 for originally reporting it to the formtech fork ([#40](https://github.com/golang-jwt/jwt/pull/40)). +* Added support for EdDSA / ED25519 ([#36](https://github.com/golang-jwt/jwt/pull/36)). +* Optimized allocations ([#33](https://github.com/golang-jwt/jwt/pull/33)). + +## 3.2.1 + +* **Import Path Change**: See MIGRATION_GUIDE.md for tips on updating your code + * Changed the import path from `github.com/dgrijalva/jwt-go` to `github.com/golang-jwt/jwt` +* Fixed type confusing issue between `string` and `[]string` in `VerifyAudience` ([#12](https://github.com/golang-jwt/jwt/pull/12)). This fixes CVE-2020-26160 + +#### 3.2.0 + +* Added method `ParseUnverified` to allow users to split up the tasks of parsing and validation +* HMAC signing method returns `ErrInvalidKeyType` instead of `ErrInvalidKey` where appropriate +* Added options to `request.ParseFromRequest`, which allows for an arbitrary list of modifiers to parsing behavior. Initial set include `WithClaims` and `WithParser`. Existing usage of this function will continue to work as before. +* Deprecated `ParseFromRequestWithClaims` to simplify API in the future. + +#### 3.1.0 + +* Improvements to `jwt` command line tool +* Added `SkipClaimsValidation` option to `Parser` +* Documentation updates + +#### 3.0.0 + +* **Compatibility Breaking Changes**: See MIGRATION_GUIDE.md for tips on updating your code + * Dropped support for `[]byte` keys when using RSA signing methods. This convenience feature could contribute to security vulnerabilities involving mismatched key types with signing methods. + * `ParseFromRequest` has been moved to `request` subpackage and usage has changed + * The `Claims` property on `Token` is now type `Claims` instead of `map[string]interface{}`. The default value is type `MapClaims`, which is an alias to `map[string]interface{}`. This makes it possible to use a custom type when decoding claims. +* Other Additions and Changes + * Added `Claims` interface type to allow users to decode the claims into a custom type + * Added `ParseWithClaims`, which takes a third argument of type `Claims`. Use this function instead of `Parse` if you have a custom type you'd like to decode into. + * Dramatically improved the functionality and flexibility of `ParseFromRequest`, which is now in the `request` subpackage + * Added `ParseFromRequestWithClaims` which is the `FromRequest` equivalent of `ParseWithClaims` + * Added new interface type `Extractor`, which is used for extracting JWT strings from http requests. Used with `ParseFromRequest` and `ParseFromRequestWithClaims`. + * Added several new, more specific, validation errors to error type bitmask + * Moved examples from README to executable example files + * Signing method registry is now thread safe + * Added new property to `ValidationError`, which contains the raw error returned by calls made by parse/verify (such as those returned by keyfunc or json parser) + +#### 2.7.0 + +This will likely be the last backwards compatible release before 3.0.0, excluding essential bug fixes. + +* Added new option `-show` to the `jwt` command that will just output the decoded token without verifying +* Error text for expired tokens includes how long it's been expired +* Fixed incorrect error returned from `ParseRSAPublicKeyFromPEM` +* Documentation updates + +#### 2.6.0 + +* Exposed inner error within ValidationError +* Fixed validation errors when using UseJSONNumber flag +* Added several unit tests + +#### 2.5.0 + +* Added support for signing method none. You shouldn't use this. The API tries to make this clear. +* Updated/fixed some documentation +* Added more helpful error message when trying to parse tokens that begin with `BEARER ` + +#### 2.4.0 + +* Added new type, Parser, to allow for configuration of various parsing parameters + * You can now specify a list of valid signing methods. Anything outside this set will be rejected. + * You can now opt to use the `json.Number` type instead of `float64` when parsing token JSON +* Added support for [Travis CI](https://travis-ci.org/dgrijalva/jwt-go) +* Fixed some bugs with ECDSA parsing + +#### 2.3.0 + +* Added support for ECDSA signing methods +* Added support for RSA PSS signing methods (requires go v1.4) + +#### 2.2.0 + +* Gracefully handle a `nil` `Keyfunc` being passed to `Parse`. Result will now be the parsed token and an error, instead of a panic. + +#### 2.1.0 + +Backwards compatible API change that was missed in 2.0.0. + +* The `SignedString` method on `Token` now takes `interface{}` instead of `[]byte` + +#### 2.0.0 + +There were two major reasons for breaking backwards compatibility with this update. The first was a refactor required to expand the width of the RSA and HMAC-SHA signing implementations. There will likely be no required code changes to support this change. + +The second update, while unfortunately requiring a small change in integration, is required to open up this library to other signing methods. Not all keys used for all signing methods have a single standard on-disk representation. Requiring `[]byte` as the type for all keys proved too limiting. Additionally, this implementation allows for pre-parsed tokens to be reused, which might matter in an application that parses a high volume of tokens with a small set of keys. Backwards compatibilty has been maintained for passing `[]byte` to the RSA signing methods, but they will also accept `*rsa.PublicKey` and `*rsa.PrivateKey`. + +It is likely the only integration change required here will be to change `func(t *jwt.Token) ([]byte, error)` to `func(t *jwt.Token) (interface{}, error)` when calling `Parse`. + +* **Compatibility Breaking Changes** + * `SigningMethodHS256` is now `*SigningMethodHMAC` instead of `type struct` + * `SigningMethodRS256` is now `*SigningMethodRSA` instead of `type struct` + * `KeyFunc` now returns `interface{}` instead of `[]byte` + * `SigningMethod.Sign` now takes `interface{}` instead of `[]byte` for the key + * `SigningMethod.Verify` now takes `interface{}` instead of `[]byte` for the key +* Renamed type `SigningMethodHS256` to `SigningMethodHMAC`. Specific sizes are now just instances of this type. + * Added public package global `SigningMethodHS256` + * Added public package global `SigningMethodHS384` + * Added public package global `SigningMethodHS512` +* Renamed type `SigningMethodRS256` to `SigningMethodRSA`. Specific sizes are now just instances of this type. + * Added public package global `SigningMethodRS256` + * Added public package global `SigningMethodRS384` + * Added public package global `SigningMethodRS512` +* Moved sample private key for HMAC tests from an inline value to a file on disk. Value is unchanged. +* Refactored the RSA implementation to be easier to read +* Exposed helper methods `ParseRSAPrivateKeyFromPEM` and `ParseRSAPublicKeyFromPEM` + +## 1.0.2 + +* Fixed bug in parsing public keys from certificates +* Added more tests around the parsing of keys for RS256 +* Code refactoring in RS256 implementation. No functional changes + +## 1.0.1 + +* Fixed panic if RS256 signing method was passed an invalid key + +## 1.0.0 + +* First versioned release +* API stabilized +* Supports creating, signing, parsing, and validating JWT tokens +* Supports RS256 and HS256 signing methods diff --git a/vendor/github.com/golang-jwt/jwt/v5/claims.go b/vendor/github.com/golang-jwt/jwt/v5/claims.go new file mode 100644 index 000000000..d50ff3dad --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/claims.go @@ -0,0 +1,16 @@ +package jwt + +// Claims represent any form of a JWT Claims Set according to +// https://datatracker.ietf.org/doc/html/rfc7519#section-4. In order to have a +// common basis for validation, it is required that an implementation is able to +// supply at least the claim names provided in +// https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 namely `exp`, +// `iat`, `nbf`, `iss`, `sub` and `aud`. +type Claims interface { + GetExpirationTime() (*NumericDate, error) + GetIssuedAt() (*NumericDate, error) + GetNotBefore() (*NumericDate, error) + GetIssuer() (string, error) + GetSubject() (string, error) + GetAudience() (ClaimStrings, error) +} diff --git a/vendor/github.com/golang-jwt/jwt/v5/doc.go b/vendor/github.com/golang-jwt/jwt/v5/doc.go new file mode 100644 index 000000000..a86dc1a3b --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/doc.go @@ -0,0 +1,4 @@ +// Package jwt is a Go implementation of JSON Web Tokens: http://self-issued.info/docs/draft-jones-json-web-token.html +// +// See README.md for more info. +package jwt diff --git a/vendor/github.com/golang-jwt/jwt/v5/ecdsa.go b/vendor/github.com/golang-jwt/jwt/v5/ecdsa.go new file mode 100644 index 000000000..4ccae2a85 --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/ecdsa.go @@ -0,0 +1,134 @@ +package jwt + +import ( + "crypto" + "crypto/ecdsa" + "crypto/rand" + "errors" + "math/big" +) + +var ( + // Sadly this is missing from crypto/ecdsa compared to crypto/rsa + ErrECDSAVerification = errors.New("crypto/ecdsa: verification error") +) + +// SigningMethodECDSA implements the ECDSA family of signing methods. +// Expects *ecdsa.PrivateKey for signing and *ecdsa.PublicKey for verification +type SigningMethodECDSA struct { + Name string + Hash crypto.Hash + KeySize int + CurveBits int +} + +// Specific instances for EC256 and company +var ( + SigningMethodES256 *SigningMethodECDSA + SigningMethodES384 *SigningMethodECDSA + SigningMethodES512 *SigningMethodECDSA +) + +func init() { + // ES256 + SigningMethodES256 = &SigningMethodECDSA{"ES256", crypto.SHA256, 32, 256} + RegisterSigningMethod(SigningMethodES256.Alg(), func() SigningMethod { + return SigningMethodES256 + }) + + // ES384 + SigningMethodES384 = &SigningMethodECDSA{"ES384", crypto.SHA384, 48, 384} + RegisterSigningMethod(SigningMethodES384.Alg(), func() SigningMethod { + return SigningMethodES384 + }) + + // ES512 + SigningMethodES512 = &SigningMethodECDSA{"ES512", crypto.SHA512, 66, 521} + RegisterSigningMethod(SigningMethodES512.Alg(), func() SigningMethod { + return SigningMethodES512 + }) +} + +func (m *SigningMethodECDSA) Alg() string { + return m.Name +} + +// Verify implements token verification for the SigningMethod. +// For this verify method, key must be an ecdsa.PublicKey struct +func (m *SigningMethodECDSA) Verify(signingString string, sig []byte, key interface{}) error { + // Get the key + var ecdsaKey *ecdsa.PublicKey + switch k := key.(type) { + case *ecdsa.PublicKey: + ecdsaKey = k + default: + return ErrInvalidKeyType + } + + if len(sig) != 2*m.KeySize { + return ErrECDSAVerification + } + + r := big.NewInt(0).SetBytes(sig[:m.KeySize]) + s := big.NewInt(0).SetBytes(sig[m.KeySize:]) + + // Create hasher + if !m.Hash.Available() { + return ErrHashUnavailable + } + hasher := m.Hash.New() + hasher.Write([]byte(signingString)) + + // Verify the signature + if verifystatus := ecdsa.Verify(ecdsaKey, hasher.Sum(nil), r, s); verifystatus { + return nil + } + + return ErrECDSAVerification +} + +// Sign implements token signing for the SigningMethod. +// For this signing method, key must be an ecdsa.PrivateKey struct +func (m *SigningMethodECDSA) Sign(signingString string, key interface{}) ([]byte, error) { + // Get the key + var ecdsaKey *ecdsa.PrivateKey + switch k := key.(type) { + case *ecdsa.PrivateKey: + ecdsaKey = k + default: + return nil, ErrInvalidKeyType + } + + // Create the hasher + if !m.Hash.Available() { + return nil, ErrHashUnavailable + } + + hasher := m.Hash.New() + hasher.Write([]byte(signingString)) + + // Sign the string and return r, s + if r, s, err := ecdsa.Sign(rand.Reader, ecdsaKey, hasher.Sum(nil)); err == nil { + curveBits := ecdsaKey.Curve.Params().BitSize + + if m.CurveBits != curveBits { + return nil, ErrInvalidKey + } + + keyBytes := curveBits / 8 + if curveBits%8 > 0 { + keyBytes += 1 + } + + // We serialize the outputs (r and s) into big-endian byte arrays + // padded with zeros on the left to make sure the sizes work out. + // Output must be 2*keyBytes long. + out := make([]byte, 2*keyBytes) + r.FillBytes(out[0:keyBytes]) // r is assigned to the first half of output. + s.FillBytes(out[keyBytes:]) // s is assigned to the second half of output. + + return out, nil + } else { + return nil, err + } +} diff --git a/vendor/github.com/golang-jwt/jwt/v5/ecdsa_utils.go b/vendor/github.com/golang-jwt/jwt/v5/ecdsa_utils.go new file mode 100644 index 000000000..5700636d3 --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/ecdsa_utils.go @@ -0,0 +1,69 @@ +package jwt + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" + "errors" +) + +var ( + ErrNotECPublicKey = errors.New("key is not a valid ECDSA public key") + ErrNotECPrivateKey = errors.New("key is not a valid ECDSA private key") +) + +// ParseECPrivateKeyFromPEM parses a PEM encoded Elliptic Curve Private Key Structure +func ParseECPrivateKeyFromPEM(key []byte) (*ecdsa.PrivateKey, error) { + var err error + + // Parse PEM block + var block *pem.Block + if block, _ = pem.Decode(key); block == nil { + return nil, ErrKeyMustBePEMEncoded + } + + // Parse the key + var parsedKey interface{} + if parsedKey, err = x509.ParseECPrivateKey(block.Bytes); err != nil { + if parsedKey, err = x509.ParsePKCS8PrivateKey(block.Bytes); err != nil { + return nil, err + } + } + + var pkey *ecdsa.PrivateKey + var ok bool + if pkey, ok = parsedKey.(*ecdsa.PrivateKey); !ok { + return nil, ErrNotECPrivateKey + } + + return pkey, nil +} + +// ParseECPublicKeyFromPEM parses a PEM encoded PKCS1 or PKCS8 public key +func ParseECPublicKeyFromPEM(key []byte) (*ecdsa.PublicKey, error) { + var err error + + // Parse PEM block + var block *pem.Block + if block, _ = pem.Decode(key); block == nil { + return nil, ErrKeyMustBePEMEncoded + } + + // Parse the key + var parsedKey interface{} + if parsedKey, err = x509.ParsePKIXPublicKey(block.Bytes); err != nil { + if cert, err := x509.ParseCertificate(block.Bytes); err == nil { + parsedKey = cert.PublicKey + } else { + return nil, err + } + } + + var pkey *ecdsa.PublicKey + var ok bool + if pkey, ok = parsedKey.(*ecdsa.PublicKey); !ok { + return nil, ErrNotECPublicKey + } + + return pkey, nil +} diff --git a/vendor/github.com/golang-jwt/jwt/v5/ed25519.go b/vendor/github.com/golang-jwt/jwt/v5/ed25519.go new file mode 100644 index 000000000..3db00e4a2 --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/ed25519.go @@ -0,0 +1,80 @@ +package jwt + +import ( + "errors" + + "crypto" + "crypto/ed25519" + "crypto/rand" +) + +var ( + ErrEd25519Verification = errors.New("ed25519: verification error") +) + +// SigningMethodEd25519 implements the EdDSA family. +// Expects ed25519.PrivateKey for signing and ed25519.PublicKey for verification +type SigningMethodEd25519 struct{} + +// Specific instance for EdDSA +var ( + SigningMethodEdDSA *SigningMethodEd25519 +) + +func init() { + SigningMethodEdDSA = &SigningMethodEd25519{} + RegisterSigningMethod(SigningMethodEdDSA.Alg(), func() SigningMethod { + return SigningMethodEdDSA + }) +} + +func (m *SigningMethodEd25519) Alg() string { + return "EdDSA" +} + +// Verify implements token verification for the SigningMethod. +// For this verify method, key must be an ed25519.PublicKey +func (m *SigningMethodEd25519) Verify(signingString string, sig []byte, key interface{}) error { + var ed25519Key ed25519.PublicKey + var ok bool + + if ed25519Key, ok = key.(ed25519.PublicKey); !ok { + return ErrInvalidKeyType + } + + if len(ed25519Key) != ed25519.PublicKeySize { + return ErrInvalidKey + } + + // Verify the signature + if !ed25519.Verify(ed25519Key, []byte(signingString), sig) { + return ErrEd25519Verification + } + + return nil +} + +// Sign implements token signing for the SigningMethod. +// For this signing method, key must be an ed25519.PrivateKey +func (m *SigningMethodEd25519) Sign(signingString string, key interface{}) ([]byte, error) { + var ed25519Key crypto.Signer + var ok bool + + if ed25519Key, ok = key.(crypto.Signer); !ok { + return nil, ErrInvalidKeyType + } + + if _, ok := ed25519Key.Public().(ed25519.PublicKey); !ok { + return nil, ErrInvalidKey + } + + // Sign the string and return the result. ed25519 performs a two-pass hash + // as part of its algorithm. Therefore, we need to pass a non-prehashed + // message into the Sign function, as indicated by crypto.Hash(0) + sig, err := ed25519Key.Sign(rand.Reader, []byte(signingString), crypto.Hash(0)) + if err != nil { + return nil, err + } + + return sig, nil +} diff --git a/vendor/github.com/golang-jwt/jwt/v5/ed25519_utils.go b/vendor/github.com/golang-jwt/jwt/v5/ed25519_utils.go new file mode 100644 index 000000000..cdb5e68e8 --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/ed25519_utils.go @@ -0,0 +1,64 @@ +package jwt + +import ( + "crypto" + "crypto/ed25519" + "crypto/x509" + "encoding/pem" + "errors" +) + +var ( + ErrNotEdPrivateKey = errors.New("key is not a valid Ed25519 private key") + ErrNotEdPublicKey = errors.New("key is not a valid Ed25519 public key") +) + +// ParseEdPrivateKeyFromPEM parses a PEM-encoded Edwards curve private key +func ParseEdPrivateKeyFromPEM(key []byte) (crypto.PrivateKey, error) { + var err error + + // Parse PEM block + var block *pem.Block + if block, _ = pem.Decode(key); block == nil { + return nil, ErrKeyMustBePEMEncoded + } + + // Parse the key + var parsedKey interface{} + if parsedKey, err = x509.ParsePKCS8PrivateKey(block.Bytes); err != nil { + return nil, err + } + + var pkey ed25519.PrivateKey + var ok bool + if pkey, ok = parsedKey.(ed25519.PrivateKey); !ok { + return nil, ErrNotEdPrivateKey + } + + return pkey, nil +} + +// ParseEdPublicKeyFromPEM parses a PEM-encoded Edwards curve public key +func ParseEdPublicKeyFromPEM(key []byte) (crypto.PublicKey, error) { + var err error + + // Parse PEM block + var block *pem.Block + if block, _ = pem.Decode(key); block == nil { + return nil, ErrKeyMustBePEMEncoded + } + + // Parse the key + var parsedKey interface{} + if parsedKey, err = x509.ParsePKIXPublicKey(block.Bytes); err != nil { + return nil, err + } + + var pkey ed25519.PublicKey + var ok bool + if pkey, ok = parsedKey.(ed25519.PublicKey); !ok { + return nil, ErrNotEdPublicKey + } + + return pkey, nil +} diff --git a/vendor/github.com/golang-jwt/jwt/v5/errors.go b/vendor/github.com/golang-jwt/jwt/v5/errors.go new file mode 100644 index 000000000..23bb616dd --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/errors.go @@ -0,0 +1,49 @@ +package jwt + +import ( + "errors" + "strings" +) + +var ( + ErrInvalidKey = errors.New("key is invalid") + ErrInvalidKeyType = errors.New("key is of invalid type") + ErrHashUnavailable = errors.New("the requested hash function is unavailable") + ErrTokenMalformed = errors.New("token is malformed") + ErrTokenUnverifiable = errors.New("token is unverifiable") + ErrTokenSignatureInvalid = errors.New("token signature is invalid") + ErrTokenRequiredClaimMissing = errors.New("token is missing required claim") + ErrTokenInvalidAudience = errors.New("token has invalid audience") + ErrTokenExpired = errors.New("token is expired") + ErrTokenUsedBeforeIssued = errors.New("token used before issued") + ErrTokenInvalidIssuer = errors.New("token has invalid issuer") + ErrTokenInvalidSubject = errors.New("token has invalid subject") + ErrTokenNotValidYet = errors.New("token is not valid yet") + ErrTokenInvalidId = errors.New("token has invalid id") + ErrTokenInvalidClaims = errors.New("token has invalid claims") + ErrInvalidType = errors.New("invalid type for claim") +) + +// joinedError is an error type that works similar to what [errors.Join] +// produces, with the exception that it has a nice error string; mainly its +// error messages are concatenated using a comma, rather than a newline. +type joinedError struct { + errs []error +} + +func (je joinedError) Error() string { + msg := []string{} + for _, err := range je.errs { + msg = append(msg, err.Error()) + } + + return strings.Join(msg, ", ") +} + +// joinErrors joins together multiple errors. Useful for scenarios where +// multiple errors next to each other occur, e.g., in claims validation. +func joinErrors(errs ...error) error { + return &joinedError{ + errs: errs, + } +} diff --git a/vendor/github.com/golang-jwt/jwt/v5/errors_go1_20.go b/vendor/github.com/golang-jwt/jwt/v5/errors_go1_20.go new file mode 100644 index 000000000..a893d355e --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/errors_go1_20.go @@ -0,0 +1,47 @@ +//go:build go1.20 +// +build go1.20 + +package jwt + +import ( + "fmt" +) + +// Unwrap implements the multiple error unwrapping for this error type, which is +// possible in Go 1.20. +func (je joinedError) Unwrap() []error { + return je.errs +} + +// newError creates a new error message with a detailed error message. The +// message will be prefixed with the contents of the supplied error type. +// Additionally, more errors, that provide more context can be supplied which +// will be appended to the message. This makes use of Go 1.20's possibility to +// include more than one %w formatting directive in [fmt.Errorf]. +// +// For example, +// +// newError("no keyfunc was provided", ErrTokenUnverifiable) +// +// will produce the error string +// +// "token is unverifiable: no keyfunc was provided" +func newError(message string, err error, more ...error) error { + var format string + var args []any + if message != "" { + format = "%w: %s" + args = []any{err, message} + } else { + format = "%w" + args = []any{err} + } + + for _, e := range more { + format += ": %w" + args = append(args, e) + } + + err = fmt.Errorf(format, args...) + return err +} diff --git a/vendor/github.com/golang-jwt/jwt/v5/errors_go_other.go b/vendor/github.com/golang-jwt/jwt/v5/errors_go_other.go new file mode 100644 index 000000000..3afb04e64 --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/errors_go_other.go @@ -0,0 +1,78 @@ +//go:build !go1.20 +// +build !go1.20 + +package jwt + +import ( + "errors" + "fmt" +) + +// Is implements checking for multiple errors using [errors.Is], since multiple +// error unwrapping is not possible in versions less than Go 1.20. +func (je joinedError) Is(err error) bool { + for _, e := range je.errs { + if errors.Is(e, err) { + return true + } + } + + return false +} + +// wrappedErrors is a workaround for wrapping multiple errors in environments +// where Go 1.20 is not available. It basically uses the already implemented +// functionatlity of joinedError to handle multiple errors with supplies a +// custom error message that is identical to the one we produce in Go 1.20 using +// multiple %w directives. +type wrappedErrors struct { + msg string + joinedError +} + +// Error returns the stored error string +func (we wrappedErrors) Error() string { + return we.msg +} + +// newError creates a new error message with a detailed error message. The +// message will be prefixed with the contents of the supplied error type. +// Additionally, more errors, that provide more context can be supplied which +// will be appended to the message. Since we cannot use of Go 1.20's possibility +// to include more than one %w formatting directive in [fmt.Errorf], we have to +// emulate that. +// +// For example, +// +// newError("no keyfunc was provided", ErrTokenUnverifiable) +// +// will produce the error string +// +// "token is unverifiable: no keyfunc was provided" +func newError(message string, err error, more ...error) error { + // We cannot wrap multiple errors here with %w, so we have to be a little + // bit creative. Basically, we are using %s instead of %w to produce the + // same error message and then throw the result into a custom error struct. + var format string + var args []any + if message != "" { + format = "%s: %s" + args = []any{err, message} + } else { + format = "%s" + args = []any{err} + } + errs := []error{err} + + for _, e := range more { + format += ": %s" + args = append(args, e) + errs = append(errs, e) + } + + err = &wrappedErrors{ + msg: fmt.Sprintf(format, args...), + joinedError: joinedError{errs: errs}, + } + return err +} diff --git a/vendor/github.com/golang-jwt/jwt/v5/hmac.go b/vendor/github.com/golang-jwt/jwt/v5/hmac.go new file mode 100644 index 000000000..91b688ba9 --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/hmac.go @@ -0,0 +1,104 @@ +package jwt + +import ( + "crypto" + "crypto/hmac" + "errors" +) + +// SigningMethodHMAC implements the HMAC-SHA family of signing methods. +// Expects key type of []byte for both signing and validation +type SigningMethodHMAC struct { + Name string + Hash crypto.Hash +} + +// Specific instances for HS256 and company +var ( + SigningMethodHS256 *SigningMethodHMAC + SigningMethodHS384 *SigningMethodHMAC + SigningMethodHS512 *SigningMethodHMAC + ErrSignatureInvalid = errors.New("signature is invalid") +) + +func init() { + // HS256 + SigningMethodHS256 = &SigningMethodHMAC{"HS256", crypto.SHA256} + RegisterSigningMethod(SigningMethodHS256.Alg(), func() SigningMethod { + return SigningMethodHS256 + }) + + // HS384 + SigningMethodHS384 = &SigningMethodHMAC{"HS384", crypto.SHA384} + RegisterSigningMethod(SigningMethodHS384.Alg(), func() SigningMethod { + return SigningMethodHS384 + }) + + // HS512 + SigningMethodHS512 = &SigningMethodHMAC{"HS512", crypto.SHA512} + RegisterSigningMethod(SigningMethodHS512.Alg(), func() SigningMethod { + return SigningMethodHS512 + }) +} + +func (m *SigningMethodHMAC) Alg() string { + return m.Name +} + +// Verify implements token verification for the SigningMethod. Returns nil if +// the signature is valid. Key must be []byte. +// +// Note it is not advised to provide a []byte which was converted from a 'human +// readable' string using a subset of ASCII characters. To maximize entropy, you +// should ideally be providing a []byte key which was produced from a +// cryptographically random source, e.g. crypto/rand. Additional information +// about this, and why we intentionally are not supporting string as a key can +// be found on our usage guide +// https://golang-jwt.github.io/jwt/usage/signing_methods/#signing-methods-and-key-types. +func (m *SigningMethodHMAC) Verify(signingString string, sig []byte, key interface{}) error { + // Verify the key is the right type + keyBytes, ok := key.([]byte) + if !ok { + return ErrInvalidKeyType + } + + // Can we use the specified hashing method? + if !m.Hash.Available() { + return ErrHashUnavailable + } + + // This signing method is symmetric, so we validate the signature + // by reproducing the signature from the signing string and key, then + // comparing that against the provided signature. + hasher := hmac.New(m.Hash.New, keyBytes) + hasher.Write([]byte(signingString)) + if !hmac.Equal(sig, hasher.Sum(nil)) { + return ErrSignatureInvalid + } + + // No validation errors. Signature is good. + return nil +} + +// Sign implements token signing for the SigningMethod. Key must be []byte. +// +// Note it is not advised to provide a []byte which was converted from a 'human +// readable' string using a subset of ASCII characters. To maximize entropy, you +// should ideally be providing a []byte key which was produced from a +// cryptographically random source, e.g. crypto/rand. Additional information +// about this, and why we intentionally are not supporting string as a key can +// be found on our usage guide https://golang-jwt.github.io/jwt/usage/signing_methods/. +func (m *SigningMethodHMAC) Sign(signingString string, key interface{}) ([]byte, error) { + if keyBytes, ok := key.([]byte); ok { + if !m.Hash.Available() { + return nil, ErrHashUnavailable + } + + hasher := hmac.New(m.Hash.New, keyBytes) + hasher.Write([]byte(signingString)) + + return hasher.Sum(nil), nil + } + + return nil, ErrInvalidKeyType +} diff --git a/vendor/github.com/golang-jwt/jwt/v5/map_claims.go b/vendor/github.com/golang-jwt/jwt/v5/map_claims.go new file mode 100644 index 000000000..b2b51a1f8 --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/map_claims.go @@ -0,0 +1,109 @@ +package jwt + +import ( + "encoding/json" + "fmt" +) + +// MapClaims is a claims type that uses the map[string]interface{} for JSON +// decoding. This is the default claims type if you don't supply one +type MapClaims map[string]interface{} + +// GetExpirationTime implements the Claims interface. +func (m MapClaims) GetExpirationTime() (*NumericDate, error) { + return m.parseNumericDate("exp") +} + +// GetNotBefore implements the Claims interface. +func (m MapClaims) GetNotBefore() (*NumericDate, error) { + return m.parseNumericDate("nbf") +} + +// GetIssuedAt implements the Claims interface. +func (m MapClaims) GetIssuedAt() (*NumericDate, error) { + return m.parseNumericDate("iat") +} + +// GetAudience implements the Claims interface. +func (m MapClaims) GetAudience() (ClaimStrings, error) { + return m.parseClaimsString("aud") +} + +// GetIssuer implements the Claims interface. +func (m MapClaims) GetIssuer() (string, error) { + return m.parseString("iss") +} + +// GetSubject implements the Claims interface. +func (m MapClaims) GetSubject() (string, error) { + return m.parseString("sub") +} + +// parseNumericDate tries to parse a key in the map claims type as a number +// date. This will succeed, if the underlying type is either a [float64] or a +// [json.Number]. Otherwise, nil will be returned. +func (m MapClaims) parseNumericDate(key string) (*NumericDate, error) { + v, ok := m[key] + if !ok { + return nil, nil + } + + switch exp := v.(type) { + case float64: + if exp == 0 { + return nil, nil + } + + return newNumericDateFromSeconds(exp), nil + case json.Number: + v, _ := exp.Float64() + + return newNumericDateFromSeconds(v), nil + } + + return nil, newError(fmt.Sprintf("%s is invalid", key), ErrInvalidType) +} + +// parseClaimsString tries to parse a key in the map claims type as a +// [ClaimsStrings] type, which can either be a string or an array of string. +func (m MapClaims) parseClaimsString(key string) (ClaimStrings, error) { + var cs []string + switch v := m[key].(type) { + case string: + cs = append(cs, v) + case []string: + cs = v + case []interface{}: + for _, a := range v { + vs, ok := a.(string) + if !ok { + return nil, newError(fmt.Sprintf("%s is invalid", key), ErrInvalidType) + } + cs = append(cs, vs) + } + } + + return cs, nil +} + +// parseString tries to parse a key in the map claims type as a [string] type. +// If the key does not exist, an empty string is returned. If the key has the +// wrong type, an error is returned. +func (m MapClaims) parseString(key string) (string, error) { + var ( + ok bool + raw interface{} + iss string + ) + raw, ok = m[key] + if !ok { + return "", nil + } + + iss, ok = raw.(string) + if !ok { + return "", newError(fmt.Sprintf("%s is invalid", key), ErrInvalidType) + } + + return iss, nil +} diff --git a/vendor/github.com/golang-jwt/jwt/v5/none.go b/vendor/github.com/golang-jwt/jwt/v5/none.go new file mode 100644 index 000000000..c93daa584 --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/none.go @@ -0,0 +1,50 @@ +package jwt + +// SigningMethodNone implements the none signing method. This is required by the spec +// but you probably should never use it. +var SigningMethodNone *signingMethodNone + +const UnsafeAllowNoneSignatureType unsafeNoneMagicConstant = "none signing method allowed" + +var NoneSignatureTypeDisallowedError error + +type signingMethodNone struct{} +type unsafeNoneMagicConstant string + +func init() { + SigningMethodNone = &signingMethodNone{} + NoneSignatureTypeDisallowedError = newError("'none' signature type is not allowed", ErrTokenUnverifiable) + + RegisterSigningMethod(SigningMethodNone.Alg(), func() SigningMethod { + return SigningMethodNone + }) +} + +func (m *signingMethodNone) Alg() string { + return "none" +} + +// Only allow 'none' alg type if UnsafeAllowNoneSignatureType is specified as the key +func (m *signingMethodNone) Verify(signingString string, sig []byte, key interface{}) (err error) { + // Key must be UnsafeAllowNoneSignatureType to prevent accidentally + // accepting 'none' signing method + if _, ok := key.(unsafeNoneMagicConstant); !ok { + return NoneSignatureTypeDisallowedError + } + // If signing method is none, signature must be an empty string + if string(sig) != "" { + return newError("'none' signing method with non-empty signature", ErrTokenUnverifiable) + } + + // Accept 'none' signing method. + return nil +} + +// Only allow 'none' signing if UnsafeAllowNoneSignatureType is specified as the key +func (m *signingMethodNone) Sign(signingString string, key interface{}) ([]byte, error) { + if _, ok := key.(unsafeNoneMagicConstant); ok { + return []byte{}, nil + } + + return nil, NoneSignatureTypeDisallowedError +} diff --git a/vendor/github.com/golang-jwt/jwt/v5/parser.go b/vendor/github.com/golang-jwt/jwt/v5/parser.go new file mode 100644 index 000000000..f4386fbaa --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/parser.go @@ -0,0 +1,215 @@ +package jwt + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +type Parser struct { + // If populated, only these methods will be considered valid. + validMethods []string + + // Use JSON Number format in JSON decoder. + useJSONNumber bool + + // Skip claims validation during token parsing. + skipClaimsValidation bool + + validator *validator + + decodeStrict bool + + decodePaddingAllowed bool +} + +// NewParser creates a new Parser with the specified options +func NewParser(options ...ParserOption) *Parser { + p := &Parser{ + validator: &validator{}, + } + + // Loop through our parsing options and apply them + for _, option := range options { + option(p) + } + + return p +} + +// Parse parses, validates, verifies the signature and returns the parsed token. +// keyFunc will receive the parsed token and should return the key for validating. +func (p *Parser) Parse(tokenString string, keyFunc Keyfunc) (*Token, error) { + return p.ParseWithClaims(tokenString, MapClaims{}, keyFunc) +} + +// ParseWithClaims parses, validates, and verifies like Parse, but supplies a default object implementing the Claims +// interface. This provides default values which can be overridden and allows a caller to use their own type, rather +// than the default MapClaims implementation of Claims. +// +// Note: If you provide a custom claim implementation that embeds one of the standard claims (such as RegisteredClaims), +// make sure that a) you either embed a non-pointer version of the claims or b) if you are using a pointer, allocate the +// proper memory for it before passing in the overall claims, otherwise you might run into a panic. +func (p *Parser) ParseWithClaims(tokenString string, claims Claims, keyFunc Keyfunc) (*Token, error) { + token, parts, err := p.ParseUnverified(tokenString, claims) + if err != nil { + return token, err + } + + // Verify signing method is in the required set + if p.validMethods != nil { + var signingMethodValid = false + var alg = token.Method.Alg() + for _, m := range p.validMethods { + if m == alg { + signingMethodValid = true + break + } + } + if !signingMethodValid { + // signing method is not in the listed set + return token, newError(fmt.Sprintf("signing method %v is invalid", alg), ErrTokenSignatureInvalid) + } + } + + // Lookup key + var key interface{} + if keyFunc == nil { + // keyFunc was not provided. short circuiting validation + return token, newError("no keyfunc was provided", ErrTokenUnverifiable) + } + if key, err = keyFunc(token); err != nil { + return token, newError("error while executing keyfunc", ErrTokenUnverifiable, err) + } + + // Decode signature + token.Signature, err = p.DecodeSegment(parts[2]) + if err != nil { + return token, newError("could not base64 decode signature", ErrTokenMalformed, err) + } + + // Perform signature validation + if err = token.Method.Verify(strings.Join(parts[0:2], "."), token.Signature, key); err != nil { + return token, newError("", ErrTokenSignatureInvalid, err) + } + + // Validate Claims + if !p.skipClaimsValidation { + // Make sure we have at least a default validator + if p.validator == nil { + p.validator = newValidator() + } + + if err := p.validator.Validate(claims); err != nil { + return token, newError("", ErrTokenInvalidClaims, err) + } + } + + // No errors so far, token is valid. + token.Valid = true + + return token, nil +} + +// ParseUnverified parses the token but doesn't validate the signature. +// +// WARNING: Don't use this method unless you know what you're doing. +// +// It's only ever useful in cases where you know the signature is valid (because it has +// been checked previously in the stack) and you want to extract values from it. +func (p *Parser) ParseUnverified(tokenString string, claims Claims) (token *Token, parts []string, err error) { + parts = strings.Split(tokenString, ".") + if len(parts) != 3 { + return nil, parts, newError("token contains an invalid number of segments", ErrTokenMalformed) + } + + token = &Token{Raw: tokenString} + + // parse Header + var headerBytes []byte + if headerBytes, err = p.DecodeSegment(parts[0]); err != nil { + if strings.HasPrefix(strings.ToLower(tokenString), "bearer ") { + return token, parts, newError("tokenstring should not contain 'bearer '", ErrTokenMalformed) + } + return token, parts, newError("could not base64 decode header", ErrTokenMalformed, err) + } + if err = json.Unmarshal(headerBytes, &token.Header); err != nil { + return token, parts, newError("could not JSON decode header", ErrTokenMalformed, err) + } + + // parse Claims + var claimBytes []byte + token.Claims = claims + + if claimBytes, err = p.DecodeSegment(parts[1]); err != nil { + return token, parts, newError("could not base64 decode claim", ErrTokenMalformed, err) + } + dec := json.NewDecoder(bytes.NewBuffer(claimBytes)) + if p.useJSONNumber { + dec.UseNumber() + } + // JSON Decode. Special case for map type to avoid weird pointer behavior + if c, ok := token.Claims.(MapClaims); ok { + err = dec.Decode(&c) + } else { + err = dec.Decode(&claims) + } + // Handle decode error + if err != nil { + return token, parts, newError("could not JSON decode claim", ErrTokenMalformed, err) + } + + // Lookup signature method + if method, ok := token.Header["alg"].(string); ok { + if token.Method = GetSigningMethod(method); token.Method == nil { + return token, parts, newError("signing method (alg) is unavailable", ErrTokenUnverifiable) + } + } else { + return token, parts, newError("signing method (alg) is unspecified", ErrTokenUnverifiable) + } + + return token, parts, nil +} + +// DecodeSegment decodes a JWT specific base64url encoding. This function will +// take into account whether the [Parser] is configured with additional options, +// such as [WithStrictDecoding] or [WithPaddingAllowed]. +func (p *Parser) DecodeSegment(seg string) ([]byte, error) { + encoding := base64.RawURLEncoding + + if p.decodePaddingAllowed { + if l := len(seg) % 4; l > 0 { + seg += strings.Repeat("=", 4-l) + } + encoding = base64.URLEncoding + } + + if p.decodeStrict { + encoding = encoding.Strict() + } + return encoding.DecodeString(seg) +} + +// Parse parses, validates, verifies the signature and returns the parsed token. +// keyFunc will receive the parsed token and should return the cryptographic key +// for verifying the signature. The caller is strongly encouraged to set the +// WithValidMethods option to validate the 'alg' claim in the token matches the +// expected algorithm. For more details about the importance of validating the +// 'alg' claim, see +// https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/ +func Parse(tokenString string, keyFunc Keyfunc, options ...ParserOption) (*Token, error) { + return NewParser(options...).Parse(tokenString, keyFunc) +} + +// ParseWithClaims is a shortcut for NewParser().ParseWithClaims(). +// +// Note: If you provide a custom claim implementation that embeds one of the +// standard claims (such as RegisteredClaims), make sure that a) you either +// embed a non-pointer version of the claims or b) if you are using a pointer, +// allocate the proper memory for it before passing in the overall claims, +// otherwise you might run into a panic. +func ParseWithClaims(tokenString string, claims Claims, keyFunc Keyfunc, options ...ParserOption) (*Token, error) { + return NewParser(options...).ParseWithClaims(tokenString, claims, keyFunc) +} diff --git a/vendor/github.com/golang-jwt/jwt/v5/parser_option.go b/vendor/github.com/golang-jwt/jwt/v5/parser_option.go new file mode 100644 index 000000000..1b5af970f --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/parser_option.go @@ -0,0 +1,120 @@ +package jwt + +import "time" + +// ParserOption is used to implement functional-style options that modify the +// behavior of the parser. To add new options, just create a function (ideally +// beginning with With or Without) that returns an anonymous function that takes +// a *Parser type as input and manipulates its configuration accordingly. +type ParserOption func(*Parser) + +// WithValidMethods is an option to supply algorithm methods that the parser +// will check. Only those methods will be considered valid. It is heavily +// encouraged to use this option in order to prevent attacks such as +// https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/. +func WithValidMethods(methods []string) ParserOption { + return func(p *Parser) { + p.validMethods = methods + } +} + +// WithJSONNumber is an option to configure the underlying JSON parser with +// UseNumber. +func WithJSONNumber() ParserOption { + return func(p *Parser) { + p.useJSONNumber = true + } +} + +// WithoutClaimsValidation is an option to disable claims validation. This +// option should only be used if you exactly know what you are doing. +func WithoutClaimsValidation() ParserOption { + return func(p *Parser) { + p.skipClaimsValidation = true + } +} + +// WithLeeway returns the ParserOption for specifying the leeway window. +func WithLeeway(leeway time.Duration) ParserOption { + return func(p *Parser) { + p.validator.leeway = leeway + } +} + +// WithTimeFunc returns the ParserOption for specifying the time func. The +// primary use-case for this is testing. If you are looking for a way to account +// for clock-skew, WithLeeway should be used instead. +func WithTimeFunc(f func() time.Time) ParserOption { + return func(p *Parser) { + p.validator.timeFunc = f + } +} + +// WithIssuedAt returns the ParserOption to enable verification +// of issued-at. +func WithIssuedAt() ParserOption { + return func(p *Parser) { + p.validator.verifyIat = true + } +} + +// WithAudience configures the validator to require the specified audience in +// the `aud` claim. Validation will fail if the audience is not listed in the +// token or the `aud` claim is missing. +// +// NOTE: While the `aud` claim is OPTIONAL in a JWT, the handling of it is +// application-specific. Since this validation API is helping developers in +// writing secure application, we decided to REQUIRE the existence of the claim, +// if an audience is expected. +func WithAudience(aud string) ParserOption { + return func(p *Parser) { + p.validator.expectedAud = aud + } +} + +// WithIssuer configures the validator to require the specified issuer in the +// `iss` claim. Validation will fail if a different issuer is specified in the +// token or the `iss` claim is missing. +// +// NOTE: While the `iss` claim is OPTIONAL in a JWT, the handling of it is +// application-specific. Since this validation API is helping developers in +// writing secure application, we decided to REQUIRE the existence of the claim, +// if an issuer is expected. +func WithIssuer(iss string) ParserOption { + return func(p *Parser) { + p.validator.expectedIss = iss + } +} + +// WithSubject configures the validator to require the specified subject in the +// `sub` claim. Validation will fail if a different subject is specified in the +// token or the `sub` claim is missing. +// +// NOTE: While the `sub` claim is OPTIONAL in a JWT, the handling of it is +// application-specific. Since this validation API is helping developers in +// writing secure application, we decided to REQUIRE the existence of the claim, +// if a subject is expected. +func WithSubject(sub string) ParserOption { + return func(p *Parser) { + p.validator.expectedSub = sub + } +} + +// WithPaddingAllowed will enable the codec used for decoding JWTs to allow +// padding. Note that the JWS RFC7515 states that the tokens will utilize a +// Base64url encoding with no padding. Unfortunately, some implementations of +// JWT are producing non-standard tokens, and thus require support for decoding. +func WithPaddingAllowed() ParserOption { + return func(p *Parser) { + p.decodePaddingAllowed = true + } +} + +// WithStrictDecoding will switch the codec used for decoding JWTs into strict +// mode. In this mode, the decoder requires that trailing padding bits are zero, +// as described in RFC 4648 section 3.5. +func WithStrictDecoding() ParserOption { + return func(p *Parser) { + p.decodeStrict = true + } +} diff --git a/vendor/github.com/golang-jwt/jwt/v5/registered_claims.go b/vendor/github.com/golang-jwt/jwt/v5/registered_claims.go new file mode 100644 index 000000000..77951a531 --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/registered_claims.go @@ -0,0 +1,63 @@ +package jwt + +// RegisteredClaims are a structured version of the JWT Claims Set, +// restricted to Registered Claim Names, as referenced at +// https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 +// +// This type can be used on its own, but then additional private and +// public claims embedded in the JWT will not be parsed. The typical use-case +// therefore is to embedded this in a user-defined claim type. +// +// See examples for how to use this with your own claim types. +type RegisteredClaims struct { + // the `iss` (Issuer) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1 + Issuer string `json:"iss,omitempty"` + + // the `sub` (Subject) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2 + Subject string `json:"sub,omitempty"` + + // the `aud` (Audience) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3 + Audience ClaimStrings `json:"aud,omitempty"` + + // the `exp` (Expiration Time) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4 + ExpiresAt *NumericDate `json:"exp,omitempty"` + + // the `nbf` (Not Before) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5 + NotBefore *NumericDate `json:"nbf,omitempty"` + + // the `iat` (Issued At) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6 + IssuedAt *NumericDate `json:"iat,omitempty"` + + // the `jti` (JWT ID) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.7 + ID string `json:"jti,omitempty"` +} + +// GetExpirationTime implements the Claims interface. +func (c RegisteredClaims) GetExpirationTime() (*NumericDate, error) { + return c.ExpiresAt, nil +} + +// GetNotBefore implements the Claims interface. +func (c RegisteredClaims) GetNotBefore() (*NumericDate, error) { + return c.NotBefore, nil +} + +// GetIssuedAt implements the Claims interface. +func (c RegisteredClaims) GetIssuedAt() (*NumericDate, error) { + return c.IssuedAt, nil +} + +// GetAudience implements the Claims interface. +func (c RegisteredClaims) GetAudience() (ClaimStrings, error) { + return c.Audience, nil +} + +// GetIssuer implements the Claims interface. +func (c RegisteredClaims) GetIssuer() (string, error) { + return c.Issuer, nil +} + +// GetSubject implements the Claims interface. +func (c RegisteredClaims) GetSubject() (string, error) { + return c.Subject, nil +} diff --git a/vendor/github.com/golang-jwt/jwt/v5/rsa.go b/vendor/github.com/golang-jwt/jwt/v5/rsa.go new file mode 100644 index 000000000..daff09431 --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/rsa.go @@ -0,0 +1,93 @@ +package jwt + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" +) + +// SigningMethodRSA implements the RSA family of signing methods. +// Expects *rsa.PrivateKey for signing and *rsa.PublicKey for validation +type SigningMethodRSA struct { + Name string + Hash crypto.Hash +} + +// Specific instances for RS256 and company +var ( + SigningMethodRS256 *SigningMethodRSA + SigningMethodRS384 *SigningMethodRSA + SigningMethodRS512 *SigningMethodRSA +) + +func init() { + // RS256 + SigningMethodRS256 = &SigningMethodRSA{"RS256", crypto.SHA256} + RegisterSigningMethod(SigningMethodRS256.Alg(), func() SigningMethod { + return SigningMethodRS256 + }) + + // RS384 + SigningMethodRS384 = &SigningMethodRSA{"RS384", crypto.SHA384} + RegisterSigningMethod(SigningMethodRS384.Alg(), func() SigningMethod { + return SigningMethodRS384 + }) + + // RS512 + SigningMethodRS512 = &SigningMethodRSA{"RS512", crypto.SHA512} + RegisterSigningMethod(SigningMethodRS512.Alg(), func() SigningMethod { + return SigningMethodRS512 + }) +} + +func (m *SigningMethodRSA) Alg() string { + return m.Name +} + +// Verify implements token verification for the SigningMethod +// For this signing method, must be an *rsa.PublicKey structure. +func (m *SigningMethodRSA) Verify(signingString string, sig []byte, key interface{}) error { + var rsaKey *rsa.PublicKey + var ok bool + + if rsaKey, ok = key.(*rsa.PublicKey); !ok { + return ErrInvalidKeyType + } + + // Create hasher + if !m.Hash.Available() { + return ErrHashUnavailable + } + hasher := m.Hash.New() + hasher.Write([]byte(signingString)) + + // Verify the signature + return rsa.VerifyPKCS1v15(rsaKey, m.Hash, hasher.Sum(nil), sig) +} + +// Sign implements token signing for the SigningMethod +// For this signing method, must be an *rsa.PrivateKey structure. +func (m *SigningMethodRSA) Sign(signingString string, key interface{}) ([]byte, error) { + var rsaKey *rsa.PrivateKey + var ok bool + + // Validate type of key + if rsaKey, ok = key.(*rsa.PrivateKey); !ok { + return nil, ErrInvalidKey + } + + // Create the hasher + if !m.Hash.Available() { + return nil, ErrHashUnavailable + } + + hasher := m.Hash.New() + hasher.Write([]byte(signingString)) + + // Sign the string and return the encoded bytes + if sigBytes, err := rsa.SignPKCS1v15(rand.Reader, rsaKey, m.Hash, hasher.Sum(nil)); err == nil { + return sigBytes, nil + } else { + return nil, err + } +} diff --git a/vendor/github.com/golang-jwt/jwt/v5/rsa_pss.go b/vendor/github.com/golang-jwt/jwt/v5/rsa_pss.go new file mode 100644 index 000000000..9599f0a46 --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/rsa_pss.go @@ -0,0 +1,135 @@ +//go:build go1.4 +// +build go1.4 + +package jwt + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" +) + +// SigningMethodRSAPSS implements the RSAPSS family of signing methods signing methods +type SigningMethodRSAPSS struct { + *SigningMethodRSA + Options *rsa.PSSOptions + // VerifyOptions is optional. If set overrides Options for rsa.VerifyPPS. + // Used to accept tokens signed with rsa.PSSSaltLengthAuto, what doesn't follow + // https://tools.ietf.org/html/rfc7518#section-3.5 but was used previously. + // See https://github.com/dgrijalva/jwt-go/issues/285#issuecomment-437451244 for details. + VerifyOptions *rsa.PSSOptions +} + +// Specific instances for RS/PS and company. +var ( + SigningMethodPS256 *SigningMethodRSAPSS + SigningMethodPS384 *SigningMethodRSAPSS + SigningMethodPS512 *SigningMethodRSAPSS +) + +func init() { + // PS256 + SigningMethodPS256 = &SigningMethodRSAPSS{ + SigningMethodRSA: &SigningMethodRSA{ + Name: "PS256", + Hash: crypto.SHA256, + }, + Options: &rsa.PSSOptions{ + SaltLength: rsa.PSSSaltLengthEqualsHash, + }, + VerifyOptions: &rsa.PSSOptions{ + SaltLength: rsa.PSSSaltLengthAuto, + }, + } + RegisterSigningMethod(SigningMethodPS256.Alg(), func() SigningMethod { + return SigningMethodPS256 + }) + + // PS384 + SigningMethodPS384 = &SigningMethodRSAPSS{ + SigningMethodRSA: &SigningMethodRSA{ + Name: "PS384", + Hash: crypto.SHA384, + }, + Options: &rsa.PSSOptions{ + SaltLength: rsa.PSSSaltLengthEqualsHash, + }, + VerifyOptions: &rsa.PSSOptions{ + SaltLength: rsa.PSSSaltLengthAuto, + }, + } + RegisterSigningMethod(SigningMethodPS384.Alg(), func() SigningMethod { + return SigningMethodPS384 + }) + + // PS512 + SigningMethodPS512 = &SigningMethodRSAPSS{ + SigningMethodRSA: &SigningMethodRSA{ + Name: "PS512", + Hash: crypto.SHA512, + }, + Options: &rsa.PSSOptions{ + SaltLength: rsa.PSSSaltLengthEqualsHash, + }, + VerifyOptions: &rsa.PSSOptions{ + SaltLength: rsa.PSSSaltLengthAuto, + }, + } + RegisterSigningMethod(SigningMethodPS512.Alg(), func() SigningMethod { + return SigningMethodPS512 + }) +} + +// Verify implements token verification for the SigningMethod. +// For this verify method, key must be an rsa.PublicKey struct +func (m *SigningMethodRSAPSS) Verify(signingString string, sig []byte, key interface{}) error { + var rsaKey *rsa.PublicKey + switch k := key.(type) { + case *rsa.PublicKey: + rsaKey = k + default: + return ErrInvalidKey + } + + // Create hasher + if !m.Hash.Available() { + return ErrHashUnavailable + } + hasher := m.Hash.New() + hasher.Write([]byte(signingString)) + + opts := m.Options + if m.VerifyOptions != nil { + opts = m.VerifyOptions + } + + return rsa.VerifyPSS(rsaKey, m.Hash, hasher.Sum(nil), sig, opts) +} + +// Sign implements token signing for the SigningMethod. +// For this signing method, key must be an rsa.PrivateKey struct +func (m *SigningMethodRSAPSS) Sign(signingString string, key interface{}) ([]byte, error) { + var rsaKey *rsa.PrivateKey + + switch k := key.(type) { + case *rsa.PrivateKey: + rsaKey = k + default: + return nil, ErrInvalidKeyType + } + + // Create the hasher + if !m.Hash.Available() { + return nil, ErrHashUnavailable + } + + hasher := m.Hash.New() + hasher.Write([]byte(signingString)) + + // Sign the string and return the encoded bytes + if sigBytes, err := rsa.SignPSS(rand.Reader, rsaKey, m.Hash, hasher.Sum(nil), m.Options); err == nil { + return sigBytes, nil + } else { + return nil, err + } +} diff --git a/vendor/github.com/golang-jwt/jwt/v5/rsa_utils.go b/vendor/github.com/golang-jwt/jwt/v5/rsa_utils.go new file mode 100644 index 000000000..b3aeebbe1 --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/rsa_utils.go @@ -0,0 +1,107 @@ +package jwt + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" +) + +var ( + ErrKeyMustBePEMEncoded = errors.New("invalid key: Key must be a PEM encoded PKCS1 or PKCS8 key") + ErrNotRSAPrivateKey = errors.New("key is not a valid RSA private key") + ErrNotRSAPublicKey = errors.New("key is not a valid RSA public key") +) + +// ParseRSAPrivateKeyFromPEM parses a PEM encoded PKCS1 or PKCS8 private key +func ParseRSAPrivateKeyFromPEM(key []byte) (*rsa.PrivateKey, error) { + var err error + + // Parse PEM block + var block *pem.Block + if block, _ = pem.Decode(key); block == nil { + return nil, ErrKeyMustBePEMEncoded + } + + var parsedKey interface{} + if parsedKey, err = x509.ParsePKCS1PrivateKey(block.Bytes); err != nil { + if parsedKey, err = x509.ParsePKCS8PrivateKey(block.Bytes); err != nil { + return nil, err + } + } + + var pkey *rsa.PrivateKey + var ok bool + if pkey, ok = parsedKey.(*rsa.PrivateKey); !ok { + return nil, ErrNotRSAPrivateKey + } + + return pkey, nil +} + +// ParseRSAPrivateKeyFromPEMWithPassword parses a PEM encoded PKCS1 or PKCS8 private key protected with password +// +// Deprecated: This function is deprecated and should not be used anymore. It uses the deprecated x509.DecryptPEMBlock +// function, which was deprecated since RFC 1423 is regarded insecure by design. Unfortunately, there is no alternative +// in the Go standard library for now. See https://github.com/golang/go/issues/8860. +func ParseRSAPrivateKeyFromPEMWithPassword(key []byte, password string) (*rsa.PrivateKey, error) { + var err error + + // Parse PEM block + var block *pem.Block + if block, _ = pem.Decode(key); block == nil { + return nil, ErrKeyMustBePEMEncoded + } + + var parsedKey interface{} + + var blockDecrypted []byte + if blockDecrypted, err = x509.DecryptPEMBlock(block, []byte(password)); err != nil { + return nil, err + } + + if parsedKey, err = x509.ParsePKCS1PrivateKey(blockDecrypted); err != nil { + if parsedKey, err = x509.ParsePKCS8PrivateKey(blockDecrypted); err != nil { + return nil, err + } + } + + var pkey *rsa.PrivateKey + var ok bool + if pkey, ok = parsedKey.(*rsa.PrivateKey); !ok { + return nil, ErrNotRSAPrivateKey + } + + return pkey, nil +} + +// ParseRSAPublicKeyFromPEM parses a certificate or a PEM encoded PKCS1 or PKIX public key +func ParseRSAPublicKeyFromPEM(key []byte) (*rsa.PublicKey, error) { + var err error + + // Parse PEM block + var block *pem.Block + if block, _ = pem.Decode(key); block == nil { + return nil, ErrKeyMustBePEMEncoded + } + + // Parse the key + var parsedKey interface{} + if parsedKey, err = x509.ParsePKIXPublicKey(block.Bytes); err != nil { + if cert, err := x509.ParseCertificate(block.Bytes); err == nil { + parsedKey = cert.PublicKey + } else { + if parsedKey, err = x509.ParsePKCS1PublicKey(block.Bytes); err != nil { + return nil, err + } + } + } + + var pkey *rsa.PublicKey + var ok bool + if pkey, ok = parsedKey.(*rsa.PublicKey); !ok { + return nil, ErrNotRSAPublicKey + } + + return pkey, nil +} diff --git a/vendor/github.com/golang-jwt/jwt/v5/signing_method.go b/vendor/github.com/golang-jwt/jwt/v5/signing_method.go new file mode 100644 index 000000000..0d73631c1 --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/signing_method.go @@ -0,0 +1,49 @@ +package jwt + +import ( + "sync" +) + +var signingMethods = map[string]func() SigningMethod{} +var signingMethodLock = new(sync.RWMutex) + +// SigningMethod can be used add new methods for signing or verifying tokens. It +// takes a decoded signature as an input in the Verify function and produces a +// signature in Sign. The signature is then usually base64 encoded as part of a +// JWT. +type SigningMethod interface { + Verify(signingString string, sig []byte, key interface{}) error // Returns nil if signature is valid + Sign(signingString string, key interface{}) ([]byte, error) // Returns signature or error + Alg() string // returns the alg identifier for this method (example: 'HS256') +} + +// RegisterSigningMethod registers the "alg" name and a factory function for signing method. +// This is typically done during init() in the method's implementation +func RegisterSigningMethod(alg string, f func() SigningMethod) { + signingMethodLock.Lock() + defer signingMethodLock.Unlock() + + signingMethods[alg] = f +} + +// GetSigningMethod retrieves a signing method from an "alg" string +func GetSigningMethod(alg string) (method SigningMethod) { + signingMethodLock.RLock() + defer signingMethodLock.RUnlock() + + if methodF, ok := signingMethods[alg]; ok { + method = methodF() + } + return +} + +// GetAlgorithms returns a list of registered "alg" names +func GetAlgorithms() (algs []string) { + signingMethodLock.RLock() + defer signingMethodLock.RUnlock() + + for alg := range signingMethods { + algs = append(algs, alg) + } + return +} diff --git a/vendor/github.com/golang-jwt/jwt/v5/staticcheck.conf b/vendor/github.com/golang-jwt/jwt/v5/staticcheck.conf new file mode 100644 index 000000000..53745d51d --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/staticcheck.conf @@ -0,0 +1 @@ +checks = ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1023"] diff --git a/vendor/github.com/golang-jwt/jwt/v5/token.go b/vendor/github.com/golang-jwt/jwt/v5/token.go new file mode 100644 index 000000000..c8ad7c783 --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/token.go @@ -0,0 +1,86 @@ +package jwt + +import ( + "encoding/base64" + "encoding/json" +) + +// Keyfunc will be used by the Parse methods as a callback function to supply +// the key for verification. The function receives the parsed, but unverified +// Token. This allows you to use properties in the Header of the token (such as +// `kid`) to identify which key to use. +type Keyfunc func(*Token) (interface{}, error) + +// Token represents a JWT Token. Different fields will be used depending on +// whether you're creating or parsing/verifying a token. +type Token struct { + Raw string // Raw contains the raw token. Populated when you [Parse] a token + Method SigningMethod // Method is the signing method used or to be used + Header map[string]interface{} // Header is the first segment of the token in decoded form + Claims Claims // Claims is the second segment of the token in decoded form + Signature []byte // Signature is the third segment of the token in decoded form. Populated when you Parse a token + Valid bool // Valid specifies if the token is valid. Populated when you Parse/Verify a token +} + +// New creates a new [Token] with the specified signing method and an empty map +// of claims. Additional options can be specified, but are currently unused. +func New(method SigningMethod, opts ...TokenOption) *Token { + return NewWithClaims(method, MapClaims{}, opts...) +} + +// NewWithClaims creates a new [Token] with the specified signing method and +// claims. Additional options can be specified, but are currently unused. +func NewWithClaims(method SigningMethod, claims Claims, opts ...TokenOption) *Token { + return &Token{ + Header: map[string]interface{}{ + "typ": "JWT", + "alg": method.Alg(), + }, + Claims: claims, + Method: method, + } +} + +// SignedString creates and returns a complete, signed JWT. The token is signed +// using the SigningMethod specified in the token. Please refer to +// https://golang-jwt.github.io/jwt/usage/signing_methods/#signing-methods-and-key-types +// for an overview of the different signing methods and their respective key +// types. +func (t *Token) SignedString(key interface{}) (string, error) { + sstr, err := t.SigningString() + if err != nil { + return "", err + } + + sig, err := t.Method.Sign(sstr, key) + if err != nil { + return "", err + } + + return sstr + "." + t.EncodeSegment(sig), nil +} + +// SigningString generates the signing string. This is the most expensive part +// of the whole deal. Unless you need this for something special, just go +// straight for the SignedString. +func (t *Token) SigningString() (string, error) { + h, err := json.Marshal(t.Header) + if err != nil { + return "", err + } + + c, err := json.Marshal(t.Claims) + if err != nil { + return "", err + } + + return t.EncodeSegment(h) + "." + t.EncodeSegment(c), nil +} + +// EncodeSegment encodes a JWT specific base64url encoding with padding +// stripped. In the future, this function might take into account a +// [TokenOption]. Therefore, this function exists as a method of [Token], rather +// than a global function. +func (*Token) EncodeSegment(seg []byte) string { + return base64.RawURLEncoding.EncodeToString(seg) +} diff --git a/vendor/github.com/golang-jwt/jwt/v5/token_option.go b/vendor/github.com/golang-jwt/jwt/v5/token_option.go new file mode 100644 index 000000000..b4ae3badf --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/token_option.go @@ -0,0 +1,5 @@ +package jwt + +// TokenOption is a reserved type, which provides some forward compatibility, +// if we ever want to introduce token creation-related options. +type TokenOption func(*Token) diff --git a/vendor/github.com/golang-jwt/jwt/v5/types.go b/vendor/github.com/golang-jwt/jwt/v5/types.go new file mode 100644 index 000000000..b82b38867 --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/types.go @@ -0,0 +1,150 @@ +package jwt + +import ( + "encoding/json" + "fmt" + "math" + "reflect" + "strconv" + "time" +) + +// TimePrecision sets the precision of times and dates within this library. This +// has an influence on the precision of times when comparing expiry or other +// related time fields. Furthermore, it is also the precision of times when +// serializing. +// +// For backwards compatibility the default precision is set to seconds, so that +// no fractional timestamps are generated. +var TimePrecision = time.Second + +// MarshalSingleStringAsArray modifies the behavior of the ClaimStrings type, +// especially its MarshalJSON function. +// +// If it is set to true (the default), it will always serialize the type as an +// array of strings, even if it just contains one element, defaulting to the +// behavior of the underlying []string. If it is set to false, it will serialize +// to a single string, if it contains one element. Otherwise, it will serialize +// to an array of strings. +var MarshalSingleStringAsArray = true + +// NumericDate represents a JSON numeric date value, as referenced at +// https://datatracker.ietf.org/doc/html/rfc7519#section-2. +type NumericDate struct { + time.Time +} + +// NewNumericDate constructs a new *NumericDate from a standard library time.Time struct. +// It will truncate the timestamp according to the precision specified in TimePrecision. +func NewNumericDate(t time.Time) *NumericDate { + return &NumericDate{t.Truncate(TimePrecision)} +} + +// newNumericDateFromSeconds creates a new *NumericDate out of a float64 representing a +// UNIX epoch with the float fraction representing non-integer seconds. +func newNumericDateFromSeconds(f float64) *NumericDate { + round, frac := math.Modf(f) + return NewNumericDate(time.Unix(int64(round), int64(frac*1e9))) +} + +// MarshalJSON is an implementation of the json.RawMessage interface and serializes the UNIX epoch +// represented in NumericDate to a byte array, using the precision specified in TimePrecision. +func (date NumericDate) MarshalJSON() (b []byte, err error) { + var prec int + if TimePrecision < time.Second { + prec = int(math.Log10(float64(time.Second) / float64(TimePrecision))) + } + truncatedDate := date.Truncate(TimePrecision) + + // For very large timestamps, UnixNano would overflow an int64, but this + // function requires nanosecond level precision, so we have to use the + // following technique to get round the issue: + // + // 1. Take the normal unix timestamp to form the whole number part of the + // output, + // 2. Take the result of the Nanosecond function, which returns the offset + // within the second of the particular unix time instance, to form the + // decimal part of the output + // 3. Concatenate them to produce the final result + seconds := strconv.FormatInt(truncatedDate.Unix(), 10) + nanosecondsOffset := strconv.FormatFloat(float64(truncatedDate.Nanosecond())/float64(time.Second), 'f', prec, 64) + + output := append([]byte(seconds), []byte(nanosecondsOffset)[1:]...) + + return output, nil +} + +// UnmarshalJSON is an implementation of the json.RawMessage interface and +// deserializes a [NumericDate] from a JSON representation, i.e. a +// [json.Number]. This number represents an UNIX epoch with either integer or +// non-integer seconds. +func (date *NumericDate) UnmarshalJSON(b []byte) (err error) { + var ( + number json.Number + f float64 + ) + + if err = json.Unmarshal(b, &number); err != nil { + return fmt.Errorf("could not parse NumericData: %w", err) + } + + if f, err = number.Float64(); err != nil { + return fmt.Errorf("could not convert json number value to float: %w", err) + } + + n := newNumericDateFromSeconds(f) + *date = *n + + return nil +} + +// ClaimStrings is basically just a slice of strings, but it can be either +// serialized from a string array or just a string. This type is necessary, +// since the "aud" claim can either be a single string or an array. +type ClaimStrings []string + +func (s *ClaimStrings) UnmarshalJSON(data []byte) (err error) { + var value interface{} + + if err = json.Unmarshal(data, &value); err != nil { + return err + } + + var aud []string + + switch v := value.(type) { + case string: + aud = append(aud, v) + case []string: + aud = ClaimStrings(v) + case []interface{}: + for _, vv := range v { + vs, ok := vv.(string) + if !ok { + return &json.UnsupportedTypeError{Type: reflect.TypeOf(vv)} + } + aud = append(aud, vs) + } + case nil: + return nil + default: + return &json.UnsupportedTypeError{Type: reflect.TypeOf(v)} + } + + *s = aud + + return +} + +func (s ClaimStrings) MarshalJSON() (b []byte, err error) { + // This handles a special case in the JWT RFC. If the string array, e.g. + // used by the "aud" field, only contains one element, it MAY be serialized + // as a single string. This may or may not be desired based on the ecosystem + // of other JWT library used, so we make it configurable by the variable + // MarshalSingleStringAsArray. + if len(s) == 1 && !MarshalSingleStringAsArray { + return json.Marshal(s[0]) + } + + return json.Marshal([]string(s)) +} diff --git a/vendor/github.com/golang-jwt/jwt/v5/validator.go b/vendor/github.com/golang-jwt/jwt/v5/validator.go new file mode 100644 index 000000000..385043893 --- /dev/null +++ b/vendor/github.com/golang-jwt/jwt/v5/validator.go @@ -0,0 +1,301 @@ +package jwt + +import ( + "crypto/subtle" + "fmt" + "time" +) + +// ClaimsValidator is an interface that can be implemented by custom claims who +// wish to execute any additional claims validation based on +// application-specific logic. The Validate function is then executed in +// addition to the regular claims validation and any error returned is appended +// to the final validation result. +// +// type MyCustomClaims struct { +// Foo string `json:"foo"` +// jwt.RegisteredClaims +// } +// +// func (m MyCustomClaims) Validate() error { +// if m.Foo != "bar" { +// return errors.New("must be foobar") +// } +// return nil +// } +type ClaimsValidator interface { + Claims + Validate() error +} + +// validator is the core of the new Validation API. It is automatically used by +// a [Parser] during parsing and can be modified with various parser options. +// +// Note: This struct is intentionally not exported (yet) as we want to +// internally finalize its API. In the future, we might make it publicly +// available. +type validator struct { + // leeway is an optional leeway that can be provided to account for clock skew. + leeway time.Duration + + // timeFunc is used to supply the current time that is needed for + // validation. If unspecified, this defaults to time.Now. + timeFunc func() time.Time + + // verifyIat specifies whether the iat (Issued At) claim will be verified. + // According to https://www.rfc-editor.org/rfc/rfc7519#section-4.1.6 this + // only specifies the age of the token, but no validation check is + // necessary. However, if wanted, it can be checked if the iat is + // unrealistic, i.e., in the future. + verifyIat bool + + // expectedAud contains the audience this token expects. Supplying an empty + // string will disable aud checking. + expectedAud string + + // expectedIss contains the issuer this token expects. Supplying an empty + // string will disable iss checking. + expectedIss string + + // expectedSub contains the subject this token expects. Supplying an empty + // string will disable sub checking. + expectedSub string +} + +// newValidator can be used to create a stand-alone validator with the supplied +// options. This validator can then be used to validate already parsed claims. +func newValidator(opts ...ParserOption) *validator { + p := NewParser(opts...) + return p.validator +} + +// Validate validates the given claims. It will also perform any custom +// validation if claims implements the [ClaimsValidator] interface. +func (v *validator) Validate(claims Claims) error { + var ( + now time.Time + errs []error = make([]error, 0, 6) + err error + ) + + // Check, if we have a time func + if v.timeFunc != nil { + now = v.timeFunc() + } else { + now = time.Now() + } + + // We always need to check the expiration time, but usage of the claim + // itself is OPTIONAL. + if err = v.verifyExpiresAt(claims, now, false); err != nil { + errs = append(errs, err) + } + + // We always need to check not-before, but usage of the claim itself is + // OPTIONAL. + if err = v.verifyNotBefore(claims, now, false); err != nil { + errs = append(errs, err) + } + + // Check issued-at if the option is enabled + if v.verifyIat { + if err = v.verifyIssuedAt(claims, now, false); err != nil { + errs = append(errs, err) + } + } + + // If we have an expected audience, we also require the audience claim + if v.expectedAud != "" { + if err = v.verifyAudience(claims, v.expectedAud, true); err != nil { + errs = append(errs, err) + } + } + + // If we have an expected issuer, we also require the issuer claim + if v.expectedIss != "" { + if err = v.verifyIssuer(claims, v.expectedIss, true); err != nil { + errs = append(errs, err) + } + } + + // If we have an expected subject, we also require the subject claim + if v.expectedSub != "" { + if err = v.verifySubject(claims, v.expectedSub, true); err != nil { + errs = append(errs, err) + } + } + + // Finally, we want to give the claim itself some possibility to do some + // additional custom validation based on a custom Validate function. + cvt, ok := claims.(ClaimsValidator) + if ok { + if err := cvt.Validate(); err != nil { + errs = append(errs, err) + } + } + + if len(errs) == 0 { + return nil + } + + return joinErrors(errs...) +} + +// verifyExpiresAt compares the exp claim in claims against cmp. This function +// will succeed if cmp < exp. Additional leeway is taken into account. +// +// If exp is not set, it will succeed if the claim is not required, +// otherwise ErrTokenRequiredClaimMissing will be returned. +// +// Additionally, if any error occurs while retrieving the claim, e.g., when its +// the wrong type, an ErrTokenUnverifiable error will be returned. +func (v *validator) verifyExpiresAt(claims Claims, cmp time.Time, required bool) error { + exp, err := claims.GetExpirationTime() + if err != nil { + return err + } + + if exp == nil { + return errorIfRequired(required, "exp") + } + + return errorIfFalse(cmp.Before((exp.Time).Add(+v.leeway)), ErrTokenExpired) +} + +// verifyIssuedAt compares the iat claim in claims against cmp. This function +// will succeed if cmp >= iat. Additional leeway is taken into account. +// +// If iat is not set, it will succeed if the claim is not required, +// otherwise ErrTokenRequiredClaimMissing will be returned. +// +// Additionally, if any error occurs while retrieving the claim, e.g., when its +// the wrong type, an ErrTokenUnverifiable error will be returned. +func (v *validator) verifyIssuedAt(claims Claims, cmp time.Time, required bool) error { + iat, err := claims.GetIssuedAt() + if err != nil { + return err + } + + if iat == nil { + return errorIfRequired(required, "iat") + } + + return errorIfFalse(!cmp.Before(iat.Add(-v.leeway)), ErrTokenUsedBeforeIssued) +} + +// verifyNotBefore compares the nbf claim in claims against cmp. This function +// will return true if cmp >= nbf. Additional leeway is taken into account. +// +// If nbf is not set, it will succeed if the claim is not required, +// otherwise ErrTokenRequiredClaimMissing will be returned. +// +// Additionally, if any error occurs while retrieving the claim, e.g., when its +// the wrong type, an ErrTokenUnverifiable error will be returned. +func (v *validator) verifyNotBefore(claims Claims, cmp time.Time, required bool) error { + nbf, err := claims.GetNotBefore() + if err != nil { + return err + } + + if nbf == nil { + return errorIfRequired(required, "nbf") + } + + return errorIfFalse(!cmp.Before(nbf.Add(-v.leeway)), ErrTokenNotValidYet) +} + +// verifyAudience compares the aud claim against cmp. +// +// If aud is not set or an empty list, it will succeed if the claim is not required, +// otherwise ErrTokenRequiredClaimMissing will be returned. +// +// Additionally, if any error occurs while retrieving the claim, e.g., when its +// the wrong type, an ErrTokenUnverifiable error will be returned. +func (v *validator) verifyAudience(claims Claims, cmp string, required bool) error { + aud, err := claims.GetAudience() + if err != nil { + return err + } + + if len(aud) == 0 { + return errorIfRequired(required, "aud") + } + + // use a var here to keep constant time compare when looping over a number of claims + result := false + + var stringClaims string + for _, a := range aud { + if subtle.ConstantTimeCompare([]byte(a), []byte(cmp)) != 0 { + result = true + } + stringClaims = stringClaims + a + } + + // case where "" is sent in one or many aud claims + if stringClaims == "" { + return errorIfRequired(required, "aud") + } + + return errorIfFalse(result, ErrTokenInvalidAudience) +} + +// verifyIssuer compares the iss claim in claims against cmp. +// +// If iss is not set, it will succeed if the claim is not required, +// otherwise ErrTokenRequiredClaimMissing will be returned. +// +// Additionally, if any error occurs while retrieving the claim, e.g., when its +// the wrong type, an ErrTokenUnverifiable error will be returned. +func (v *validator) verifyIssuer(claims Claims, cmp string, required bool) error { + iss, err := claims.GetIssuer() + if err != nil { + return err + } + + if iss == "" { + return errorIfRequired(required, "iss") + } + + return errorIfFalse(iss == cmp, ErrTokenInvalidIssuer) +} + +// verifySubject compares the sub claim against cmp. +// +// If sub is not set, it will succeed if the claim is not required, +// otherwise ErrTokenRequiredClaimMissing will be returned. +// +// Additionally, if any error occurs while retrieving the claim, e.g., when its +// the wrong type, an ErrTokenUnverifiable error will be returned. +func (v *validator) verifySubject(claims Claims, cmp string, required bool) error { + sub, err := claims.GetSubject() + if err != nil { + return err + } + + if sub == "" { + return errorIfRequired(required, "sub") + } + + return errorIfFalse(sub == cmp, ErrTokenInvalidSubject) +} + +// errorIfFalse returns the error specified in err, if the value is true. +// Otherwise, nil is returned. +func errorIfFalse(value bool, err error) error { + if value { + return nil + } else { + return err + } +} + +// errorIfRequired returns an ErrTokenRequiredClaimMissing error if required is +// true. Otherwise, nil is returned. +func errorIfRequired(required bool, claim string) error { + if required { + return newError(fmt.Sprintf("%s claim is required", claim), ErrTokenRequiredClaimMissing) + } else { + return nil + } +} diff --git a/vendor/golang.org/x/crypto/acme/version_go112.go b/vendor/golang.org/x/crypto/acme/version_go112.go index b9efdb59e..cc5fab604 100644 --- a/vendor/golang.org/x/crypto/acme/version_go112.go +++ b/vendor/golang.org/x/crypto/acme/version_go112.go @@ -3,7 +3,6 @@ // license that can be found in the LICENSE file. //go:build go1.12 -// +build go1.12 package acme diff --git a/vendor/golang.org/x/crypto/sha3/hashes_generic.go b/vendor/golang.org/x/crypto/sha3/hashes_generic.go index c74fc20fc..fe8c84793 100644 --- a/vendor/golang.org/x/crypto/sha3/hashes_generic.go +++ b/vendor/golang.org/x/crypto/sha3/hashes_generic.go @@ -3,7 +3,6 @@ // license that can be found in the LICENSE file. //go:build !gc || purego || !s390x -// +build !gc purego !s390x package sha3 diff --git a/vendor/golang.org/x/crypto/sha3/keccakf.go b/vendor/golang.org/x/crypto/sha3/keccakf.go index e5faa375c..ce48b1dd3 100644 --- a/vendor/golang.org/x/crypto/sha3/keccakf.go +++ b/vendor/golang.org/x/crypto/sha3/keccakf.go @@ -3,7 +3,6 @@ // license that can be found in the LICENSE file. //go:build !amd64 || purego || !gc -// +build !amd64 purego !gc package sha3 diff --git a/vendor/golang.org/x/crypto/sha3/keccakf_amd64.go b/vendor/golang.org/x/crypto/sha3/keccakf_amd64.go index 248a38241..b908696be 100644 --- a/vendor/golang.org/x/crypto/sha3/keccakf_amd64.go +++ b/vendor/golang.org/x/crypto/sha3/keccakf_amd64.go @@ -3,7 +3,6 @@ // license that can be found in the LICENSE file. //go:build amd64 && !purego && gc -// +build amd64,!purego,gc package sha3 diff --git a/vendor/golang.org/x/crypto/sha3/keccakf_amd64.s b/vendor/golang.org/x/crypto/sha3/keccakf_amd64.s index 4cfa54383..1f5393886 100644 --- a/vendor/golang.org/x/crypto/sha3/keccakf_amd64.s +++ b/vendor/golang.org/x/crypto/sha3/keccakf_amd64.s @@ -3,7 +3,6 @@ // license that can be found in the LICENSE file. //go:build amd64 && !purego && gc -// +build amd64,!purego,gc // This code was translated into a form compatible with 6a from the public // domain sources at https://github.com/gvanas/KeccakCodePackage @@ -320,9 +319,9 @@ MOVQ rDi, _si(oState); \ MOVQ rDo, _so(oState) \ -// func keccakF1600(state *[25]uint64) +// func keccakF1600(a *[25]uint64) TEXT ·keccakF1600(SB), 0, $200-8 - MOVQ state+0(FP), rpState + MOVQ a+0(FP), rpState // Convert the user state into an internal state NOTQ _be(rpState) diff --git a/vendor/golang.org/x/crypto/sha3/register.go b/vendor/golang.org/x/crypto/sha3/register.go index 8b4453aac..addfd5049 100644 --- a/vendor/golang.org/x/crypto/sha3/register.go +++ b/vendor/golang.org/x/crypto/sha3/register.go @@ -3,7 +3,6 @@ // license that can be found in the LICENSE file. //go:build go1.4 -// +build go1.4 package sha3 diff --git a/vendor/golang.org/x/crypto/sha3/sha3_s390x.go b/vendor/golang.org/x/crypto/sha3/sha3_s390x.go index ec26f147f..d861bca52 100644 --- a/vendor/golang.org/x/crypto/sha3/sha3_s390x.go +++ b/vendor/golang.org/x/crypto/sha3/sha3_s390x.go @@ -3,7 +3,6 @@ // license that can be found in the LICENSE file. //go:build gc && !purego -// +build gc,!purego package sha3 diff --git a/vendor/golang.org/x/crypto/sha3/sha3_s390x.s b/vendor/golang.org/x/crypto/sha3/sha3_s390x.s index a0e051b04..826b862c7 100644 --- a/vendor/golang.org/x/crypto/sha3/sha3_s390x.s +++ b/vendor/golang.org/x/crypto/sha3/sha3_s390x.s @@ -3,7 +3,6 @@ // license that can be found in the LICENSE file. //go:build gc && !purego -// +build gc,!purego #include "textflag.h" diff --git a/vendor/golang.org/x/crypto/sha3/shake_generic.go b/vendor/golang.org/x/crypto/sha3/shake_generic.go index 5c0710ef9..8d31cf5be 100644 --- a/vendor/golang.org/x/crypto/sha3/shake_generic.go +++ b/vendor/golang.org/x/crypto/sha3/shake_generic.go @@ -3,7 +3,6 @@ // license that can be found in the LICENSE file. //go:build !gc || purego || !s390x -// +build !gc purego !s390x package sha3 diff --git a/vendor/golang.org/x/crypto/sha3/xor.go b/vendor/golang.org/x/crypto/sha3/xor.go index 59c8eb941..7337cca88 100644 --- a/vendor/golang.org/x/crypto/sha3/xor.go +++ b/vendor/golang.org/x/crypto/sha3/xor.go @@ -3,7 +3,6 @@ // license that can be found in the LICENSE file. //go:build (!amd64 && !386 && !ppc64le) || purego -// +build !amd64,!386,!ppc64le purego package sha3 diff --git a/vendor/golang.org/x/crypto/sha3/xor_unaligned.go b/vendor/golang.org/x/crypto/sha3/xor_unaligned.go index 1ce606246..870e2d16e 100644 --- a/vendor/golang.org/x/crypto/sha3/xor_unaligned.go +++ b/vendor/golang.org/x/crypto/sha3/xor_unaligned.go @@ -3,8 +3,6 @@ // license that can be found in the LICENSE file. //go:build (amd64 || 386 || ppc64le) && !purego -// +build amd64 386 ppc64le -// +build !purego package sha3 diff --git a/vendor/golang.org/x/sys/unix/fcntl.go b/vendor/golang.org/x/sys/unix/fcntl.go index 58c6bfc70..6200876fb 100644 --- a/vendor/golang.org/x/sys/unix/fcntl.go +++ b/vendor/golang.org/x/sys/unix/fcntl.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//go:build dragonfly || freebsd || linux || netbsd || openbsd +//go:build dragonfly || freebsd || linux || netbsd package unix diff --git a/vendor/golang.org/x/sys/unix/ioctl_linux.go b/vendor/golang.org/x/sys/unix/ioctl_linux.go index 0d12c0851..dbe680eab 100644 --- a/vendor/golang.org/x/sys/unix/ioctl_linux.go +++ b/vendor/golang.org/x/sys/unix/ioctl_linux.go @@ -231,3 +231,8 @@ func IoctlLoopGetStatus64(fd int) (*LoopInfo64, error) { func IoctlLoopSetStatus64(fd int, value *LoopInfo64) error { return ioctlPtr(fd, LOOP_SET_STATUS64, unsafe.Pointer(value)) } + +// IoctlLoopConfigure configures all loop device parameters in a single step +func IoctlLoopConfigure(fd int, value *LoopConfig) error { + return ioctlPtr(fd, LOOP_CONFIGURE, unsafe.Pointer(value)) +} diff --git a/vendor/golang.org/x/sys/unix/mkerrors.sh b/vendor/golang.org/x/sys/unix/mkerrors.sh index cbe24150a..6202638ba 100644 --- a/vendor/golang.org/x/sys/unix/mkerrors.sh +++ b/vendor/golang.org/x/sys/unix/mkerrors.sh @@ -519,6 +519,7 @@ ccflags="$@" $2 ~ /^LOCK_(SH|EX|NB|UN)$/ || $2 ~ /^LO_(KEY|NAME)_SIZE$/ || $2 ~ /^LOOP_(CLR|CTL|GET|SET)_/ || + $2 == "LOOP_CONFIGURE" || $2 ~ /^(AF|SOCK|SO|SOL|IPPROTO|IP|IPV6|TCP|MCAST|EVFILT|NOTE|SHUT|PROT|MAP|MREMAP|MFD|T?PACKET|MSG|SCM|MCL|DT|MADV|PR|LOCAL|TCPOPT|UDP)_/ || $2 ~ /^NFC_(GENL|PROTO|COMM|RF|SE|DIRECTION|LLCP|SOCKPROTO)_/ || $2 ~ /^NFC_.*_(MAX)?SIZE$/ || @@ -560,7 +561,7 @@ ccflags="$@" $2 ~ /^RLIMIT_(AS|CORE|CPU|DATA|FSIZE|LOCKS|MEMLOCK|MSGQUEUE|NICE|NOFILE|NPROC|RSS|RTPRIO|RTTIME|SIGPENDING|STACK)|RLIM_INFINITY/ || $2 ~ /^PRIO_(PROCESS|PGRP|USER)/ || $2 ~ /^CLONE_[A-Z_]+/ || - $2 !~ /^(BPF_TIMEVAL|BPF_FIB_LOOKUP_[A-Z]+)$/ && + $2 !~ /^(BPF_TIMEVAL|BPF_FIB_LOOKUP_[A-Z]+|BPF_F_LINK)$/ && $2 ~ /^(BPF|DLT)_/ || $2 ~ /^AUDIT_/ || $2 ~ /^(CLOCK|TIMER)_/ || diff --git a/vendor/golang.org/x/sys/unix/syscall_bsd.go b/vendor/golang.org/x/sys/unix/syscall_bsd.go index 6f328e3a5..a00c3e545 100644 --- a/vendor/golang.org/x/sys/unix/syscall_bsd.go +++ b/vendor/golang.org/x/sys/unix/syscall_bsd.go @@ -316,7 +316,7 @@ func GetsockoptString(fd, level, opt int) (string, error) { if err != nil { return "", err } - return string(buf[:vallen-1]), nil + return ByteSliceToString(buf[:vallen]), nil } //sys recvfrom(fd int, p []byte, flags int, from *RawSockaddrAny, fromlen *_Socklen) (n int, err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux.go b/vendor/golang.org/x/sys/unix/syscall_linux.go index a5e1c10e3..0f85e29e6 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux.go @@ -61,15 +61,23 @@ func FanotifyMark(fd int, flags uint, mask uint64, dirFd int, pathname string) ( } //sys fchmodat(dirfd int, path string, mode uint32) (err error) - -func Fchmodat(dirfd int, path string, mode uint32, flags int) (err error) { - // Linux fchmodat doesn't support the flags parameter. Mimick glibc's behavior - // and check the flags. Otherwise the mode would be applied to the symlink - // destination which is not what the user expects. - if flags&^AT_SYMLINK_NOFOLLOW != 0 { - return EINVAL - } else if flags&AT_SYMLINK_NOFOLLOW != 0 { - return EOPNOTSUPP +//sys fchmodat2(dirfd int, path string, mode uint32, flags int) (err error) + +func Fchmodat(dirfd int, path string, mode uint32, flags int) error { + // Linux fchmodat doesn't support the flags parameter, but fchmodat2 does. + // Try fchmodat2 if flags are specified. + if flags != 0 { + err := fchmodat2(dirfd, path, mode, flags) + if err == ENOSYS { + // fchmodat2 isn't available. If the flags are known to be valid, + // return EOPNOTSUPP to indicate that fchmodat doesn't support them. + if flags&^(AT_SYMLINK_NOFOLLOW|AT_EMPTY_PATH) != 0 { + return EINVAL + } else if flags&(AT_SYMLINK_NOFOLLOW|AT_EMPTY_PATH) != 0 { + return EOPNOTSUPP + } + } + return err } return fchmodat(dirfd, path, mode) } @@ -1302,7 +1310,7 @@ func GetsockoptString(fd, level, opt int) (string, error) { return "", err } } - return string(buf[:vallen-1]), nil + return ByteSliceToString(buf[:vallen]), nil } func GetsockoptTpacketStats(fd, level, opt int) (*TpacketStats, error) { diff --git a/vendor/golang.org/x/sys/unix/syscall_openbsd.go b/vendor/golang.org/x/sys/unix/syscall_openbsd.go index d2882ee04..b25343c71 100644 --- a/vendor/golang.org/x/sys/unix/syscall_openbsd.go +++ b/vendor/golang.org/x/sys/unix/syscall_openbsd.go @@ -166,6 +166,20 @@ func Getresgid() (rgid, egid, sgid int) { //sys sysctl(mib []_C_int, old *byte, oldlen *uintptr, new *byte, newlen uintptr) (err error) = SYS___SYSCTL +//sys fcntl(fd int, cmd int, arg int) (n int, err error) +//sys fcntlPtr(fd int, cmd int, arg unsafe.Pointer) (n int, err error) = SYS_FCNTL + +// FcntlInt performs a fcntl syscall on fd with the provided command and argument. +func FcntlInt(fd uintptr, cmd, arg int) (int, error) { + return fcntl(int(fd), cmd, arg) +} + +// FcntlFlock performs a fcntl syscall for the F_GETLK, F_SETLK or F_SETLKW command. +func FcntlFlock(fd uintptr, cmd int, lk *Flock_t) error { + _, err := fcntlPtr(int(fd), cmd, unsafe.Pointer(lk)) + return err +} + //sys ppoll(fds *PollFd, nfds int, timeout *Timespec, sigmask *Sigset_t) (n int, err error) func Ppoll(fds []PollFd, timeout *Timespec, sigmask *Sigset_t) (n int, err error) { diff --git a/vendor/golang.org/x/sys/unix/syscall_solaris.go b/vendor/golang.org/x/sys/unix/syscall_solaris.go index 60c8142d4..21974af06 100644 --- a/vendor/golang.org/x/sys/unix/syscall_solaris.go +++ b/vendor/golang.org/x/sys/unix/syscall_solaris.go @@ -158,7 +158,7 @@ func GetsockoptString(fd, level, opt int) (string, error) { if err != nil { return "", err } - return string(buf[:vallen-1]), nil + return ByteSliceToString(buf[:vallen]), nil } const ImplementsGetwd = true diff --git a/vendor/golang.org/x/sys/unix/syscall_zos_s390x.go b/vendor/golang.org/x/sys/unix/syscall_zos_s390x.go index d99d05f1b..b473038c6 100644 --- a/vendor/golang.org/x/sys/unix/syscall_zos_s390x.go +++ b/vendor/golang.org/x/sys/unix/syscall_zos_s390x.go @@ -1104,7 +1104,7 @@ func GetsockoptString(fd, level, opt int) (string, error) { return "", err } - return string(buf[:vallen-1]), nil + return ByteSliceToString(buf[:vallen]), nil } func Recvmsg(fd int, p, oob []byte, flags int) (n, oobn int, recvflags int, from Sockaddr, err error) { diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux.go b/vendor/golang.org/x/sys/unix/zerrors_linux.go index 9c00cbf51..c73cfe2f1 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux.go @@ -486,7 +486,6 @@ const ( BPF_F_ANY_ALIGNMENT = 0x2 BPF_F_BEFORE = 0x8 BPF_F_ID = 0x20 - BPF_F_LINK = 0x2000 BPF_F_NETFILTER_IP_DEFRAG = 0x1 BPF_F_QUERY_EFFECTIVE = 0x1 BPF_F_REPLACE = 0x4 @@ -1802,6 +1801,7 @@ const ( LOCK_SH = 0x1 LOCK_UN = 0x8 LOOP_CLR_FD = 0x4c01 + LOOP_CONFIGURE = 0x4c0a LOOP_CTL_ADD = 0x4c80 LOOP_CTL_GET_FREE = 0x4c82 LOOP_CTL_REMOVE = 0x4c81 diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux.go b/vendor/golang.org/x/sys/unix/zsyscall_linux.go index faca7a557..1488d2712 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux.go @@ -37,6 +37,21 @@ func fchmodat(dirfd int, path string, mode uint32) (err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT +func fchmodat2(dirfd int, path string, mode uint32, flags int) (err error) { + var _p0 *byte + _p0, err = BytePtrFromString(path) + if err != nil { + return + } + _, _, e1 := Syscall6(SYS_FCHMODAT2, uintptr(dirfd), uintptr(unsafe.Pointer(_p0)), uintptr(mode), uintptr(flags), 0, 0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + func ioctl(fd int, req uint, arg uintptr) (err error) { _, _, e1 := Syscall(SYS_IOCTL, uintptr(fd), uintptr(req), uintptr(arg)) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_386.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_386.go index 88bfc2885..a1d061597 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_386.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_386.go @@ -584,6 +584,32 @@ var libc_sysctl_trampoline_addr uintptr // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT +func fcntl(fd int, cmd int, arg int) (n int, err error) { + r0, _, e1 := syscall_syscall(libc_fcntl_trampoline_addr, uintptr(fd), uintptr(cmd), uintptr(arg)) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_fcntl_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_fcntl fcntl "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func fcntlPtr(fd int, cmd int, arg unsafe.Pointer) (n int, err error) { + r0, _, e1 := syscall_syscall(libc_fcntl_trampoline_addr, uintptr(fd), uintptr(cmd), uintptr(arg)) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + func ppoll(fds *PollFd, nfds int, timeout *Timespec, sigmask *Sigset_t) (n int, err error) { r0, _, e1 := syscall_syscall6(libc_ppoll_trampoline_addr, uintptr(unsafe.Pointer(fds)), uintptr(nfds), uintptr(unsafe.Pointer(timeout)), uintptr(unsafe.Pointer(sigmask)), 0, 0) n = int(r0) diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_386.s b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_386.s index 4cbeff171..41b561731 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_386.s +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_386.s @@ -178,6 +178,11 @@ TEXT libc_sysctl_trampoline<>(SB),NOSPLIT,$0-0 GLOBL ·libc_sysctl_trampoline_addr(SB), RODATA, $4 DATA ·libc_sysctl_trampoline_addr(SB)/4, $libc_sysctl_trampoline<>(SB) +TEXT libc_fcntl_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_fcntl(SB) +GLOBL ·libc_fcntl_trampoline_addr(SB), RODATA, $4 +DATA ·libc_fcntl_trampoline_addr(SB)/4, $libc_fcntl_trampoline<>(SB) + TEXT libc_ppoll_trampoline<>(SB),NOSPLIT,$0-0 JMP libc_ppoll(SB) GLOBL ·libc_ppoll_trampoline_addr(SB), RODATA, $4 diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_amd64.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_amd64.go index b8a67b99a..5b2a74097 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_amd64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_amd64.go @@ -584,6 +584,32 @@ var libc_sysctl_trampoline_addr uintptr // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT +func fcntl(fd int, cmd int, arg int) (n int, err error) { + r0, _, e1 := syscall_syscall(libc_fcntl_trampoline_addr, uintptr(fd), uintptr(cmd), uintptr(arg)) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_fcntl_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_fcntl fcntl "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func fcntlPtr(fd int, cmd int, arg unsafe.Pointer) (n int, err error) { + r0, _, e1 := syscall_syscall(libc_fcntl_trampoline_addr, uintptr(fd), uintptr(cmd), uintptr(arg)) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + func ppoll(fds *PollFd, nfds int, timeout *Timespec, sigmask *Sigset_t) (n int, err error) { r0, _, e1 := syscall_syscall6(libc_ppoll_trampoline_addr, uintptr(unsafe.Pointer(fds)), uintptr(nfds), uintptr(unsafe.Pointer(timeout)), uintptr(unsafe.Pointer(sigmask)), 0, 0) n = int(r0) diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_amd64.s b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_amd64.s index 1123f2757..4019a656f 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_amd64.s +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_amd64.s @@ -178,6 +178,11 @@ TEXT libc_sysctl_trampoline<>(SB),NOSPLIT,$0-0 GLOBL ·libc_sysctl_trampoline_addr(SB), RODATA, $8 DATA ·libc_sysctl_trampoline_addr(SB)/8, $libc_sysctl_trampoline<>(SB) +TEXT libc_fcntl_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_fcntl(SB) +GLOBL ·libc_fcntl_trampoline_addr(SB), RODATA, $8 +DATA ·libc_fcntl_trampoline_addr(SB)/8, $libc_fcntl_trampoline<>(SB) + TEXT libc_ppoll_trampoline<>(SB),NOSPLIT,$0-0 JMP libc_ppoll(SB) GLOBL ·libc_ppoll_trampoline_addr(SB), RODATA, $8 diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm.go index af50a65c0..f6eda1344 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm.go @@ -584,6 +584,32 @@ var libc_sysctl_trampoline_addr uintptr // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT +func fcntl(fd int, cmd int, arg int) (n int, err error) { + r0, _, e1 := syscall_syscall(libc_fcntl_trampoline_addr, uintptr(fd), uintptr(cmd), uintptr(arg)) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_fcntl_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_fcntl fcntl "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func fcntlPtr(fd int, cmd int, arg unsafe.Pointer) (n int, err error) { + r0, _, e1 := syscall_syscall(libc_fcntl_trampoline_addr, uintptr(fd), uintptr(cmd), uintptr(arg)) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + func ppoll(fds *PollFd, nfds int, timeout *Timespec, sigmask *Sigset_t) (n int, err error) { r0, _, e1 := syscall_syscall6(libc_ppoll_trampoline_addr, uintptr(unsafe.Pointer(fds)), uintptr(nfds), uintptr(unsafe.Pointer(timeout)), uintptr(unsafe.Pointer(sigmask)), 0, 0) n = int(r0) diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm.s b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm.s index 82badae39..ac4af24f9 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm.s +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm.s @@ -178,6 +178,11 @@ TEXT libc_sysctl_trampoline<>(SB),NOSPLIT,$0-0 GLOBL ·libc_sysctl_trampoline_addr(SB), RODATA, $4 DATA ·libc_sysctl_trampoline_addr(SB)/4, $libc_sysctl_trampoline<>(SB) +TEXT libc_fcntl_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_fcntl(SB) +GLOBL ·libc_fcntl_trampoline_addr(SB), RODATA, $4 +DATA ·libc_fcntl_trampoline_addr(SB)/4, $libc_fcntl_trampoline<>(SB) + TEXT libc_ppoll_trampoline<>(SB),NOSPLIT,$0-0 JMP libc_ppoll(SB) GLOBL ·libc_ppoll_trampoline_addr(SB), RODATA, $4 diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm64.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm64.go index 8fb4ff36a..55df20ae9 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm64.go @@ -584,6 +584,32 @@ var libc_sysctl_trampoline_addr uintptr // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT +func fcntl(fd int, cmd int, arg int) (n int, err error) { + r0, _, e1 := syscall_syscall(libc_fcntl_trampoline_addr, uintptr(fd), uintptr(cmd), uintptr(arg)) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_fcntl_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_fcntl fcntl "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func fcntlPtr(fd int, cmd int, arg unsafe.Pointer) (n int, err error) { + r0, _, e1 := syscall_syscall(libc_fcntl_trampoline_addr, uintptr(fd), uintptr(cmd), uintptr(arg)) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + func ppoll(fds *PollFd, nfds int, timeout *Timespec, sigmask *Sigset_t) (n int, err error) { r0, _, e1 := syscall_syscall6(libc_ppoll_trampoline_addr, uintptr(unsafe.Pointer(fds)), uintptr(nfds), uintptr(unsafe.Pointer(timeout)), uintptr(unsafe.Pointer(sigmask)), 0, 0) n = int(r0) diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm64.s b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm64.s index 24d7eecb9..f77d53212 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm64.s +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm64.s @@ -178,6 +178,11 @@ TEXT libc_sysctl_trampoline<>(SB),NOSPLIT,$0-0 GLOBL ·libc_sysctl_trampoline_addr(SB), RODATA, $8 DATA ·libc_sysctl_trampoline_addr(SB)/8, $libc_sysctl_trampoline<>(SB) +TEXT libc_fcntl_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_fcntl(SB) +GLOBL ·libc_fcntl_trampoline_addr(SB), RODATA, $8 +DATA ·libc_fcntl_trampoline_addr(SB)/8, $libc_fcntl_trampoline<>(SB) + TEXT libc_ppoll_trampoline<>(SB),NOSPLIT,$0-0 JMP libc_ppoll(SB) GLOBL ·libc_ppoll_trampoline_addr(SB), RODATA, $8 diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_mips64.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_mips64.go index f469a83ee..8c1155cbc 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_mips64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_mips64.go @@ -584,6 +584,32 @@ var libc_sysctl_trampoline_addr uintptr // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT +func fcntl(fd int, cmd int, arg int) (n int, err error) { + r0, _, e1 := syscall_syscall(libc_fcntl_trampoline_addr, uintptr(fd), uintptr(cmd), uintptr(arg)) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_fcntl_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_fcntl fcntl "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func fcntlPtr(fd int, cmd int, arg unsafe.Pointer) (n int, err error) { + r0, _, e1 := syscall_syscall(libc_fcntl_trampoline_addr, uintptr(fd), uintptr(cmd), uintptr(arg)) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + func ppoll(fds *PollFd, nfds int, timeout *Timespec, sigmask *Sigset_t) (n int, err error) { r0, _, e1 := syscall_syscall6(libc_ppoll_trampoline_addr, uintptr(unsafe.Pointer(fds)), uintptr(nfds), uintptr(unsafe.Pointer(timeout)), uintptr(unsafe.Pointer(sigmask)), 0, 0) n = int(r0) diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_mips64.s b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_mips64.s index 9a498a067..fae140b62 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_mips64.s +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_mips64.s @@ -178,6 +178,11 @@ TEXT libc_sysctl_trampoline<>(SB),NOSPLIT,$0-0 GLOBL ·libc_sysctl_trampoline_addr(SB), RODATA, $8 DATA ·libc_sysctl_trampoline_addr(SB)/8, $libc_sysctl_trampoline<>(SB) +TEXT libc_fcntl_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_fcntl(SB) +GLOBL ·libc_fcntl_trampoline_addr(SB), RODATA, $8 +DATA ·libc_fcntl_trampoline_addr(SB)/8, $libc_fcntl_trampoline<>(SB) + TEXT libc_ppoll_trampoline<>(SB),NOSPLIT,$0-0 JMP libc_ppoll(SB) GLOBL ·libc_ppoll_trampoline_addr(SB), RODATA, $8 diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_ppc64.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_ppc64.go index c26ca2e1a..7cc80c58d 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_ppc64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_ppc64.go @@ -584,6 +584,32 @@ var libc_sysctl_trampoline_addr uintptr // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT +func fcntl(fd int, cmd int, arg int) (n int, err error) { + r0, _, e1 := syscall_syscall(libc_fcntl_trampoline_addr, uintptr(fd), uintptr(cmd), uintptr(arg)) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_fcntl_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_fcntl fcntl "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func fcntlPtr(fd int, cmd int, arg unsafe.Pointer) (n int, err error) { + r0, _, e1 := syscall_syscall(libc_fcntl_trampoline_addr, uintptr(fd), uintptr(cmd), uintptr(arg)) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + func ppoll(fds *PollFd, nfds int, timeout *Timespec, sigmask *Sigset_t) (n int, err error) { r0, _, e1 := syscall_syscall6(libc_ppoll_trampoline_addr, uintptr(unsafe.Pointer(fds)), uintptr(nfds), uintptr(unsafe.Pointer(timeout)), uintptr(unsafe.Pointer(sigmask)), 0, 0) n = int(r0) diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_ppc64.s b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_ppc64.s index 1f224aa41..9d1e0ff06 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_ppc64.s +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_ppc64.s @@ -213,6 +213,12 @@ TEXT libc_sysctl_trampoline<>(SB),NOSPLIT,$0-0 GLOBL ·libc_sysctl_trampoline_addr(SB), RODATA, $8 DATA ·libc_sysctl_trampoline_addr(SB)/8, $libc_sysctl_trampoline<>(SB) +TEXT libc_fcntl_trampoline<>(SB),NOSPLIT,$0-0 + CALL libc_fcntl(SB) + RET +GLOBL ·libc_fcntl_trampoline_addr(SB), RODATA, $8 +DATA ·libc_fcntl_trampoline_addr(SB)/8, $libc_fcntl_trampoline<>(SB) + TEXT libc_ppoll_trampoline<>(SB),NOSPLIT,$0-0 CALL libc_ppoll(SB) RET diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_riscv64.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_riscv64.go index bcc920dd2..0688737f4 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_riscv64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_riscv64.go @@ -584,6 +584,32 @@ var libc_sysctl_trampoline_addr uintptr // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT +func fcntl(fd int, cmd int, arg int) (n int, err error) { + r0, _, e1 := syscall_syscall(libc_fcntl_trampoline_addr, uintptr(fd), uintptr(cmd), uintptr(arg)) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_fcntl_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_fcntl fcntl "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func fcntlPtr(fd int, cmd int, arg unsafe.Pointer) (n int, err error) { + r0, _, e1 := syscall_syscall(libc_fcntl_trampoline_addr, uintptr(fd), uintptr(cmd), uintptr(arg)) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + func ppoll(fds *PollFd, nfds int, timeout *Timespec, sigmask *Sigset_t) (n int, err error) { r0, _, e1 := syscall_syscall6(libc_ppoll_trampoline_addr, uintptr(unsafe.Pointer(fds)), uintptr(nfds), uintptr(unsafe.Pointer(timeout)), uintptr(unsafe.Pointer(sigmask)), 0, 0) n = int(r0) diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_riscv64.s b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_riscv64.s index 87a79c709..da115f9a4 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_riscv64.s +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_riscv64.s @@ -178,6 +178,11 @@ TEXT libc_sysctl_trampoline<>(SB),NOSPLIT,$0-0 GLOBL ·libc_sysctl_trampoline_addr(SB), RODATA, $8 DATA ·libc_sysctl_trampoline_addr(SB)/8, $libc_sysctl_trampoline<>(SB) +TEXT libc_fcntl_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_fcntl(SB) +GLOBL ·libc_fcntl_trampoline_addr(SB), RODATA, $8 +DATA ·libc_fcntl_trampoline_addr(SB)/8, $libc_fcntl_trampoline<>(SB) + TEXT libc_ppoll_trampoline<>(SB),NOSPLIT,$0-0 JMP libc_ppoll(SB) GLOBL ·libc_ppoll_trampoline_addr(SB), RODATA, $8 diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux.go b/vendor/golang.org/x/sys/unix/ztypes_linux.go index 997bcd55a..bbf8399ff 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux.go @@ -2671,6 +2671,7 @@ const ( BPF_PROG_TYPE_LSM = 0x1d BPF_PROG_TYPE_SK_LOOKUP = 0x1e BPF_PROG_TYPE_SYSCALL = 0x1f + BPF_PROG_TYPE_NETFILTER = 0x20 BPF_CGROUP_INET_INGRESS = 0x0 BPF_CGROUP_INET_EGRESS = 0x1 BPF_CGROUP_INET_SOCK_CREATE = 0x2 @@ -2715,6 +2716,11 @@ const ( BPF_PERF_EVENT = 0x29 BPF_TRACE_KPROBE_MULTI = 0x2a BPF_LSM_CGROUP = 0x2b + BPF_STRUCT_OPS = 0x2c + BPF_NETFILTER = 0x2d + BPF_TCX_INGRESS = 0x2e + BPF_TCX_EGRESS = 0x2f + BPF_TRACE_UPROBE_MULTI = 0x30 BPF_LINK_TYPE_UNSPEC = 0x0 BPF_LINK_TYPE_RAW_TRACEPOINT = 0x1 BPF_LINK_TYPE_TRACING = 0x2 @@ -2725,6 +2731,18 @@ const ( BPF_LINK_TYPE_PERF_EVENT = 0x7 BPF_LINK_TYPE_KPROBE_MULTI = 0x8 BPF_LINK_TYPE_STRUCT_OPS = 0x9 + BPF_LINK_TYPE_NETFILTER = 0xa + BPF_LINK_TYPE_TCX = 0xb + BPF_LINK_TYPE_UPROBE_MULTI = 0xc + BPF_PERF_EVENT_UNSPEC = 0x0 + BPF_PERF_EVENT_UPROBE = 0x1 + BPF_PERF_EVENT_URETPROBE = 0x2 + BPF_PERF_EVENT_KPROBE = 0x3 + BPF_PERF_EVENT_KRETPROBE = 0x4 + BPF_PERF_EVENT_TRACEPOINT = 0x5 + BPF_PERF_EVENT_EVENT = 0x6 + BPF_F_KPROBE_MULTI_RETURN = 0x1 + BPF_F_UPROBE_MULTI_RETURN = 0x1 BPF_ANY = 0x0 BPF_NOEXIST = 0x1 BPF_EXIST = 0x2 @@ -2742,6 +2760,8 @@ const ( BPF_F_MMAPABLE = 0x400 BPF_F_PRESERVE_ELEMS = 0x800 BPF_F_INNER_MAP = 0x1000 + BPF_F_LINK = 0x2000 + BPF_F_PATH_FD = 0x4000 BPF_STATS_RUN_TIME = 0x0 BPF_STACK_BUILD_ID_EMPTY = 0x0 BPF_STACK_BUILD_ID_VALID = 0x1 @@ -2762,6 +2782,7 @@ const ( BPF_F_ZERO_CSUM_TX = 0x2 BPF_F_DONT_FRAGMENT = 0x4 BPF_F_SEQ_NUMBER = 0x8 + BPF_F_NO_TUNNEL_KEY = 0x10 BPF_F_TUNINFO_FLAGS = 0x10 BPF_F_INDEX_MASK = 0xffffffff BPF_F_CURRENT_CPU = 0xffffffff @@ -2778,6 +2799,8 @@ const ( BPF_F_ADJ_ROOM_ENCAP_L4_UDP = 0x10 BPF_F_ADJ_ROOM_NO_CSUM_RESET = 0x20 BPF_F_ADJ_ROOM_ENCAP_L2_ETH = 0x40 + BPF_F_ADJ_ROOM_DECAP_L3_IPV4 = 0x80 + BPF_F_ADJ_ROOM_DECAP_L3_IPV6 = 0x100 BPF_ADJ_ROOM_ENCAP_L2_MASK = 0xff BPF_ADJ_ROOM_ENCAP_L2_SHIFT = 0x38 BPF_F_SYSCTL_BASE_NAME = 0x1 @@ -2866,6 +2889,8 @@ const ( BPF_DEVCG_DEV_CHAR = 0x2 BPF_FIB_LOOKUP_DIRECT = 0x1 BPF_FIB_LOOKUP_OUTPUT = 0x2 + BPF_FIB_LOOKUP_SKIP_NEIGH = 0x4 + BPF_FIB_LOOKUP_TBID = 0x8 BPF_FIB_LKUP_RET_SUCCESS = 0x0 BPF_FIB_LKUP_RET_BLACKHOLE = 0x1 BPF_FIB_LKUP_RET_UNREACHABLE = 0x2 @@ -2901,6 +2926,7 @@ const ( BPF_CORE_ENUMVAL_EXISTS = 0xa BPF_CORE_ENUMVAL_VALUE = 0xb BPF_CORE_TYPE_MATCHES = 0xc + BPF_F_TIMER_ABS = 0x1 ) const ( @@ -2979,6 +3005,12 @@ type LoopInfo64 struct { Encrypt_key [32]uint8 Init [2]uint64 } +type LoopConfig struct { + Fd uint32 + Size uint32 + Info LoopInfo64 + _ [8]uint64 +} type TIPCSocketAddr struct { Ref uint32 diff --git a/vendor/golang.org/x/sys/windows/syscall_windows.go b/vendor/golang.org/x/sys/windows/syscall_windows.go index fb6cfd046..47dc57967 100644 --- a/vendor/golang.org/x/sys/windows/syscall_windows.go +++ b/vendor/golang.org/x/sys/windows/syscall_windows.go @@ -155,6 +155,8 @@ func NewCallbackCDecl(fn interface{}) uintptr { //sys GetModuleFileName(module Handle, filename *uint16, size uint32) (n uint32, err error) = kernel32.GetModuleFileNameW //sys GetModuleHandleEx(flags uint32, moduleName *uint16, module *Handle) (err error) = kernel32.GetModuleHandleExW //sys SetDefaultDllDirectories(directoryFlags uint32) (err error) +//sys AddDllDirectory(path *uint16) (cookie uintptr, err error) = kernel32.AddDllDirectory +//sys RemoveDllDirectory(cookie uintptr) (err error) = kernel32.RemoveDllDirectory //sys SetDllDirectory(path string) (err error) = kernel32.SetDllDirectoryW //sys GetVersion() (ver uint32, err error) //sys FormatMessage(flags uint32, msgsrc uintptr, msgid uint32, langid uint32, buf []uint16, args *byte) (n uint32, err error) = FormatMessageW diff --git a/vendor/golang.org/x/sys/windows/zsyscall_windows.go b/vendor/golang.org/x/sys/windows/zsyscall_windows.go index db6282e00..146a1f019 100644 --- a/vendor/golang.org/x/sys/windows/zsyscall_windows.go +++ b/vendor/golang.org/x/sys/windows/zsyscall_windows.go @@ -184,6 +184,7 @@ var ( procGetAdaptersInfo = modiphlpapi.NewProc("GetAdaptersInfo") procGetBestInterfaceEx = modiphlpapi.NewProc("GetBestInterfaceEx") procGetIfEntry = modiphlpapi.NewProc("GetIfEntry") + procAddDllDirectory = modkernel32.NewProc("AddDllDirectory") procAssignProcessToJobObject = modkernel32.NewProc("AssignProcessToJobObject") procCancelIo = modkernel32.NewProc("CancelIo") procCancelIoEx = modkernel32.NewProc("CancelIoEx") @@ -330,6 +331,7 @@ var ( procReadProcessMemory = modkernel32.NewProc("ReadProcessMemory") procReleaseMutex = modkernel32.NewProc("ReleaseMutex") procRemoveDirectoryW = modkernel32.NewProc("RemoveDirectoryW") + procRemoveDllDirectory = modkernel32.NewProc("RemoveDllDirectory") procResetEvent = modkernel32.NewProc("ResetEvent") procResizePseudoConsole = modkernel32.NewProc("ResizePseudoConsole") procResumeThread = modkernel32.NewProc("ResumeThread") @@ -1605,6 +1607,15 @@ func GetIfEntry(pIfRow *MibIfRow) (errcode error) { return } +func AddDllDirectory(path *uint16) (cookie uintptr, err error) { + r0, _, e1 := syscall.Syscall(procAddDllDirectory.Addr(), 1, uintptr(unsafe.Pointer(path)), 0, 0) + cookie = uintptr(r0) + if cookie == 0 { + err = errnoErr(e1) + } + return +} + func AssignProcessToJobObject(job Handle, process Handle) (err error) { r1, _, e1 := syscall.Syscall(procAssignProcessToJobObject.Addr(), 2, uintptr(job), uintptr(process), 0) if r1 == 0 { @@ -2879,6 +2890,14 @@ func RemoveDirectory(path *uint16) (err error) { return } +func RemoveDllDirectory(cookie uintptr) (err error) { + r1, _, e1 := syscall.Syscall(procRemoveDllDirectory.Addr(), 1, uintptr(cookie), 0, 0) + if r1 == 0 { + err = errnoErr(e1) + } + return +} + func ResetEvent(event Handle) (err error) { r1, _, e1 := syscall.Syscall(procResetEvent.Addr(), 1, uintptr(event), 0, 0) if r1 == 0 { diff --git a/vendor/golang.org/x/term/term_unix.go b/vendor/golang.org/x/term/term_unix.go index 62c2b3f41..1ad0ddfe3 100644 --- a/vendor/golang.org/x/term/term_unix.go +++ b/vendor/golang.org/x/term/term_unix.go @@ -3,7 +3,6 @@ // license that can be found in the LICENSE file. //go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || zos -// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris zos package term diff --git a/vendor/golang.org/x/term/term_unix_bsd.go b/vendor/golang.org/x/term/term_unix_bsd.go index 853b3d698..9dbf54629 100644 --- a/vendor/golang.org/x/term/term_unix_bsd.go +++ b/vendor/golang.org/x/term/term_unix_bsd.go @@ -3,7 +3,6 @@ // license that can be found in the LICENSE file. //go:build darwin || dragonfly || freebsd || netbsd || openbsd -// +build darwin dragonfly freebsd netbsd openbsd package term diff --git a/vendor/golang.org/x/term/term_unix_other.go b/vendor/golang.org/x/term/term_unix_other.go index 1e8955c93..1b36de799 100644 --- a/vendor/golang.org/x/term/term_unix_other.go +++ b/vendor/golang.org/x/term/term_unix_other.go @@ -3,7 +3,6 @@ // license that can be found in the LICENSE file. //go:build aix || linux || solaris || zos -// +build aix linux solaris zos package term diff --git a/vendor/golang.org/x/term/term_unsupported.go b/vendor/golang.org/x/term/term_unsupported.go index f1df85065..3c409e588 100644 --- a/vendor/golang.org/x/term/term_unsupported.go +++ b/vendor/golang.org/x/term/term_unsupported.go @@ -3,7 +3,6 @@ // license that can be found in the LICENSE file. //go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !zos && !windows && !solaris && !plan9 -// +build !aix,!darwin,!dragonfly,!freebsd,!linux,!netbsd,!openbsd,!zos,!windows,!solaris,!plan9 package term diff --git a/vendor/modules.txt b/vendor/modules.txt index 812836526..c4d703831 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -14,7 +14,7 @@ github.com/IBM/sarama # github.com/Joker/jade v1.1.3 ## explicit; go 1.14 github.com/Joker/jade -# github.com/Nerzal/gocloak/v13 v13.8.0 +# github.com/Nerzal/gocloak/v13 v13.9.0 ## explicit; go 1.18 github.com/Nerzal/gocloak/v13 github.com/Nerzal/gocloak/v13/pkg/jwx @@ -252,6 +252,9 @@ github.com/goccy/go-json/internal/runtime # github.com/golang-jwt/jwt/v4 v4.5.0 ## explicit; go 1.16 github.com/golang-jwt/jwt/v4 +# github.com/golang-jwt/jwt/v5 v5.0.0 +## explicit; go 1.18 +github.com/golang-jwt/jwt/v5 # github.com/golang/mock v1.6.0 ## explicit; go 1.11 github.com/golang/mock/gomock @@ -763,8 +766,8 @@ go.uber.org/zap/zapcore # golang.org/x/arch v0.6.0 ## explicit; go 1.18 golang.org/x/arch/x86/x86asm -# golang.org/x/crypto v0.14.0 -## explicit; go 1.17 +# golang.org/x/crypto v0.17.0 +## explicit; go 1.18 golang.org/x/crypto/acme golang.org/x/crypto/acme/autocert golang.org/x/crypto/md4 @@ -811,15 +814,15 @@ golang.org/x/oauth2/internal golang.org/x/sync/errgroup golang.org/x/sync/semaphore golang.org/x/sync/singleflight -# golang.org/x/sys v0.14.0 +# golang.org/x/sys v0.15.0 ## explicit; go 1.18 golang.org/x/sys/cpu golang.org/x/sys/execabs golang.org/x/sys/plan9 golang.org/x/sys/unix golang.org/x/sys/windows -# golang.org/x/term v0.13.0 -## explicit; go 1.17 +# golang.org/x/term v0.15.0 +## explicit; go 1.18 golang.org/x/term # golang.org/x/text v0.14.0 ## explicit; go 1.18 From 3f9eb229e99e8dce7310c67551481976afdb4990 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 19 Jun 2024 11:45:32 -0700 Subject: [PATCH 40/62] role field. --- user/profile.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/user/profile.go b/user/profile.go index 79a53703b..b3d852972 100644 --- a/user/profile.go +++ b/user/profile.go @@ -122,9 +122,9 @@ type LegacyPatientProfile struct { } type ClinicProfile struct { - Name string `json:"name"` - Role []string `json:"role"` - Telephone string `json:"telephone"` + Name string `json:"name"` + Role string `json:"role"` + Telephone string `json:"telephone"` } func (up *UserProfile) ToAttributes() map[string][]string { From af7234f837b6460e83997bcd2935cbda75c1773f Mon Sep 17 00:00:00 2001 From: lostlevels Date: Thu, 20 Jun 2024 08:13:13 -0700 Subject: [PATCH 41/62] Omit profile fields if empty in response. --- user/profile.go | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/user/profile.go b/user/profile.go index b3d852972..a22f500f9 100644 --- a/user/profile.go +++ b/user/profile.go @@ -38,14 +38,14 @@ type Date string // somewhat redundantly as UserProfile instead of Profile because there already // exists a type Profile in this package. type UserProfile struct { - FullName string `json:"fullName"` - Birthday Date `json:"birthday"` - DiagnosisDate Date `json:"diagnosisDate"` - DiagnosisType string `json:"diagnosisType"` - TargetDevices []string `json:"targetDevices"` - TargetTimezone string `json:"targetTimezone"` - About string `json:"about"` - MRN string `json:"mrn"` + FullName string `json:"fullName,omitempty"` + Birthday Date `json:"birthday,omitempty"` + DiagnosisDate Date `json:"diagnosisDate,omitempty"` + DiagnosisType string `json:"diagnosisType,omitempty"` + TargetDevices []string `json:"targetDevices,omitempty"` + TargetTimezone string `json:"targetTimezone,omitempty"` + About string `json:"about,omitempty"` + MRN string `json:"mrn,omitempty"` Custodian *Custodian `json:"custodian,omitempty"` } @@ -103,7 +103,7 @@ func (p *LegacyUserProfile) ToUserProfile() *UserProfile { // LegacyUserProfile represents the old seagull format for a profile. type LegacyUserProfile struct { - FullName string `json:"fullName"` + FullName string `json:"fullName,omitempty"` Patient *LegacyPatientProfile `json:"patient,omitempty"` Clinic *ClinicProfile `json:"clinic,omitempty"` MigrationStatus migrationStatus `json:"-"` @@ -111,20 +111,21 @@ type LegacyUserProfile struct { type LegacyPatientProfile struct { FullName string `json:"fullName,omitempty"` // This is only non-empty if the user is also a fake child (has the patient.isOtherPerson field set) - Birthday Date `json:"birthday"` - DiagnosisDate Date `json:"diagnosisDate"` - DiagnosisType string `json:"diagnosisType"` - TargetDevices []string `json:"targetDevices"` - TargetTimezone string `json:"targetTimezone"` - About string `json:"about"` + Birthday Date `json:"birthday,omitempty"` + DiagnosisDate Date `json:"diagnosisDate,omitempty"` + DiagnosisType string `json:"diagnosisType,omitempty"` + TargetDevices []string `json:"targetDevices,omitempty"` + TargetTimezone string `json:"targetTimezone,omitempty"` + About string `json:"about,omitempty"` IsOtherPerson bool `json:"isOtherPerson,omitempty"` - MRN string `json:"mrn"` + MRN string `json:"mrn,omitempty"` } type ClinicProfile struct { - Name string `json:"name"` - Role string `json:"role"` - Telephone string `json:"telephone"` + Name string `json:"name,omitempty"` + Role string `json:"role,omitempty"` + Telephone string `json:"telephone,omitempty"` + NPI string `json:"npi,omitempty"` } func (up *UserProfile) ToAttributes() map[string][]string { From 196a609265758415c5bd35ab58195e981ab30561 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 24 Jun 2024 13:49:57 -0700 Subject: [PATCH 42/62] Add clinic profile fields. --- user/legacy_raw_seagull_profile.go | 3 +- user/profile.go | 81 ++++++++++++++++++++++++------ 2 files changed, 66 insertions(+), 18 deletions(-) diff --git a/user/legacy_raw_seagull_profile.go b/user/legacy_raw_seagull_profile.go index ba1d1bd3d..15ae15023 100644 --- a/user/legacy_raw_seagull_profile.go +++ b/user/legacy_raw_seagull_profile.go @@ -66,8 +66,7 @@ func extractSeagullValue(valueRaw string) (valueAsMap map[string]any, err error) // collection"), then returns the marshaled version of it. It returns // this new object as a raw string to be compatible with the seagull // collection. This is done to preserve any non profile fields that were -// stored in the "value" field - TODO: is this even necessary? was any -// non profile info just junk? TODO confirm +// stored in the "value" field func AddProfileToSeagullValue(valueRaw string, profile *LegacyUserProfile) (updatedValueRaw string, err error) { valueObj, err := extractSeagullValue(valueRaw) // If there was an error, just make a new field "value" value. diff --git a/user/profile.go b/user/profile.go index a22f500f9..8b1a77844 100644 --- a/user/profile.go +++ b/user/profile.go @@ -38,15 +38,24 @@ type Date string // somewhat redundantly as UserProfile instead of Profile because there already // exists a type Profile in this package. type UserProfile struct { - FullName string `json:"fullName,omitempty"` - Birthday Date `json:"birthday,omitempty"` - DiagnosisDate Date `json:"diagnosisDate,omitempty"` - DiagnosisType string `json:"diagnosisType,omitempty"` - TargetDevices []string `json:"targetDevices,omitempty"` - TargetTimezone string `json:"targetTimezone,omitempty"` - About string `json:"about,omitempty"` - MRN string `json:"mrn,omitempty"` - Custodian *Custodian `json:"custodian,omitempty"` + FullName string `json:"fullName,omitempty"` + Birthday Date `json:"birthday,omitempty"` + DiagnosisDate Date `json:"diagnosisDate,omitempty"` + DiagnosisType string `json:"diagnosisType,omitempty"` + TargetDevices []string `json:"targetDevices,omitempty"` + TargetTimezone string `json:"targetTimezone,omitempty"` + About string `json:"about,omitempty"` + MRN string `json:"mrn,omitempty"` + Custodian *Custodian `json:"custodian,omitempty"` + Clinic *ClinicProfile `json:"-"` // This is not returned to users in any new user profile routes but needs to be saved as it's not known where the old seagull value.profile.clinic is read +} + +type ClinicProfile struct { + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` + Role string `json:"role,omitempty"` + Telephone string `json:"telephone,omitempty"` + NPI string `json:"npi,omitempty"` } type Custodian struct { @@ -64,6 +73,7 @@ func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { About: up.About, MRN: up.MRN, }, + Clinic: up.Clinic, MigrationStatus: migrationCompleted, // If we have a non legacy UserProfile, then that means the legacy version has been migrated from seagull (or it never existed which is equivalent for the new user profile purposes) } // only custodiaL fake child accounts have Patient.FullName set @@ -78,6 +88,7 @@ func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { func (p *LegacyUserProfile) ToUserProfile() *UserProfile { up := &UserProfile{ FullName: p.FullName, + Clinic: p.Clinic, } if p.Patient != nil { up.FullName = p.Patient.FullName @@ -121,13 +132,6 @@ type LegacyPatientProfile struct { MRN string `json:"mrn,omitempty"` } -type ClinicProfile struct { - Name string `json:"name,omitempty"` - Role string `json:"role,omitempty"` - Telephone string `json:"telephone,omitempty"` - NPI string `json:"npi,omitempty"` -} - func (up *UserProfile) ToAttributes() map[string][]string { attributes := map[string][]string{} @@ -160,6 +164,24 @@ func (up *UserProfile) ToAttributes() map[string][]string { addAttribute(attributes, "mrn", up.MRN) } + if up.Clinic != nil { + if up.Clinic.Email != "" { + addAttribute(attributes, "clinic_email", up.Clinic.Email) + } + if up.Clinic.Name != "" { + addAttribute(attributes, "clinic_name", up.Clinic.Name) + } + if up.Clinic.Role != "" { + addAttribute(attributes, "clinic_role", up.Clinic.Role) + } + if up.Clinic.Telephone != "" { + addAttribute(attributes, "clinic_telephone", up.Clinic.Telephone) + } + if up.Clinic.NPI != "" { + addAttribute(attributes, "clinic_npi", up.Clinic.NPI) + } + } + return attributes } @@ -204,6 +226,33 @@ func ProfileFromAttributes(attributes map[string][]string) (profile *UserProfile ok = true } + var clinicProfile ClinicProfile + var clinicOK bool + if val := getAttribute(attributes, "clinic_email"); val != "" { + clinicProfile.Email = val + clinicOK = true + } + if val := getAttribute(attributes, "clinic_name"); val != "" { + clinicProfile.Name = val + clinicOK = true + } + if val := getAttribute(attributes, "clinic_role"); val != "" { + clinicProfile.Role = val + clinicOK = true + } + if val := getAttribute(attributes, "clinic_telephone"); val != "" { + clinicProfile.Telephone = val + clinicOK = true + } + if val := getAttribute(attributes, "clinic_npi"); val != "" { + clinicProfile.NPI = val + clinicOK = true + } + if clinicOK { + up.Clinic = &clinicProfile + ok = true + } + return up, ok } From c0a951380be9ba86ad81b2bbd293adc4c156523c Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 25 Jun 2024 14:18:20 -0700 Subject: [PATCH 43/62] Add normalizer methods for profiles. --- user/profile.go | 77 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 66 insertions(+), 11 deletions(-) diff --git a/user/profile.go b/user/profile.go index 8b1a77844..e6406cbac 100644 --- a/user/profile.go +++ b/user/profile.go @@ -2,6 +2,7 @@ package user import ( "slices" + "strings" "time" "github.com/tidepool-org/platform/structure" @@ -14,8 +15,7 @@ const ( migrationCompleted migrationInProgress - maxAboutLength = 256 - maxNameLength = 256 + maxProfileFieldLen = 256 ) var ( @@ -302,40 +302,95 @@ func containsAnyAttributeKeys(attributes map[string][]string, keys ...string) bo return false } -func (d Date) Validate(v structure.Validator) { - if d == "" { +func (d *Date) Validate(v structure.Validator) { + if d == nil || *d == "" { return } - str := string(d) + str := string(*d) v.String("date", &str).AsTime(time.DateOnly) } +func (d *Date) Normalize(normalizer structure.Normalizer) { + if d == nil || *d == "" { + return + } + *d = Date(strings.TrimSpace(string(*d))) +} + func (up *UserProfile) Validate(v structure.Validator) { + v.String("fullName", &up.FullName).LengthLessThanOrEqualTo(maxProfileFieldLen) + v.String("diagnosisType", &up.DiagnosisType).LengthLessThanOrEqualTo(maxProfileFieldLen) + v.String("targetTimezone", &up.TargetTimezone).LengthLessThanOrEqualTo(maxProfileFieldLen) + v.String("about", &up.About).LengthLessThanOrEqualTo(maxProfileFieldLen) + v.String("mrn", &up.MRN).LengthLessThanOrEqualTo(maxProfileFieldLen) + up.Birthday.Validate(v.WithReference("birthday")) up.DiagnosisDate.Validate(v.WithReference("diagnosisDate")) - v.String("fullName", &up.FullName).LengthLessThanOrEqualTo(maxNameLength) if up.DiagnosisType != "" { v.String("diagnosisType", &up.DiagnosisType).OneOf(diabetesTypes...) } } -func (p *ClinicProfile) Validate(v structure.Validator) { - // TODO: confirm: can be empty - v.String("name", &p.Name).NotEmpty().LengthLessThanOrEqualTo(maxNameLength) +func (up *UserProfile) Normalize(normalizer structure.Normalizer) { + up.FullName = strings.TrimSpace(up.FullName) + up.DiagnosisType = strings.TrimSpace(up.DiagnosisType) + up.TargetTimezone = strings.TrimSpace(up.TargetTimezone) + up.About = strings.TrimSpace(up.About) + up.MRN = strings.TrimSpace(up.MRN) + + up.Birthday.Normalize(normalizer.WithReference("birthday")) + up.DiagnosisDate.Normalize(normalizer.WithReference("diagnosisDate")) + if up.Clinic != nil { + up.Clinic.Normalize(normalizer.WithReference("clinic")) + } +} + +func (p *ClinicProfile) Normalize(normalizer structure.Normalizer) { + p.Email = strings.TrimSpace(p.Email) + p.Name = strings.TrimSpace(p.Name) + p.Role = strings.TrimSpace(p.Role) + p.Telephone = strings.TrimSpace(p.Telephone) + p.NPI = strings.TrimSpace(p.NPI) } func (up *LegacyUserProfile) Validate(v structure.Validator) { if up.Patient != nil { up.Patient.Validate(v.WithReference("patient")) } - v.String("fullName", &up.FullName).LengthLessThanOrEqualTo(maxNameLength) + v.String("fullName", &up.FullName).LengthLessThanOrEqualTo(maxProfileFieldLen) +} + +func (up *LegacyUserProfile) Normalize(normalizer structure.Normalizer) { + up.FullName = strings.TrimSpace(up.FullName) + if up.Patient != nil { + up.Patient.Normalize(normalizer.WithReference("patient")) + } + if up.Clinic != nil { + up.Clinic.Normalize(normalizer.WithReference("clinic")) + } } func (pp *LegacyPatientProfile) Validate(v structure.Validator) { pp.Birthday.Validate(v.WithReference("birthday")) pp.DiagnosisDate.Validate(v.WithReference("diagnosisDate")) - v.String("fullName", &pp.FullName).LengthLessThanOrEqualTo(maxNameLength) + + v.String("fullName", &pp.FullName).LengthLessThanOrEqualTo(maxProfileFieldLen) + v.String("targetTimezone", &pp.TargetTimezone).LengthLessThanOrEqualTo(maxProfileFieldLen) + v.String("about", &pp.About).LengthLessThanOrEqualTo(maxProfileFieldLen) + v.String("mrn", &pp.MRN).LengthLessThanOrEqualTo(maxProfileFieldLen) + if pp.DiagnosisType != "" { v.String("diagnosisType", &pp.DiagnosisType).OneOf(diabetesTypes...) } } + +func (pp *LegacyPatientProfile) Normalize(normalizer structure.Normalizer) { + pp.Birthday.Normalize(normalizer.WithReference("birthday")) + pp.DiagnosisDate.Normalize(normalizer.WithReference("diagnosisDate")) + + pp.FullName = strings.TrimSpace(pp.FullName) + pp.DiagnosisType = strings.TrimSpace(pp.DiagnosisType) + pp.TargetTimezone = strings.TrimSpace(pp.TargetTimezone) + pp.About = strings.TrimSpace(pp.About) + pp.MRN = strings.TrimSpace(pp.MRN) +} From 07508bbe4c1bd001d9aceea075247b2850ff7645 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 8 Jul 2024 08:17:58 -0700 Subject: [PATCH 44/62] Account for empty profile fullName. --- user/legacy_raw_seagull_profile.go | 13 +++++++++++- user/profile.go | 32 +++++++++++++++++++----------- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/user/legacy_raw_seagull_profile.go b/user/legacy_raw_seagull_profile.go index 15ae15023..da09ac914 100644 --- a/user/legacy_raw_seagull_profile.go +++ b/user/legacy_raw_seagull_profile.go @@ -3,6 +3,8 @@ package user import ( "encoding/json" "time" + + "github.com/tidepool-org/platform/pointer" ) // LegacySeagullDocument is the database model representation of the legacy @@ -39,6 +41,15 @@ func (doc *LegacySeagullDocument) ToLegacyProfile() (*LegacyUserProfile, error) return nil, err } + // Add some default names if it is an empty name for the fake child or parent of them + isFakeChild := legacyProfile.Patient != nil && legacyProfile.Patient.IsOtherPerson + if isFakeChild && pointer.ToString(legacyProfile.Patient.FullName) == "" { + legacyProfile.Patient.FullName = pointer.FromString(emptyFakeChildDefaultName) + } + if isFakeChild && legacyProfile.FullName == "" { + legacyProfile.FullName = emptyFakeChildCustodianName + } + legacyProfile.MigrationStatus = migrationUnmigrated if doc.MigrationStart != nil && doc.MigrationEnd != nil { legacyProfile.MigrationStatus = migrationCompleted @@ -84,7 +95,7 @@ func AddProfileToSeagullValue(valueRaw string, profile *LegacyUserProfile) (upda // MarshalThenUnmarshal marshal's src into JSON, then Unmarshals that // JSON into dst. This is useful if src has some fields fields common to // dst but are defined explicitly or in the same way. -func MarshalThenUnmarshal(src, dst any) error { +func MarshalThenUnmarshal(src any, dst *LegacyUserProfile) error { bytes, err := json.Marshal(src) if err != nil { return err diff --git a/user/profile.go b/user/profile.go index e6406cbac..967927e24 100644 --- a/user/profile.go +++ b/user/profile.go @@ -1,10 +1,12 @@ package user import ( + "cmp" "slices" "strings" "time" + "github.com/tidepool-org/platform/pointer" "github.com/tidepool-org/platform/structure" ) @@ -18,6 +20,13 @@ const ( maxProfileFieldLen = 256 ) +const ( + // emptyFakeChildDefaultName is a placeholder name to be used if a fake child account profile has no patient.fullName field + emptyFakeChildDefaultName = "Child User" + // emptyFakeChildCustodianName is a placeholder name to be used if a fake child account profile has no custodian / parent fullName field - seagull.value.profile.fullName + emptyFakeChildCustodianName = "Custodian User" +) + var ( diabetesTypes = []string{ "type1", @@ -79,7 +88,7 @@ func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { // only custodiaL fake child accounts have Patient.FullName set if up.Custodian != nil { legacyProfile.FullName = up.Custodian.FullName - legacyProfile.Patient.FullName = up.FullName + legacyProfile.Patient.FullName = pointer.FromString(cmp.Or(up.FullName, emptyFakeChildDefaultName)) legacyProfile.Patient.IsOtherPerson = true } return legacyProfile @@ -87,19 +96,16 @@ func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { func (p *LegacyUserProfile) ToUserProfile() *UserProfile { up := &UserProfile{ - FullName: p.FullName, + FullName: cmp.Or(p.FullName, emptyFakeChildCustodianName), Clinic: p.Clinic, } if p.Patient != nil { - up.FullName = p.Patient.FullName + up.FullName = cmp.Or(pointer.ToString(p.Patient.FullName), p.FullName) // Only users with isOtherPerson set has a patient.fullName field set so // they have a custodian. - if p.Patient.FullName != "" || p.Patient.IsOtherPerson { + if p.Patient.IsOtherPerson { up.Custodian = &Custodian{ - FullName: p.FullName, - } - if up.Custodian.FullName == "" { - up.Custodian.FullName = p.FullName + FullName: cmp.Or(p.FullName, emptyFakeChildCustodianName), } } up.Birthday = p.Patient.Birthday @@ -114,14 +120,14 @@ func (p *LegacyUserProfile) ToUserProfile() *UserProfile { // LegacyUserProfile represents the old seagull format for a profile. type LegacyUserProfile struct { - FullName string `json:"fullName,omitempty"` + FullName string `json:"fullName,omitempty"` // string pointer because some old profiles have empty string as full name Patient *LegacyPatientProfile `json:"patient,omitempty"` Clinic *ClinicProfile `json:"clinic,omitempty"` MigrationStatus migrationStatus `json:"-"` } type LegacyPatientProfile struct { - FullName string `json:"fullName,omitempty"` // This is only non-empty if the user is also a fake child (has the patient.isOtherPerson field set) + FullName *string `json:"fullName,omitempty"` // This is only non-empty if the user is also a fake child (has the patient.isOtherPerson field set - there are cases where it is an empty string but the field exists) Birthday Date `json:"birthday,omitempty"` DiagnosisDate Date `json:"diagnosisDate,omitempty"` DiagnosisType string `json:"diagnosisType,omitempty"` @@ -374,7 +380,7 @@ func (pp *LegacyPatientProfile) Validate(v structure.Validator) { pp.Birthday.Validate(v.WithReference("birthday")) pp.DiagnosisDate.Validate(v.WithReference("diagnosisDate")) - v.String("fullName", &pp.FullName).LengthLessThanOrEqualTo(maxProfileFieldLen) + v.String("fullName", pp.FullName).LengthLessThanOrEqualTo(maxProfileFieldLen) v.String("targetTimezone", &pp.TargetTimezone).LengthLessThanOrEqualTo(maxProfileFieldLen) v.String("about", &pp.About).LengthLessThanOrEqualTo(maxProfileFieldLen) v.String("mrn", &pp.MRN).LengthLessThanOrEqualTo(maxProfileFieldLen) @@ -388,7 +394,9 @@ func (pp *LegacyPatientProfile) Normalize(normalizer structure.Normalizer) { pp.Birthday.Normalize(normalizer.WithReference("birthday")) pp.DiagnosisDate.Normalize(normalizer.WithReference("diagnosisDate")) - pp.FullName = strings.TrimSpace(pp.FullName) + if pp.FullName != nil { + pp.FullName = pointer.FromString(strings.TrimSpace(pointer.ToString(pp.FullName))) + } pp.DiagnosisType = strings.TrimSpace(pp.DiagnosisType) pp.TargetTimezone = strings.TrimSpace(pp.TargetTimezone) pp.About = strings.TrimSpace(pp.About) From 50f373b4f8651ae90e0e23bdb6173d7b728cbc2d Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 9 Jul 2024 20:24:50 -0700 Subject: [PATCH 45/62] [BACK-3046] Create initial shared users with profiles path w/o filtering. --- auth/service/api/v1/profile.go | 40 ++++++++++++++++++++++++++++++++++ permission/permission.go | 12 ++++++++++ user/user.go | 3 +++ 3 files changed, 55 insertions(+) diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index c8ed1f8dd..5fe51143d 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -15,6 +15,7 @@ import ( func (r *Router) ProfileRoutes() []*rest.Route { return []*rest.Route{ rest.Get("/v1/users/:userId/profile", r.requireMembership("userId", r.GetProfile)), + rest.Get("/v1/users/:userId/users", r.requireMembership("userId", r.GetUsersWithProfiles)), rest.Get("/v1/users/legacy/:userId/profile", r.requireMembership("userId", r.GetLegacyProfile)), rest.Put("/v1/users/:userId/profile", r.requireCustodian("userId", r.UpdateProfile)), rest.Put("/v1/users/legacy/:userId/profile", r.requireCustodian("userId", r.UpdateLegacyProfile)), @@ -56,6 +57,45 @@ func (r *Router) GetProfile(res rest.ResponseWriter, req *rest.Request) { responder.Data(http.StatusOK, profile) } +func (r *Router) GetUsersWithProfiles(res rest.ResponseWriter, req *rest.Request) { + responder := request.MustNewResponder(res, req) + ctx := req.Context() + targetUserID := req.PathParam("userId") + if r.handledUserNotExists(ctx, responder, targetUserID) { + return + } + + trustorPerms, err := r.PermissionsClient().GroupsForUser(ctx, targetUserID) + if err != nil { + responder.InternalServerError(err) + return + } + results := make([]*user.User, 0, len(trustorPerms)) + for userID, perms := range trustorPerms { + if userID == targetUserID { + // Don't include own user in result + continue + } + if perms.HasReadPermissions() { + sharedUser, err := r.UserAccessor().FindUserById(ctx, userID) + if err != nil { + responder.InternalServerError(err) + return + } + profile, err := r.getProfile(ctx, userID) + if err != nil && !errors.Is(err, user.ErrUserProfileNotFound) { + r.handleProfileErr(responder, err) + return + } + sharedUser.Profile = profile + perms := perms + sharedUser.TrustorPermissions = &perms + results = append(results, sharedUser) + } + } + responder.Data(http.StatusOK, results) +} + func (r *Router) GetLegacyProfile(res rest.ResponseWriter, req *rest.Request) { responder := request.MustNewResponder(res, req) ctx := req.Context() diff --git a/permission/permission.go b/permission/permission.go index 3f5e3352b..da73aa740 100644 --- a/permission/permission.go +++ b/permission/permission.go @@ -6,6 +6,8 @@ import ( type Permission map[string]interface{} type Permissions map[string]Permission + +// GroupedPermissions are permissions that are keyed by userID. type GroupedPermissions map[string]Permissions const ( @@ -18,6 +20,7 @@ const ( type Client interface { GetUserPermissions(ctx context.Context, requestUserID string, targetUserID string) (Permissions, error) + // GroupsForUser returns permissions that have been shared with granteeUserID. It is keyed by the user that has shared something with granteeUserID GroupsForUser(ctx context.Context, granteeUserID string) (GroupedPermissions, error) HasMembershipRelationship(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) HasCustodianPermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) @@ -42,3 +45,12 @@ func FixOwnerPermissions(permissions Permissions) Permissions { } return permissions } + +func (p Permissions) HasReadPermissions() bool { + for _, perm := range []string{Custodian, Owner, Read, Write} { + if _, ok := p[perm]; ok { + return true + } + } + return false +} diff --git a/user/user.go b/user/user.go index 061e4b607..4569833f7 100644 --- a/user/user.go +++ b/user/user.go @@ -7,6 +7,7 @@ import ( "time" "github.com/tidepool-org/platform/id" + "github.com/tidepool-org/platform/permission" "github.com/tidepool-org/platform/request" "github.com/tidepool-org/platform/structure" structureValidator "github.com/tidepool-org/platform/structure/validator" @@ -53,6 +54,8 @@ type User struct { Profile *UserProfile `json:"-"` FirstName string `json:"firstName,omitempty"` LastName string `json:"lastName,omitempty"` + // TrustorPermissions is only returned for the route that returns users that have shared their data w/ another user + TrustorPermissions *permission.Permissions `json:"trustorPermissions,omitempty"` } func (u *User) Parse(parser structure.ObjectParser) { From 55f00072eb4e39e669da6feb6cb2dc7da0b3757e Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 10 Jul 2024 06:28:50 -0700 Subject: [PATCH 46/62] Start metadata/users/:userid/users filter params. --- auth/service/api/v1/profile.go | 5 +++ blob/blob.go | 73 ++++++++++++++++++++++++++++++++++ permission/permission.go | 2 +- user/profile.go | 14 +++++++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index 5fe51143d..830d5bb31 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -88,8 +88,13 @@ func (r *Router) GetUsersWithProfiles(res rest.ResponseWriter, req *rest.Request return } sharedUser.Profile = profile + // Seems no sharedUser.Sanitize call to filter out "protected" fields in seagull except sanitizeUser to remove "passwordExists" field perms := perms sharedUser.TrustorPermissions = &perms + if len(perms) == 0 && profile != nil { + sharedUser.Profile = nil + } + results = append(results, sharedUser) } } diff --git a/blob/blob.go b/blob/blob.go index 369f231dc..56dd2b5db 100644 --- a/blob/blob.go +++ b/blob/blob.go @@ -5,6 +5,8 @@ import ( "io" "net/http" "regexp" + "slices" + "strings" "time" "github.com/tidepool-org/platform/crypto" @@ -24,6 +26,77 @@ const ( StatusCreated = "created" ) +var ( + ANY = []string{"any"} + NONE = []string{"none"} + TRUES = []string{"true", "yes", "y", "1"} +) + +type parsedQueryPermissions []string + +// parsePermissions replicates the functionality of the seagull node.js +// parsePermissions function which parses a query string value to a permissions +// slice +func parsePermissions(queryValuesOfKey string) parsedQueryPermissions { + queryValuesOfKey = strings.TrimSpace(queryValuesOfKey) + if queryValuesOfKey == "" { + return nil + } + var vals []string + for _, val := range strings.Split(queryValuesOfKey, ",") { + val = strings.TrimSpace(val) + // Remove falsey values that are strings + if val == "0" || val == "false" || val == "" { + continue + } + vals = append(vals, val) + } + if slices.Compare(vals, ANY) == 0 { + return slices.Clone(ANY) + } + if slices.Compare(vals, NONE) == 0 { + return slices.Clone(NONE) + } + for _, val := range vals { + nonEmpty := val != "" + if !nonEmpty { + return vals + } + } + return nil +} + +func arePermissionsValid(perms parsedQueryPermissions) bool { + if len(perms) > 1 { + union := append(ANY, NONE...) + for _, perm := range perms { + // quadratic time complexity but very few elements so don't care + if slices.Contains(union, perm) { + return false + } + } + } + return true +} + +func arePermissionsSatisfied(queryPermissions, userPermissions parsedQueryPermissions) bool { + if slices.Compare(queryPermissions, ANY) == 0 { + nonEmpty := len(userPermissions) > 0 + return nonEmpty + } + if slices.Compare(queryPermissions, NONE) == 0 { + empty := len(userPermissions) == 0 + return empty + } + // Todo: really test this part + for _, userPerm := range userPermissions { + if !slices.Contains(queryPermissions, userPerm) { + return false + } + } + return true +} + func Statuses() []string { return []string{ StatusAvailable, diff --git a/permission/permission.go b/permission/permission.go index da73aa740..b718b122f 100644 --- a/permission/permission.go +++ b/permission/permission.go @@ -7,7 +7,7 @@ import ( type Permission map[string]interface{} type Permissions map[string]Permission -// GroupedPermissions are permissions that are keyed by userID. +// GroupedPermissions are permissions that are keyed by userID. As an example a response may be {"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa":{"root":{}},"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb":{"note":{},"upload":{},"view":{}}} type GroupedPermissions map[string]Permissions const ( diff --git a/user/profile.go b/user/profile.go index 967927e24..4ee39dafd 100644 --- a/user/profile.go +++ b/user/profile.go @@ -57,6 +57,7 @@ type UserProfile struct { MRN string `json:"mrn,omitempty"` Custodian *Custodian `json:"custodian,omitempty"` Clinic *ClinicProfile `json:"-"` // This is not returned to users in any new user profile routes but needs to be saved as it's not known where the old seagull value.profile.clinic is read + BiologicalSex string `json:"biologicalSex,omitempty"` } type ClinicProfile struct { @@ -81,6 +82,7 @@ func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { TargetTimezone: up.TargetTimezone, About: up.About, MRN: up.MRN, + BiologicalSex: up.BiologicalSex, }, Clinic: up.Clinic, MigrationStatus: migrationCompleted, // If we have a non legacy UserProfile, then that means the legacy version has been migrated from seagull (or it never existed which is equivalent for the new user profile purposes) @@ -114,6 +116,7 @@ func (p *LegacyUserProfile) ToUserProfile() *UserProfile { up.TargetTimezone = p.Patient.TargetTimezone up.About = p.Patient.About up.MRN = p.Patient.MRN + up.BiologicalSex = p.Patient.BiologicalSex } return up } @@ -136,6 +139,7 @@ type LegacyPatientProfile struct { About string `json:"about,omitempty"` IsOtherPerson bool `json:"isOtherPerson,omitempty"` MRN string `json:"mrn,omitempty"` + BiologicalSex string `json:"biologicalSex,omitempty"` } func (up *UserProfile) ToAttributes() map[string][]string { @@ -169,6 +173,9 @@ func (up *UserProfile) ToAttributes() map[string][]string { if up.MRN != "" { addAttribute(attributes, "mrn", up.MRN) } + if up.BiologicalSex != "" { + addAttribute(attributes, "biological_sex", up.BiologicalSex) + } if up.Clinic != nil { if up.Clinic.Email != "" { @@ -231,6 +238,10 @@ func ProfileFromAttributes(attributes map[string][]string) (profile *UserProfile up.MRN = val ok = true } + if val := getAttribute(attributes, "biological_sex"); val != "" { + up.BiologicalSex = val + ok = true + } var clinicProfile ClinicProfile var clinicOK bool @@ -329,6 +340,7 @@ func (up *UserProfile) Validate(v structure.Validator) { v.String("targetTimezone", &up.TargetTimezone).LengthLessThanOrEqualTo(maxProfileFieldLen) v.String("about", &up.About).LengthLessThanOrEqualTo(maxProfileFieldLen) v.String("mrn", &up.MRN).LengthLessThanOrEqualTo(maxProfileFieldLen) + v.String("biologicalSex", &up.BiologicalSex).LengthLessThanOrEqualTo(maxProfileFieldLen) up.Birthday.Validate(v.WithReference("birthday")) up.DiagnosisDate.Validate(v.WithReference("diagnosisDate")) @@ -343,6 +355,7 @@ func (up *UserProfile) Normalize(normalizer structure.Normalizer) { up.TargetTimezone = strings.TrimSpace(up.TargetTimezone) up.About = strings.TrimSpace(up.About) up.MRN = strings.TrimSpace(up.MRN) + up.BiologicalSex = strings.TrimSpace(up.BiologicalSex) up.Birthday.Normalize(normalizer.WithReference("birthday")) up.DiagnosisDate.Normalize(normalizer.WithReference("diagnosisDate")) @@ -401,4 +414,5 @@ func (pp *LegacyPatientProfile) Normalize(normalizer structure.Normalizer) { pp.TargetTimezone = strings.TrimSpace(pp.TargetTimezone) pp.About = strings.TrimSpace(pp.About) pp.MRN = strings.TrimSpace(pp.MRN) + pp.BiologicalSex = strings.TrimSpace(pp.BiologicalSex) } From c8d9d7f92bdfb4b9e668095bdaffd970e69bcd81 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 10 Jul 2024 14:11:33 -0700 Subject: [PATCH 47/62] Parse users profiles query filter. --- auth/service/api/v1/profile.go | 19 ++- auth/service/api/v1/profile_filter.go | 223 ++++++++++++++++++++++++++ blob/blob.go | 73 --------- permission/client/client.go | 20 +++ permission/permission.go | 7 + user/user.go | 5 +- 6 files changed, 269 insertions(+), 78 deletions(-) create mode 100644 auth/service/api/v1/profile_filter.go diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index 830d5bb31..9c6617959 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -2,11 +2,13 @@ package v1 import ( "context" - "errors" + stdErrs "errors" "net/http" "github.com/ant0ine/go-json-rest/rest" + "github.com/tidepool-org/platform/errors" + "github.com/tidepool-org/platform/permission" "github.com/tidepool-org/platform/request" structValidator "github.com/tidepool-org/platform/structure/validator" "github.com/tidepool-org/platform/user" @@ -65,6 +67,12 @@ func (r *Router) GetUsersWithProfiles(res rest.ResponseWriter, req *rest.Request return } + filter := parseUsersQuery(req.URL.Query()) + if !isUsersQueryValid(filter) { + responder.Error(http.StatusBadRequest, errors.New("unable to parse users query")) + return + } + mergedUserPerms := map[string]*permission.TrustPermissions{} trustorPerms, err := r.PermissionsClient().GroupsForUser(ctx, targetUserID) if err != nil { responder.InternalServerError(err) @@ -76,6 +84,11 @@ func (r *Router) GetUsersWithProfiles(res rest.ResponseWriter, req *rest.Request // Don't include own user in result continue } + + mergedUserPerms[userID] = &permission.TrustPermissions{ + TrustorPermissions: &perms, + } + if perms.HasReadPermissions() { sharedUser, err := r.UserAccessor().FindUserById(ctx, userID) if err != nil { @@ -83,7 +96,7 @@ func (r *Router) GetUsersWithProfiles(res rest.ResponseWriter, req *rest.Request return } profile, err := r.getProfile(ctx, userID) - if err != nil && !errors.Is(err, user.ErrUserProfileNotFound) { + if err != nil && !stdErrs.Is(err, user.ErrUserProfileNotFound) { r.handleProfileErr(responder, err) return } @@ -173,7 +186,7 @@ func (r *Router) DeleteProfile(res rest.ResponseWriter, req *rest.Request) { func (r *Router) handleProfileErr(responder *request.Responder, err error) { switch { - case errors.Is(err, user.ErrUserNotFound), errors.Is(err, user.ErrUserProfileNotFound): + case stdErrs.Is(err, user.ErrUserNotFound), stdErrs.Is(err, user.ErrUserProfileNotFound): responder.Empty(http.StatusNotFound) return default: diff --git a/auth/service/api/v1/profile_filter.go b/auth/service/api/v1/profile_filter.go new file mode 100644 index 000000000..6621f1848 --- /dev/null +++ b/auth/service/api/v1/profile_filter.go @@ -0,0 +1,223 @@ +package v1 + +import ( + "net/url" + "regexp" + "slices" + "strings" + + "github.com/tidepool-org/platform/permission" + "github.com/tidepool-org/platform/pointer" + userLib "github.com/tidepool-org/platform/user" +) + +var ( + ANY = []string{"any"} + NONE = []string{"none"} + TRUES = []string{"true", "yes", "y", "1"} +) + +type parsedQueryPermissions []string + +type usersProfileFilter struct { + TrustorPermissions parsedQueryPermissions + TrusteePermissions parsedQueryPermissions + Email *regexp.Regexp + EmailVerified *bool + TermsAccepted *regexp.Regexp + Name *regexp.Regexp + Birthday *regexp.Regexp + DiagnosisDate *regexp.Regexp +} + +// parsePermissions replicates the functionality of the seagull node.js +// parsePermissions function which parses a query string value to a permissions +// slice +func parsePermissions(queryValuesOfKey string) parsedQueryPermissions { + queryValuesOfKey = strings.TrimSpace(queryValuesOfKey) + if queryValuesOfKey == "" { + return nil + } + var vals []string + for _, val := range strings.Split(queryValuesOfKey, ",") { + val = strings.TrimSpace(val) + // Remove falsey values that are strings + if val == "0" || val == "false" || val == "" { + continue + } + vals = append(vals, val) + } + if slices.Compare(vals, ANY) == 0 { + return slices.Clone(ANY) + } + if slices.Compare(vals, NONE) == 0 { + return slices.Clone(NONE) + } + for _, val := range vals { + nonEmpty := val != "" + if !nonEmpty { + return vals + } + } + return nil +} + +// This is the logic I'm most unsure about. +// It's seems to be saying if a parsed query contains either of ANY or NONE that it is not a valid permissions query. +// Yet later in arePermissionsValid it allows those values. +func arePermissionsValid(perms parsedQueryPermissions) bool { + if len(perms) > 1 { + union := append(ANY, NONE...) + for _, perm := range perms { + // quadratic time complexity but very few elements so don't care + if slices.Contains(union, perm) { + return false + } + } + } + return true +} + +func arePermissionsSatisfied(queryPermissions parsedQueryPermissions, userPermissions permission.Permission) bool { + if slices.Compare(queryPermissions, ANY) == 0 { + nonEmpty := len(userPermissions) > 0 + return nonEmpty + } + if slices.Compare(queryPermissions, NONE) == 0 { + empty := len(userPermissions) == 0 + return empty + } + // Todo: really test this part + for _, queryPerm := range queryPermissions { + if _, ok := userPermissions[queryPerm]; !ok { + return false + } + } + return true +} + +func stringToBoolean(value string) bool { + value = strings.TrimSpace(strings.ToLower(value)) + return slices.Contains(TRUES, value) +} + +func parseUsersQuery(query url.Values) *usersProfileFilter { + var filter usersProfileFilter + trustorPermissions := parsePermissions(query.Get("trustorPermissions")) + // The original seagull code checks for nil so I don't know if just checking + // for a zero length array is safe or not but I believe so - todo add test for this + if len(trustorPermissions) > 0 { + filter.TrustorPermissions = trustorPermissions + } + + trusteePermissions := parsePermissions(query.Get("trusteePermissions")) + if len(trusteePermissions) > 0 { + filter.TrusteePermissions = trusteePermissions + } + + if email := strings.TrimSpace(query.Get("email")); email != "" { + if regex, err := regexp.Compile("(?i)" + email); err == nil { + filter.Email = regex + } + } + + if emailVerified := strings.TrimSpace(query.Get("emailVerified")); emailVerified != "" { + filter.EmailVerified = pointer.FromBool(stringToBoolean(emailVerified)) + } + + if termsAccepted := strings.TrimSpace(query.Get("termsAccepted")); termsAccepted != "" { + if regex, err := regexp.Compile("(?i)" + termsAccepted); err == nil { + filter.TermsAccepted = regex + } + } + + if name := strings.TrimSpace(query.Get("name")); name != "" { + if regex, err := regexp.Compile("(?i)" + name); err == nil { + filter.Name = regex + } + } + + if birthday := strings.TrimSpace(query.Get("birthday")); birthday != "" { + if regex, err := regexp.Compile("(?i)" + birthday); err == nil { + filter.Birthday = regex + } + } + + if diagnosisDate := strings.TrimSpace(query.Get("diagnosisDate")); diagnosisDate != "" { + if regex, err := regexp.Compile("(?i)" + diagnosisDate); err == nil { + filter.DiagnosisDate = regex + } + } + + return &filter +} + +func isUsersQueryValid(filter *usersProfileFilter) bool { + if filter == nil { + return true + } + if len(filter.TrustorPermissions) > 0 && !arePermissionsValid(filter.TrustorPermissions) { + return false + } + if len(filter.TrusteePermissions) > 0 && !arePermissionsValid(filter.TrusteePermissions) { + return false + } + return true +} +func userMatchesQueryOnPermissions(user *userLib.User, filter *usersProfileFilter) bool { + if filter == nil { + return true + } + if len(filter.TrustorPermissions) > 0 && !arePermissionsSatisfied(filter.TrustorPermissions, *user.TrustorPermissions) { + return false + } + if len(filter.TrusteePermissions) > 0 && !arePermissionsSatisfied(filter.TrusteePermissions, *user.TrusteePermissions) { + return false + } + + return true +} +func userMatchesQueryOnUser(user *userLib.User, filter *usersProfileFilter) bool { + if filter == nil { + return true + } + if filter.Email != nil && filter.Email.FindStringIndex(user.Email()) == nil { + return false + } + if filter.EmailVerified != nil && (user.EmailVerified == nil || *filter.EmailVerified != *user.EmailVerified) { + return false + } + if filter.TermsAccepted != nil && (user.TermsAccepted == nil || filter.TermsAccepted.FindStringIndex(*user.TermsAccepted) == nil) { + return false + } + return true +} + +func userMatchesQueryOnProfile(user *userLib.User, filter *usersProfileFilter) bool { + if filter == nil { + return true + } + profile := user.Profile + if filter.Name != nil && (profile == nil || filter.Name.FindStringIndex(profile.FullName) == nil) { + return false + } + if filter.Birthday != nil && (profile == nil || filter.Birthday.FindStringIndex(string(profile.Birthday)) == nil) { + return false + } + if filter.DiagnosisDate != nil && (profile == nil || filter.DiagnosisDate.FindStringIndex(string(profile.DiagnosisDate)) == nil) { + return false + } + return true +} + +func userMatchingQuery(user *userLib.User, filter *usersProfileFilter) *userLib.User { + if filter == nil { + return user + } + if !userMatchesQueryOnPermissions(user, filter) || + !userMatchesQueryOnUser(user, filter) || + !userMatchesQueryOnProfile(user, filter) { + return nil + } + return user +} diff --git a/blob/blob.go b/blob/blob.go index 56dd2b5db..369f231dc 100644 --- a/blob/blob.go +++ b/blob/blob.go @@ -5,8 +5,6 @@ import ( "io" "net/http" "regexp" - "slices" - "strings" "time" "github.com/tidepool-org/platform/crypto" @@ -26,77 +24,6 @@ const ( StatusCreated = "created" ) -var ( - ANY = []string{"any"} - NONE = []string{"none"} - TRUES = []string{"true", "yes", "y", "1"} -) - -type parsedQueryPermissions []string - -// parsePermissions replicates the functionality of the seagull node.js -// parsePermissions function which parses a query string value to a permissions -// slice -func parsePermissions(queryValuesOfKey string) parsedQueryPermissions { - queryValuesOfKey = strings.TrimSpace(queryValuesOfKey) - if queryValuesOfKey == "" { - return nil - } - var vals []string - for _, val := range strings.Split(queryValuesOfKey, ",") { - val = strings.TrimSpace(val) - // Remove falsey values that are strings - if val == "0" || val == "false" || val == "" { - continue - } - vals = append(vals, val) - } - if slices.Compare(vals, ANY) == 0 { - return slices.Clone(ANY) - } - if slices.Compare(vals, NONE) == 0 { - return slices.Clone(NONE) - } - for _, val := range vals { - nonEmpty := val != "" - if !nonEmpty { - return vals - } - } - return nil -} - -func arePermissionsValid(perms parsedQueryPermissions) bool { - if len(perms) > 1 { - union := append(ANY, NONE...) - for _, perm := range perms { - // quadratic time complexity but very few elements so don't care - if slices.Contains(union, perm) { - return false - } - } - } - return true -} - -func arePermissionsSatisfied(queryPermissions, userPermissions parsedQueryPermissions) bool { - if slices.Compare(queryPermissions, ANY) == 0 { - nonEmpty := len(userPermissions) > 0 - return nonEmpty - } - if slices.Compare(queryPermissions, NONE) == 0 { - empty := len(userPermissions) == 0 - return empty - } - // Todo: really test this part - for _, userPerm := range userPermissions { - if !slices.Contains(queryPermissions, userPerm) { - return false - } - } - return true -} - func Statuses() []string { return []string{ StatusAvailable, diff --git a/permission/client/client.go b/permission/client/client.go index 413f18886..d7030687c 100644 --- a/permission/client/client.go +++ b/permission/client/client.go @@ -69,6 +69,26 @@ func (c *Client) GroupsForUser(ctx context.Context, granteeUserID string) (permi return permission.FixGroupedOwnerPermissions(result), nil } +func (c *Client) UsersInGroup(ctx context.Context, sharerID string) (permission.GroupedPermissions, error) { + if ctx == nil { + return nil, errors.New("context is missing") + } + if sharerID == "" { + return nil, errors.New("user id is missing") + } + + url := c.client.ConstructURL("access", sharerID) + result := permission.GroupedPermissions{} + if err := c.client.RequestData(ctx, "GET", url, nil, nil, &result); err != nil { + if request.IsErrorResourceNotFound(err) { + return nil, request.ErrorUnauthorized() + } + return nil, err + } + + return permission.FixGroupedOwnerPermissions(result), nil +} + func (c *Client) HasMembershipRelationship(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) { fromTo, err := c.GetUserPermissions(ctx, granteeUserID, grantorUserID) if err != nil { diff --git a/permission/permission.go b/permission/permission.go index b718b122f..545e9ea27 100644 --- a/permission/permission.go +++ b/permission/permission.go @@ -10,6 +10,11 @@ type Permissions map[string]Permission // GroupedPermissions are permissions that are keyed by userID. As an example a response may be {"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa":{"root":{}},"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb":{"note":{},"upload":{},"view":{}}} type GroupedPermissions map[string]Permissions +type TrustPermissions struct { + TrustorPermissions *Permission + TrusteePermissions *Permission +} + const ( Follow = "follow" Custodian = "custodian" @@ -22,6 +27,8 @@ type Client interface { GetUserPermissions(ctx context.Context, requestUserID string, targetUserID string) (Permissions, error) // GroupsForUser returns permissions that have been shared with granteeUserID. It is keyed by the user that has shared something with granteeUserID GroupsForUser(ctx context.Context, granteeUserID string) (GroupedPermissions, error) + // UsersInGroup returns permissions that the user with id sharerID has shared with others, keyed by user id. + UsersInGroup(ctx context.Context, sharerID string) (GroupedPermissions, error) HasMembershipRelationship(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) HasCustodianPermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) HasWritePermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) diff --git a/user/user.go b/user/user.go index 4569833f7..275453cdc 100644 --- a/user/user.go +++ b/user/user.go @@ -54,8 +54,9 @@ type User struct { Profile *UserProfile `json:"-"` FirstName string `json:"firstName,omitempty"` LastName string `json:"lastName,omitempty"` - // TrustorPermissions is only returned for the route that returns users that have shared their data w/ another user - TrustorPermissions *permission.Permissions `json:"trustorPermissions,omitempty"` + // The following 2 properties are only returned for the route that returns users that have shared their data w/ another user + TrustorPermissions *permission.Permission `json:"trustorPermissions,omitempty"` + TrusteePermissions *permission.Permission `json:"trusteePermissions,omitempty"` } func (u *User) Parse(parser structure.ObjectParser) { From a2bfbbbccb9aa0891767fb88d263f23788b39240 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 15 Jul 2024 13:56:39 -0700 Subject: [PATCH 48/62] Update users route to properly filter out users. Document Permission / Permissions. --- auth/service/api/v1/profile.go | 92 +++++++++++++++++++++------ auth/service/api/v1/profile_filter.go | 12 ++-- permission/client/client.go | 12 ++-- permission/permission.go | 39 +++++++----- user/profile.go | 18 ++++++ 5 files changed, 126 insertions(+), 47 deletions(-) diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index 9c6617959..4978db619 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -3,6 +3,7 @@ package v1 import ( "context" stdErrs "errors" + "maps" "net/http" "github.com/ant0ine/go-json-rest/rest" @@ -78,39 +79,90 @@ func (r *Router) GetUsersWithProfiles(res rest.ResponseWriter, req *rest.Request responder.InternalServerError(err) return } - results := make([]*user.User, 0, len(trustorPerms)) for userID, perms := range trustorPerms { if userID == targetUserID { // Don't include own user in result continue } + clone := maps.Clone(perms) mergedUserPerms[userID] = &permission.TrustPermissions{ - TrustorPermissions: &perms, + TrustorPermissions: &clone, } + } - if perms.HasReadPermissions() { - sharedUser, err := r.UserAccessor().FindUserById(ctx, userID) - if err != nil { - responder.InternalServerError(err) - return - } - profile, err := r.getProfile(ctx, userID) - if err != nil && !stdErrs.Is(err, user.ErrUserProfileNotFound) { - r.handleProfileErr(responder, err) - return + trusteePerms, err := r.PermissionsClient().UsersInGroup(ctx, targetUserID) + if err != nil { + responder.InternalServerError(err) + return + } + for userID, perms := range trusteePerms { + if userID == targetUserID { + // Don't include own user in result + continue + } + + if _, ok := mergedUserPerms[userID]; !ok { + mergedUserPerms[userID] = &permission.TrustPermissions{} + } + clone := maps.Clone(perms) + mergedUserPerms[userID].TrusteePermissions = &clone + } + filteredUserPerms := make(map[string]*permission.TrustPermissions, len(mergedUserPerms)) + + for userID, trustPerms := range mergedUserPerms { + if userMatchesQueryOnPermissions(*trustPerms, filter) { + filteredUserPerms[userID] = trustPerms + } + } + + results := make([]*user.User, 0, len(mergedUserPerms)) + // just doing sequentially fetching of users for now + for userID, trustPerms := range filteredUserPerms { + // Does this mean all users should already be migrated + // to keycloak before this call? Or should UserAccessor have a "fallback" like shoreline's legacy mongodb repo? + sharedUser, err := r.UserAccessor().FindUserById(ctx, userID) + if stdErrs.Is(err, user.ErrUserNotFound) || sharedUser == nil { + // According to seagull code, "It's possible for a user profile to be deleted before the sharing permissions", so we can ignore if user or profile not found. + continue + } + if err != nil { + responder.InternalServerError(err) + return + } + if !userMatchesQueryOnUser(sharedUser, filter) { + continue + } + profile, err := r.getProfile(ctx, userID) + if stdErrs.Is(err, user.ErrUserProfileNotFound) || profile == nil { + continue + } + if err != nil { + r.handleProfileErr(responder, err) + return + } + trustorPerms := trustPerms.TrustorPermissions + if trustorPerms == nil || len(*trustorPerms) == 0 { + profile = profile.ClearPatientInfo() + } else { + + if trustorPerms.HasAny(permission.Custodian, permission.Read, permission.Write) { + // TODO: need to read seagull.value.settings } - sharedUser.Profile = profile - // Seems no sharedUser.Sanitize call to filter out "protected" fields in seagull except sanitizeUser to remove "passwordExists" field - perms := perms - sharedUser.TrustorPermissions = &perms - if len(perms) == 0 && profile != nil { - sharedUser.Profile = nil + if trustorPerms.Has(permission.Custodian) { + // TODO: need to read seagull.value.preferences } - - results = append(results, sharedUser) + } + sharedUser.Profile = profile + sharedUser.TrusteePermissions = trustPerms.TrusteePermissions + sharedUser.TrustorPermissions = trustPerms.TrustorPermissions + // Seems no sharedUser.Sanitize call to filter out "protected" fields in seagull except sanitizeUser to remove "passwordExists" field - which doesn't exist in current platform/user.User + matchedUser := userMatchingQuery(sharedUser, filter) + if matchedUser != nil { + results = append(results, matchedUser) } } + responder.Data(http.StatusOK, results) } diff --git a/auth/service/api/v1/profile_filter.go b/auth/service/api/v1/profile_filter.go index 6621f1848..6285f5a38 100644 --- a/auth/service/api/v1/profile_filter.go +++ b/auth/service/api/v1/profile_filter.go @@ -164,14 +164,14 @@ func isUsersQueryValid(filter *usersProfileFilter) bool { } return true } -func userMatchesQueryOnPermissions(user *userLib.User, filter *usersProfileFilter) bool { +func userMatchesQueryOnPermissions(trustPerms permission.TrustPermissions, filter *usersProfileFilter) bool { if filter == nil { return true } - if len(filter.TrustorPermissions) > 0 && !arePermissionsSatisfied(filter.TrustorPermissions, *user.TrustorPermissions) { + if len(filter.TrustorPermissions) > 0 && (trustPerms.TrustorPermissions == nil || !arePermissionsSatisfied(filter.TrustorPermissions, *trustPerms.TrustorPermissions)) { return false } - if len(filter.TrusteePermissions) > 0 && !arePermissionsSatisfied(filter.TrusteePermissions, *user.TrusteePermissions) { + if len(filter.TrusteePermissions) > 0 && (trustPerms.TrusteePermissions == nil || !arePermissionsSatisfied(filter.TrusteePermissions, *trustPerms.TrusteePermissions)) { return false } @@ -214,7 +214,11 @@ func userMatchingQuery(user *userLib.User, filter *usersProfileFilter) *userLib. if filter == nil { return user } - if !userMatchesQueryOnPermissions(user, filter) || + trustPerms := permission.TrustPermissions{ + TrustorPermissions: user.TrustorPermissions, + TrusteePermissions: user.TrusteePermissions, + } + if !userMatchesQueryOnPermissions(trustPerms, filter) || !userMatchesQueryOnUser(user, filter) || !userMatchesQueryOnProfile(user, filter) { return nil diff --git a/permission/client/client.go b/permission/client/client.go index d7030687c..ff2ace2bd 100644 --- a/permission/client/client.go +++ b/permission/client/client.go @@ -49,7 +49,7 @@ func (c *Client) GetUserPermissions(ctx context.Context, requestUserID string, t // GroupsForUser returns what users have shared permissions with the user with an id of granteeUserID. // The GroupedPermissions are keyed by the id of the user who shared their permissions with granteeUserID. -func (c *Client) GroupsForUser(ctx context.Context, granteeUserID string) (permission.GroupedPermissions, error) { +func (c *Client) GroupsForUser(ctx context.Context, granteeUserID string) (permission.Permissions, error) { if ctx == nil { return nil, errors.New("context is missing") } @@ -58,7 +58,7 @@ func (c *Client) GroupsForUser(ctx context.Context, granteeUserID string) (permi } url := c.client.ConstructURL("access", "groups", granteeUserID) - result := permission.GroupedPermissions{} + result := permission.Permissions{} if err := c.client.RequestData(ctx, "GET", url, nil, nil, &result); err != nil { if request.IsErrorResourceNotFound(err) { return nil, request.ErrorUnauthorized() @@ -66,10 +66,10 @@ func (c *Client) GroupsForUser(ctx context.Context, granteeUserID string) (permi return nil, err } - return permission.FixGroupedOwnerPermissions(result), nil + return result, nil } -func (c *Client) UsersInGroup(ctx context.Context, sharerID string) (permission.GroupedPermissions, error) { +func (c *Client) UsersInGroup(ctx context.Context, sharerID string) (permission.Permissions, error) { if ctx == nil { return nil, errors.New("context is missing") } @@ -78,7 +78,7 @@ func (c *Client) UsersInGroup(ctx context.Context, sharerID string) (permission. } url := c.client.ConstructURL("access", sharerID) - result := permission.GroupedPermissions{} + result := permission.Permissions{} if err := c.client.RequestData(ctx, "GET", url, nil, nil, &result); err != nil { if request.IsErrorResourceNotFound(err) { return nil, request.ErrorUnauthorized() @@ -86,7 +86,7 @@ func (c *Client) UsersInGroup(ctx context.Context, sharerID string) (permission. return nil, err } - return permission.FixGroupedOwnerPermissions(result), nil + return result, nil } func (c *Client) HasMembershipRelationship(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) { diff --git a/permission/permission.go b/permission/permission.go index 545e9ea27..b0ab97c20 100644 --- a/permission/permission.go +++ b/permission/permission.go @@ -5,10 +5,17 @@ import ( ) type Permission map[string]interface{} -type Permissions map[string]Permission -// GroupedPermissions are permissions that are keyed by userID. As an example a response may be {"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa":{"root":{}},"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb":{"note":{},"upload":{},"view":{}}} -type GroupedPermissions map[string]Permissions +// Permissions are permissions that are keyed depending on the type of permissions that are being retrieved. +// +// If it is a one to one user to user permission check, then it is keyed by permssion type (Follow, Custodian, etc): +// +// Permissions{"follow": struct{}{}, "upload": struct{}{}} +// +// If it is a grouped set of permissions, it is keyed by userId: +// +// Permissions{"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa":{"root":{}},"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb":{"note":{},"upload":{},"view":{}}} +type Permissions map[string]Permission type TrustPermissions struct { TrustorPermissions *Permission @@ -25,22 +32,15 @@ const ( type Client interface { GetUserPermissions(ctx context.Context, requestUserID string, targetUserID string) (Permissions, error) - // GroupsForUser returns permissions that have been shared with granteeUserID. It is keyed by the user that has shared something with granteeUserID - GroupsForUser(ctx context.Context, granteeUserID string) (GroupedPermissions, error) - // UsersInGroup returns permissions that the user with id sharerID has shared with others, keyed by user id. - UsersInGroup(ctx context.Context, sharerID string) (GroupedPermissions, error) + // GroupsForUser returns permissions that have been shared with granteeUserID. It is keyed by the user that has shared something with granteeUserID. It includes the user themself. + GroupsForUser(ctx context.Context, granteeUserID string) (Permissions, error) + // UsersInGroup returns permissions that the user with id sharerID has shared with others, keyed by user id. It includes the user themself. + UsersInGroup(ctx context.Context, sharerID string) (Permissions, error) HasMembershipRelationship(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) HasCustodianPermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) HasWritePermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) } -func FixGroupedOwnerPermissions(groupPermissions GroupedPermissions) GroupedPermissions { - for key, perms := range groupPermissions { - groupPermissions[key] = FixOwnerPermissions(perms) - } - return groupPermissions -} - func FixOwnerPermissions(permissions Permissions) Permissions { if ownerPermission, ok := permissions[Owner]; ok { if _, ok = permissions[Write]; !ok { @@ -53,9 +53,14 @@ func FixOwnerPermissions(permissions Permissions) Permissions { return permissions } -func (p Permissions) HasReadPermissions() bool { - for _, perm := range []string{Custodian, Owner, Read, Write} { - if _, ok := p[perm]; ok { +func (p Permission) Has(permissionType string) bool { + _, exists := p[permissionType] + return exists +} + +func (p Permission) HasAny(permissionTypes ...string) bool { + for _, perm := range permissionTypes { + if p.Has(perm) { return true } } diff --git a/user/profile.go b/user/profile.go index 4ee39dafd..1eb2373d3 100644 --- a/user/profile.go +++ b/user/profile.go @@ -96,6 +96,24 @@ func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { return legacyProfile } +// ClearPatientInfo makes a copy of up, clearing out certain patient information - this is called usually due to lack of permissions to the patient information +func (up *UserProfile) ClearPatientInfo() *UserProfile { + // explicitly specifying the type to make sure it's a value instead of pointer + var newProfile UserProfile = *up + newProfile.Birthday = "" + newProfile.DiagnosisDate = "" + newProfile.TargetDevices = nil + newProfile.TargetTimezone = "" + newProfile.About = "" + newProfile.MRN = "" + newProfile.BiologicalSex = "" + + // TODO: should these be cleared out? + newProfile.Custodian = nil + newProfile.Clinic = nil + return &newProfile +} + func (p *LegacyUserProfile) ToUserProfile() *UserProfile { up := &UserProfile{ FullName: cmp.Or(p.FullName, emptyFakeChildCustodianName), From c80234d1795ecd8442e55720e6c4b3cafdc6fd6e Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 17 Jul 2024 16:15:55 -0700 Subject: [PATCH 49/62] Remove unused query filter on users profiles. --- auth/service/api/v1/profile.go | 111 ++++++------- auth/service/api/v1/profile_filter.go | 227 -------------------------- permission/permission.go | 5 - 3 files changed, 53 insertions(+), 290 deletions(-) delete mode 100644 auth/service/api/v1/profile_filter.go diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index 4978db619..8f7bc8aba 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -5,16 +5,22 @@ import ( stdErrs "errors" "maps" "net/http" + "sync" "github.com/ant0ine/go-json-rest/rest" + "golang.org/x/sync/errgroup" - "github.com/tidepool-org/platform/errors" "github.com/tidepool-org/platform/permission" "github.com/tidepool-org/platform/request" structValidator "github.com/tidepool-org/platform/structure/validator" "github.com/tidepool-org/platform/user" ) +type trustPermissions struct { + TrustorPermissions *permission.Permission + TrusteePermissions *permission.Permission +} + func (r *Router) ProfileRoutes() []*rest.Route { return []*rest.Route{ rest.Get("/v1/users/:userId/profile", r.requireMembership("userId", r.GetProfile)), @@ -68,12 +74,7 @@ func (r *Router) GetUsersWithProfiles(res rest.ResponseWriter, req *rest.Request return } - filter := parseUsersQuery(req.URL.Query()) - if !isUsersQueryValid(filter) { - responder.Error(http.StatusBadRequest, errors.New("unable to parse users query")) - return - } - mergedUserPerms := map[string]*permission.TrustPermissions{} + mergedUserPerms := map[string]*trustPermissions{} trustorPerms, err := r.PermissionsClient().GroupsForUser(ctx, targetUserID) if err != nil { responder.InternalServerError(err) @@ -86,7 +87,7 @@ func (r *Router) GetUsersWithProfiles(res rest.ResponseWriter, req *rest.Request } clone := maps.Clone(perms) - mergedUserPerms[userID] = &permission.TrustPermissions{ + mergedUserPerms[userID] = &trustPermissions{ TrustorPermissions: &clone, } } @@ -103,64 +104,58 @@ func (r *Router) GetUsersWithProfiles(res rest.ResponseWriter, req *rest.Request } if _, ok := mergedUserPerms[userID]; !ok { - mergedUserPerms[userID] = &permission.TrustPermissions{} + mergedUserPerms[userID] = &trustPermissions{} } clone := maps.Clone(perms) mergedUserPerms[userID].TrusteePermissions = &clone } - filteredUserPerms := make(map[string]*permission.TrustPermissions, len(mergedUserPerms)) - - for userID, trustPerms := range mergedUserPerms { - if userMatchesQueryOnPermissions(*trustPerms, filter) { - filteredUserPerms[userID] = trustPerms - } - } + lock := &sync.Mutex{} results := make([]*user.User, 0, len(mergedUserPerms)) - // just doing sequentially fetching of users for now - for userID, trustPerms := range filteredUserPerms { - // Does this mean all users should already be migrated - // to keycloak before this call? Or should UserAccessor have a "fallback" like shoreline's legacy mongodb repo? - sharedUser, err := r.UserAccessor().FindUserById(ctx, userID) - if stdErrs.Is(err, user.ErrUserNotFound) || sharedUser == nil { - // According to seagull code, "It's possible for a user profile to be deleted before the sharing permissions", so we can ignore if user or profile not found. - continue - } - if err != nil { - responder.InternalServerError(err) - return - } - if !userMatchesQueryOnUser(sharedUser, filter) { - continue - } - profile, err := r.getProfile(ctx, userID) - if stdErrs.Is(err, user.ErrUserProfileNotFound) || profile == nil { - continue - } - if err != nil { - r.handleProfileErr(responder, err) - return - } - trustorPerms := trustPerms.TrustorPermissions - if trustorPerms == nil || len(*trustorPerms) == 0 { - profile = profile.ClearPatientInfo() - } else { - - if trustorPerms.HasAny(permission.Custodian, permission.Read, permission.Write) { - // TODO: need to read seagull.value.settings + group, ctx := errgroup.WithContext(ctx) + group.SetLimit(20) // do up to 20 concurrent requests like seagull did + for userID, trustPerms := range mergedUserPerms { + userID, trustPerms := userID, trustPerms + group.Go(func() error { + sharedUser, err := r.UserAccessor().FindUserById(ctx, userID) + if stdErrs.Is(err, user.ErrUserNotFound) || sharedUser == nil { + // According to seagull code, "It's possible for a user profile to be deleted before the sharing permissions", so we can ignore if user or profile not found. + return nil } - if trustorPerms.Has(permission.Custodian) { - // TODO: need to read seagull.value.preferences + if err != nil { + return err } - } - sharedUser.Profile = profile - sharedUser.TrusteePermissions = trustPerms.TrusteePermissions - sharedUser.TrustorPermissions = trustPerms.TrustorPermissions - // Seems no sharedUser.Sanitize call to filter out "protected" fields in seagull except sanitizeUser to remove "passwordExists" field - which doesn't exist in current platform/user.User - matchedUser := userMatchingQuery(sharedUser, filter) - if matchedUser != nil { - results = append(results, matchedUser) - } + profile, err := r.getProfile(ctx, userID) + if stdErrs.Is(err, user.ErrUserProfileNotFound) || profile == nil { + return nil + } + if err != nil { + return err + } + trustorPerms := trustPerms.TrustorPermissions + if trustorPerms == nil || len(*trustorPerms) == 0 { + profile = profile.ClearPatientInfo() + } else { + if trustorPerms.HasAny(permission.Custodian, permission.Read, permission.Write) { + // TODO: need to read seagull.value.settings - confirm this is actually used + } + if trustorPerms.Has(permission.Custodian) { + // TODO: need to read seagull.value.preferences - confirm this is actually used + } + } + sharedUser.Profile = profile + sharedUser.TrusteePermissions = trustPerms.TrusteePermissions + sharedUser.TrustorPermissions = trustPerms.TrustorPermissions + // Seems no sharedUser.Sanitize call to filter out "protected" fields in seagull except sanitizeUser to remove "passwordExists" field - which doesn't exist in current platform/user.User + lock.Lock() + results = append(results, sharedUser) + lock.Unlock() + return nil + }) + } + if err := group.Wait(); err != nil { + r.handleProfileErr(responder, err) + return } responder.Data(http.StatusOK, results) diff --git a/auth/service/api/v1/profile_filter.go b/auth/service/api/v1/profile_filter.go deleted file mode 100644 index 6285f5a38..000000000 --- a/auth/service/api/v1/profile_filter.go +++ /dev/null @@ -1,227 +0,0 @@ -package v1 - -import ( - "net/url" - "regexp" - "slices" - "strings" - - "github.com/tidepool-org/platform/permission" - "github.com/tidepool-org/platform/pointer" - userLib "github.com/tidepool-org/platform/user" -) - -var ( - ANY = []string{"any"} - NONE = []string{"none"} - TRUES = []string{"true", "yes", "y", "1"} -) - -type parsedQueryPermissions []string - -type usersProfileFilter struct { - TrustorPermissions parsedQueryPermissions - TrusteePermissions parsedQueryPermissions - Email *regexp.Regexp - EmailVerified *bool - TermsAccepted *regexp.Regexp - Name *regexp.Regexp - Birthday *regexp.Regexp - DiagnosisDate *regexp.Regexp -} - -// parsePermissions replicates the functionality of the seagull node.js -// parsePermissions function which parses a query string value to a permissions -// slice -func parsePermissions(queryValuesOfKey string) parsedQueryPermissions { - queryValuesOfKey = strings.TrimSpace(queryValuesOfKey) - if queryValuesOfKey == "" { - return nil - } - var vals []string - for _, val := range strings.Split(queryValuesOfKey, ",") { - val = strings.TrimSpace(val) - // Remove falsey values that are strings - if val == "0" || val == "false" || val == "" { - continue - } - vals = append(vals, val) - } - if slices.Compare(vals, ANY) == 0 { - return slices.Clone(ANY) - } - if slices.Compare(vals, NONE) == 0 { - return slices.Clone(NONE) - } - for _, val := range vals { - nonEmpty := val != "" - if !nonEmpty { - return vals - } - } - return nil -} - -// This is the logic I'm most unsure about. -// It's seems to be saying if a parsed query contains either of ANY or NONE that it is not a valid permissions query. -// Yet later in arePermissionsValid it allows those values. -func arePermissionsValid(perms parsedQueryPermissions) bool { - if len(perms) > 1 { - union := append(ANY, NONE...) - for _, perm := range perms { - // quadratic time complexity but very few elements so don't care - if slices.Contains(union, perm) { - return false - } - } - } - return true -} - -func arePermissionsSatisfied(queryPermissions parsedQueryPermissions, userPermissions permission.Permission) bool { - if slices.Compare(queryPermissions, ANY) == 0 { - nonEmpty := len(userPermissions) > 0 - return nonEmpty - } - if slices.Compare(queryPermissions, NONE) == 0 { - empty := len(userPermissions) == 0 - return empty - } - // Todo: really test this part - for _, queryPerm := range queryPermissions { - if _, ok := userPermissions[queryPerm]; !ok { - return false - } - } - return true -} - -func stringToBoolean(value string) bool { - value = strings.TrimSpace(strings.ToLower(value)) - return slices.Contains(TRUES, value) -} - -func parseUsersQuery(query url.Values) *usersProfileFilter { - var filter usersProfileFilter - trustorPermissions := parsePermissions(query.Get("trustorPermissions")) - // The original seagull code checks for nil so I don't know if just checking - // for a zero length array is safe or not but I believe so - todo add test for this - if len(trustorPermissions) > 0 { - filter.TrustorPermissions = trustorPermissions - } - - trusteePermissions := parsePermissions(query.Get("trusteePermissions")) - if len(trusteePermissions) > 0 { - filter.TrusteePermissions = trusteePermissions - } - - if email := strings.TrimSpace(query.Get("email")); email != "" { - if regex, err := regexp.Compile("(?i)" + email); err == nil { - filter.Email = regex - } - } - - if emailVerified := strings.TrimSpace(query.Get("emailVerified")); emailVerified != "" { - filter.EmailVerified = pointer.FromBool(stringToBoolean(emailVerified)) - } - - if termsAccepted := strings.TrimSpace(query.Get("termsAccepted")); termsAccepted != "" { - if regex, err := regexp.Compile("(?i)" + termsAccepted); err == nil { - filter.TermsAccepted = regex - } - } - - if name := strings.TrimSpace(query.Get("name")); name != "" { - if regex, err := regexp.Compile("(?i)" + name); err == nil { - filter.Name = regex - } - } - - if birthday := strings.TrimSpace(query.Get("birthday")); birthday != "" { - if regex, err := regexp.Compile("(?i)" + birthday); err == nil { - filter.Birthday = regex - } - } - - if diagnosisDate := strings.TrimSpace(query.Get("diagnosisDate")); diagnosisDate != "" { - if regex, err := regexp.Compile("(?i)" + diagnosisDate); err == nil { - filter.DiagnosisDate = regex - } - } - - return &filter -} - -func isUsersQueryValid(filter *usersProfileFilter) bool { - if filter == nil { - return true - } - if len(filter.TrustorPermissions) > 0 && !arePermissionsValid(filter.TrustorPermissions) { - return false - } - if len(filter.TrusteePermissions) > 0 && !arePermissionsValid(filter.TrusteePermissions) { - return false - } - return true -} -func userMatchesQueryOnPermissions(trustPerms permission.TrustPermissions, filter *usersProfileFilter) bool { - if filter == nil { - return true - } - if len(filter.TrustorPermissions) > 0 && (trustPerms.TrustorPermissions == nil || !arePermissionsSatisfied(filter.TrustorPermissions, *trustPerms.TrustorPermissions)) { - return false - } - if len(filter.TrusteePermissions) > 0 && (trustPerms.TrusteePermissions == nil || !arePermissionsSatisfied(filter.TrusteePermissions, *trustPerms.TrusteePermissions)) { - return false - } - - return true -} -func userMatchesQueryOnUser(user *userLib.User, filter *usersProfileFilter) bool { - if filter == nil { - return true - } - if filter.Email != nil && filter.Email.FindStringIndex(user.Email()) == nil { - return false - } - if filter.EmailVerified != nil && (user.EmailVerified == nil || *filter.EmailVerified != *user.EmailVerified) { - return false - } - if filter.TermsAccepted != nil && (user.TermsAccepted == nil || filter.TermsAccepted.FindStringIndex(*user.TermsAccepted) == nil) { - return false - } - return true -} - -func userMatchesQueryOnProfile(user *userLib.User, filter *usersProfileFilter) bool { - if filter == nil { - return true - } - profile := user.Profile - if filter.Name != nil && (profile == nil || filter.Name.FindStringIndex(profile.FullName) == nil) { - return false - } - if filter.Birthday != nil && (profile == nil || filter.Birthday.FindStringIndex(string(profile.Birthday)) == nil) { - return false - } - if filter.DiagnosisDate != nil && (profile == nil || filter.DiagnosisDate.FindStringIndex(string(profile.DiagnosisDate)) == nil) { - return false - } - return true -} - -func userMatchingQuery(user *userLib.User, filter *usersProfileFilter) *userLib.User { - if filter == nil { - return user - } - trustPerms := permission.TrustPermissions{ - TrustorPermissions: user.TrustorPermissions, - TrusteePermissions: user.TrusteePermissions, - } - if !userMatchesQueryOnPermissions(trustPerms, filter) || - !userMatchesQueryOnUser(user, filter) || - !userMatchesQueryOnProfile(user, filter) { - return nil - } - return user -} diff --git a/permission/permission.go b/permission/permission.go index b0ab97c20..391468fbf 100644 --- a/permission/permission.go +++ b/permission/permission.go @@ -17,11 +17,6 @@ type Permission map[string]interface{} // Permissions{"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa":{"root":{}},"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb":{"note":{},"upload":{},"view":{}}} type Permissions map[string]Permission -type TrustPermissions struct { - TrustorPermissions *Permission - TrusteePermissions *Permission -} - const ( Follow = "follow" Custodian = "custodian" From 2247fc01a0c1a65fbb6de2fe4c08295ec5f584cd Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 29 Jul 2024 22:36:03 -0700 Subject: [PATCH 50/62] Handle email and emails in legacy seagull profiles. --- auth/service/api/v1/profile.go | 2 + .../legacy_seagull_profile_repository.go | 28 ++++-- user/fallback_user_accessor.go | 21 +++++ user/keycloak/client.go | 2 +- user/legacy_raw_seagull_profile.go | 49 +++++++--- user/profile.go | 89 ++++++++++++------- 6 files changed, 141 insertions(+), 50 deletions(-) diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index 8f7bc8aba..18ad54a63 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -50,6 +50,7 @@ func (r *Router) getProfile(ctx context.Context, userID string) (*user.UserProfi return profile, nil } +// GetProfile returns the user's profile in the new, non seagull, format func (r *Router) GetProfile(res rest.ResponseWriter, req *rest.Request) { responder := request.MustNewResponder(res, req) ctx := req.Context() @@ -161,6 +162,7 @@ func (r *Router) GetUsersWithProfiles(res rest.ResponseWriter, req *rest.Request responder.Data(http.StatusOK, results) } +// GetLegacyProfile returns user profiles in the legacy seagull format. func (r *Router) GetLegacyProfile(res rest.ResponseWriter, req *rest.Request) { responder := request.MustNewResponder(res, req) ctx := req.Context() diff --git a/auth/store/mongo/legacy_seagull_profile_repository.go b/auth/store/mongo/legacy_seagull_profile_repository.go index e31828a8e..df201589d 100644 --- a/auth/store/mongo/legacy_seagull_profile_repository.go +++ b/auth/store/mongo/legacy_seagull_profile_repository.go @@ -71,13 +71,19 @@ func (p *LegacySeagullProfileRepository) UpdateUserProfile(ctx context.Context, return err } var doc user.LegacySeagullDocument - selector := bson.M{"userId": userID} + selector := bson.M{ + "userId": userID, + } err := p.FindOne(ctx, selector).Decode(&doc) - // A user can have no profile set - see seagull/lib/routes/seagullApi.js `if (err.statusCode == 404 && addIfNotThere)` if err != nil && !stdErrors.Is(err, mongo.ErrNoDocuments) { return err } + hasExistingProfile := err == nil + // We need to make a distinction b/t a seagull profile not existing (in which case we can upsert) versus a seagull profile actively being migrated, which is why we need to actually read the document. + if hasExistingProfile && doc.IsMigrating() { + return user.ErrUserProfileMigrationInProgress + } // This will create a new value even if doc.Value is empty updatedValueRaw, err := user.AddProfileToSeagullValue(doc.Value, legacyProfile) @@ -85,16 +91,26 @@ func (p *LegacySeagullProfileRepository) UpdateUserProfile(ctx context.Context, return err } - uselector := bson.M{"userId": userID} + uopts := options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After) + uselector := bson.M{ + "userId": userID, + } update := bson.M{ "$set": bson.M{ "value": updatedValueRaw, "userId": userID, // Set because of possible upsert }, } - uopts := options.Update().SetUpsert(true) - _, err = p.UpdateOne(ctx, uselector, update, uopts) - return err + var updatedDoc user.LegacySeagullDocument + err = p.FindOneAndUpdate(ctx, uselector, update, uopts).Decode(&updatedDoc) + if err != nil { + return err + } + // Handle case where a migration was started in between the start of this function and the update + if updatedDoc.IsMigrating() { + return user.ErrUserProfileMigrationInProgress + } + return nil } func (p *LegacySeagullProfileRepository) DeleteUserProfile(ctx context.Context, userID string) error { diff --git a/user/fallback_user_accessor.go b/user/fallback_user_accessor.go index e5850a41e..8f287d5dd 100644 --- a/user/fallback_user_accessor.go +++ b/user/fallback_user_accessor.go @@ -3,6 +3,7 @@ package user import ( "context" "errors" + "time" ) // FallbackLegacyUserAccessor acts as an intermediary between seagulls @@ -42,12 +43,32 @@ func (f *FallbackLegacyUserAccessor) FindUserProfile(ctx context.Context, id str } func (f *FallbackLegacyUserAccessor) UpdateUserProfile(ctx context.Context, id string, profile *UserProfile) error { + // retry in case a migration happens during this call - a migration should not take more than a few seconds + // so this is acceptable IMO. + retryLimit := 3 + var err error + for i := 0; i < retryLimit; i++ { + err = f.updateUserProfile(ctx, id, profile) + if errors.Is(err, ErrUserProfileMigrationInProgress) { + time.Sleep(time.Second * time.Duration(i+1)) + continue + } + if err != nil { + return err + } + } + return err +} + +func (f *FallbackLegacyUserAccessor) updateUserProfile(ctx context.Context, id string, profile *UserProfile) error { seagullProfile, err := f.legacy.FindUserProfile(ctx, id) if err != nil && !errors.Is(err, ErrUserProfileNotFound) { return err } // An unmigrated profile should be returned until the profile has been migrated if seagullProfile != nil && seagullProfile.MigrationStatus == migrationUnmigrated { + // During an attempt to update a seagull profile, the migration process may have started in b/t the previous call and the attempt to update. + // In this we will retry as the migration time. return f.legacy.UpdateUserProfile(ctx, id, profile) } return f.accessor.UpdateUserProfile(ctx, id, profile) diff --git a/user/keycloak/client.go b/user/keycloak/client.go index e1fdaec85..6ea3a10a9 100644 --- a/user/keycloak/client.go +++ b/user/keycloak/client.go @@ -460,7 +460,7 @@ func newKeycloakUser(gocloakUser *gocloak.User) *keycloakUser { if ts, ok := attrs[termsAcceptedAttribute]; ok { user.Attributes.TermsAcceptedDate = ts } - if prof, ok := userLib.ProfileFromAttributes(attrs); ok { + if prof, ok := userLib.ProfileFromAttributes(pointer.ToString(gocloakUser.Username), attrs); ok { user.Attributes.Profile = prof } } diff --git a/user/legacy_raw_seagull_profile.go b/user/legacy_raw_seagull_profile.go index da09ac914..3e5248290 100644 --- a/user/legacy_raw_seagull_profile.go +++ b/user/legacy_raw_seagull_profile.go @@ -1,12 +1,18 @@ package user import ( + "cmp" "encoding/json" + "errors" "time" "github.com/tidepool-org/platform/pointer" ) +var ( + ErrSeagullFieldNotFound = errors.New("seagull field not found within value object string") +) + // LegacySeagullDocument is the database model representation of the legacy // seagull collection object. The value is a raw stringified JSON blob. TODO: // delete once all profiles are migrated over @@ -21,7 +27,7 @@ type LegacySeagullDocument struct { MigrationStart *time.Time `bson:"_migrationStart,omitempty"` // The presence of migrationEnd means the profile is fully migrated and all reads / writes to a user profile should go through keycloak MigrationEnd *time.Time `bson:"_migrationEnd,omitempty"` - MigrationError string `bson:"_migrationError,omitempty"` + MigrationError *string `bson:"_migrationError,omitempty"` MigrationErrorTime *time.Time `bson:"_migrationErrorTime,omitempty"` } @@ -43,20 +49,20 @@ func (doc *LegacySeagullDocument) ToLegacyProfile() (*LegacyUserProfile, error) // Add some default names if it is an empty name for the fake child or parent of them isFakeChild := legacyProfile.Patient != nil && legacyProfile.Patient.IsOtherPerson - if isFakeChild && pointer.ToString(legacyProfile.Patient.FullName) == "" { - legacyProfile.Patient.FullName = pointer.FromString(emptyFakeChildDefaultName) - } - if isFakeChild && legacyProfile.FullName == "" { - legacyProfile.FullName = emptyFakeChildCustodianName + if isFakeChild { + // Some fake child accounts have profiles w/ an empty patient fullName or profile fullName (but not both). + // In this case, use the non empty name for both. + parentName := legacyProfile.FullName + childName := pointer.ToString(legacyProfile.Patient.FullName) + var fullName string + if parentName == "" || childName == "" { + fullName = cmp.Or(parentName, childName) + legacyProfile.Patient.FullName = &fullName + legacyProfile.FullName = fullName + } } - legacyProfile.MigrationStatus = migrationUnmigrated - if doc.MigrationStart != nil && doc.MigrationEnd != nil { - legacyProfile.MigrationStatus = migrationCompleted - } - if doc.MigrationStart != nil && doc.MigrationEnd == nil && doc.MigrationError == "" { - legacyProfile.MigrationStatus = migrationInProgress - } + legacyProfile.MigrationStatus = doc.MigrationStatus() return &legacyProfile, nil } @@ -102,3 +108,20 @@ func MarshalThenUnmarshal(src any, dst *LegacyUserProfile) error { } return json.Unmarshal(bytes, dst) } + +func (doc *LegacySeagullDocument) MigrationStatus() migrationStatus { + if doc.MigrationStart != nil && doc.MigrationEnd != nil { + return migrationCompleted + } + if doc.MigrationStart != nil && doc.MigrationEnd == nil && doc.MigrationError == nil { + return migrationInProgress + } + if doc.MigrationStart != nil && doc.MigrationError != nil { + return migrationError + } + return migrationUnmigrated +} + +func (doc *LegacySeagullDocument) IsMigrating() bool { + return doc.MigrationStatus() != migrationUnmigrated +} diff --git a/user/profile.go b/user/profile.go index 1eb2373d3..cec280846 100644 --- a/user/profile.go +++ b/user/profile.go @@ -16,17 +16,11 @@ const ( migrationUnmigrated migrationStatus = iota migrationCompleted migrationInProgress + migrationError maxProfileFieldLen = 256 ) -const ( - // emptyFakeChildDefaultName is a placeholder name to be used if a fake child account profile has no patient.fullName field - emptyFakeChildDefaultName = "Child User" - // emptyFakeChildCustodianName is a placeholder name to be used if a fake child account profile has no custodian / parent fullName field - seagull.value.profile.fullName - emptyFakeChildCustodianName = "Custodian User" -) - var ( diabetesTypes = []string{ "type1", @@ -58,10 +52,11 @@ type UserProfile struct { Custodian *Custodian `json:"custodian,omitempty"` Clinic *ClinicProfile `json:"-"` // This is not returned to users in any new user profile routes but needs to be saved as it's not known where the old seagull value.profile.clinic is read BiologicalSex string `json:"biologicalSex,omitempty"` + Email string `json:"-"` // This is used when returning profiles in the legacy format. It is not stored in the profile, but is populated from the keycloak username and not returned in the new profiles route. } type ClinicProfile struct { - Email string `json:"email,omitempty"` + // Email string `json:"email,omitempty"` // only found a handle of fake profiles w/ email in clinic, but will confirm doesn't exist Name string `json:"name,omitempty"` Role string `json:"role,omitempty"` Telephone string `json:"telephone,omitempty"` @@ -72,10 +67,24 @@ type Custodian struct { FullName string `json:"fullName"` } +// IsPatientProfile returns true if the profile is associated with a patient - note that this is not mutually exclusive w/ a clinician, as some users have both +func (up *UserProfile) IsPatientProfile() bool { + return up.DiagnosisDate != "" || up.DiagnosisType != "" || len(up.TargetDevices) > 0 || up.MRN != "" || up.About != "" || up.BiologicalSex != "" || up.Birthday != "" || up.Custodian != nil +} + +// IsClinicianProfile returns true if the profile is associated with a clinician - note that this is not mutually exclusive w/ a patient, as some users have both +func (up *UserProfile) IsClinicianProfile() bool { + return up.Clinic != nil +} + func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { legacyProfile := &LegacyUserProfile{ - FullName: up.FullName, - Patient: &LegacyPatientProfile{ + FullName: up.FullName, + MigrationStatus: migrationCompleted, // If we have a non legacy UserProfile, then that means the legacy version has been migrated from seagull (or it never existed which is equivalent for the new user profile purposes) + } + + if up.IsPatientProfile() { + legacyProfile.Patient = &LegacyPatientProfile{ Birthday: up.Birthday, DiagnosisDate: up.DiagnosisDate, TargetDevices: up.TargetDevices, @@ -83,15 +92,23 @@ func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { About: up.About, MRN: up.MRN, BiologicalSex: up.BiologicalSex, - }, - Clinic: up.Clinic, - MigrationStatus: migrationCompleted, // If we have a non legacy UserProfile, then that means the legacy version has been migrated from seagull (or it never existed which is equivalent for the new user profile purposes) + } + if up.Email != "" { + legacyProfile.Patient.Email = up.Email + legacyProfile.Patient.Emails = []string{up.Email} + legacyProfile.Email = up.Email + legacyProfile.Emails = []string{up.Email} + } } // only custodiaL fake child accounts have Patient.FullName set if up.Custodian != nil { - legacyProfile.FullName = up.Custodian.FullName - legacyProfile.Patient.FullName = pointer.FromString(cmp.Or(up.FullName, emptyFakeChildDefaultName)) legacyProfile.Patient.IsOtherPerson = true + // Handle case where Custodian user (contains fake child) and one of the FullName's is empty. + legacyProfile.FullName = cmp.Or(up.Custodian.FullName, up.FullName) + legacyProfile.Patient.FullName = pointer.FromString(cmp.Or(up.FullName, up.Custodian.FullName)) + } + if up.IsClinicianProfile() { + legacyProfile.Clinic = up.Clinic } return legacyProfile } @@ -107,25 +124,27 @@ func (up *UserProfile) ClearPatientInfo() *UserProfile { newProfile.About = "" newProfile.MRN = "" newProfile.BiologicalSex = "" - - // TODO: should these be cleared out? newProfile.Custodian = nil newProfile.Clinic = nil return &newProfile } func (p *LegacyUserProfile) ToUserProfile() *UserProfile { + fullName := p.FullName up := &UserProfile{ - FullName: cmp.Or(p.FullName, emptyFakeChildCustodianName), - Clinic: p.Clinic, + Clinic: p.Clinic, } if p.Patient != nil { up.FullName = cmp.Or(pointer.ToString(p.Patient.FullName), p.FullName) // Only users with isOtherPerson set has a patient.fullName field set so // they have a custodian. if p.Patient.IsOtherPerson { + // Handle the few cases where one of either the fake child fullName or the profile fullName is empty (neither are both empty) + if fullName == "" { + fullName = pointer.ToString(p.Patient.FullName) + } up.Custodian = &Custodian{ - FullName: cmp.Or(p.FullName, emptyFakeChildCustodianName), + FullName: cmp.Or(p.FullName, fullName), } } up.Birthday = p.Patient.Birthday @@ -136,6 +155,7 @@ func (p *LegacyUserProfile) ToUserProfile() *UserProfile { up.MRN = p.Patient.MRN up.BiologicalSex = p.Patient.BiologicalSex } + up.FullName = fullName return up } @@ -145,6 +165,9 @@ type LegacyUserProfile struct { Patient *LegacyPatientProfile `json:"patient,omitempty"` Clinic *ClinicProfile `json:"clinic,omitempty"` MigrationStatus migrationStatus `json:"-"` + // The Email and Emails fields are legacy properties that will be populated from the keycloak user if the profile is finished migrating, otherwise from the seagull collection + Email string `json:"email,omitempty"` + Emails []string `json:"emails,omitempty"` } type LegacyPatientProfile struct { @@ -158,6 +181,9 @@ type LegacyPatientProfile struct { IsOtherPerson bool `json:"isOtherPerson,omitempty"` MRN string `json:"mrn,omitempty"` BiologicalSex string `json:"biologicalSex,omitempty"` + // The Email and Emails fields are legacy properties that will be populated from the keycloak user if the profile is finished migrating, otherwise from the seagull collection + Email string `json:"email,omitempty"` + Emails []string `json:"emails,omitempty"` } func (up *UserProfile) ToAttributes() map[string][]string { @@ -196,9 +222,9 @@ func (up *UserProfile) ToAttributes() map[string][]string { } if up.Clinic != nil { - if up.Clinic.Email != "" { - addAttribute(attributes, "clinic_email", up.Clinic.Email) - } + // if up.Clinic.Email != "" { + // addAttribute(attributes, "clinic_email", up.Clinic.Email) + // } if up.Clinic.Name != "" { addAttribute(attributes, "clinic_name", up.Clinic.Name) } @@ -216,8 +242,10 @@ func (up *UserProfile) ToAttributes() map[string][]string { return attributes } -func ProfileFromAttributes(attributes map[string][]string) (profile *UserProfile, ok bool) { - up := &UserProfile{} +func ProfileFromAttributes(username string, attributes map[string][]string) (profile *UserProfile, ok bool) { + up := &UserProfile{ + Email: username, + } if val := getAttribute(attributes, "full_name"); val != "" { up.FullName = val ok = true @@ -263,10 +291,10 @@ func ProfileFromAttributes(attributes map[string][]string) (profile *UserProfile var clinicProfile ClinicProfile var clinicOK bool - if val := getAttribute(attributes, "clinic_email"); val != "" { - clinicProfile.Email = val - clinicOK = true - } + // if val := getAttribute(attributes, "clinic_email"); val != "" { + // clinicProfile.Email = val + // clinicOK = true + // } if val := getAttribute(attributes, "clinic_name"); val != "" { clinicProfile.Name = val clinicOK = true @@ -383,7 +411,7 @@ func (up *UserProfile) Normalize(normalizer structure.Normalizer) { } func (p *ClinicProfile) Normalize(normalizer structure.Normalizer) { - p.Email = strings.TrimSpace(p.Email) + // p.Email = strings.TrimSpace(p.Email) p.Name = strings.TrimSpace(p.Name) p.Role = strings.TrimSpace(p.Role) p.Telephone = strings.TrimSpace(p.Telephone) @@ -405,6 +433,7 @@ func (up *LegacyUserProfile) Normalize(normalizer structure.Normalizer) { if up.Clinic != nil { up.Clinic.Normalize(normalizer.WithReference("clinic")) } + // Email and Emails are read-only so they are ignored in normalizing / validation } func (pp *LegacyPatientProfile) Validate(v structure.Validator) { From d78c148f01c8b13bba413d69d9c1a5a5204ae80c Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 31 Jul 2024 12:16:26 -0700 Subject: [PATCH 51/62] Read raw value as map from seagull value. --- user/legacy_raw_seagull_profile.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/user/legacy_raw_seagull_profile.go b/user/legacy_raw_seagull_profile.go index 3e5248290..77ce27701 100644 --- a/user/legacy_raw_seagull_profile.go +++ b/user/legacy_raw_seagull_profile.go @@ -66,6 +66,10 @@ func (doc *LegacySeagullDocument) ToLegacyProfile() (*LegacyUserProfile, error) return &legacyProfile, nil } +func (doc *LegacySeagullDocument) RawValue() (valueAsMap map[string]any, err error) { + return extractSeagullValue(doc.Value) +} + // extractSeagullValue unmarshals the jsonified string field "value" in the // seagull collection to a map[string]any - the reason the fields aren't // explicitly defined is because there is / was no defined schema at the From 3401464c4ff617bfc02c57cf0db32d38e2938d06 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 31 Jul 2024 12:58:38 -0700 Subject: [PATCH 52/62] Allow setting of profile on seagull document's value field. --- user/legacy_raw_seagull_profile.go | 16 ++++++++++++++++ user/profile.go | 30 ++++++++++++++++++++---------- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/user/legacy_raw_seagull_profile.go b/user/legacy_raw_seagull_profile.go index 77ce27701..3bd3c333a 100644 --- a/user/legacy_raw_seagull_profile.go +++ b/user/legacy_raw_seagull_profile.go @@ -70,6 +70,22 @@ func (doc *LegacySeagullDocument) RawValue() (valueAsMap map[string]any, err err return extractSeagullValue(doc.Value) } +// SetRawValueProfile updates the document's jsonified Value field to contain a "profile" field with the given profile +func (doc *LegacySeagullDocument) SetRawValueProfile(profile map[string]any) error { + valueObj, err := doc.RawValue() + // If there was an error, just make a new field "value" value. + if err != nil { + valueObj = map[string]any{} + } + valueObj["profile"] = profile + bytes, err := json.Marshal(valueObj) + if err != nil { + return err + } + doc.Value = string(bytes) + return nil +} + // extractSeagullValue unmarshals the jsonified string field "value" in the // seagull collection to a map[string]any - the reason the fields aren't // explicitly defined is because there is / was no defined schema at the diff --git a/user/profile.go b/user/profile.go index cec280846..91d6435c7 100644 --- a/user/profile.go +++ b/user/profile.go @@ -21,15 +21,25 @@ const ( maxProfileFieldLen = 256 ) +const ( + DiabetesTypeType1 = "type1" + DiabetesTypeType2 = "type2" + DiabetesTypeGestational = "gestational" + DiabetesTypeLada = "lada" + DiabetesTypeOther = "other" + DiabetesTypePrediabetes = "prediabetes" + DiabetesTypeMody = "mody" +) + var ( - diabetesTypes = []string{ - "type1", - "type2", - "gestational", - "lada", - "other", - "prediabetes", - "mody", + DiabetesTypes = []string{ + DiabetesTypeType1, + DiabetesTypeType2, + DiabetesTypeGestational, + DiabetesTypeLada, + DiabetesTypeOther, + DiabetesTypePrediabetes, + DiabetesTypeMody, } ) @@ -391,7 +401,7 @@ func (up *UserProfile) Validate(v structure.Validator) { up.Birthday.Validate(v.WithReference("birthday")) up.DiagnosisDate.Validate(v.WithReference("diagnosisDate")) if up.DiagnosisType != "" { - v.String("diagnosisType", &up.DiagnosisType).OneOf(diabetesTypes...) + v.String("diagnosisType", &up.DiagnosisType).OneOf(DiabetesTypes...) } } @@ -446,7 +456,7 @@ func (pp *LegacyPatientProfile) Validate(v structure.Validator) { v.String("mrn", &pp.MRN).LengthLessThanOrEqualTo(maxProfileFieldLen) if pp.DiagnosisType != "" { - v.String("diagnosisType", &pp.DiagnosisType).OneOf(diabetesTypes...) + v.String("diagnosisType", &pp.DiagnosisType).OneOf(DiabetesTypes...) } } From e4f607e4dce15497fa82a6136b39de4f9cb7bac4 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 31 Jul 2024 16:15:09 -0700 Subject: [PATCH 53/62] Migrate diagnosisType. --- user/profile.go | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/user/profile.go b/user/profile.go index 91d6435c7..bed237880 100644 --- a/user/profile.go +++ b/user/profile.go @@ -51,18 +51,19 @@ type Date string // somewhat redundantly as UserProfile instead of Profile because there already // exists a type Profile in this package. type UserProfile struct { - FullName string `json:"fullName,omitempty"` - Birthday Date `json:"birthday,omitempty"` - DiagnosisDate Date `json:"diagnosisDate,omitempty"` - DiagnosisType string `json:"diagnosisType,omitempty"` - TargetDevices []string `json:"targetDevices,omitempty"` - TargetTimezone string `json:"targetTimezone,omitempty"` - About string `json:"about,omitempty"` - MRN string `json:"mrn,omitempty"` - Custodian *Custodian `json:"custodian,omitempty"` - Clinic *ClinicProfile `json:"-"` // This is not returned to users in any new user profile routes but needs to be saved as it's not known where the old seagull value.profile.clinic is read - BiologicalSex string `json:"biologicalSex,omitempty"` - Email string `json:"-"` // This is used when returning profiles in the legacy format. It is not stored in the profile, but is populated from the keycloak username and not returned in the new profiles route. + FullName string `json:"fullName,omitempty"` + Birthday Date `json:"birthday,omitempty"` + DiagnosisDate Date `json:"diagnosisDate,omitempty"` + DiagnosisType string `json:"diagnosisType,omitempty"` + TargetDevices []string `json:"targetDevices,omitempty"` + TargetTimezone string `json:"targetTimezone,omitempty"` + About string `json:"about,omitempty"` + MRN string `json:"mrn,omitempty"` + BiologicalSex string `json:"biologicalSex,omitempty"` + + Custodian *Custodian `json:"custodian,omitempty"` + Clinic *ClinicProfile `json:"-"` // This is not returned to users in any new user profile routes but needs to be saved as it's not known where the old seagull value.profile.clinic is read + Email string `json:"-"` // This is used when returning profiles in the legacy format. It is not stored in the profile, but is populated from the keycloak username and not returned in the new profiles route. } type ClinicProfile struct { @@ -97,6 +98,7 @@ func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { legacyProfile.Patient = &LegacyPatientProfile{ Birthday: up.Birthday, DiagnosisDate: up.DiagnosisDate, + DiagnosisType: up.DiagnosisType, TargetDevices: up.TargetDevices, TargetTimezone: up.TargetTimezone, About: up.About, @@ -159,6 +161,7 @@ func (p *LegacyUserProfile) ToUserProfile() *UserProfile { } up.Birthday = p.Patient.Birthday up.DiagnosisDate = p.Patient.DiagnosisDate + up.DiagnosisType = p.Patient.DiagnosisType up.TargetDevices = p.Patient.TargetDevices up.TargetTimezone = p.Patient.TargetTimezone up.About = p.Patient.About From f4316d641979f61c890ef6539486199a547a11f4 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 6 Aug 2024 08:23:54 -0700 Subject: [PATCH 54/62] Remove email field from clinic as confirmed only a few fake clinic profiles had them. --- user/keycloak/user_accessor.go | 2 +- user/profile.go | 9 --------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/user/keycloak/user_accessor.go b/user/keycloak/user_accessor.go index 0e5532525..dbd388875 100644 --- a/user/keycloak/user_accessor.go +++ b/user/keycloak/user_accessor.go @@ -44,7 +44,7 @@ func (m *keycloakUserAccessor) FindUser(ctx context.Context, user *userLib.User) } else if err == nil && keycloakUser != nil { return newUserFromKeycloakUser(keycloakUser), nil } - // expected all users to already be migrated(?) + // All users should be migrated into keycloak by the time this code is released. return nil, userLib.ErrUserNotMigrated } diff --git a/user/profile.go b/user/profile.go index bed237880..e0b0ed1b9 100644 --- a/user/profile.go +++ b/user/profile.go @@ -67,7 +67,6 @@ type UserProfile struct { } type ClinicProfile struct { - // Email string `json:"email,omitempty"` // only found a handle of fake profiles w/ email in clinic, but will confirm doesn't exist Name string `json:"name,omitempty"` Role string `json:"role,omitempty"` Telephone string `json:"telephone,omitempty"` @@ -235,9 +234,6 @@ func (up *UserProfile) ToAttributes() map[string][]string { } if up.Clinic != nil { - // if up.Clinic.Email != "" { - // addAttribute(attributes, "clinic_email", up.Clinic.Email) - // } if up.Clinic.Name != "" { addAttribute(attributes, "clinic_name", up.Clinic.Name) } @@ -304,10 +300,6 @@ func ProfileFromAttributes(username string, attributes map[string][]string) (pro var clinicProfile ClinicProfile var clinicOK bool - // if val := getAttribute(attributes, "clinic_email"); val != "" { - // clinicProfile.Email = val - // clinicOK = true - // } if val := getAttribute(attributes, "clinic_name"); val != "" { clinicProfile.Name = val clinicOK = true @@ -424,7 +416,6 @@ func (up *UserProfile) Normalize(normalizer structure.Normalizer) { } func (p *ClinicProfile) Normalize(normalizer structure.Normalizer) { - // p.Email = strings.TrimSpace(p.Email) p.Name = strings.TrimSpace(p.Name) p.Role = strings.TrimSpace(p.Role) p.Telephone = strings.TrimSpace(p.Telephone) From 83222e2fa81c045123204b37938929c373df56b0 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 7 Aug 2024 12:38:04 -0700 Subject: [PATCH 55/62] Use correct FullName in case of fake children. --- user/profile.go | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/user/profile.go b/user/profile.go index e0b0ed1b9..f40b86703 100644 --- a/user/profile.go +++ b/user/profile.go @@ -51,7 +51,7 @@ type Date string // somewhat redundantly as UserProfile instead of Profile because there already // exists a type Profile in this package. type UserProfile struct { - FullName string `json:"fullName,omitempty"` + FullName string `json:"fullName,omitempty"` // Name of the patient, fake child, or clinician Birthday Date `json:"birthday,omitempty"` DiagnosisDate Date `json:"diagnosisDate,omitempty"` DiagnosisType string `json:"diagnosisType,omitempty"` @@ -67,7 +67,7 @@ type UserProfile struct { } type ClinicProfile struct { - Name string `json:"name,omitempty"` + Name string `json:"name,omitempty"` // Refers to the name of the clinic, not clinician Role string `json:"role,omitempty"` Telephone string `json:"telephone,omitempty"` NPI string `json:"npi,omitempty"` @@ -141,21 +141,22 @@ func (up *UserProfile) ClearPatientInfo() *UserProfile { } func (p *LegacyUserProfile) ToUserProfile() *UserProfile { - fullName := p.FullName up := &UserProfile{ - Clinic: p.Clinic, + FullName: p.FullName, + Clinic: p.Clinic, } if p.Patient != nil { + // The new profiles FullName refer to the true "owner" of the profile - which + // may be the "fake child" so set it to the FullName within the Patient Object if it exists. up.FullName = cmp.Or(pointer.ToString(p.Patient.FullName), p.FullName) - // Only users with isOtherPerson set has a patient.fullName field set so - // they have a custodian. + // Only users with isOtherPerson set has a patient.fullName field set so these users + // also have a custodian if p.Patient.IsOtherPerson { // Handle the few cases where one of either the fake child fullName or the profile fullName is empty (neither are both empty) - if fullName == "" { - fullName = pointer.ToString(p.Patient.FullName) - } + // The custodian's name would be the the profile.fullName field in the legacy + // format. But there are few cases where it's empty so set it to profile.patient.fullName if it exists up.Custodian = &Custodian{ - FullName: cmp.Or(p.FullName, fullName), + FullName: cmp.Or(p.FullName, pointer.ToString(p.Patient.FullName)), } } up.Birthday = p.Patient.Birthday @@ -167,7 +168,6 @@ func (p *LegacyUserProfile) ToUserProfile() *UserProfile { up.MRN = p.Patient.MRN up.BiologicalSex = p.Patient.BiologicalSex } - up.FullName = fullName return up } From 621062b5d0a478510ecae6380f5d6361356e4200 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Thu, 8 Aug 2024 07:15:27 -0700 Subject: [PATCH 56/62] Handle certain incorrect types in legacy seagull profile. --- user/profile.go | 47 +++++++++++++++++++++++++++++++++++++++++++++-- user/user.go | 1 + 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/user/profile.go b/user/profile.go index f40b86703..6884e23a3 100644 --- a/user/profile.go +++ b/user/profile.go @@ -2,6 +2,8 @@ package user import ( "cmp" + "encoding/json" + "regexp" "slices" "strings" "time" @@ -12,6 +14,10 @@ import ( type migrationStatus int +var ( + nonLetters = regexp.MustCompile(`[^A-Za-z]`) +) + const ( migrationUnmigrated migrationStatus = iota migrationCompleted @@ -79,7 +85,7 @@ type Custodian struct { // IsPatientProfile returns true if the profile is associated with a patient - note that this is not mutually exclusive w/ a clinician, as some users have both func (up *UserProfile) IsPatientProfile() bool { - return up.DiagnosisDate != "" || up.DiagnosisType != "" || len(up.TargetDevices) > 0 || up.MRN != "" || up.About != "" || up.BiologicalSex != "" || up.Birthday != "" || up.Custodian != nil + return up.DiagnosisDate != "" || up.DiagnosisType != "" || len(up.TargetDevices) > 0 || up.MRN != "" || up.About != "" || up.BiologicalSex != "" || up.Birthday != "" || up.Custodian != nil || up.Clinic == nil } // IsClinicianProfile returns true if the profile is associated with a clinician - note that this is not mutually exclusive w/ a patient, as some users have both @@ -190,7 +196,7 @@ type LegacyPatientProfile struct { TargetDevices []string `json:"targetDevices,omitempty"` TargetTimezone string `json:"targetTimezone,omitempty"` About string `json:"about,omitempty"` - IsOtherPerson bool `json:"isOtherPerson,omitempty"` + IsOtherPerson jsonBool `json:"isOtherPerson,omitempty"` MRN string `json:"mrn,omitempty"` BiologicalSex string `json:"biologicalSex,omitempty"` // The Email and Emails fields are legacy properties that will be populated from the keycloak user if the profile is finished migrating, otherwise from the seagull collection @@ -198,6 +204,43 @@ type LegacyPatientProfile struct { Emails []string `json:"emails,omitempty"` } +func (l *LegacyPatientProfile) UnmarshalJSON(data []byte) error { + if len(data) == 0 || string(data) == "null" { + return nil + } + + // Handle some old seagull fields that contained an empty string for the patient field, return an empty object in that case + dataStr := string(data) + if dataStr == `""` { + return nil + } + + // Create a new type definition w/ same underlying type as + // LegacyPatientProfile so we can use the "default" UnmarshalJSON of + // LegacyPatientProfile as if it didn't implement json.Unmarshaler (to + // prevent an infinite loop) + type tempType LegacyPatientProfile + return json.Unmarshal(data, (*tempType)(l)) +} + +// jsonBool is a bool type that can be marshaled from string fields - this is only in support of legacy seagull profiles. +// Once all seagull profiles have been migrated over, LegacyProfile along w/ jsonBool will be removed +type jsonBool bool + +func (b *jsonBool) UnmarshalJSON(data []byte) error { + if len(data) == 0 || string(data) == "null" { + return nil + } + dataStr := string(data) + boolStr := strings.ToLower(nonLetters.ReplaceAllString(dataStr, "")) + if boolStr == "true" { + *b = true + } else { + *b = false + } + return nil +} + func (up *UserProfile) ToAttributes() map[string][]string { attributes := map[string][]string{} diff --git a/user/user.go b/user/user.go index 275453cdc..b02868b68 100644 --- a/user/user.go +++ b/user/user.go @@ -179,6 +179,7 @@ func ValidateID(value string) error { var idExpression = regexp.MustCompile(`^([0-9a-f]{10}|[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12})$`) +// IsValidUserID return true if the string is in a human readable uuid hex 8-4-4-4-12 format or legacy alphanumeric 10 characters func IsValidUserID(id string) bool { ok, _ := regexp.MatchString(`^([a-fA-F0-9]{10})$|^([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})$`, id) return ok From 2908e3cbaf5303c28126257b85c0fb5bd97a174a Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 12 Aug 2024 06:53:11 -0700 Subject: [PATCH 57/62] Fix some logic and tests for profiles. Updates --- auth/service/api/v1/router_test.go | 103 +++++++++++++++- auth/service/test/service.go | 31 ++++- auth/test/mock.go | 1 - task/test/mock.go | 1 - user/fallback_user_accessor.go | 6 +- user/keycloak/client.go | 1 - user/legacy_raw_seagull_profile.go | 10 +- user/profile.go | 10 +- user/profile_test.go | 106 ++++++++++++++++ user/user_accessor.go | 2 + user/user_mock.go | 189 +++++++++++++++++++++++++++++ 11 files changed, 439 insertions(+), 21 deletions(-) create mode 100644 user/profile_test.go create mode 100644 user/user_mock.go diff --git a/auth/service/api/v1/router_test.go b/auth/service/api/v1/router_test.go index 0d7b528ae..0c872ffb9 100644 --- a/auth/service/api/v1/router_test.go +++ b/auth/service/api/v1/router_test.go @@ -1,20 +1,40 @@ package v1_test import ( + "context" + "encoding/json" + "fmt" + "net/http" + + gomock "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/ant0ine/go-json-rest/rest" authServiceApiV1 "github.com/tidepool-org/platform/auth/service/api/v1" serviceTest "github.com/tidepool-org/platform/auth/service/test" + authTest "github.com/tidepool-org/platform/auth/test" "github.com/tidepool-org/platform/errors" errorsTest "github.com/tidepool-org/platform/errors/test" + "github.com/tidepool-org/platform/log" + logTest "github.com/tidepool-org/platform/log/test" + "github.com/tidepool-org/platform/pointer" + "github.com/tidepool-org/platform/request" + testRest "github.com/tidepool-org/platform/test/rest" + "github.com/tidepool-org/platform/user" + userTest "github.com/tidepool-org/platform/user/test" ) var _ = Describe("Router", func() { + var ctrl *gomock.Controller var svc *serviceTest.Service + var userAccessor *user.MockUserAccessor + var profileAccessor *user.MockUserProfileAccessor BeforeEach(func() { - svc = serviceTest.NewService() + ctrl = gomock.NewController(GinkgoT()) + svc, userAccessor, profileAccessor = serviceTest.NewMockedService(ctrl) }) Context("NewRouter", func() { @@ -45,6 +65,87 @@ var _ = Describe("Router", func() { It("returns the expected routes", func() { Expect(rtr.Routes()).ToNot(BeEmpty()) }) + + Context("Profile", func() { + var res *testRest.ResponseWriter + var req *rest.Request + var ctx context.Context + var handlerFunc rest.HandlerFunc + var userID string + var details request.AuthDetails + var userProfile *user.UserProfile + var userDetails *user.User + + JustBeforeEach(func() { + app, err := rest.MakeRouter(rtr.Routes()...) + Expect(err).ToNot(HaveOccurred()) + Expect(app).ToNot(BeNil()) + handlerFunc = app.AppFunc() + }) + + BeforeEach(func() { + userID = userTest.RandomID() + res = testRest.NewResponseWriter() + res.HeaderOutput = &http.Header{} + req = testRest.NewRequest() + ctx = log.NewContextWithLogger(req.Context(), logTest.NewLogger()) + req.Request = req.WithContext(ctx) + + userProfile = &user.UserProfile{ + FullName: "Some User Profile", + Birthday: "2001-02-03", + DiagnosisDate: "2002-03-04", + About: "About me", + MRN: "11223344", + } + userDetails = &user.User{ + UserID: pointer.FromString("abcdefghij"), + Username: pointer.FromString("dev@tidepool.org"), + } + + profileAccessor.EXPECT().FindUserProfile(gomock.Any(), userID). + Return(userProfile, nil).AnyTimes() + + userAccessor.EXPECT().FindUserById(gomock.Any(), userID). + Return(userDetails, nil).AnyTimes() + }) + + Context("GetProfile", func() { + BeforeEach(func() { + req.Method = http.MethodGet + req.URL.Path = fmt.Sprintf("/v1/users/%s/profile", userID) + }) + BeforeEach(func() { + res.WriteOutputs = []testRest.WriteOutput{{BytesWritten: 0, Error: nil}} + }) + AfterEach(func() { + res.AssertOutputsEmpty() + }) + Context("as service", func() { + BeforeEach(func() { + details = request.NewAuthDetails(request.MethodServiceSecret, "", authTest.NewSessionToken()) + req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) + }) + + It("it succeeds if the profile exists", func() { + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(userProfile)).To(MatchJSON(res.WriteInputs[0])) + }) + }) + Context("as user", func() { + BeforeEach(func() { + details = request.NewAuthDetails(request.MethodSessionToken, userID, authTest.NewSessionToken()) + req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) + }) + + It("retrieves user's profile if this is a service to service request", func() { + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + }) + }) + }) + }) }) }) }) diff --git a/auth/service/test/service.go b/auth/service/test/service.go index 61f06b943..4ad3ddbe6 100644 --- a/auth/service/test/service.go +++ b/auth/service/test/service.go @@ -3,6 +3,7 @@ package test import ( "context" + "github.com/golang/mock/gomock" "github.com/tidepool-org/platform/apple" "github.com/tidepool-org/platform/user" @@ -34,6 +35,8 @@ type Service struct { StatusInvocations int StatusOutputs []*service.Status confirmationClient confirmationClient.ClientWithResponsesInterface + userAccessor user.UserAccessor + profileAccessor user.UserProfileAccessor } func NewService() *Service { @@ -45,6 +48,22 @@ func NewService() *Service { } } +// NewMockedService uses a combination of the "old" style manual stub / fakes / +// mocks and newer gomocks for convenience so that the current code doesn't +// have to be refactored too much +func NewMockedService(ctrl *gomock.Controller) (svc *Service, userAccessor *user.MockUserAccessor, profileAccessor *user.MockUserProfileAccessor) { + userAccessor = user.NewMockUserAccessor(ctrl) + profileAccessor = user.NewMockUserProfileAccessor(ctrl) + return &Service{ + Service: serviceTest.NewService(), + AuthStoreImpl: authStoreTest.NewStore(), + ProviderFactoryImpl: providerTest.NewFactory(), + TaskClientImpl: taskTest.NewClient(), + userAccessor: userAccessor, + profileAccessor: profileAccessor, + }, userAccessor, profileAccessor +} + func (s *Service) Domain() string { s.DomainInvocations++ @@ -81,10 +100,6 @@ func (s *Service) DeviceCheck() apple.DeviceCheck { return nil } -func (s *Service) UserAccessor() user.UserAccessor { - return nil -} - func (s *Service) PermissionsClient() permission.Client { return nil } @@ -106,3 +121,11 @@ func (s *Service) Expectations() { s.TaskClientImpl.Expectations() gomega.Expect(s.StatusOutputs).To(gomega.BeEmpty()) } + +func (s *Service) UserAccessor() user.UserAccessor { + return s.userAccessor +} + +func (s *Service) UserProfileAccessor() user.UserProfileAccessor { + return s.profileAccessor +} diff --git a/auth/test/mock.go b/auth/test/mock.go index 055a6a994..6ee9f5383 100644 --- a/auth/test/mock.go +++ b/auth/test/mock.go @@ -9,7 +9,6 @@ import ( reflect "reflect" gomock "github.com/golang/mock/gomock" - auth "github.com/tidepool-org/platform/auth" page "github.com/tidepool-org/platform/page" request "github.com/tidepool-org/platform/request" diff --git a/task/test/mock.go b/task/test/mock.go index 7c06ef90b..c70209542 100644 --- a/task/test/mock.go +++ b/task/test/mock.go @@ -9,7 +9,6 @@ import ( reflect "reflect" gomock "github.com/golang/mock/gomock" - page "github.com/tidepool-org/platform/page" task "github.com/tidepool-org/platform/task" ) diff --git a/user/fallback_user_accessor.go b/user/fallback_user_accessor.go index 8f287d5dd..2af6a34f0 100644 --- a/user/fallback_user_accessor.go +++ b/user/fallback_user_accessor.go @@ -29,7 +29,7 @@ func (f *FallbackLegacyUserAccessor) FindUserProfile(ctx context.Context, id str if err != nil && !errors.Is(err, ErrUserProfileNotFound) { return nil, err } - if seagullProfile != nil && seagullProfile.MigrationStatus == migrationUnmigrated { + if seagullProfile != nil && seagullProfile.MigrationStatus == MigrationUnmigrated { return seagullProfile.ToUserProfile(), nil } profile, err := f.accessor.FindUserProfile(ctx, id) @@ -66,7 +66,7 @@ func (f *FallbackLegacyUserAccessor) updateUserProfile(ctx context.Context, id s return err } // An unmigrated profile should be returned until the profile has been migrated - if seagullProfile != nil && seagullProfile.MigrationStatus == migrationUnmigrated { + if seagullProfile != nil && seagullProfile.MigrationStatus == MigrationUnmigrated { // During an attempt to update a seagull profile, the migration process may have started in b/t the previous call and the attempt to update. // In this we will retry as the migration time. return f.legacy.UpdateUserProfile(ctx, id, profile) @@ -79,7 +79,7 @@ func (f *FallbackLegacyUserAccessor) DeleteUserProfile(ctx context.Context, id s if err != nil && !errors.Is(err, ErrUserProfileNotFound) { return err } - if seagullProfile != nil && seagullProfile.MigrationStatus == migrationUnmigrated { + if seagullProfile != nil && seagullProfile.MigrationStatus == MigrationUnmigrated { return f.legacy.DeleteUserProfile(ctx, id) } return f.accessor.DeleteUserProfile(ctx, id) diff --git a/user/keycloak/client.go b/user/keycloak/client.go index 6ea3a10a9..aed500c05 100644 --- a/user/keycloak/client.go +++ b/user/keycloak/client.go @@ -99,7 +99,6 @@ func (c *keycloakClient) doLogin(ctx context.Context, clientId, clientSecret, us func (c *keycloakClient) GetBackendServiceToken(ctx context.Context) (*oauth2.Token, error) { jwt, err := c.keycloak.LoginClient(ctx, c.cfg.BackendClientID, c.cfg.BackendClientSecret, c.cfg.Realm) - fmt.Println("GetBackendServiceToken LoginClient", c.cfg.BackendClientID, c.cfg.BackendClientSecret, c.cfg.Realm) if err != nil { return nil, err } diff --git a/user/legacy_raw_seagull_profile.go b/user/legacy_raw_seagull_profile.go index 3bd3c333a..6b9996f4e 100644 --- a/user/legacy_raw_seagull_profile.go +++ b/user/legacy_raw_seagull_profile.go @@ -131,17 +131,17 @@ func MarshalThenUnmarshal(src any, dst *LegacyUserProfile) error { func (doc *LegacySeagullDocument) MigrationStatus() migrationStatus { if doc.MigrationStart != nil && doc.MigrationEnd != nil { - return migrationCompleted + return MigrationCompleted } if doc.MigrationStart != nil && doc.MigrationEnd == nil && doc.MigrationError == nil { - return migrationInProgress + return MigrationInProgress } if doc.MigrationStart != nil && doc.MigrationError != nil { - return migrationError + return MigrationError } - return migrationUnmigrated + return MigrationUnmigrated } func (doc *LegacySeagullDocument) IsMigrating() bool { - return doc.MigrationStatus() != migrationUnmigrated + return doc.MigrationStatus() != MigrationUnmigrated } diff --git a/user/profile.go b/user/profile.go index 6884e23a3..f51feffea 100644 --- a/user/profile.go +++ b/user/profile.go @@ -19,10 +19,10 @@ var ( ) const ( - migrationUnmigrated migrationStatus = iota - migrationCompleted - migrationInProgress - migrationError + MigrationUnmigrated migrationStatus = iota + MigrationCompleted + MigrationInProgress + MigrationError maxProfileFieldLen = 256 ) @@ -96,7 +96,7 @@ func (up *UserProfile) IsClinicianProfile() bool { func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { legacyProfile := &LegacyUserProfile{ FullName: up.FullName, - MigrationStatus: migrationCompleted, // If we have a non legacy UserProfile, then that means the legacy version has been migrated from seagull (or it never existed which is equivalent for the new user profile purposes) + MigrationStatus: MigrationCompleted, // If we have a non legacy UserProfile, then that means the legacy version has been migrated from seagull (or it never existed which is equivalent for the new user profile purposes) } if up.IsPatientProfile() { diff --git a/user/profile_test.go b/user/profile_test.go new file mode 100644 index 000000000..58428e169 --- /dev/null +++ b/user/profile_test.go @@ -0,0 +1,106 @@ +package user_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/tidepool-org/platform/pointer" + "github.com/tidepool-org/platform/user" +) + +var _ = Describe("User", func() { + Context("LegacySeagullDocument", func() { + Context("AddProfileToSeagullValue", func() { + It("Preserves non profile seagull fields such as settings, etc", func() { + seagullValueBefore := `{ + "profile": {"fullName": "something"}, + "preferences": { "clickedUploaderBannerTime": "2023-01-10T10:11:12-08:00" }, + "settings": { "bgTarget": { "high": 160, "low": 60 }, "units": { "bg": "mg/dL" } } + }` + addedProfile := &user.LegacyUserProfile{ + FullName: "Some Name", + Patient: &user.LegacyPatientProfile{ + Birthday: "2000-03-04", + DiagnosisDate: "2001-03-05", + About: "About me", + }, + MigrationStatus: user.MigrationCompleted, + } + expectedNewSeagullValue := `{ + "profile": {"fullName": "Some Name", "patient": { "birthday": "2000-03-04", "diagnosisDate": "2001-03-05", "about": "About me"}}, + "preferences": { "clickedUploaderBannerTime": "2023-01-10T10:11:12-08:00" }, + "settings": { "bgTarget": { "high": 160, "low": 60 }, "units": { "bg": "mg/dL" } }}` + + newValue, err := user.AddProfileToSeagullValue(seagullValueBefore, addedProfile) + Expect(err).ShouldNot(HaveOccurred()) + Expect(newValue).To(MatchJSON(expectedNewSeagullValue)) + }) + }) + }) + + Context("Profile", func() { + DescribeTable("ToLegacyProfile", + func(profile *user.UserProfile, legacyProfile *user.LegacyUserProfile) { + Expect(profile.ToLegacyProfile()).To(BeComparableTo(legacyProfile)) + }, + Entry("Regular patient", &user.UserProfile{ + FullName: "Bob", + Birthday: "2000-02-03", + About: "About me", + MRN: "1112222", + TargetDevices: []string{"SomeDevice900"}, + TargetTimezone: "UTC", + }, + &user.LegacyUserProfile{ + FullName: "Bob", + Patient: &user.LegacyPatientProfile{ + Birthday: "2000-02-03", + About: "About me", + MRN: "1112222", + TargetDevices: []string{"SomeDevice900"}, + TargetTimezone: "UTC", + }, + MigrationStatus: user.MigrationCompleted, + }), + Entry("Fake child", &user.UserProfile{ + FullName: "Child Name", + Birthday: "2000-02-03", + DiagnosisDate: "2001-02-03", + About: "About me", + Custodian: &user.Custodian{ + FullName: "Parent Name", + }, + }, + &user.LegacyUserProfile{ + FullName: "Parent Name", + Patient: &user.LegacyPatientProfile{ + FullName: pointer.FromString("Child Name"), + Birthday: "2000-02-03", + DiagnosisDate: "2001-02-03", + About: "About me", + IsOtherPerson: true, + }, + MigrationStatus: user.MigrationCompleted, + }), + Entry("Clinic", &user.UserProfile{ + FullName: "Clinician Name", + Clinic: &user.ClinicProfile{ + Name: "Clinic Name", + Role: "Some Role", + Telephone: "123-123-3456", + NPI: "1234567890", + }, + }, + &user.LegacyUserProfile{ + FullName: "Clinician Name", + Clinic: &user.ClinicProfile{ + Name: "Clinic Name", + Role: "Some Role", + Telephone: "123-123-3456", + NPI: "1234567890", + }, + MigrationStatus: user.MigrationCompleted, + }), + ) + }) +}) diff --git a/user/user_accessor.go b/user/user_accessor.go index ffab4c474..e820a52b5 100644 --- a/user/user_accessor.go +++ b/user/user_accessor.go @@ -13,6 +13,8 @@ const ( TimestampFormat = "2006-01-02T15:04:05-07:00" ) +//go:generate mockgen -build_flags=--mod=mod -destination=./user_mock.go -package=user . UserProfileAccessor,UserAccessor + var ( ShorelineManagedRoles = map[string]struct{}{"patient": {}, "clinic": {}, "clinician": {}, "custodial_account": {}} diff --git a/user/user_mock.go b/user/user_mock.go new file mode 100644 index 000000000..b1e6a1df5 --- /dev/null +++ b/user/user_mock.go @@ -0,0 +1,189 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/tidepool-org/platform/user (interfaces: UserProfileAccessor,UserAccessor) + +// Package user is a generated GoMock package. +package user + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockUserProfileAccessor is a mock of UserProfileAccessor interface. +type MockUserProfileAccessor struct { + ctrl *gomock.Controller + recorder *MockUserProfileAccessorMockRecorder +} + +// MockUserProfileAccessorMockRecorder is the mock recorder for MockUserProfileAccessor. +type MockUserProfileAccessorMockRecorder struct { + mock *MockUserProfileAccessor +} + +// NewMockUserProfileAccessor creates a new mock instance. +func NewMockUserProfileAccessor(ctrl *gomock.Controller) *MockUserProfileAccessor { + mock := &MockUserProfileAccessor{ctrl: ctrl} + mock.recorder = &MockUserProfileAccessorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockUserProfileAccessor) EXPECT() *MockUserProfileAccessorMockRecorder { + return m.recorder +} + +// DeleteUserProfile mocks base method. +func (m *MockUserProfileAccessor) DeleteUserProfile(arg0 context.Context, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteUserProfile", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteUserProfile indicates an expected call of DeleteUserProfile. +func (mr *MockUserProfileAccessorMockRecorder) DeleteUserProfile(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserProfile", reflect.TypeOf((*MockUserProfileAccessor)(nil).DeleteUserProfile), arg0, arg1) +} + +// FindUserProfile mocks base method. +func (m *MockUserProfileAccessor) FindUserProfile(arg0 context.Context, arg1 string) (*UserProfile, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindUserProfile", arg0, arg1) + ret0, _ := ret[0].(*UserProfile) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindUserProfile indicates an expected call of FindUserProfile. +func (mr *MockUserProfileAccessorMockRecorder) FindUserProfile(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindUserProfile", reflect.TypeOf((*MockUserProfileAccessor)(nil).FindUserProfile), arg0, arg1) +} + +// UpdateUserProfile mocks base method. +func (m *MockUserProfileAccessor) UpdateUserProfile(arg0 context.Context, arg1 string, arg2 *UserProfile) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserProfile", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateUserProfile indicates an expected call of UpdateUserProfile. +func (mr *MockUserProfileAccessorMockRecorder) UpdateUserProfile(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserProfile", reflect.TypeOf((*MockUserProfileAccessor)(nil).UpdateUserProfile), arg0, arg1, arg2) +} + +// MockUserAccessor is a mock of UserAccessor interface. +type MockUserAccessor struct { + ctrl *gomock.Controller + recorder *MockUserAccessorMockRecorder +} + +// MockUserAccessorMockRecorder is the mock recorder for MockUserAccessor. +type MockUserAccessorMockRecorder struct { + mock *MockUserAccessor +} + +// NewMockUserAccessor creates a new mock instance. +func NewMockUserAccessor(ctrl *gomock.Controller) *MockUserAccessor { + mock := &MockUserAccessor{ctrl: ctrl} + mock.recorder = &MockUserAccessorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockUserAccessor) EXPECT() *MockUserAccessorMockRecorder { + return m.recorder +} + +// DeleteUserProfile mocks base method. +func (m *MockUserAccessor) DeleteUserProfile(arg0 context.Context, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteUserProfile", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteUserProfile indicates an expected call of DeleteUserProfile. +func (mr *MockUserAccessorMockRecorder) DeleteUserProfile(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserProfile", reflect.TypeOf((*MockUserAccessor)(nil).DeleteUserProfile), arg0, arg1) +} + +// FindUser mocks base method. +func (m *MockUserAccessor) FindUser(arg0 context.Context, arg1 *User) (*User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindUser", arg0, arg1) + ret0, _ := ret[0].(*User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindUser indicates an expected call of FindUser. +func (mr *MockUserAccessorMockRecorder) FindUser(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindUser", reflect.TypeOf((*MockUserAccessor)(nil).FindUser), arg0, arg1) +} + +// FindUserById mocks base method. +func (m *MockUserAccessor) FindUserById(arg0 context.Context, arg1 string) (*User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindUserById", arg0, arg1) + ret0, _ := ret[0].(*User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindUserById indicates an expected call of FindUserById. +func (mr *MockUserAccessorMockRecorder) FindUserById(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindUserById", reflect.TypeOf((*MockUserAccessor)(nil).FindUserById), arg0, arg1) +} + +// FindUserProfile mocks base method. +func (m *MockUserAccessor) FindUserProfile(arg0 context.Context, arg1 string) (*UserProfile, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindUserProfile", arg0, arg1) + ret0, _ := ret[0].(*UserProfile) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindUserProfile indicates an expected call of FindUserProfile. +func (mr *MockUserAccessorMockRecorder) FindUserProfile(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindUserProfile", reflect.TypeOf((*MockUserAccessor)(nil).FindUserProfile), arg0, arg1) +} + +// FindUsersWithIds mocks base method. +func (m *MockUserAccessor) FindUsersWithIds(arg0 context.Context, arg1 []string) ([]*User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindUsersWithIds", arg0, arg1) + ret0, _ := ret[0].([]*User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindUsersWithIds indicates an expected call of FindUsersWithIds. +func (mr *MockUserAccessorMockRecorder) FindUsersWithIds(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindUsersWithIds", reflect.TypeOf((*MockUserAccessor)(nil).FindUsersWithIds), arg0, arg1) +} + +// UpdateUserProfile mocks base method. +func (m *MockUserAccessor) UpdateUserProfile(arg0 context.Context, arg1 string, arg2 *UserProfile) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserProfile", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateUserProfile indicates an expected call of UpdateUserProfile. +func (mr *MockUserAccessorMockRecorder) UpdateUserProfile(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserProfile", reflect.TypeOf((*MockUserAccessor)(nil).UpdateUserProfile), arg0, arg1, arg2) +} From aba0355240ed968ec7a9045656a0a957affe3448 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Mon, 12 Aug 2024 10:28:24 -0700 Subject: [PATCH 58/62] Set max profile field length to equal keycloak < 24 --- user/profile.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user/profile.go b/user/profile.go index f51feffea..49a0197fe 100644 --- a/user/profile.go +++ b/user/profile.go @@ -24,7 +24,7 @@ const ( MigrationInProgress MigrationError - maxProfileFieldLen = 256 + maxProfileFieldLen = 255 ) const ( From 47e7e45e16b46009dbf3781d90c83b1c3851449a Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 14 Aug 2024 07:56:48 -0700 Subject: [PATCH 59/62] Make some profile values pointers so that some legacy migration profiles null fields work properly w/o changes. --- auth/service/api/v1/profile.go | 23 ++++++++++- auth/service/api/v1/router_test.go | 3 +- user/profile.go | 62 ++++++++++++++++++------------ 3 files changed, 60 insertions(+), 28 deletions(-) diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index 18ad54a63..668c26577 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -198,7 +198,7 @@ func (r *Router) UpdateLegacyProfile(res rest.ResponseWriter, req *rest.Request) responder.Error(http.StatusBadRequest, err) return } - r.updateProfile(res, req, profile.ToUserProfile()) + r.updateLegacyProfile(res, req, profile) } func (r *Router) updateProfile(res rest.ResponseWriter, req *rest.Request, profile *user.UserProfile) { @@ -217,7 +217,26 @@ func (r *Router) updateProfile(res rest.ResponseWriter, req *rest.Request, profi r.handleProfileErr(responder, err) return } - responder.Empty(http.StatusOK) + responder.Data(http.StatusOK, profile) +} + +func (r *Router) updateLegacyProfile(res rest.ResponseWriter, req *rest.Request, profile *user.LegacyUserProfile) { + responder := request.MustNewResponder(res, req) + ctx := req.Context() + userID := req.PathParam("userId") + if err := structValidator.New().Validate(profile); err != nil { + responder.Error(http.StatusBadRequest, err) + return + } + if r.handledUserNotExists(ctx, responder, userID) { + return + } + // Once seagull migration is complete, we can use r.UserAccessor().UpdateUserProfile. + if err := r.UserProfileAccessor().UpdateUserProfile(ctx, userID, profile.ToUserProfile()); err != nil { + r.handleProfileErr(responder, err) + return + } + responder.Data(http.StatusOK, profile) } func (r *Router) DeleteProfile(res rest.ResponseWriter, req *rest.Request) { diff --git a/auth/service/api/v1/router_test.go b/auth/service/api/v1/router_test.go index 0c872ffb9..cb9d934df 100644 --- a/auth/service/api/v1/router_test.go +++ b/auth/service/api/v1/router_test.go @@ -139,9 +139,10 @@ var _ = Describe("Router", func() { req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) }) - It("retrieves user's profile if this is a service to service request", func() { + It("retrieves user's own profile", func() { handlerFunc(res, req) Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(userProfile)).To(MatchJSON(res.WriteInputs[0])) }) }) }) diff --git a/user/profile.go b/user/profile.go index 49a0197fe..05a84b092 100644 --- a/user/profile.go +++ b/user/profile.go @@ -73,10 +73,10 @@ type UserProfile struct { } type ClinicProfile struct { - Name string `json:"name,omitempty"` // Refers to the name of the clinic, not clinician - Role string `json:"role,omitempty"` - Telephone string `json:"telephone,omitempty"` - NPI string `json:"npi,omitempty"` + Name *string `json:"name,omitempty"` // Refers to the name of the clinic, not clinician + Role *string `json:"role,omitempty"` + Telephone *string `json:"telephone,omitempty"` + NPI *string `json:"npi,omitempty"` } type Custodian struct { @@ -105,7 +105,7 @@ func (up *UserProfile) ToLegacyProfile() *LegacyUserProfile { DiagnosisDate: up.DiagnosisDate, DiagnosisType: up.DiagnosisType, TargetDevices: up.TargetDevices, - TargetTimezone: up.TargetTimezone, + TargetTimezone: pointer.FromString(up.TargetTimezone), About: up.About, MRN: up.MRN, BiologicalSex: up.BiologicalSex, @@ -169,7 +169,7 @@ func (p *LegacyUserProfile) ToUserProfile() *UserProfile { up.DiagnosisDate = p.Patient.DiagnosisDate up.DiagnosisType = p.Patient.DiagnosisType up.TargetDevices = p.Patient.TargetDevices - up.TargetTimezone = p.Patient.TargetTimezone + up.TargetTimezone = pointer.ToString(p.Patient.TargetTimezone) up.About = p.Patient.About up.MRN = p.Patient.MRN up.BiologicalSex = p.Patient.BiologicalSex @@ -194,7 +194,7 @@ type LegacyPatientProfile struct { DiagnosisDate Date `json:"diagnosisDate,omitempty"` DiagnosisType string `json:"diagnosisType,omitempty"` TargetDevices []string `json:"targetDevices,omitempty"` - TargetTimezone string `json:"targetTimezone,omitempty"` + TargetTimezone *string `json:"targetTimezone,omitempty"` About string `json:"about,omitempty"` IsOtherPerson jsonBool `json:"isOtherPerson,omitempty"` MRN string `json:"mrn,omitempty"` @@ -277,17 +277,17 @@ func (up *UserProfile) ToAttributes() map[string][]string { } if up.Clinic != nil { - if up.Clinic.Name != "" { - addAttribute(attributes, "clinic_name", up.Clinic.Name) + if val := pointer.ToString(up.Clinic.Name); val != "" { + addAttribute(attributes, "clinic_name", val) } - if up.Clinic.Role != "" { - addAttribute(attributes, "clinic_role", up.Clinic.Role) + if val := pointer.ToString(up.Clinic.Role); val != "" { + addAttribute(attributes, "clinic_role", val) } - if up.Clinic.Telephone != "" { - addAttribute(attributes, "clinic_telephone", up.Clinic.Telephone) + if val := pointer.ToString(up.Clinic.Telephone); val != "" { + addAttribute(attributes, "clinic_telephone", val) } - if up.Clinic.NPI != "" { - addAttribute(attributes, "clinic_npi", up.Clinic.NPI) + if val := pointer.ToString(up.Clinic.NPI); val != "" { + addAttribute(attributes, "clinic_npi", val) } } @@ -344,19 +344,19 @@ func ProfileFromAttributes(username string, attributes map[string][]string) (pro var clinicProfile ClinicProfile var clinicOK bool if val := getAttribute(attributes, "clinic_name"); val != "" { - clinicProfile.Name = val + clinicProfile.Name = pointer.FromString(val) clinicOK = true } if val := getAttribute(attributes, "clinic_role"); val != "" { - clinicProfile.Role = val + clinicProfile.Role = pointer.FromString(val) clinicOK = true } if val := getAttribute(attributes, "clinic_telephone"); val != "" { - clinicProfile.Telephone = val + clinicProfile.Telephone = pointer.FromString(val) clinicOK = true } if val := getAttribute(attributes, "clinic_npi"); val != "" { - clinicProfile.NPI = val + clinicProfile.NPI = pointer.FromString(val) clinicOK = true } if clinicOK { @@ -459,10 +459,18 @@ func (up *UserProfile) Normalize(normalizer structure.Normalizer) { } func (p *ClinicProfile) Normalize(normalizer structure.Normalizer) { - p.Name = strings.TrimSpace(p.Name) - p.Role = strings.TrimSpace(p.Role) - p.Telephone = strings.TrimSpace(p.Telephone) - p.NPI = strings.TrimSpace(p.NPI) + if p.Name != nil { + *p.Name = strings.TrimSpace(*p.Name) + } + if p.Role != nil { + *p.Role = strings.TrimSpace(*p.Role) + } + if p.Telephone != nil { + *p.Telephone = strings.TrimSpace(*p.Telephone) + } + if p.NPI != nil { + *p.NPI = strings.TrimSpace(*p.NPI) + } } func (up *LegacyUserProfile) Validate(v structure.Validator) { @@ -488,7 +496,9 @@ func (pp *LegacyPatientProfile) Validate(v structure.Validator) { pp.DiagnosisDate.Validate(v.WithReference("diagnosisDate")) v.String("fullName", pp.FullName).LengthLessThanOrEqualTo(maxProfileFieldLen) - v.String("targetTimezone", &pp.TargetTimezone).LengthLessThanOrEqualTo(maxProfileFieldLen) + if targetTimeZone := pointer.ToString(pp.TargetTimezone); pp.TargetTimezone != nil && targetTimeZone != "" { + v.String("targetTimezone", pp.TargetTimezone).LengthLessThanOrEqualTo(maxProfileFieldLen) + } v.String("about", &pp.About).LengthLessThanOrEqualTo(maxProfileFieldLen) v.String("mrn", &pp.MRN).LengthLessThanOrEqualTo(maxProfileFieldLen) @@ -505,7 +515,9 @@ func (pp *LegacyPatientProfile) Normalize(normalizer structure.Normalizer) { pp.FullName = pointer.FromString(strings.TrimSpace(pointer.ToString(pp.FullName))) } pp.DiagnosisType = strings.TrimSpace(pp.DiagnosisType) - pp.TargetTimezone = strings.TrimSpace(pp.TargetTimezone) + if pp.TargetTimezone != nil { + *pp.TargetTimezone = strings.TrimSpace(*pp.TargetTimezone) + } pp.About = strings.TrimSpace(pp.About) pp.MRN = strings.TrimSpace(pp.MRN) pp.BiologicalSex = strings.TrimSpace(pp.BiologicalSex) From 7bd8a85ab9ea7b5c7dabe68e0b64e1e4c2957b05 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Wed, 14 Aug 2024 08:22:53 -0700 Subject: [PATCH 60/62] Export MaxProfileFieldLen --- user/profile.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/user/profile.go b/user/profile.go index 05a84b092..66498fd6f 100644 --- a/user/profile.go +++ b/user/profile.go @@ -24,7 +24,7 @@ const ( MigrationInProgress MigrationError - maxProfileFieldLen = 255 + MaxProfileFieldLen = 255 ) const ( @@ -429,12 +429,12 @@ func (d *Date) Normalize(normalizer structure.Normalizer) { } func (up *UserProfile) Validate(v structure.Validator) { - v.String("fullName", &up.FullName).LengthLessThanOrEqualTo(maxProfileFieldLen) - v.String("diagnosisType", &up.DiagnosisType).LengthLessThanOrEqualTo(maxProfileFieldLen) - v.String("targetTimezone", &up.TargetTimezone).LengthLessThanOrEqualTo(maxProfileFieldLen) - v.String("about", &up.About).LengthLessThanOrEqualTo(maxProfileFieldLen) - v.String("mrn", &up.MRN).LengthLessThanOrEqualTo(maxProfileFieldLen) - v.String("biologicalSex", &up.BiologicalSex).LengthLessThanOrEqualTo(maxProfileFieldLen) + v.String("fullName", &up.FullName).LengthLessThanOrEqualTo(MaxProfileFieldLen) + v.String("diagnosisType", &up.DiagnosisType).LengthLessThanOrEqualTo(MaxProfileFieldLen) + v.String("targetTimezone", &up.TargetTimezone).LengthLessThanOrEqualTo(MaxProfileFieldLen) + v.String("about", &up.About).LengthLessThanOrEqualTo(MaxProfileFieldLen) + v.String("mrn", &up.MRN).LengthLessThanOrEqualTo(MaxProfileFieldLen) + v.String("biologicalSex", &up.BiologicalSex).LengthLessThanOrEqualTo(MaxProfileFieldLen) up.Birthday.Validate(v.WithReference("birthday")) up.DiagnosisDate.Validate(v.WithReference("diagnosisDate")) @@ -477,7 +477,7 @@ func (up *LegacyUserProfile) Validate(v structure.Validator) { if up.Patient != nil { up.Patient.Validate(v.WithReference("patient")) } - v.String("fullName", &up.FullName).LengthLessThanOrEqualTo(maxProfileFieldLen) + v.String("fullName", &up.FullName).LengthLessThanOrEqualTo(MaxProfileFieldLen) } func (up *LegacyUserProfile) Normalize(normalizer structure.Normalizer) { @@ -495,12 +495,12 @@ func (pp *LegacyPatientProfile) Validate(v structure.Validator) { pp.Birthday.Validate(v.WithReference("birthday")) pp.DiagnosisDate.Validate(v.WithReference("diagnosisDate")) - v.String("fullName", pp.FullName).LengthLessThanOrEqualTo(maxProfileFieldLen) + v.String("fullName", pp.FullName).LengthLessThanOrEqualTo(MaxProfileFieldLen) if targetTimeZone := pointer.ToString(pp.TargetTimezone); pp.TargetTimezone != nil && targetTimeZone != "" { - v.String("targetTimezone", pp.TargetTimezone).LengthLessThanOrEqualTo(maxProfileFieldLen) + v.String("targetTimezone", pp.TargetTimezone).LengthLessThanOrEqualTo(MaxProfileFieldLen) } - v.String("about", &pp.About).LengthLessThanOrEqualTo(maxProfileFieldLen) - v.String("mrn", &pp.MRN).LengthLessThanOrEqualTo(maxProfileFieldLen) + v.String("about", &pp.About).LengthLessThanOrEqualTo(MaxProfileFieldLen) + v.String("mrn", &pp.MRN).LengthLessThanOrEqualTo(MaxProfileFieldLen) if pp.DiagnosisType != "" { v.String("diagnosisType", &pp.DiagnosisType).OneOf(DiabetesTypes...) From eeee7575e376e07e0dc667595105e43fa781c3a3 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Thu, 15 Aug 2024 09:45:10 -0700 Subject: [PATCH 61/62] Remove unused field, add tests, synchronize keycloak access. --- auth/service/api/v1/profile.go | 4 +- auth/service/api/v1/router_test.go | 64 ++++++++++++++- auth/service/test/service.go | 9 ++- permission/client_mock.go | 125 +++++++++++++++++++++++++++++ permission/permission.go | 1 + user/keycloak/client.go | 24 ++++-- user/user.go | 54 ++++++++----- 7 files changed, 247 insertions(+), 34 deletions(-) create mode 100644 permission/client_mock.go diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index 668c26577..9273fc97a 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -112,7 +112,7 @@ func (r *Router) GetUsersWithProfiles(res rest.ResponseWriter, req *rest.Request } lock := &sync.Mutex{} - results := make([]*user.User, 0, len(mergedUserPerms)) + results := make(user.Users, 0, len(mergedUserPerms)) group, ctx := errgroup.WithContext(ctx) group.SetLimit(20) // do up to 20 concurrent requests like seagull did for userID, trustPerms := range mergedUserPerms { @@ -147,7 +147,7 @@ func (r *Router) GetUsersWithProfiles(res rest.ResponseWriter, req *rest.Request sharedUser.Profile = profile sharedUser.TrusteePermissions = trustPerms.TrusteePermissions sharedUser.TrustorPermissions = trustPerms.TrustorPermissions - // Seems no sharedUser.Sanitize call to filter out "protected" fields in seagull except sanitizeUser to remove "passwordExists" field - which doesn't exist in current platform/user.User + // type Users implements Sanitize to hide any properties for non service requests lock.Lock() results = append(results, sharedUser) lock.Unlock() diff --git a/auth/service/api/v1/router_test.go b/auth/service/api/v1/router_test.go index cb9d934df..c343f28c0 100644 --- a/auth/service/api/v1/router_test.go +++ b/auth/service/api/v1/router_test.go @@ -19,6 +19,7 @@ import ( errorsTest "github.com/tidepool-org/platform/errors/test" "github.com/tidepool-org/platform/log" logTest "github.com/tidepool-org/platform/log/test" + "github.com/tidepool-org/platform/permission" "github.com/tidepool-org/platform/pointer" "github.com/tidepool-org/platform/request" testRest "github.com/tidepool-org/platform/test/rest" @@ -31,10 +32,11 @@ var _ = Describe("Router", func() { var svc *serviceTest.Service var userAccessor *user.MockUserAccessor var profileAccessor *user.MockUserProfileAccessor + var permsClient *permission.MockClient BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) - svc, userAccessor, profileAccessor = serviceTest.NewMockedService(ctrl) + svc, userAccessor, profileAccessor, permsClient = serviceTest.NewMockedService(ctrl) }) Context("NewRouter", func() { @@ -103,10 +105,12 @@ var _ = Describe("Router", func() { Username: pointer.FromString("dev@tidepool.org"), } - profileAccessor.EXPECT().FindUserProfile(gomock.Any(), userID). + profileAccessor.EXPECT(). + FindUserProfile(gomock.Any(), userID). Return(userProfile, nil).AnyTimes() - userAccessor.EXPECT().FindUserById(gomock.Any(), userID). + userAccessor.EXPECT(). + FindUserById(gomock.Any(), userID). Return(userDetails, nil).AnyTimes() }) @@ -121,10 +125,20 @@ var _ = Describe("Router", func() { AfterEach(func() { res.AssertOutputsEmpty() }) + Context("as service", func() { BeforeEach(func() { details = request.NewAuthDetails(request.MethodServiceSecret, "", authTest.NewSessionToken()) req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) + permsClient.EXPECT(). + HasMembershipRelationship(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + permsClient.EXPECT(). + HasCustodianPermissions(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + permsClient.EXPECT(). + HasWritePermissions(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() }) It("it succeeds if the profile exists", func() { @@ -133,6 +147,7 @@ var _ = Describe("Router", func() { Expect(json.Marshal(userProfile)).To(MatchJSON(res.WriteInputs[0])) }) }) + Context("as user", func() { BeforeEach(func() { details = request.NewAuthDetails(request.MethodSessionToken, userID, authTest.NewSessionToken()) @@ -144,6 +159,49 @@ var _ = Describe("Router", func() { Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) Expect(json.Marshal(userProfile)).To(MatchJSON(res.WriteInputs[0])) }) + + Context("other persons profile", func() { + var otherPersonID string + var otherProfile *user.UserProfile + var otherDetails *user.User + BeforeEach(func() { + otherPersonID = userTest.RandomID() + req.URL.Path = fmt.Sprintf("/v1/users/%s/profile", otherPersonID) + otherProfile = &user.UserProfile{ + FullName: "Someone Else's Profile", + Birthday: "2002-03-04", + DiagnosisDate: "2003-04-05", + About: "Not about me", + MRN: "11223346", + } + otherDetails = &user.User{ + UserID: pointer.FromString(otherPersonID), + Username: pointer.FromString("dev+other@tidepool.org"), + } + }) + It("retrieves another person's profile if user has access", func() { + permsClient.EXPECT(). + HasMembershipRelationship(gomock.Any(), userID, otherPersonID). + Return(true, nil).AnyTimes() + profileAccessor.EXPECT(). + FindUserProfile(gomock.Any(), otherPersonID). + Return(otherProfile, nil).AnyTimes() + userAccessor.EXPECT(). + FindUserById(gomock.Any(), otherPersonID). + Return(otherDetails, nil).AnyTimes() + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(otherProfile)).To(MatchJSON(res.WriteInputs[0])) + }) + It("fails to retrieve another person's profile if user does not have access", func() { + permsClient.EXPECT(). + HasMembershipRelationship(gomock.Any(), userID, otherPersonID). + Return(false, nil).AnyTimes() + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusForbidden})) + res.WriteOutputs = nil + }) + }) }) }) }) diff --git a/auth/service/test/service.go b/auth/service/test/service.go index 4ad3ddbe6..9595ab797 100644 --- a/auth/service/test/service.go +++ b/auth/service/test/service.go @@ -36,6 +36,7 @@ type Service struct { StatusOutputs []*service.Status confirmationClient confirmationClient.ClientWithResponsesInterface userAccessor user.UserAccessor + permsClient permission.Client profileAccessor user.UserProfileAccessor } @@ -51,9 +52,10 @@ func NewService() *Service { // NewMockedService uses a combination of the "old" style manual stub / fakes / // mocks and newer gomocks for convenience so that the current code doesn't // have to be refactored too much -func NewMockedService(ctrl *gomock.Controller) (svc *Service, userAccessor *user.MockUserAccessor, profileAccessor *user.MockUserProfileAccessor) { +func NewMockedService(ctrl *gomock.Controller) (svc *Service, userAccessor *user.MockUserAccessor, profileAccessor *user.MockUserProfileAccessor, permsClient *permission.MockClient) { userAccessor = user.NewMockUserAccessor(ctrl) profileAccessor = user.NewMockUserProfileAccessor(ctrl) + permsClient = permission.NewMockClient(ctrl) return &Service{ Service: serviceTest.NewService(), AuthStoreImpl: authStoreTest.NewStore(), @@ -61,7 +63,8 @@ func NewMockedService(ctrl *gomock.Controller) (svc *Service, userAccessor *user TaskClientImpl: taskTest.NewClient(), userAccessor: userAccessor, profileAccessor: profileAccessor, - }, userAccessor, profileAccessor + permsClient: permsClient, + }, userAccessor, profileAccessor, permsClient } func (s *Service) Domain() string { @@ -101,7 +104,7 @@ func (s *Service) DeviceCheck() apple.DeviceCheck { } func (s *Service) PermissionsClient() permission.Client { - return nil + return s.permsClient } func (s *Service) Status(ctx context.Context) *service.Status { diff --git a/permission/client_mock.go b/permission/client_mock.go new file mode 100644 index 000000000..bfe85e4e0 --- /dev/null +++ b/permission/client_mock.go @@ -0,0 +1,125 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/tidepool-org/platform/permission (interfaces: Client) + +// Package permission is a generated GoMock package. +package permission + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// GetUserPermissions mocks base method. +func (m *MockClient) GetUserPermissions(arg0 context.Context, arg1, arg2 string) (Permissions, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserPermissions", arg0, arg1, arg2) + ret0, _ := ret[0].(Permissions) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserPermissions indicates an expected call of GetUserPermissions. +func (mr *MockClientMockRecorder) GetUserPermissions(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserPermissions", reflect.TypeOf((*MockClient)(nil).GetUserPermissions), arg0, arg1, arg2) +} + +// GroupsForUser mocks base method. +func (m *MockClient) GroupsForUser(arg0 context.Context, arg1 string) (Permissions, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GroupsForUser", arg0, arg1) + ret0, _ := ret[0].(Permissions) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GroupsForUser indicates an expected call of GroupsForUser. +func (mr *MockClientMockRecorder) GroupsForUser(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GroupsForUser", reflect.TypeOf((*MockClient)(nil).GroupsForUser), arg0, arg1) +} + +// HasCustodianPermissions mocks base method. +func (m *MockClient) HasCustodianPermissions(arg0 context.Context, arg1, arg2 string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HasCustodianPermissions", arg0, arg1, arg2) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HasCustodianPermissions indicates an expected call of HasCustodianPermissions. +func (mr *MockClientMockRecorder) HasCustodianPermissions(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasCustodianPermissions", reflect.TypeOf((*MockClient)(nil).HasCustodianPermissions), arg0, arg1, arg2) +} + +// HasMembershipRelationship mocks base method. +func (m *MockClient) HasMembershipRelationship(arg0 context.Context, arg1, arg2 string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HasMembershipRelationship", arg0, arg1, arg2) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HasMembershipRelationship indicates an expected call of HasMembershipRelationship. +func (mr *MockClientMockRecorder) HasMembershipRelationship(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasMembershipRelationship", reflect.TypeOf((*MockClient)(nil).HasMembershipRelationship), arg0, arg1, arg2) +} + +// HasWritePermissions mocks base method. +func (m *MockClient) HasWritePermissions(arg0 context.Context, arg1, arg2 string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HasWritePermissions", arg0, arg1, arg2) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HasWritePermissions indicates an expected call of HasWritePermissions. +func (mr *MockClientMockRecorder) HasWritePermissions(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasWritePermissions", reflect.TypeOf((*MockClient)(nil).HasWritePermissions), arg0, arg1, arg2) +} + +// UsersInGroup mocks base method. +func (m *MockClient) UsersInGroup(arg0 context.Context, arg1 string) (Permissions, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UsersInGroup", arg0, arg1) + ret0, _ := ret[0].(Permissions) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UsersInGroup indicates an expected call of UsersInGroup. +func (mr *MockClientMockRecorder) UsersInGroup(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UsersInGroup", reflect.TypeOf((*MockClient)(nil).UsersInGroup), arg0, arg1) +} diff --git a/permission/permission.go b/permission/permission.go index 391468fbf..66333c9ca 100644 --- a/permission/permission.go +++ b/permission/permission.go @@ -25,6 +25,7 @@ const ( Write = "upload" ) +//go:generate mockgen -build_flags=--mod=mod -destination=./client_mock.go -package=permission . Client type Client interface { GetUserPermissions(ctx context.Context, requestUserID string, targetUserID string) (Permissions, error) // GroupsForUser returns permissions that have been shared with granteeUserID. It is keyed by the user that has shared something with granteeUserID. It includes the user themself. diff --git a/user/keycloak/client.go b/user/keycloak/client.go index aed500c05..c00f57edc 100644 --- a/user/keycloak/client.go +++ b/user/keycloak/client.go @@ -6,6 +6,7 @@ import ( "maps" "net/http" "strings" + "sync" "time" "github.com/Nerzal/gocloak/v13" @@ -65,12 +66,14 @@ type keycloakClient struct { adminToken *oauth2.Token adminTokenRefreshExpires time.Time keycloak *gocloak.GoCloak + adminTokenLock *sync.RWMutex } func newKeycloakClient(config *KeycloakConfig) *keycloakClient { return &keycloakClient{ - cfg: config, - keycloak: gocloak.NewClient(config.BaseUrl), + cfg: config, + keycloak: gocloak.NewClient(config.BaseUrl), + adminTokenLock: &sync.RWMutex{}, } } @@ -328,13 +331,15 @@ func (c *keycloakClient) getRealmURL(realm string, path ...string) string { return strings.Join(path, "/") } -func (c *keycloakClient) getAdminToken(ctx context.Context) (*oauth2.Token, error) { +func (c *keycloakClient) getAdminToken(ctx context.Context) (oauth2.Token, error) { var err error if c.adminTokenIsExpired() { err = c.loginAsAdmin(ctx) } - return c.adminToken, err + c.adminTokenLock.RLock() + defer c.adminTokenLock.RUnlock() + return *c.adminToken, err } func (c *keycloakClient) loginAsAdmin(ctx context.Context) error { @@ -348,12 +353,21 @@ func (c *keycloakClient) loginAsAdmin(ctx context.Context) error { return err } + c.adminTokenLock.Lock() + defer c.adminTokenLock.Unlock() c.adminToken = c.jwtToAccessToken(jwt) - c.adminTokenRefreshExpires = time.Now().Add(time.Duration(jwt.ExpiresIn) * time.Second) + expiration := time.Now().Add(time.Duration(jwt.ExpiresIn)*time.Second - time.Second*5) // check if adding a small buffer to expire time to allow earlier refresh still results in a time in the future + if expiration.After(time.Now()) { + c.adminTokenRefreshExpires = expiration + } else { + c.adminTokenRefreshExpires = time.Now().Add(time.Duration(jwt.ExpiresIn) * time.Second) + } return nil } func (c *keycloakClient) adminTokenIsExpired() bool { + c.adminTokenLock.RLock() + defer c.adminTokenLock.RUnlock() return c.adminToken == nil || time.Now().After(c.adminTokenRefreshExpires) } diff --git a/user/user.go b/user/user.go index b02868b68..a70d9efc7 100644 --- a/user/user.go +++ b/user/user.go @@ -33,32 +33,43 @@ type Client interface { } type User struct { - UserID *string `json:"userid,omitempty" bson:"userid,omitempty"` - Username *string `json:"username,omitempty" bson:"username,omitempty"` - EmailVerified *bool `json:"emailVerified,omitempty" bson:"emailVerified,omitempty"` - TermsAccepted *string `json:"termsAccepted,omitempty" bson:"termsAccepted,omitempty"` - Roles *[]string `json:"roles,omitempty" bson:"roles,omitempty"` - Emails []string `json:"emails,omitempty" bson:"emails,omitempty"` - PwHash string `json:"-" bson:"pwhash,omitempty"` - Hash string `json:"-" bson:"userhash,omitempty"` - IsMigrated bool `json:"-" bson:"-"` - IsUnclaimedCustodial bool `json:"-" bson:"-"` - Enabled bool `json:"-" bson:"-"` - CreatedTime string `json:"createdTime,omitempty" bson:"createdTime,omitempty"` - CreatedUserID string `json:"createdUserId,omitempty" bson:"createdUserId,omitempty"` - ModifiedTime string `json:"modifiedTime,omitempty" bson:"modifiedTime,omitempty"` - ModifiedUserID string `json:"modifiedUserId,omitempty" bson:"modifiedUserId,omitempty"` - DeletedTime string `json:"deletedTime,omitempty" bson:"deletedTime,omitempty"` - DeletedUserID string `json:"deletedUserId,omitempty" bson:"deletedUserId,omitempty"` - Attributes map[string][]string `json:"-"` - Profile *UserProfile `json:"-"` - FirstName string `json:"firstName,omitempty"` - LastName string `json:"lastName,omitempty"` + UserID *string `json:"userid,omitempty" bson:"userid,omitempty"` + Username *string `json:"username,omitempty" bson:"username,omitempty"` + EmailVerified *bool `json:"emailVerified,omitempty" bson:"emailVerified,omitempty"` + TermsAccepted *string `json:"termsAccepted,omitempty" bson:"termsAccepted,omitempty"` + Roles *[]string `json:"roles,omitempty" bson:"roles,omitempty"` + Emails []string `json:"emails,omitempty" bson:"emails,omitempty"` + PwHash string `json:"-" bson:"pwhash,omitempty"` + Hash string `json:"-" bson:"userhash,omitempty"` + IsMigrated bool `json:"-" bson:"-"` + IsUnclaimedCustodial bool `json:"-" bson:"-"` + Enabled bool `json:"-" bson:"-"` + CreatedTime string `json:"createdTime,omitempty" bson:"createdTime,omitempty"` + CreatedUserID string `json:"createdUserId,omitempty" bson:"createdUserId,omitempty"` + ModifiedTime string `json:"modifiedTime,omitempty" bson:"modifiedTime,omitempty"` + ModifiedUserID string `json:"modifiedUserId,omitempty" bson:"modifiedUserId,omitempty"` + DeletedTime string `json:"deletedTime,omitempty" bson:"deletedTime,omitempty"` + DeletedUserID string `json:"deletedUserId,omitempty" bson:"deletedUserId,omitempty"` + Profile *UserProfile `json:"profile,omitempty"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` + PasswordExists *bool `json:"passwordExists,omitempty"` // The following 2 properties are only returned for the route that returns users that have shared their data w/ another user TrustorPermissions *permission.Permission `json:"trustorPermissions,omitempty"` TrusteePermissions *permission.Permission `json:"trusteePermissions,omitempty"` } +type Users []*User + +func (us Users) Sanitize(details request.AuthDetails) error { + for i := range us { + if err := us[i].Sanitize(details); err != nil { + return err + } + } + return nil +} + func (u *User) Parse(parser structure.ObjectParser) { u.UserID = parser.String("userid") u.Username = parser.String("username") @@ -100,6 +111,7 @@ func (u *User) Sanitize(details request.AuthDetails) error { u.EmailVerified = nil u.TermsAccepted = nil u.Roles = nil + u.PasswordExists = nil } return nil } From 2b1f2fbdacc5e9a7c16003c8f199de02e0559c2a Mon Sep 17 00:00:00 2001 From: lostlevels Date: Thu, 15 Aug 2024 13:43:07 -0700 Subject: [PATCH 62/62] Use existing UsersArray type, add update tests. --- auth/service/api/v1/profile.go | 4 +- auth/service/api/v1/router_test.go | 81 +++++++++++++++++++++++++++++- user/profile_test.go | 18 +++---- user/user.go | 11 ---- 4 files changed, 91 insertions(+), 23 deletions(-) diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go index 9273fc97a..4f7f43213 100644 --- a/auth/service/api/v1/profile.go +++ b/auth/service/api/v1/profile.go @@ -112,7 +112,7 @@ func (r *Router) GetUsersWithProfiles(res rest.ResponseWriter, req *rest.Request } lock := &sync.Mutex{} - results := make(user.Users, 0, len(mergedUserPerms)) + results := make(user.UserArray, 0, len(mergedUserPerms)) group, ctx := errgroup.WithContext(ctx) group.SetLimit(20) // do up to 20 concurrent requests like seagull did for userID, trustPerms := range mergedUserPerms { @@ -147,7 +147,7 @@ func (r *Router) GetUsersWithProfiles(res rest.ResponseWriter, req *rest.Request sharedUser.Profile = profile sharedUser.TrusteePermissions = trustPerms.TrusteePermissions sharedUser.TrustorPermissions = trustPerms.TrustorPermissions - // type Users implements Sanitize to hide any properties for non service requests + // type UsersArray implements Sanitize to hide any properties for non service requests lock.Lock() results = append(results, sharedUser) lock.Unlock() diff --git a/auth/service/api/v1/router_test.go b/auth/service/api/v1/router_test.go index c343f28c0..46ef64d88 100644 --- a/auth/service/api/v1/router_test.go +++ b/auth/service/api/v1/router_test.go @@ -1,9 +1,11 @@ package v1_test import ( + "bytes" "context" "encoding/json" "fmt" + "io" "net/http" gomock "github.com/golang/mock/gomock" @@ -101,7 +103,7 @@ var _ = Describe("Router", func() { MRN: "11223344", } userDetails = &user.User{ - UserID: pointer.FromString("abcdefghij"), + UserID: pointer.FromString(userID), Username: pointer.FromString("dev@tidepool.org"), } @@ -204,6 +206,83 @@ var _ = Describe("Router", func() { }) }) }) + + Context("UpdateProfile", func() { + var updatedProfile *user.UserProfile + BeforeEach(func() { + req.Method = http.MethodPost + req.URL.Path = fmt.Sprintf("/v1/users/%s/profile", userID) + + updatedProfile = &user.UserProfile{ + FullName: "Updated User Profile", + Birthday: "2000-01-02", + DiagnosisDate: "2001-02-03", + About: "Updated info", + MRN: "11223345", + } + + bites, err := json.Marshal(updatedProfile) + + Expect(err).ToNot(HaveOccurred()) + req.Body = io.NopCloser(bytes.NewReader(bites)) + res.WriteOutputs = []testRest.WriteOutput{{BytesWritten: 0, Error: nil}} + }) + AfterEach(func() { + res.AssertOutputsEmpty() + }) + + Context("as service", func() { + BeforeEach(func() { + details = request.NewAuthDetails(request.MethodServiceSecret, "", authTest.NewSessionToken()) + req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) + permsClient.EXPECT(). + HasMembershipRelationship(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + permsClient.EXPECT(). + HasCustodianPermissions(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + permsClient.EXPECT(). + HasWritePermissions(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + + profileAccessor.EXPECT(). + UpdateUserProfile(gomock.Any(), userID, updatedProfile). + Return(nil).AnyTimes() + }) + + It("succeeds", func() { + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(updatedProfile)).To(MatchJSON(res.WriteInputs[0])) + }) + }) + + Context("as user", func() { + BeforeEach(func() { + details = request.NewAuthDetails(request.MethodSessionToken, userID, authTest.NewSessionToken()) + req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) + profileAccessor.EXPECT(). + UpdateUserProfile(gomock.Any(), userID, updatedProfile). + Return(nil).AnyTimes() + }) + + It("successfully updates own profile", func() { + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(updatedProfile)).To(MatchJSON(res.WriteInputs[0])) + }) + It("fails to update another person's profile that the user does not have custodian access to", func() { + otherPersonID := userTest.RandomID() + req.URL.Path = fmt.Sprintf("/v1/users/%s/profile", otherPersonID) + permsClient.EXPECT(). + HasCustodianPermissions(gomock.Any(), userID, gomock.Not(userID)). + Return(false, nil).AnyTimes() + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusForbidden})) + res.WriteOutputs = nil + }) + }) + }) }) }) }) diff --git a/user/profile_test.go b/user/profile_test.go index 58428e169..88288a133 100644 --- a/user/profile_test.go +++ b/user/profile_test.go @@ -58,7 +58,7 @@ var _ = Describe("User", func() { About: "About me", MRN: "1112222", TargetDevices: []string{"SomeDevice900"}, - TargetTimezone: "UTC", + TargetTimezone: pointer.FromString("UTC"), }, MigrationStatus: user.MigrationCompleted, }), @@ -85,19 +85,19 @@ var _ = Describe("User", func() { Entry("Clinic", &user.UserProfile{ FullName: "Clinician Name", Clinic: &user.ClinicProfile{ - Name: "Clinic Name", - Role: "Some Role", - Telephone: "123-123-3456", - NPI: "1234567890", + Name: pointer.FromString("Clinic Name"), + Role: pointer.FromString("Some Role"), + Telephone: pointer.FromString("123-123-3456"), + NPI: pointer.FromString("1234567890"), }, }, &user.LegacyUserProfile{ FullName: "Clinician Name", Clinic: &user.ClinicProfile{ - Name: "Clinic Name", - Role: "Some Role", - Telephone: "123-123-3456", - NPI: "1234567890", + Name: pointer.FromString("Clinic Name"), + Role: pointer.FromString("Some Role"), + Telephone: pointer.FromString("123-123-3456"), + NPI: pointer.FromString("1234567890"), }, MigrationStatus: user.MigrationCompleted, }), diff --git a/user/user.go b/user/user.go index a70d9efc7..e241c1d39 100644 --- a/user/user.go +++ b/user/user.go @@ -59,17 +59,6 @@ type User struct { TrusteePermissions *permission.Permission `json:"trusteePermissions,omitempty"` } -type Users []*User - -func (us Users) Sanitize(details request.AuthDetails) error { - for i := range us { - if err := us[i].Sanitize(details); err != nil { - return err - } - } - return nil -} - func (u *User) Parse(parser structure.ObjectParser) { u.UserID = parser.String("userid") u.Username = parser.String("username")