From 4304662a75d80fb0086adc5996623354be490277 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 11:46:45 +0200 Subject: [PATCH 01/39] chore(auth): add jwt and argon2 dependencies --- go.mod | 10 +++---- go.sum | 90 +++++++++------------------------------------------------- 2 files changed, 19 insertions(+), 81 deletions(-) diff --git a/go.mod b/go.mod index 84eaa1dc..d7714874 100644 --- a/go.mod +++ b/go.mod @@ -49,8 +49,8 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.53.0 // indirect - golang.org/x/sync v0.21.0 // indirect + golang.org/x/crypto v0.56.0 // indirect + golang.org/x/sync v0.22.0 // indirect ) require ( @@ -61,9 +61,9 @@ require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 go.mongodb.org/mongo-driver v1.17.9 - golang.org/x/net v0.56.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect ) diff --git a/go.sum b/go.sum index 577f1fda..a4801b8c 100644 --- a/go.sum +++ b/go.sum @@ -12,83 +12,46 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/analysis v0.25.0 h1:EnjAq1yO8wEO9HbPmY8vLPEIkdZuuFhCAKBPvCB7bCs= -github.com/go-openapi/analysis v0.25.0/go.mod h1:5WFTRE43WLkPG9r9OtlMfqkkvUTYLVVCIxLlEpyF8kE= github.com/go-openapi/analysis v0.25.3 h1:4zlcg85pd2xq3sEgjW887n1IpwCpCqTmqeT6dP9OxDw= github.com/go-openapi/analysis v0.25.3/go.mod h1:6PEmUIra9/rn6SPstzbrMkhFAsMB2qm7g6E+4DRFyCU= -github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA= -github.com/go-openapi/errors v0.22.7/go.mod h1://QW6SD9OsWtH6gHllUCddOXDL0tk0ZGNYHwsw4sW3w= github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= -github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= -github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= github.com/go-openapi/jsonpointer v0.23.2 h1:DK7R/3zAt4xTytxNkw7jARGPFI7rkaSsii58n8X45x0= github.com/go-openapi/jsonpointer v0.23.2/go.mod h1:noUOckXtq7b4bVkqw0sbHKieq9uEZRN7p6EF/dalc4w= -github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= -github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= -github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= -github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= github.com/go-openapi/loads v0.23.4 h1:UMC8JClHQeASS+bh1Uc8ShGG6IrKt1kbM2DgFhx/vF0= github.com/go-openapi/loads v0.23.4/go.mod h1:oXw5oD+IGqI5BdfQgN7y9OXR8JhsAfEDpwWKxpGzeno= github.com/go-openapi/runtime v0.29.5 h1:uc5+/TtqLIfDBTUxnF3uppoGMt+9DzonwUWsviINlrY= github.com/go-openapi/runtime v0.29.5/go.mod h1:D9IUbWccdYv+km8QwmAm90FZvDcQk47vP2Y7y5as/D8= -github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= -github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic= github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= -github.com/go-openapi/strfmt v0.26.2 h1:ysjheCh4i1rmFEo2LanhELDNucNzfWTZhUDKgWWPaFM= -github.com/go-openapi/strfmt v0.26.2/go.mod h1:fXh1e449cyUn2NYuz+wb3wARBUdMl7qPEZwX00nqivY= github.com/go-openapi/strfmt v0.26.4 h1:yI6IAEfcWow459BD5UzFY430KUwXZwBHrYusPFkhWlc= github.com/go-openapi/strfmt v0.26.4/go.mod h1:hNJi6nb5ETD6i7A1yRo03M9S6ZoTPPoWff1iUexmfUc= -github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= -github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= github.com/go-openapi/swag/conv v0.26.1 h1:slr5FVkg9Wc3Y5zcwenD8Sd/PQ94b2I/QJI7N7KTBpg= github.com/go-openapi/swag/conv v0.26.1/go.mod h1:mvQXgPptZk9GTrFgGwWvT4q+dN+zQej9JfmGwnipz1A= -github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= -github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc= github.com/go-openapi/swag/fileutils v0.26.1 h1:K1XCM2CGhfNsc6YDt6v7Q5+1e59rftYWdcu/isZhvFw= github.com/go-openapi/swag/fileutils v0.26.1/go.mod h1:mYUgxQAKX4ShS3qvvySx+/9yrlUnDhjiD1CalaQl8lQ= -github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= -github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= -github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= -github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= github.com/go-openapi/swag/jsonutils v0.26.1 h1:2hdBfFkHg+7Wrz2VsCbeyR6hzkRDs7AztnMR2u84yOY= github.com/go-openapi/swag/jsonutils v0.26.1/go.mod h1:U+RMJH3wa+6BRiphuRtIyI8fW9HPFqFQ4sHk2oRx0UQ= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1 h1:1CD7NiLLb/TXl3tOnFYU4b+mNfb5rtgHkaA+q7RMYYQ= -github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= -github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1/go.mod h1:ZWafc8nMdYzTE3uYY6W86f0n46+IF0g4uUyRhJw/kXc= github.com/go-openapi/swag/loading v0.26.1 h1:E9K4wqXeROlhjFQ13K9zMz6ojFGXIggGe+ad1odrK9w= github.com/go-openapi/swag/loading v0.26.1/go.mod h1:3qvRIlWzWdq1HvmldwmuJ2ohpcAryN6xVt2OTKd0/7E= -github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw= -github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY= github.com/go-openapi/swag/mangling v0.26.1 h1:gpYI4WuPKFJJVjV5cDLGlDVJhFIxYjQc7yN5eEb4CqM= github.com/go-openapi/swag/mangling v0.26.1/go.mod h1:POETDH01hqAdASXfw7ISEd9bCOE6xBHOt8NHmGZRmYM= -github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= -github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= github.com/go-openapi/swag/stringutils v0.26.1 h1:f88uYyTso7TnHrKM/bUBsQ5e2wKf37cpgo6pvbzd9yU= github.com/go-openapi/swag/stringutils v0.26.1/go.mod h1:Sc6d3bU8fgk5AyZR8/8jEQ+Is/Ald+TD/IIggPN8UJk= -github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= -github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= github.com/go-openapi/swag/typeutils v0.26.1 h1:yg42FgMzRR6PVQ3M3qHz1s+Y6/P4HoJ3cBarXa3OVnU= github.com/go-openapi/swag/typeutils v0.26.1/go.mod h1:VfnV+oUtSP2vCSCn2aJgnr8OevUYemyIzzS1VOzS10o= -github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= -github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= github.com/go-openapi/swag/yamlutils v0.26.1 h1:0TSLK+lXs9vfIhAWzBeI/lOzEnIoot6WTCO1aAeWFTk= github.com/go-openapi/swag/yamlutils v0.26.1/go.mod h1:7W5b7PRX9MxwL7TjeG7H8HkyBGRsIDRObhyMWFgBI2M= -github.com/go-openapi/testify/enable/yaml/v2 v2.5.0 h1:3hZD1fwydvCx/cc1R2uYNQirHqf2s6lqpKV3FcNTURA= -github.com/go-openapi/testify/enable/yaml/v2 v2.5.0/go.mod h1:TvDZKBH7ZbMaF3EqH2AwTvNQCmzyZq8K1agRjf1B+Nk= github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= -github.com/go-openapi/testify/v2 v2.5.0 h1:UOCr63aAsMIDydZbZGqo5Ev01D4eydItRbekDuZMJLw= -github.com/go-openapi/testify/v2 v2.5.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= -github.com/go-openapi/validate v0.25.2 h1:12NsfLAwGegqbGWr2CnvT65X/Q2USJipmJ9b7xDJZz0= -github.com/go-openapi/validate v0.25.2/go.mod h1:Pgl1LpPPGFnZ+ys4/hTlDiRYQdI1ocKypgE+8Q8BLfY= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-openapi/validate v0.25.3 h1:4nzAIavcJ7WveHK2+V1UAkZK3kWcjzxZCzjfZAfavKs= github.com/go-openapi/validate v0.25.3/go.mod h1:GemfuGMyYpIaBoKpX3z8sLywrmxpzWVOoJ7R0VeAVuk= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= @@ -101,14 +64,10 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8 h1:NpbJl/eVbvrGE0MJ6X16X9SAifesl6Fwxg/YmCvubRI= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8/go.mod h1:mi7YA+gCzVem12exXy46ZespvGtX/lZmD/RLnQhVW7U= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= -github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -130,8 +89,6 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= -github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= @@ -165,74 +122,55 @@ go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWv go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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/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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E= -google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk= google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d h1:xr2lwHI91bn3UiXcnyzRMQjp2LRiM8wEHzwUaE0YhTs= google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d/go.mod h1:O0ZOWSrfWfJ+Z5HbwZ+wNtHsg/vk1k2C/w67eww8PfQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= From 7c6c0675b7fa9a1e7b8cdf6bedde6fabde36ff80 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 11:47:25 +0200 Subject: [PATCH 02/39] feat(auth): add permissions and service scope types --- internal/auth/permission.go | 104 +++++++++++++++++++++++++++++++ internal/auth/permission_test.go | 37 +++++++++++ internal/auth/scope.go | 58 +++++++++++++++++ internal/auth/scope_test.go | 24 +++++++ 4 files changed, 223 insertions(+) create mode 100644 internal/auth/permission.go create mode 100644 internal/auth/permission_test.go create mode 100644 internal/auth/scope.go create mode 100644 internal/auth/scope_test.go diff --git a/internal/auth/permission.go b/internal/auth/permission.go new file mode 100644 index 00000000..805c18ac --- /dev/null +++ b/internal/auth/permission.go @@ -0,0 +1,104 @@ +// Package auth contains the transport-agnostic building blocks of Tracker +// authentication: permissions, principals, passwords, API keys and sessions. +package auth + +import ( + "fmt" + "sort" + "strings" +) + +// Permission is a right granted to a team, formatted as "domain:action". +type Permission string + +const ( + PermEventRead Permission = "event:read" + PermEventWrite Permission = "event:write" + PermCatalogRead Permission = "catalog:read" + PermCatalogWrite Permission = "catalog:write" + PermLockRead Permission = "lock:read" + PermLockWrite Permission = "lock:write" + PermLinksRead Permission = "links:read" + PermLinksWrite Permission = "links:write" + PermAccessManage Permission = "access:manage" + + // PermPublic and PermAuthenticated are pseudo permissions used only in the + // authorization table. They can never be granted to a team. + PermPublic Permission = "public" + PermAuthenticated Permission = "authenticated" +) + +var allPermissions = []Permission{ + PermEventRead, PermEventWrite, + PermCatalogRead, PermCatalogWrite, + PermLockRead, PermLockWrite, + PermLinksRead, PermLinksWrite, + PermAccessManage, +} + +// AllPermissions returns a copy of every grantable permission. +func AllPermissions() []Permission { + out := make([]Permission, len(allPermissions)) + copy(out, allPermissions) + return out +} + +// IsValidPermission reports whether p is a grantable permission. +func IsValidPermission(p Permission) bool { + for _, known := range allPermissions { + if known == p { + return true + } + } + return false +} + +// ParsePermissions parses a comma separated list such as "event:read,lock:write". +// Blank items are ignored. An unknown permission is an error. +func ParsePermissions(raw string) ([]Permission, error) { + out := []Permission{} + for _, part := range strings.Split(raw, ",") { + p := Permission(strings.TrimSpace(part)) + if p == "" { + continue + } + if !IsValidPermission(p) { + return nil, fmt.Errorf("unknown permission %q", p) + } + out = append(out, p) + } + return out, nil +} + +// PermissionSet is an unordered set of permissions. +type PermissionSet map[Permission]struct{} + +// NewPermissionSet builds a set from the given permissions. +func NewPermissionSet(perms ...Permission) PermissionSet { + s := PermissionSet{} + s.Add(perms...) + return s +} + +// Has reports whether p is in the set. +func (s PermissionSet) Has(p Permission) bool { + _, ok := s[p] + return ok +} + +// Add inserts permissions into the set. +func (s PermissionSet) Add(perms ...Permission) { + for _, p := range perms { + s[p] = struct{}{} + } +} + +// Slice returns the permissions sorted alphabetically. +func (s PermissionSet) Slice() []Permission { + out := make([]Permission, 0, len(s)) + for p := range s { + out = append(out, p) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} diff --git a/internal/auth/permission_test.go b/internal/auth/permission_test.go new file mode 100644 index 00000000..92cee474 --- /dev/null +++ b/internal/auth/permission_test.go @@ -0,0 +1,37 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParsePermissions(t *testing.T) { + perms, err := ParsePermissions(" event:read, catalog:write ,, ") + require.NoError(t, err) + assert.Equal(t, []Permission{PermEventRead, PermCatalogWrite}, perms) + + perms, err = ParsePermissions("") + require.NoError(t, err) + assert.Empty(t, perms) + + _, err = ParsePermissions("event:read,bogus") + assert.EqualError(t, err, `unknown permission "bogus"`) +} + +func TestPermissionSet(t *testing.T) { + s := NewPermissionSet(PermLockWrite, PermEventRead) + assert.True(t, s.Has(PermEventRead)) + assert.False(t, s.Has(PermEventWrite)) + s.Add(PermEventWrite) + assert.True(t, s.Has(PermEventWrite)) + assert.Equal(t, []Permission{PermEventRead, PermEventWrite, PermLockWrite}, s.Slice()) +} + +func TestAllPermissionsIsACopy(t *testing.T) { + all := AllPermissions() + all[0] = "tampered" + assert.True(t, IsValidPermission(PermEventRead)) + assert.False(t, IsValidPermission(PermPublic), "pseudo permissions are not grantable") +} diff --git a/internal/auth/scope.go b/internal/auth/scope.go new file mode 100644 index 00000000..c09a50f5 --- /dev/null +++ b/internal/auth/scope.go @@ -0,0 +1,58 @@ +package auth + +import "sort" + +// Scope restricts a principal to a set of catalog services. +// All=true means every service, including ones that do not exist yet. +type Scope struct { + All bool + Services map[string]struct{} +} + +// ScopeAll returns an unrestricted scope. +func ScopeAll() Scope { + return Scope{All: true, Services: map[string]struct{}{}} +} + +// ScopeOf returns a scope restricted to the given services. +func ScopeOf(services ...string) Scope { + s := Scope{Services: make(map[string]struct{}, len(services))} + for _, svc := range services { + s.Services[svc] = struct{}{} + } + return s +} + +// Allows reports whether the service is inside the scope. +func (s Scope) Allows(service string) bool { + if s.All { + return true + } + _, ok := s.Services[service] + return ok +} + +// Union returns a scope allowing everything s or o allows. +func (s Scope) Union(o Scope) Scope { + if s.All || o.All { + return ScopeAll() + } + out := ScopeOf() + for svc := range s.Services { + out.Services[svc] = struct{}{} + } + for svc := range o.Services { + out.Services[svc] = struct{}{} + } + return out +} + +// ServiceList returns the explicitly allowed services, sorted. +func (s Scope) ServiceList() []string { + out := make([]string, 0, len(s.Services)) + for svc := range s.Services { + out = append(out, svc) + } + sort.Strings(out) + return out +} diff --git a/internal/auth/scope_test.go b/internal/auth/scope_test.go new file mode 100644 index 00000000..a419ccb9 --- /dev/null +++ b/internal/auth/scope_test.go @@ -0,0 +1,24 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestScope(t *testing.T) { + all := ScopeAll() + assert.True(t, all.Allows("anything")) + + partial := ScopeOf("api", "web") + assert.True(t, partial.Allows("api")) + assert.False(t, partial.Allows("batch")) + assert.Equal(t, []string{"api", "web"}, partial.ServiceList()) + + union := partial.Union(ScopeOf("batch")) + assert.True(t, union.Allows("batch")) + assert.False(t, union.All) + + assert.True(t, partial.Union(all).All) + assert.False(t, ScopeOf().Allows("api"), "empty scope allows nothing") +} From 6e05068b026317570f8c6eab4da0bf89e259f0c5 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 11:47:50 +0200 Subject: [PATCH 03/39] feat(auth): add principal type and context helpers --- internal/auth/principal.go | 59 +++++++++++++++++++++++++++++++++ internal/auth/principal_test.go | 29 ++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 internal/auth/principal.go create mode 100644 internal/auth/principal_test.go diff --git a/internal/auth/principal.go b/internal/auth/principal.go new file mode 100644 index 00000000..841d7d99 --- /dev/null +++ b/internal/auth/principal.go @@ -0,0 +1,59 @@ +package auth + +import "context" + +// Kind tells how a principal was authenticated. +type Kind string + +const ( + KindAnonymous Kind = "anonymous" + KindUser Kind = "user" + KindAPIKey Kind = "apikey" +) + +// Principal is the resolved identity of a request, whatever the transport. +type Principal struct { + Kind Kind + UserID string + Username string + TeamIDs []string + Permissions PermissionSet + Scope Scope + // IsAdmin is true for members of the built-in Administrators team and for global API keys. + IsAdmin bool + // KeyPrefix is set when Kind is KindAPIKey, for logging. + KeyPrefix string +} + +// Anonymous returns the principal used for unauthenticated requests. +func Anonymous(perms []Permission) Principal { + return Principal{ + Kind: KindAnonymous, + Username: "anonymous", + Permissions: NewPermissionSet(perms...), + Scope: ScopeAll(), + } +} + +// IsAuthenticated reports whether the principal is a user or an API key. +func (p Principal) IsAuthenticated() bool { + return p.Kind == KindUser || p.Kind == KindAPIKey +} + +// Has reports whether the principal holds the permission. +func (p Principal) Has(perm Permission) bool { + return p.Permissions.Has(perm) +} + +type principalKey struct{} + +// WithPrincipal stores the principal in the context. +func WithPrincipal(ctx context.Context, p Principal) context.Context { + return context.WithValue(ctx, principalKey{}, p) +} + +// FromContext returns the principal stored by WithPrincipal. +func FromContext(ctx context.Context) (Principal, bool) { + p, ok := ctx.Value(principalKey{}).(Principal) + return p, ok +} diff --git a/internal/auth/principal_test.go b/internal/auth/principal_test.go new file mode 100644 index 00000000..2233ad50 --- /dev/null +++ b/internal/auth/principal_test.go @@ -0,0 +1,29 @@ +package auth + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAnonymousPrincipal(t *testing.T) { + p := Anonymous([]Permission{PermEventRead}) + assert.False(t, p.IsAuthenticated()) + assert.True(t, p.Has(PermEventRead)) + assert.False(t, p.Has(PermEventWrite)) + assert.True(t, p.Scope.All) + assert.Equal(t, "anonymous", p.Username) +} + +func TestPrincipalContext(t *testing.T) { + _, ok := FromContext(context.Background()) + assert.False(t, ok) + + p := Principal{Kind: KindUser, UserID: "42", Username: "alice", Permissions: NewPermissionSet(PermLockRead)} + ctx := WithPrincipal(context.Background(), p) + got, ok := FromContext(ctx) + assert.True(t, ok) + assert.Equal(t, "alice", got.Username) + assert.True(t, got.IsAuthenticated()) +} From 755a961fdbcfcd852535c7fd0b1ef3cbf70acdc0 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 11:50:29 +0200 Subject: [PATCH 04/39] feat(auth): add argon2id password hashing --- internal/auth/password.go | 95 ++++++++++++++++++++++++++++++++++ internal/auth/password_test.go | 46 ++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 internal/auth/password.go create mode 100644 internal/auth/password_test.go diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 00000000..be7a39ac --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,95 @@ +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + "sync" + "unicode/utf8" + + "golang.org/x/crypto/argon2" +) + +const ( + PasswordMinLength = 12 + PasswordMaxLength = 128 + + argonTime uint32 = 3 + argonMemory uint32 = 64 * 1024 + argonThreads uint8 = 2 + argonKeyLen uint32 = 32 + argonSaltLen = 16 +) + +// ErrPasswordPolicy is returned when a password does not meet the length policy. +var ErrPasswordPolicy = fmt.Errorf("password must be between %d and %d characters", PasswordMinLength, PasswordMaxLength) + +// ValidatePasswordPolicy checks the password length policy. +func ValidatePasswordPolicy(password string) error { + n := utf8.RuneCountInString(password) + if n < PasswordMinLength || n > PasswordMaxLength { + return ErrPasswordPolicy + } + return nil +} + +// HashPassword returns an Argon2id hash in PHC string format. +func HashPassword(password string) (string, error) { + if err := ValidatePasswordPolicy(password); err != nil { + return "", err + } + salt := make([]byte, argonSaltLen) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("generate salt: %w", err) + } + key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, argonMemory, argonTime, argonThreads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(key), + ), nil +} + +// VerifyPassword compares a password with a PHC encoded Argon2id hash. +func VerifyPassword(encoded, password string) (bool, error) { + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false, errors.New("unsupported password hash format") + } + var version int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version { + return false, errors.New("unsupported argon2 version") + } + var memory, iterations uint32 + var threads uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil { + return false, fmt.Errorf("invalid argon2 parameters: %w", err) + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false, fmt.Errorf("invalid salt: %w", err) + } + expected, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false, fmt.Errorf("invalid hash: %w", err) + } + key := argon2.IDKey([]byte(password), salt, iterations, memory, threads, uint32(len(expected))) + return subtle.ConstantTimeCompare(key, expected) == 1, nil +} + +var ( + dummyHashOnce sync.Once + dummyHash string +) + +// DummyVerify burns the same CPU time as a real verification. Call it when the +// user does not exist so that login timing does not reveal valid usernames. +func DummyVerify(password string) { + dummyHashOnce.Do(func() { + dummyHash, _ = HashPassword("tracker-dummy-password-for-timing") + }) + _, _ = VerifyPassword(dummyHash, password) +} diff --git a/internal/auth/password_test.go b/internal/auth/password_test.go new file mode 100644 index 00000000..f098ec99 --- /dev/null +++ b/internal/auth/password_test.go @@ -0,0 +1,46 @@ +package auth + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHashAndVerifyPassword(t *testing.T) { + hash, err := HashPassword("correct horse battery") + require.NoError(t, err) + assert.True(t, strings.HasPrefix(hash, "$argon2id$v=19$m=65536,t=3,p=2$")) + + ok, err := VerifyPassword(hash, "correct horse battery") + require.NoError(t, err) + assert.True(t, ok) + + ok, err = VerifyPassword(hash, "wrong horse battery!") + require.NoError(t, err) + assert.False(t, ok) + + other, err := HashPassword("correct horse battery") + require.NoError(t, err) + assert.NotEqual(t, hash, other, "salt must be random") +} + +func TestPasswordPolicy(t *testing.T) { + assert.ErrorIs(t, ValidatePasswordPolicy("short"), ErrPasswordPolicy) + assert.ErrorIs(t, ValidatePasswordPolicy(strings.Repeat("x", 129)), ErrPasswordPolicy) + assert.NoError(t, ValidatePasswordPolicy("exactly12chr")) + _, err := HashPassword("short") + assert.ErrorIs(t, err, ErrPasswordPolicy) +} + +func TestVerifyPasswordRejectsGarbage(t *testing.T) { + _, err := VerifyPassword("not-a-phc-string", "whatever-password") + assert.Error(t, err) + _, err = VerifyPassword("$bcrypt$foo", "whatever-password") + assert.Error(t, err) +} + +func TestDummyVerifyDoesNotPanic(t *testing.T) { + DummyVerify("anything") +} From 90c273ac2c420a1d1c1f1171b0f78e3414aab435 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 11:50:55 +0200 Subject: [PATCH 05/39] feat(auth): add API key generation and hashing --- internal/auth/apikey.go | 74 ++++++++++++++++++++++++++++++++++++ internal/auth/apikey_test.go | 36 ++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 internal/auth/apikey.go create mode 100644 internal/auth/apikey_test.go diff --git a/internal/auth/apikey.go b/internal/auth/apikey.go new file mode 100644 index 00000000..65c785c9 --- /dev/null +++ b/internal/auth/apikey.go @@ -0,0 +1,74 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "fmt" + "math/big" + "strings" +) + +const ( + apiKeyMarker = "trk_" + apiKeyPrefixLen = 8 + apiKeySecretLen = 32 + apiKeyAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789" +) + +// GeneratedAPIKey is a freshly created key. Secret is shown once to the user, +// only Prefix and Hash are persisted. +type GeneratedAPIKey struct { + Secret string + Prefix string + Hash string +} + +// GenerateAPIKey creates a key formatted as trk__. +func GenerateAPIKey() (GeneratedAPIKey, error) { + prefix := make([]byte, apiKeyPrefixLen) + for i := range prefix { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(apiKeyAlphabet)))) + if err != nil { + return GeneratedAPIKey{}, fmt.Errorf("generate prefix: %w", err) + } + prefix[i] = apiKeyAlphabet[n.Int64()] + } + raw := make([]byte, apiKeySecretLen) + if _, err := rand.Read(raw); err != nil { + return GeneratedAPIKey{}, fmt.Errorf("generate secret: %w", err) + } + secret := apiKeyMarker + string(prefix) + "_" + base64.RawURLEncoding.EncodeToString(raw) + return GeneratedAPIKey{Secret: secret, Prefix: string(prefix), Hash: HashAPIKey(secret)}, nil +} + +// IsAPIKey reports whether the token looks like an API key rather than a session token. +func IsAPIKey(token string) bool { + return strings.HasPrefix(token, apiKeyMarker) +} + +// ParseAPIKeyPrefix extracts the lookup prefix from a full secret. +func ParseAPIKeyPrefix(secret string) (string, bool) { + if !IsAPIKey(secret) { + return "", false + } + rest := secret[len(apiKeyMarker):] + sep := strings.IndexByte(rest, '_') + if sep != apiKeyPrefixLen || len(rest) <= sep+1 { + return "", false + } + return rest[:sep], true +} + +// HashAPIKey returns the hex SHA-256 of the full secret. +func HashAPIKey(secret string) string { + sum := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(sum[:]) +} + +// APIKeyMatches compares a stored hash with a presented secret in constant time. +func APIKeyMatches(hash, secret string) bool { + return subtle.ConstantTimeCompare([]byte(hash), []byte(HashAPIKey(secret))) == 1 +} diff --git a/internal/auth/apikey_test.go b/internal/auth/apikey_test.go new file mode 100644 index 00000000..d7172def --- /dev/null +++ b/internal/auth/apikey_test.go @@ -0,0 +1,36 @@ +package auth + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateAPIKey(t *testing.T) { + k, err := GenerateAPIKey() + require.NoError(t, err) + assert.Regexp(t, regexp.MustCompile(`^trk_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$`), k.Secret) + assert.Len(t, k.Prefix, 8) + assert.Equal(t, HashAPIKey(k.Secret), k.Hash) + assert.True(t, APIKeyMatches(k.Hash, k.Secret)) + assert.False(t, APIKeyMatches(k.Hash, k.Secret+"x")) + + prefix, ok := ParseAPIKeyPrefix(k.Secret) + assert.True(t, ok) + assert.Equal(t, k.Prefix, prefix) + + other, err := GenerateAPIKey() + require.NoError(t, err) + assert.NotEqual(t, k.Secret, other.Secret) +} + +func TestParseAPIKeyPrefixRejectsMalformed(t *testing.T) { + for _, s := range []string{"", "trk_", "trk_short_abc", "trk_abcdefgh", "trk_abcdefgh_", "abc_abcdefgh_secret", "eyJhbGciOi"} { + _, ok := ParseAPIKeyPrefix(s) + assert.False(t, ok, s) + } + assert.True(t, IsAPIKey("trk_anything")) + assert.False(t, IsAPIKey("eyJhbGciOi")) +} From 027c336451c1b1364ef287c75eb75650464f1e7d Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 11:51:29 +0200 Subject: [PATCH 06/39] feat(auth): add JWT session manager --- go.mod | 3 +- go.sum | 2 + internal/auth/session.go | 87 +++++++++++++++++++++++++++++++++++ internal/auth/session_test.go | 56 ++++++++++++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 internal/auth/session.go create mode 100644 internal/auth/session_test.go diff --git a/go.mod b/go.mod index d7714874..6c9e0502 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.26.1 require ( github.com/go-openapi/runtime v0.29.5 + github.com/golang-jwt/jwt/v5 v5.3.0 + golang.org/x/crypto v0.56.0 google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 @@ -49,7 +51,6 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.56.0 // indirect golang.org/x/sync v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index a4801b8c..fac28015 100644 --- a/go.sum +++ b/go.sum @@ -56,6 +56,8 @@ github.com/go-openapi/validate v0.25.3 h1:4nzAIavcJ7WveHK2+V1UAkZK3kWcjzxZCzjfZA github.com/go-openapi/validate v0.25.3/go.mod h1:GemfuGMyYpIaBoKpX3z8sLywrmxpzWVOoJ7R0VeAVuk= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 00000000..ab734514 --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,87 @@ +package auth + +import ( + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// SessionSecretLength is the minimum HMAC secret size in bytes. +const SessionSecretLength = 32 + +// ErrInvalidSession is returned for any token that cannot be trusted. +var ErrInvalidSession = errors.New("invalid session") + +// SessionManager issues and verifies HS256 session tokens. +type SessionManager struct { + secret []byte + ttl time.Duration + // Now is overridable in tests. + Now func() time.Time +} + +// Session is the verified content of a token. +type Session struct { + UserID string + SessionVersion int + ExpiresAt time.Time +} + +type sessionClaims struct { + jwt.RegisteredClaims + SessionVersion int `json:"sv"` +} + +// NewSessionManager validates the secret and TTL. +func NewSessionManager(secret []byte, ttl time.Duration) (*SessionManager, error) { + if len(secret) < SessionSecretLength { + return nil, fmt.Errorf("session secret must be at least %d bytes", SessionSecretLength) + } + if ttl <= 0 { + return nil, errors.New("session ttl must be positive") + } + return &SessionManager{secret: secret, ttl: ttl, Now: time.Now}, nil +} + +// TTL returns the configured session lifetime. +func (m *SessionManager) TTL() time.Duration { return m.ttl } + +// Issue signs a token for the user. +func (m *SessionManager) Issue(userID string, sessionVersion int) (string, time.Time, error) { + now := m.Now() + expires := now.Add(m.ttl) + claims := sessionClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(expires), + }, + SessionVersion: sessionVersion, + } + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(m.secret) + if err != nil { + return "", time.Time{}, fmt.Errorf("sign session: %w", err) + } + return token, expires, nil +} + +// Verify parses and validates a token. +func (m *SessionManager) Verify(token string) (Session, error) { + var claims sessionClaims + parsed, err := jwt.ParseWithClaims(token, &claims, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, ErrInvalidSession + } + return m.secret, nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithTimeFunc(m.Now), jwt.WithExpirationRequired()) + if err != nil || !parsed.Valid || claims.Subject == "" { + return Session{}, ErrInvalidSession + } + return Session{ + UserID: claims.Subject, + SessionVersion: claims.SessionVersion, + ExpiresAt: claims.ExpiresAt.Time, + }, nil +} diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go new file mode 100644 index 00000000..9f4dc71b --- /dev/null +++ b/internal/auth/session_test.go @@ -0,0 +1,56 @@ +package auth + +import ( + "bytes" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testSecret() []byte { return bytes.Repeat([]byte{7}, 32) } + +func TestSessionRoundTrip(t *testing.T) { + m, err := NewSessionManager(testSecret(), time.Hour) + require.NoError(t, err) + now := time.Date(2026, 9, 5, 10, 0, 0, 0, time.UTC) + m.Now = func() time.Time { return now } + + token, expires, err := m.Issue("user-1", 3) + require.NoError(t, err) + assert.Equal(t, now.Add(time.Hour), expires) + + s, err := m.Verify(token) + require.NoError(t, err) + assert.Equal(t, "user-1", s.UserID) + assert.Equal(t, 3, s.SessionVersion) + assert.Equal(t, expires.Unix(), s.ExpiresAt.Unix()) +} + +func TestSessionExpired(t *testing.T) { + m, _ := NewSessionManager(testSecret(), time.Hour) + now := time.Now() + m.Now = func() time.Time { return now } + token, _, _ := m.Issue("user-1", 1) + m.Now = func() time.Time { return now.Add(2 * time.Hour) } + _, err := m.Verify(token) + assert.ErrorIs(t, err, ErrInvalidSession) +} + +func TestSessionWrongSecret(t *testing.T) { + a, _ := NewSessionManager(testSecret(), time.Hour) + b, _ := NewSessionManager(bytes.Repeat([]byte{9}, 32), time.Hour) + token, _, _ := a.Issue("user-1", 1) + _, err := b.Verify(token) + assert.ErrorIs(t, err, ErrInvalidSession) + _, err = a.Verify("garbage") + assert.ErrorIs(t, err, ErrInvalidSession) +} + +func TestSessionManagerValidation(t *testing.T) { + _, err := NewSessionManager([]byte("short"), time.Hour) + assert.Error(t, err) + _, err = NewSessionManager(testSecret(), 0) + assert.Error(t, err) +} From 9a65c19e4cd07d530e9ce38d35251bc4f02709d7 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 11:53:36 +0200 Subject: [PATCH 07/39] feat(auth): load AUTH_* configuration from environment --- internal/auth/config.go | 100 +++++++++++++++++++++++++++++++++++ internal/auth/config_test.go | 68 ++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 internal/auth/config.go create mode 100644 internal/auth/config_test.go diff --git a/internal/auth/config.go b/internal/auth/config.go new file mode 100644 index 00000000..317d5451 --- /dev/null +++ b/internal/auth/config.go @@ -0,0 +1,100 @@ +package auth + +import ( + "encoding/base64" + "fmt" + "strings" + "time" +) + +// Config is the authentication configuration read from the environment. +type Config struct { + // SessionSecret is nil when AUTH_SESSION_SECRET is not set; the caller then + // loads or generates a persisted secret. + SessionSecret []byte + SessionTTL time.Duration + AnonymousPermissions []Permission + // AnonymousDefaulted is true when the transitional default was applied + // because AUTH_ANONYMOUS_PERMISSIONS is not set. + AnonymousDefaulted bool + AdminPassword string + PublicURL string + CookieSecure bool + TrustProxy bool + DemoMode bool +} + +// LookupEnv has the signature of os.LookupEnv. +type LookupEnv func(key string) (string, bool) + +// ReadOnlyPermissions is what anonymous visitors get in demo mode. +func ReadOnlyPermissions() []Permission { + return []Permission{PermEventRead, PermCatalogRead, PermLockRead, PermLinksRead} +} + +// TransitionalAnonymousPermissions is the default applied while authentication +// is being rolled out: everything but access management. It becomes empty in +// the next major release. +func TransitionalAnonymousPermissions() []Permission { + out := []Permission{} + for _, p := range AllPermissions() { + if p != PermAccessManage { + out = append(out, p) + } + } + return out +} + +// LoadConfig reads and validates the AUTH_* variables. +func LoadConfig(lookup LookupEnv) (Config, error) { + get := func(key string) string { + v, _ := lookup(key) + return strings.TrimSpace(v) + } + cfg := Config{SessionTTL: 12 * time.Hour} + cfg.DemoMode = get("DEMO_MODE") == "true" + + if v := get("AUTH_SESSION_SECRET"); v != "" { + secret, err := base64.StdEncoding.DecodeString(v) + if err != nil { + return Config{}, fmt.Errorf("AUTH_SESSION_SECRET must be base64: %w", err) + } + if len(secret) < SessionSecretLength { + return Config{}, fmt.Errorf("AUTH_SESSION_SECRET must decode to at least %d bytes", SessionSecretLength) + } + cfg.SessionSecret = secret + } + + if v := get("AUTH_SESSION_TTL"); v != "" { + ttl, err := time.ParseDuration(v) + if err != nil || ttl <= 0 { + return Config{}, fmt.Errorf("AUTH_SESSION_TTL must be a positive duration such as 12h, got %q", v) + } + cfg.SessionTTL = ttl + } + + if raw, ok := lookup("AUTH_ANONYMOUS_PERMISSIONS"); ok { + perms, err := ParsePermissions(raw) + if err != nil { + return Config{}, fmt.Errorf("AUTH_ANONYMOUS_PERMISSIONS: %w", err) + } + cfg.AnonymousPermissions = perms + } else if cfg.DemoMode { + cfg.AnonymousPermissions = ReadOnlyPermissions() + } else { + cfg.AnonymousPermissions = TransitionalAnonymousPermissions() + cfg.AnonymousDefaulted = true + } + + cfg.AdminPassword = get("AUTH_ADMIN_PASSWORD") + if cfg.AdminPassword != "" { + if err := ValidatePasswordPolicy(cfg.AdminPassword); err != nil { + return Config{}, fmt.Errorf("AUTH_ADMIN_PASSWORD: %w", err) + } + } + + cfg.PublicURL = strings.TrimRight(get("AUTH_PUBLIC_URL"), "/") + cfg.CookieSecure = strings.HasPrefix(cfg.PublicURL, "https://") || get("AUTH_COOKIE_SECURE") == "true" + cfg.TrustProxy = get("AUTH_TRUST_PROXY") == "true" + return cfg, nil +} diff --git a/internal/auth/config_test.go b/internal/auth/config_test.go new file mode 100644 index 00000000..9105736e --- /dev/null +++ b/internal/auth/config_test.go @@ -0,0 +1,68 @@ +package auth + +import ( + "encoding/base64" + "bytes" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func envOf(m map[string]string) LookupEnv { + return func(k string) (string, bool) { v, ok := m[k]; return v, ok } +} + +func TestLoadConfigDefaults(t *testing.T) { + cfg, err := LoadConfig(envOf(map[string]string{})) + require.NoError(t, err) + assert.Nil(t, cfg.SessionSecret) + assert.Equal(t, 12*time.Hour, cfg.SessionTTL) + assert.True(t, cfg.AnonymousDefaulted) + assert.ElementsMatch(t, TransitionalAnonymousPermissions(), cfg.AnonymousPermissions) + assert.NotContains(t, cfg.AnonymousPermissions, PermAccessManage) + assert.False(t, cfg.CookieSecure) + assert.False(t, cfg.DemoMode) +} + +func TestLoadConfigDemoMode(t *testing.T) { + cfg, err := LoadConfig(envOf(map[string]string{"DEMO_MODE": "true"})) + require.NoError(t, err) + assert.True(t, cfg.DemoMode) + assert.False(t, cfg.AnonymousDefaulted) + assert.ElementsMatch(t, ReadOnlyPermissions(), cfg.AnonymousPermissions) +} + +func TestLoadConfigExplicit(t *testing.T) { + secret := bytes.Repeat([]byte{1}, 32) + cfg, err := LoadConfig(envOf(map[string]string{ + "AUTH_SESSION_SECRET": base64.StdEncoding.EncodeToString(secret), + "AUTH_SESSION_TTL": "30m", + "AUTH_ANONYMOUS_PERMISSIONS": "", + "AUTH_ADMIN_PASSWORD": "a-long-enough-password", + "AUTH_PUBLIC_URL": "https://tracker.example.com/", + "AUTH_TRUST_PROXY": "true", + })) + require.NoError(t, err) + assert.Equal(t, secret, cfg.SessionSecret) + assert.Equal(t, 30*time.Minute, cfg.SessionTTL) + assert.Empty(t, cfg.AnonymousPermissions) + assert.False(t, cfg.AnonymousDefaulted) + assert.Equal(t, "https://tracker.example.com", cfg.PublicURL) + assert.True(t, cfg.CookieSecure) + assert.True(t, cfg.TrustProxy) +} + +func TestLoadConfigErrors(t *testing.T) { + _, err := LoadConfig(envOf(map[string]string{"AUTH_SESSION_SECRET": "not-base64!"})) + assert.Error(t, err) + _, err = LoadConfig(envOf(map[string]string{"AUTH_SESSION_SECRET": base64.StdEncoding.EncodeToString([]byte("short"))})) + assert.Error(t, err) + _, err = LoadConfig(envOf(map[string]string{"AUTH_SESSION_TTL": "soon"})) + assert.Error(t, err) + _, err = LoadConfig(envOf(map[string]string{"AUTH_ANONYMOUS_PERMISSIONS": "event:read,nope"})) + assert.Error(t, err) + _, err = LoadConfig(envOf(map[string]string{"AUTH_ADMIN_PASSWORD": "short"})) + assert.ErrorIs(t, err, ErrPasswordPolicy) +} From 0fe633d70a3870246ca29e2900f658361766895a Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 11:54:05 +0200 Subject: [PATCH 08/39] feat(auth): extract credentials from HTTP and gRPC requests --- internal/auth/credentials.go | 116 ++++++++++++++++++++++++++++++ internal/auth/credentials_test.go | 66 +++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 internal/auth/credentials.go create mode 100644 internal/auth/credentials_test.go diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go new file mode 100644 index 00000000..dc51da57 --- /dev/null +++ b/internal/auth/credentials.go @@ -0,0 +1,116 @@ +package auth + +import ( + "net" + "net/http" + "strings" + "time" + + "google.golang.org/grpc/metadata" +) + +const ( + // SessionCookieName carries the session token for the SPA. + SessionCookieName = "tracker_session" + // APIKeyHeader carries an API key. + APIKeyHeader = "X-Api-Key" +) + +// Credentials are the raw secrets found on a request, before any lookup. +type Credentials struct { + APIKey string + SessionToken string +} + +// Empty reports whether no credential was presented. +func (c Credentials) Empty() bool { return c.APIKey == "" && c.SessionToken == "" } + +// CredentialsFromHTTP extracts credentials in priority order: +// X-Api-Key header, Authorization bearer, session cookie. +func CredentialsFromHTTP(r *http.Request) Credentials { + if v := strings.TrimSpace(r.Header.Get(APIKeyHeader)); v != "" { + return Credentials{APIKey: v} + } + if c, ok := fromBearer(r.Header.Get("Authorization")); ok { + return c + } + if ck, err := r.Cookie(SessionCookieName); err == nil && ck.Value != "" { + return Credentials{SessionToken: ck.Value} + } + return Credentials{} +} + +// CredentialsFromMetadata extracts credentials from gRPC metadata. +func CredentialsFromMetadata(md metadata.MD) Credentials { + first := func(key string) string { + if v := md.Get(key); len(v) > 0 { + return strings.TrimSpace(v[0]) + } + return "" + } + if v := first("x-api-key"); v != "" { + return Credentials{APIKey: v} + } + if c, ok := fromBearer(first("authorization")); ok { + return c + } + return Credentials{} +} + +func fromBearer(header string) (Credentials, bool) { + const prefix = "bearer " + if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) { + return Credentials{}, false + } + token := strings.TrimSpace(header[len(prefix):]) + if token == "" { + return Credentials{}, false + } + if IsAPIKey(token) { + return Credentials{APIKey: token}, true + } + return Credentials{SessionToken: token}, true +} + +// SessionCookie builds the cookie carrying a session token. +func SessionCookie(token string, expires time.Time, secure bool) *http.Cookie { + return &http.Cookie{ + Name: SessionCookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + Expires: expires, + MaxAge: int(time.Until(expires).Seconds()), + } +} + +// ClearSessionCookie builds the cookie that removes the session. +func ClearSessionCookie(secure bool) *http.Cookie { + return &http.Cookie{ + Name: SessionCookieName, + Value: "", + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + Expires: time.Unix(0, 0), + MaxAge: -1, + } +} + +// ClientIP returns the peer address, honouring X-Forwarded-For only when the +// deployment declares a trusted reverse proxy. +func ClientIP(r *http.Request, trustProxy bool) string { + if trustProxy { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + return strings.TrimSpace(strings.Split(xff, ",")[0]) + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/internal/auth/credentials_test.go b/internal/auth/credentials_test.go new file mode 100644 index 00000000..0948a3e0 --- /dev/null +++ b/internal/auth/credentials_test.go @@ -0,0 +1,66 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "google.golang.org/grpc/metadata" +) + +func TestCredentialsFromHTTP(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + assert.True(t, CredentialsFromHTTP(r).Empty()) + + r.Header.Set("X-Api-Key", " trk_abcdefgh_secret ") + assert.Equal(t, Credentials{APIKey: "trk_abcdefgh_secret"}, CredentialsFromHTTP(r)) + + r = httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "Bearer trk_abcdefgh_secret") + assert.Equal(t, Credentials{APIKey: "trk_abcdefgh_secret"}, CredentialsFromHTTP(r)) + + r = httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "bearer eyJ.jwt") + assert.Equal(t, Credentials{SessionToken: "eyJ.jwt"}, CredentialsFromHTTP(r)) + + r = httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "cookie.jwt"}) + assert.Equal(t, Credentials{SessionToken: "cookie.jwt"}, CredentialsFromHTTP(r)) + + // Header wins over cookie. + r.Header.Set("X-Api-Key", "trk_abcdefgh_secret") + assert.Equal(t, Credentials{APIKey: "trk_abcdefgh_secret"}, CredentialsFromHTTP(r)) +} + +func TestCredentialsFromMetadata(t *testing.T) { + assert.True(t, CredentialsFromMetadata(metadata.MD{}).Empty()) + md := metadata.Pairs("authorization", "Bearer eyJ.jwt") + assert.Equal(t, Credentials{SessionToken: "eyJ.jwt"}, CredentialsFromMetadata(md)) + md = metadata.Pairs("x-api-key", "trk_abcdefgh_secret") + assert.Equal(t, Credentials{APIKey: "trk_abcdefgh_secret"}, CredentialsFromMetadata(md)) +} + +func TestSessionCookies(t *testing.T) { + expires := time.Now().Add(time.Hour) + c := SessionCookie("tok", expires, true) + assert.Equal(t, SessionCookieName, c.Name) + assert.True(t, c.HttpOnly) + assert.True(t, c.Secure) + assert.Equal(t, http.SameSiteLaxMode, c.SameSite) + assert.Equal(t, "/", c.Path) + assert.Greater(t, c.MaxAge, 3500) + + cleared := ClearSessionCookie(false) + assert.Equal(t, -1, cleared.MaxAge) + assert.Empty(t, cleared.Value) +} + +func TestClientIP(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = "10.0.0.5:4444" + r.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1") + assert.Equal(t, "10.0.0.5", ClientIP(r, false)) + assert.Equal(t, "203.0.113.9", ClientIP(r, true)) +} From 13c25876eed55f98df96c4a4ef428233f170c63a Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 11:54:30 +0200 Subject: [PATCH 09/39] feat(auth): add HTTP middleware and gRPC interceptors --- internal/auth/transport.go | 58 ++++++++++++++++++++++++++++ internal/auth/transport_test.go | 67 +++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 internal/auth/transport.go create mode 100644 internal/auth/transport_test.go diff --git a/internal/auth/transport.go b/internal/auth/transport.go new file mode 100644 index 00000000..b6bc4d32 --- /dev/null +++ b/internal/auth/transport.go @@ -0,0 +1,58 @@ +package auth + +import ( + "context" + "net/http" + + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +// Resolver turns raw credentials into a principal. It never fails: an invalid +// credential yields the anonymous principal. +type Resolver interface { + Resolve(ctx context.Context, creds Credentials) Principal +} + +// ResolverFunc adapts a function to the Resolver interface. +type ResolverFunc func(ctx context.Context, creds Credentials) Principal + +// Resolve implements Resolver. +func (f ResolverFunc) Resolve(ctx context.Context, creds Credentials) Principal { return f(ctx, creds) } + +// HTTPMiddleware resolves the principal of every HTTP request and stores it in +// the request context. It never rejects a request: authorization happens later. +func HTTPMiddleware(r Resolver) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + p := r.Resolve(req.Context(), CredentialsFromHTTP(req)) + next.ServeHTTP(w, req.WithContext(WithPrincipal(req.Context(), p))) + }) + } +} + +// UnaryInterceptor is the gRPC counterpart of HTTPMiddleware. +func UnaryInterceptor(r Resolver) grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + return handler(withResolvedPrincipal(ctx, r), req) + } +} + +// StreamInterceptor is the streaming counterpart of UnaryInterceptor. +func StreamInterceptor(r Resolver) grpc.StreamServerInterceptor { + return func(srv any, ss grpc.ServerStream, _ *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + return handler(srv, &principalStream{ServerStream: ss, ctx: withResolvedPrincipal(ss.Context(), r)}) + } +} + +func withResolvedPrincipal(ctx context.Context, r Resolver) context.Context { + md, _ := metadata.FromIncomingContext(ctx) + return WithPrincipal(ctx, r.Resolve(ctx, CredentialsFromMetadata(md))) +} + +type principalStream struct { + grpc.ServerStream + ctx context.Context +} + +func (s *principalStream) Context() context.Context { return s.ctx } diff --git a/internal/auth/transport_test.go b/internal/auth/transport_test.go new file mode 100644 index 00000000..75553df2 --- /dev/null +++ b/internal/auth/transport_test.go @@ -0,0 +1,67 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +func fakeResolver() Resolver { + return ResolverFunc(func(_ context.Context, c Credentials) Principal { + if c.APIKey == "trk_abcdefgh_ok" { + return Principal{Kind: KindAPIKey, Username: "apikey:abcdefgh", Permissions: NewPermissionSet(PermEventRead)} + } + return Anonymous(nil) + }) +} + +func TestHTTPMiddlewareStoresPrincipal(t *testing.T) { + var seen Principal + h := HTTPMiddleware(fakeResolver())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen, _ = FromContext(r.Context()) + })) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("X-Api-Key", "trk_abcdefgh_ok") + h.ServeHTTP(httptest.NewRecorder(), r) + assert.Equal(t, KindAPIKey, seen.Kind) + + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) + assert.Equal(t, KindAnonymous, seen.Kind) +} + +func TestUnaryInterceptorStoresPrincipal(t *testing.T) { + var seen Principal + handler := func(ctx context.Context, req any) (any, error) { + seen, _ = FromContext(ctx) + return nil, nil + } + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-api-key", "trk_abcdefgh_ok")) + _, err := UnaryInterceptor(fakeResolver())(ctx, nil, &grpc.UnaryServerInfo{FullMethod: "/x/Y"}, handler) + require.NoError(t, err) + assert.Equal(t, KindAPIKey, seen.Kind) +} + +type fakeStream struct { + grpc.ServerStream + ctx context.Context +} + +func (s fakeStream) Context() context.Context { return s.ctx } + +func TestStreamInterceptorStoresPrincipal(t *testing.T) { + var seen Principal + handler := func(srv any, ss grpc.ServerStream) error { + seen, _ = FromContext(ss.Context()) + return nil + } + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-api-key", "trk_abcdefgh_ok")) + err := StreamInterceptor(fakeResolver())(nil, fakeStream{ctx: ctx}, &grpc.StreamServerInfo{FullMethod: "/x/Y"}, handler) + require.NoError(t, err) + assert.Equal(t, KindAPIKey, seen.Kind) +} From 1fbdddadb4af7c07e23a2dcbeb95376c84dd8d2b Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 11:54:51 +0200 Subject: [PATCH 10/39] feat(auth): add in-memory login rate limiter --- internal/auth/ratelimit.go | 60 +++++++++++++++++++++++++++++++++ internal/auth/ratelimit_test.go | 32 ++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 internal/auth/ratelimit.go create mode 100644 internal/auth/ratelimit_test.go diff --git a/internal/auth/ratelimit.go b/internal/auth/ratelimit.go new file mode 100644 index 00000000..859b6514 --- /dev/null +++ b/internal/auth/ratelimit.go @@ -0,0 +1,60 @@ +package auth + +import ( + "sync" + "time" +) + +// LoginLimiter blocks a key after too many failures inside a sliding window. +// It is in-memory and per process, which is enough to slow down online guessing. +type LoginLimiter struct { + mu sync.Mutex + maxFailures int + window time.Duration + failures map[string][]time.Time + // Now is overridable in tests. + Now func() time.Time +} + +// NewLoginLimiter creates a limiter allowing maxFailures per window. +func NewLoginLimiter(maxFailures int, window time.Duration) *LoginLimiter { + return &LoginLimiter{maxFailures: maxFailures, window: window, failures: map[string][]time.Time{}, Now: time.Now} +} + +// Blocked reports whether the key has reached the failure budget. +func (l *LoginLimiter) Blocked(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + l.prune(key, l.Now()) + return len(l.failures[key]) >= l.maxFailures +} + +// RecordFailure adds a failed attempt for the key. +func (l *LoginLimiter) RecordFailure(key string) { + l.mu.Lock() + defer l.mu.Unlock() + now := l.Now() + l.prune(key, now) + l.failures[key] = append(l.failures[key], now) +} + +// Reset forgets the failures of the key, after a successful login. +func (l *LoginLimiter) Reset(key string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.failures, key) +} + +func (l *LoginLimiter) prune(key string, now time.Time) { + kept := l.failures[key][:0] + for _, at := range l.failures[key] { + if now.Sub(at) < l.window { + kept = append(kept, at) + } + } + if len(kept) == 0 { + delete(l.failures, key) + return + } + l.failures[key] = kept +} diff --git a/internal/auth/ratelimit_test.go b/internal/auth/ratelimit_test.go new file mode 100644 index 00000000..085bd7e5 --- /dev/null +++ b/internal/auth/ratelimit_test.go @@ -0,0 +1,32 @@ +package auth + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestLoginLimiter(t *testing.T) { + l := NewLoginLimiter(3, time.Minute) + now := time.Now() + l.Now = func() time.Time { return now } + + assert.False(t, l.Blocked("alice|1.1.1.1")) + l.RecordFailure("alice|1.1.1.1") + l.RecordFailure("alice|1.1.1.1") + assert.False(t, l.Blocked("alice|1.1.1.1")) + l.RecordFailure("alice|1.1.1.1") + assert.True(t, l.Blocked("alice|1.1.1.1")) + assert.False(t, l.Blocked("bob|1.1.1.1"), "keys are independent") + + now = now.Add(61 * time.Second) + assert.False(t, l.Blocked("alice|1.1.1.1"), "failures expire with the window") + + l.RecordFailure("alice|1.1.1.1") + l.RecordFailure("alice|1.1.1.1") + l.RecordFailure("alice|1.1.1.1") + assert.True(t, l.Blocked("alice|1.1.1.1")) + l.Reset("alice|1.1.1.1") + assert.False(t, l.Blocked("alice|1.1.1.1")) +} From 30aec2d578424186c8deef3b81af226de2b0fc8d Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 11:57:54 +0200 Subject: [PATCH 11/39] feat(auth): add method permission table and authorization checks --- internal/auth/authz/authz.go | 117 ++++++++++++++++++++++++++++ internal/auth/authz/authz_test.go | 60 ++++++++++++++ internal/auth/authz/methods.go | 52 +++++++++++++ internal/auth/authz/methods_test.go | 60 ++++++++++++++ 4 files changed, 289 insertions(+) create mode 100644 internal/auth/authz/authz.go create mode 100644 internal/auth/authz/authz_test.go create mode 100644 internal/auth/authz/methods.go create mode 100644 internal/auth/authz/methods_test.go diff --git a/internal/auth/authz/authz.go b/internal/auth/authz/authz.go new file mode 100644 index 00000000..3809e685 --- /dev/null +++ b/internal/auth/authz/authz.go @@ -0,0 +1,117 @@ +// Package authz decides whether a principal may call a method. It is the only +// place where permissions are checked, whatever the transport. +package authz + +import ( + "context" + "log/slog" + "net/http" + + "github.com/bananaops/tracker/internal/auth" + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/prometheus/client_golang/prometheus" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var authRequests = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "tracker_auth_requests_total", + Help: "Authorization decisions by principal kind and result", + }, + []string{"principal", "result"}, +) + +func init() { + prometheus.MustRegister(authRequests) +} + +// MethodFromContext returns the full RPC method name, on gRPC or through the gateway. +func MethodFromContext(ctx context.Context) string { + if m, ok := grpc.Method(ctx); ok { + return m + } + if m, ok := runtime.RPCMethod(ctx); ok { + return m + } + return "" +} + +// Authorize checks the current principal against the current RPC method. +// Call it as the first statement of every service method. +func Authorize(ctx context.Context) error { + p, ok := auth.FromContext(ctx) + if !ok { + p = auth.Anonymous(nil) + } + method := MethodFromContext(ctx) + err := Check(p, method) + observe(p, method, err) + return err +} + +// Check is the pure decision for a principal and a method. +func Check(p auth.Principal, method string) error { + perm, ok := MethodPermissions[method] + if !ok { + slog.Error("authz: method not in permission table, denying", "method", method) + return status.Error(codes.PermissionDenied, "method not authorized") + } + return CheckPermission(p, perm) +} + +// CheckPermission is the pure decision for a principal and a permission. +func CheckPermission(p auth.Principal, perm auth.Permission) error { + switch perm { + case auth.PermPublic: + return nil + case auth.PermAuthenticated: + if p.IsAuthenticated() { + return nil + } + return status.Error(codes.Unauthenticated, "authentication required") + } + if p.Has(perm) { + return nil + } + if !p.IsAuthenticated() { + return status.Error(codes.Unauthenticated, "authentication required") + } + return status.Errorf(codes.PermissionDenied, "permission %s required", perm) +} + +// RequireHTTP guards a grpc-gateway custom handler with a permission. +func RequireHTTP(perm auth.Permission, next runtime.HandlerFunc) runtime.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request, params map[string]string) { + p, ok := auth.FromContext(r.Context()) + if !ok { + p = auth.Anonymous(nil) + } + err := CheckPermission(p, perm) + observe(p, r.Method+" "+r.URL.Path, err) + if err != nil { + code := http.StatusForbidden + if status.Code(err) == codes.Unauthenticated { + code = http.StatusUnauthorized + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _, _ = w.Write([]byte(`{"error":"` + status.Convert(err).Message() + `"}`)) + return + } + next(w, r, params) + } +} + +func observe(p auth.Principal, method string, err error) { + result := "allowed" + if err != nil { + result = "denied" + if status.Code(err) == codes.Unauthenticated { + result = "unauthenticated" + } + slog.Warn("authz denied", "method", method, "principal", p.Username, "kind", p.Kind, "reason", status.Convert(err).Message()) + } + authRequests.WithLabelValues(string(p.Kind), result).Inc() +} diff --git a/internal/auth/authz/authz_test.go b/internal/auth/authz/authz_test.go new file mode 100644 index 00000000..69dfced9 --- /dev/null +++ b/internal/auth/authz/authz_test.go @@ -0,0 +1,60 @@ +package authz + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/bananaops/tracker/internal/auth" + "github.com/stretchr/testify/assert" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const listEvents = "/tracker.event.v1alpha1.EventService/ListEvents" + +func TestCheck(t *testing.T) { + anon := auth.Anonymous(nil) + reader := auth.Principal{Kind: auth.KindUser, Username: "r", Permissions: auth.NewPermissionSet(auth.PermEventRead)} + + assert.Equal(t, codes.Unauthenticated, status.Code(Check(anon, listEvents))) + assert.NoError(t, Check(reader, listEvents)) + assert.Equal(t, codes.PermissionDenied, status.Code(Check(reader, "/tracker.event.v1alpha1.EventService/CreateEvent"))) + assert.Equal(t, codes.PermissionDenied, status.Code(Check(reader, "/tracker.nope.v1/Svc/Method")), "unmapped method is denied") + + anonReader := auth.Anonymous([]auth.Permission{auth.PermEventRead}) + assert.NoError(t, Check(anonReader, listEvents)) + + assert.NoError(t, Check(anon, "/tracker.auth.v1alpha1.AuthService/GetAuthConfig"), "public") + assert.NoError(t, Check(anon, "/tracker.auth.v1alpha1.AuthService/Me"), "public") + assert.Equal(t, codes.Unauthenticated, status.Code(Check(anon, "/tracker.auth.v1alpha1.AuthService/ListUsers"))) + assert.Equal(t, codes.PermissionDenied, status.Code(Check(reader, "/tracker.auth.v1alpha1.AuthService/ListUsers"))) +} + +func TestAuthorizeWithoutPrincipalIsAnonymous(t *testing.T) { + err := Authorize(context.Background()) + assert.Equal(t, codes.PermissionDenied, status.Code(err), "no method in context: unmapped, denied") +} + +func TestRequireHTTP(t *testing.T) { + called := false + h := RequireHTTP(auth.PermLinksWrite, func(w http.ResponseWriter, r *http.Request, _ map[string]string) { called = true }) + + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodPost, "/api/links", nil), nil) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.False(t, called) + + ctx := auth.WithPrincipal(context.Background(), auth.Principal{Kind: auth.KindUser, Permissions: auth.NewPermissionSet(auth.PermLinksRead)}) + rec = httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodPost, "/api/links", nil).WithContext(ctx), nil) + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.False(t, called) + + ctx = auth.WithPrincipal(context.Background(), auth.Principal{Kind: auth.KindUser, Permissions: auth.NewPermissionSet(auth.PermLinksWrite)}) + rec = httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodPost, "/api/links", nil).WithContext(ctx), nil) + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, called) +} diff --git a/internal/auth/authz/methods.go b/internal/auth/authz/methods.go new file mode 100644 index 00000000..30de59f6 --- /dev/null +++ b/internal/auth/authz/methods.go @@ -0,0 +1,52 @@ +package authz + +import "github.com/bananaops/tracker/internal/auth" + +// MethodPermissions maps every RPC full name to the permission it requires. +// A method missing from this table is denied. methods_test.go enforces that +// every RPC declared in the protos has an entry. +var MethodPermissions = map[string]auth.Permission{ + // EventService + "/tracker.event.v1alpha1.EventService/CreateEvent": auth.PermEventWrite, + "/tracker.event.v1alpha1.EventService/UpdateEvent": auth.PermEventWrite, + "/tracker.event.v1alpha1.EventService/DeleteEvents": auth.PermEventWrite, + "/tracker.event.v1alpha1.EventService/GetEvent": auth.PermEventRead, + "/tracker.event.v1alpha1.EventService/SearchEvents": auth.PermEventRead, + "/tracker.event.v1alpha1.EventService/ListEvents": auth.PermEventRead, + "/tracker.event.v1alpha1.EventService/TodayEvents": auth.PermEventRead, + "/tracker.event.v1alpha1.EventService/AddChangelogEntry": auth.PermEventWrite, + "/tracker.event.v1alpha1.EventService/GetEventChangelog": auth.PermEventRead, + "/tracker.event.v1alpha1.EventService/AddSlackId": auth.PermEventWrite, + "/tracker.event.v1alpha1.EventService/GetEventStats": auth.PermEventRead, + "/tracker.event.v1alpha1.EventService/GetEventStatsByMonth": auth.PermEventRead, + + // CatalogService + "/tracker.catalog.v1alpha1.CatalogService/CreateUpdateCatalog": auth.PermCatalogWrite, + "/tracker.catalog.v1alpha1.CatalogService/GetCatalog": auth.PermCatalogRead, + "/tracker.catalog.v1alpha1.CatalogService/DeleteCatalog": auth.PermCatalogWrite, + "/tracker.catalog.v1alpha1.CatalogService/ListCatalogs": auth.PermCatalogRead, + "/tracker.catalog.v1alpha1.CatalogService/GetVersionCompliance": auth.PermCatalogRead, + "/tracker.catalog.v1alpha1.CatalogService/UpdateVersions": auth.PermCatalogWrite, + "/tracker.catalog.v1alpha1.CatalogService/UpdateDependencies": auth.PermCatalogWrite, + + // LockService + "/tracker.lock.v1alpha1.LockService/CreateLock": auth.PermLockWrite, + "/tracker.lock.v1alpha1.LockService/GetLock": auth.PermLockRead, + "/tracker.lock.v1alpha1.LockService/UpdateLock": auth.PermLockWrite, + "/tracker.lock.v1alpha1.LockService/UnLock": auth.PermLockWrite, + "/tracker.lock.v1alpha1.LockService/ListLocks": auth.PermLockRead, + + // AuthService (proto added in Task 12) + "/tracker.auth.v1alpha1.AuthService/GetAuthConfig": auth.PermPublic, + "/tracker.auth.v1alpha1.AuthService/Me": auth.PermPublic, + "/tracker.auth.v1alpha1.AuthService/ListUsers": auth.PermAccessManage, + "/tracker.auth.v1alpha1.AuthService/CreateUser": auth.PermAccessManage, + "/tracker.auth.v1alpha1.AuthService/UpdateUser": auth.PermAccessManage, + "/tracker.auth.v1alpha1.AuthService/ListTeams": auth.PermAccessManage, + "/tracker.auth.v1alpha1.AuthService/CreateTeam": auth.PermAccessManage, + "/tracker.auth.v1alpha1.AuthService/UpdateTeam": auth.PermAccessManage, + "/tracker.auth.v1alpha1.AuthService/DeleteTeam": auth.PermAccessManage, + "/tracker.auth.v1alpha1.AuthService/ListApiKeys": auth.PermAccessManage, + "/tracker.auth.v1alpha1.AuthService/CreateApiKey": auth.PermAccessManage, + "/tracker.auth.v1alpha1.AuthService/RevokeApiKey": auth.PermAccessManage, +} diff --git a/internal/auth/authz/methods_test.go b/internal/auth/authz/methods_test.go new file mode 100644 index 00000000..8cd7de94 --- /dev/null +++ b/internal/auth/authz/methods_test.go @@ -0,0 +1,60 @@ +package authz + +import ( + "fmt" + "strings" + "testing" + + "github.com/bananaops/tracker/internal/auth" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + + _ "github.com/bananaops/tracker/generated/proto/catalog/v1alpha1" + _ "github.com/bananaops/tracker/generated/proto/event/v1alpha1" + _ "github.com/bananaops/tracker/generated/proto/lock/v1alpha1" +) + +const ( + PermPublicAlias = auth.PermPublic + PermAuthenticatedAlias = auth.PermAuthenticated +) + +func isGrantable(p auth.Permission) bool { return auth.IsValidPermission(p) } + +func registeredMethods() map[string]struct{} { + out := map[string]struct{}{} + protoregistry.GlobalFiles.RangeFiles(func(fd protoreflect.FileDescriptor) bool { + if !strings.HasPrefix(string(fd.Package()), "tracker.") { + return true + } + services := fd.Services() + for i := 0; i < services.Len(); i++ { + svc := services.Get(i) + methods := svc.Methods() + for j := 0; j < methods.Len(); j++ { + out[fmt.Sprintf("/%s/%s", svc.FullName(), methods.Get(j).Name())] = struct{}{} + } + } + return true + }) + return out +} + +func TestEveryRPCMethodIsMapped(t *testing.T) { + registered := registeredMethods() + assert.NotEmpty(t, registered) + for name := range registered { + _, ok := MethodPermissions[name] + assert.True(t, ok, "RPC %s has no entry in authz.MethodPermissions", name) + } +} + +func TestTableOnlyContainsGrantableOrPseudoPermissions(t *testing.T) { + for name, perm := range MethodPermissions { + if perm == PermPublicAlias || perm == PermAuthenticatedAlias { + continue + } + assert.True(t, isGrantable(perm), "%s maps to unknown permission %q", name, perm) + } +} From 49d93c8a014b5b1037535f5a81193c9a16c8aa79 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 11:59:44 +0200 Subject: [PATCH 12/39] style(auth): gofmt config test imports --- internal/auth/config_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/auth/config_test.go b/internal/auth/config_test.go index 9105736e..10f10a9c 100644 --- a/internal/auth/config_test.go +++ b/internal/auth/config_test.go @@ -1,8 +1,8 @@ package auth import ( - "encoding/base64" "bytes" + "encoding/base64" "testing" "time" From cb21bab4b1a371cb950eaafe8e59fd653fa34455 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 12:01:00 +0200 Subject: [PATCH 13/39] feat(auth): add user store, auth indexes and shared mongo connection --- internal/stores/auth_models.go | 80 +++++++++++++++++ internal/stores/auth_testing_test.go | 36 ++++++++ internal/stores/auth_users.go | 124 +++++++++++++++++++++++++++ internal/stores/auth_users_test.go | 68 +++++++++++++++ internal/stores/db.go | 18 ++-- internal/stores/indexes.go | 38 ++++++++ 6 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 internal/stores/auth_models.go create mode 100644 internal/stores/auth_testing_test.go create mode 100644 internal/stores/auth_users.go create mode 100644 internal/stores/auth_users_test.go diff --git a/internal/stores/auth_models.go b/internal/stores/auth_models.go new file mode 100644 index 00000000..36153a14 --- /dev/null +++ b/internal/stores/auth_models.go @@ -0,0 +1,80 @@ +package store + +import ( + "errors" + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +const ( + UserSourceLocal = "local" + UserSourceOIDC = "oidc" + + // AdministratorsTeamName is the built-in team that holds every permission. + AdministratorsTeamName = "Administrators" + + authUsersCollection = "auth_users" + authTeamsCollection = "auth_teams" + authAPIKeysCollection = "auth_api_keys" + authSettingsCollection = "auth_settings" +) + +var ( + ErrNotFound = errors.New("not found") + ErrAlreadyExists = errors.New("already exists") +) + +// User is a local or OIDC account. +type User struct { + ID primitive.ObjectID `bson:"_id,omitempty"` + Username string `bson:"username"` + UsernameLower string `bson:"usernameLower"` + Email string `bson:"email"` + DisplayName string `bson:"displayName"` + Source string `bson:"source"` + PasswordHash string `bson:"passwordHash,omitempty"` + OIDCIssuer string `bson:"oidcIssuer,omitempty"` + OIDCSubject string `bson:"oidcSubject,omitempty"` + Teams []primitive.ObjectID `bson:"teams"` + Disabled bool `bson:"disabled"` + MustChangePassword bool `bson:"mustChangePassword"` + SessionVersion int `bson:"sessionVersion"` + CreatedAt time.Time `bson:"createdAt"` + UpdatedAt time.Time `bson:"updatedAt"` + LastLoginAt *time.Time `bson:"lastLoginAt,omitempty"` +} + +// TeamScope restricts a team to catalog services. All wins over Services. +type TeamScope struct { + All bool `bson:"all"` + Services []string `bson:"services"` +} + +// Team groups users and API keys and carries permissions. +type Team struct { + ID primitive.ObjectID `bson:"_id,omitempty"` + Name string `bson:"name"` + NameLower string `bson:"nameLower"` + Description string `bson:"description"` + Permissions []string `bson:"permissions"` + Scope TeamScope `bson:"scope"` + OIDCGroups []string `bson:"oidcGroups"` + Builtin bool `bson:"builtin"` + CreatedAt time.Time `bson:"createdAt"` + UpdatedAt time.Time `bson:"updatedAt"` +} + +// APIKey is a machine credential. TeamID nil means a global key. +type APIKey struct { + ID primitive.ObjectID `bson:"_id,omitempty"` + Prefix string `bson:"prefix"` + Hash string `bson:"hash"` + Name string `bson:"name"` + TeamID *primitive.ObjectID `bson:"teamId"` + CreatedBy primitive.ObjectID `bson:"createdBy"` + CreatedAt time.Time `bson:"createdAt"` + ExpiresAt *time.Time `bson:"expiresAt,omitempty"` + LastUsedAt *time.Time `bson:"lastUsedAt,omitempty"` + RevokedAt *time.Time `bson:"revokedAt,omitempty"` +} diff --git a/internal/stores/auth_testing_test.go b/internal/stores/auth_testing_test.go new file mode 100644 index 00000000..313c3869 --- /dev/null +++ b/internal/stores/auth_testing_test.go @@ -0,0 +1,36 @@ +package store + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// testDatabase connects to MONGO_TEST_URI, creates a throwaway database with +// all indexes and drops it at the end of the test. +func testDatabase(t *testing.T) *mongo.Database { + t.Helper() + uri := os.Getenv("MONGO_TEST_URI") + if uri == "" { + t.Skip("MONGO_TEST_URI not set") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri)) + require.NoError(t, err) + db := client.Database(fmt.Sprintf("tracker_test_%d", time.Now().UnixNano())) + t.Cleanup(func() { + c, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = db.Drop(c) + _ = client.Disconnect(c) + }) + require.NoError(t, EnsureIndexes(ctx, db)) + return db +} diff --git a/internal/stores/auth_users.go b/internal/stores/auth_users.go new file mode 100644 index 00000000..e601d67e --- /dev/null +++ b/internal/stores/auth_users.go @@ -0,0 +1,124 @@ +package store + +import ( + "context" + "errors" + "strings" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// AuthUserStore persists users. +type AuthUserStore struct { + coll *mongo.Collection +} + +// NewAuthUserStore connects to the configured database. +func NewAuthUserStore() *AuthUserStore { + return NewAuthUserStoreFromCollection(NewClient(authUsersCollection)) +} + +// NewAuthUserStoreFromCollection wraps an existing collection (tests). +func NewAuthUserStoreFromCollection(coll *mongo.Collection) *AuthUserStore { + return &AuthUserStore{coll: coll} +} + +func (s *AuthUserStore) Create(ctx context.Context, u *User) error { + now := time.Now().UTC() + u.UsernameLower = strings.ToLower(u.Username) + u.CreatedAt, u.UpdatedAt = now, now + if u.Teams == nil { + u.Teams = []primitive.ObjectID{} + } + res, err := s.coll.InsertOne(ctx, u) + if mongo.IsDuplicateKeyError(err) { + return ErrAlreadyExists + } + if err != nil { + return err + } + u.ID = res.InsertedID.(primitive.ObjectID) + return nil +} + +func (s *AuthUserStore) GetByID(ctx context.Context, id primitive.ObjectID) (*User, error) { + return s.findOne(ctx, bson.M{"_id": id}) +} + +func (s *AuthUserStore) GetByUsername(ctx context.Context, username string) (*User, error) { + return s.findOne(ctx, bson.M{"usernameLower": strings.ToLower(strings.TrimSpace(username))}) +} + +func (s *AuthUserStore) findOne(ctx context.Context, filter bson.M) (*User, error) { + var u User + err := s.coll.FindOne(ctx, filter).Decode(&u) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &u, nil +} + +func (s *AuthUserStore) List(ctx context.Context) ([]*User, error) { + cur, err := s.coll.Find(ctx, bson.M{}, options.Find().SetSort(bson.D{{Key: "usernameLower", Value: 1}})) + if err != nil { + return nil, err + } + var out []*User + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + if out == nil { + out = []*User{} + } + return out, nil +} + +func (s *AuthUserStore) Update(ctx context.Context, u *User) error { + u.UsernameLower = strings.ToLower(u.Username) + u.UpdatedAt = time.Now().UTC() + if u.Teams == nil { + u.Teams = []primitive.ObjectID{} + } + res, err := s.coll.ReplaceOne(ctx, bson.M{"_id": u.ID}, u) + if mongo.IsDuplicateKeyError(err) { + return ErrAlreadyExists + } + if err != nil { + return err + } + if res.MatchedCount == 0 { + return ErrNotFound + } + return nil +} + +func (s *AuthUserStore) Count(ctx context.Context) (int64, error) { + return s.coll.CountDocuments(ctx, bson.M{}) +} + +func (s *AuthUserStore) TouchLogin(ctx context.Context, id primitive.ObjectID, at time.Time) error { + _, err := s.coll.UpdateByID(ctx, id, bson.M{"$set": bson.M{"lastLoginAt": at}}) + return err +} + +// RemoveTeam pulls a deleted team out of every user. +func (s *AuthUserStore) RemoveTeam(ctx context.Context, teamID primitive.ObjectID) error { + _, err := s.coll.UpdateMany(ctx, bson.M{"teams": teamID}, bson.M{"$pull": bson.M{"teams": teamID}}) + return err +} + +// CountEnabledInTeam counts enabled members of a team, ignoring excludeUser. +func (s *AuthUserStore) CountEnabledInTeam(ctx context.Context, teamID, excludeUser primitive.ObjectID) (int64, error) { + filter := bson.M{"teams": teamID, "disabled": false} + if !excludeUser.IsZero() { + filter["_id"] = bson.M{"$ne": excludeUser} + } + return s.coll.CountDocuments(ctx, filter) +} diff --git a/internal/stores/auth_users_test.go b/internal/stores/auth_users_test.go new file mode 100644 index 00000000..1e8bfe31 --- /dev/null +++ b/internal/stores/auth_users_test.go @@ -0,0 +1,68 @@ +package store + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func TestAuthUserStoreCRUD(t *testing.T) { + db := testDatabase(t) + s := NewAuthUserStoreFromCollection(db.Collection(authUsersCollection)) + ctx := context.Background() + + n, err := s.Count(ctx) + require.NoError(t, err) + assert.Zero(t, n) + + team := primitive.NewObjectID() + u := &User{Username: "Alice", Email: "alice@example.com", Source: UserSourceLocal, PasswordHash: "x", Teams: []primitive.ObjectID{team}} + require.NoError(t, s.Create(ctx, u)) + assert.False(t, u.ID.IsZero()) + assert.Equal(t, "alice", u.UsernameLower) + + dup := &User{Username: "ALICE", Source: UserSourceLocal} + assert.ErrorIs(t, s.Create(ctx, dup), ErrAlreadyExists) + + got, err := s.GetByUsername(ctx, "aLiCe") + require.NoError(t, err) + assert.Equal(t, u.ID, got.ID) + + _, err = s.GetByUsername(ctx, "nobody") + assert.ErrorIs(t, err, ErrNotFound) + + got.DisplayName = "Alice A." + got.SessionVersion++ + require.NoError(t, s.Update(ctx, got)) + again, err := s.GetByID(ctx, u.ID) + require.NoError(t, err) + assert.Equal(t, "Alice A.", again.DisplayName) + assert.Equal(t, 1, again.SessionVersion) + + at := time.Now().UTC().Truncate(time.Millisecond) + require.NoError(t, s.TouchLogin(ctx, u.ID, at)) + again, _ = s.GetByID(ctx, u.ID) + require.NotNil(t, again.LastLoginAt) + assert.Equal(t, at, again.LastLoginAt.UTC()) + + count, err := s.CountEnabledInTeam(ctx, team, primitive.NilObjectID) + require.NoError(t, err) + assert.Equal(t, int64(1), count) + count, err = s.CountEnabledInTeam(ctx, team, u.ID) + require.NoError(t, err) + assert.Zero(t, count) + + require.NoError(t, s.RemoveTeam(ctx, team)) + again, _ = s.GetByID(ctx, u.ID) + assert.Empty(t, again.Teams) + + list, err := s.List(ctx) + require.NoError(t, err) + assert.Len(t, list, 1) + + assert.ErrorIs(t, s.Update(ctx, &User{ID: primitive.NewObjectID(), Username: "ghost"}), ErrNotFound) +} diff --git a/internal/stores/db.go b/internal/stores/db.go index dd922dcd..cb0b7521 100644 --- a/internal/stores/db.go +++ b/internal/stores/db.go @@ -24,13 +24,17 @@ var cert tls.Certificate var mongoDatabase *mongo.Database func NewClient(collection string) (c *mongo.Collection) { + ctx := context.Background() + if mongoDatabase != nil { + ensureCollection(ctx, mongoDatabase, collection) + return mongoDatabase.Collection(collection) + } config := config.ConfigDatabase var m *mongo.Client var err error uri := createMongoUri(config) - ctx := context.Background() if config.CAFile != "" { tlsConfig := loadTlsCerts(config) @@ -45,17 +49,17 @@ func NewClient(collection string) (c *mongo.Collection) { } } - // Stocker la database pour l'initialisation des index + // Stocker la database pour l'initialisation des index et les stores suivants mongoDatabase = m.Database(config.Name) + ensureCollection(ctx, mongoDatabase, collection) + return mongoDatabase.Collection(collection) +} - // init client collection - err = mongoDatabase.CreateCollection(ctx, collection) - if err != nil { +func ensureCollection(ctx context.Context, db *mongo.Database, collection string) { + if err := db.CreateCollection(ctx, collection); err != nil { // Ignorer l'erreur si la collection existe déjà log.Printf("collection %s may already exist: %v", collection, err) } - - return mongoDatabase.Collection(collection) } // GetDatabase retourne l'instance de la base de données MongoDB diff --git a/internal/stores/indexes.go b/internal/stores/indexes.go index 52ffe0a4..970f6485 100644 --- a/internal/stores/indexes.go +++ b/internal/stores/indexes.go @@ -2,6 +2,7 @@ package store import ( "context" + "fmt" "log/slog" "time" @@ -35,6 +36,11 @@ func EnsureIndexes(ctx context.Context, db *mongo.Database) error { return err } + // Index pour les collections auth_* + if err := ensureAuthIndexes(ctx, db, logger); err != nil { + return err + } + logger.Info("All database indexes ensured successfully") return nil } @@ -192,6 +198,38 @@ func ensureLinksIndexes(ctx context.Context, db *mongo.Database, logger *slog.Lo return createIndexes(ctx, collection, indexes, logger, "links") } +func ensureAuthIndexes(ctx context.Context, db *mongo.Database, logger *slog.Logger) error { + users := []mongo.IndexModel{ + {Keys: bson.D{{Key: "usernameLower", Value: 1}}, Options: options.Index().SetUnique(true).SetName("idx_username_lower")}, + { + Keys: bson.D{{Key: "oidcIssuer", Value: 1}, {Key: "oidcSubject", Value: 1}}, + Options: options.Index().SetUnique(true).SetName("idx_oidc_identity"). + SetPartialFilterExpression(bson.D{{Key: "oidcSubject", Value: bson.D{{Key: "$exists", Value: true}}}}), + }, + {Keys: bson.D{{Key: "teams", Value: 1}}, Options: options.Index().SetName("idx_user_teams")}, + } + if _, err := db.Collection(authUsersCollection).Indexes().CreateMany(ctx, users); err != nil { + return fmt.Errorf("create %s indexes: %w", authUsersCollection, err) + } + + teams := []mongo.IndexModel{ + {Keys: bson.D{{Key: "nameLower", Value: 1}}, Options: options.Index().SetUnique(true).SetName("idx_team_name_lower")}, + } + if _, err := db.Collection(authTeamsCollection).Indexes().CreateMany(ctx, teams); err != nil { + return fmt.Errorf("create %s indexes: %w", authTeamsCollection, err) + } + + keys := []mongo.IndexModel{ + {Keys: bson.D{{Key: "prefix", Value: 1}}, Options: options.Index().SetUnique(true).SetName("idx_apikey_prefix")}, + {Keys: bson.D{{Key: "teamId", Value: 1}}, Options: options.Index().SetName("idx_apikey_team")}, + } + if _, err := db.Collection(authAPIKeysCollection).Indexes().CreateMany(ctx, keys); err != nil { + return fmt.Errorf("create %s indexes: %w", authAPIKeysCollection, err) + } + logger.Info("Auth indexes ensured") + return nil +} + func createIndexes(ctx context.Context, collection *mongo.Collection, indexes []mongo.IndexModel, logger *slog.Logger, collectionName string) error { // Créer un contexte avec timeout pour éviter les blocages ctxTimeout, cancel := context.WithTimeout(ctx, 30*time.Second) From c2b76ea13e8e5d5417bb72c8f8ee33408358b351 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 12:02:03 +0200 Subject: [PATCH 14/39] feat(auth): add team, API key and settings stores --- internal/stores/auth_apikeys.go | 92 +++++++++++++++++++ internal/stores/auth_apikeys_test.go | 52 +++++++++++ internal/stores/auth_settings.go | 48 ++++++++++ internal/stores/auth_settings_test.go | 22 +++++ internal/stores/auth_teams.go | 125 ++++++++++++++++++++++++++ internal/stores/auth_teams_test.go | 49 ++++++++++ 6 files changed, 388 insertions(+) create mode 100644 internal/stores/auth_apikeys.go create mode 100644 internal/stores/auth_apikeys_test.go create mode 100644 internal/stores/auth_settings.go create mode 100644 internal/stores/auth_settings_test.go create mode 100644 internal/stores/auth_teams.go create mode 100644 internal/stores/auth_teams_test.go diff --git a/internal/stores/auth_apikeys.go b/internal/stores/auth_apikeys.go new file mode 100644 index 00000000..74fa0b96 --- /dev/null +++ b/internal/stores/auth_apikeys.go @@ -0,0 +1,92 @@ +package store + +import ( + "context" + "errors" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// AuthAPIKeyStore persists API keys. Only the hash of a secret is stored. +type AuthAPIKeyStore struct { + coll *mongo.Collection +} + +func NewAuthAPIKeyStore() *AuthAPIKeyStore { + return NewAuthAPIKeyStoreFromCollection(NewClient(authAPIKeysCollection)) +} + +func NewAuthAPIKeyStoreFromCollection(coll *mongo.Collection) *AuthAPIKeyStore { + return &AuthAPIKeyStore{coll: coll} +} + +func (s *AuthAPIKeyStore) Create(ctx context.Context, k *APIKey) error { + k.CreatedAt = time.Now().UTC() + res, err := s.coll.InsertOne(ctx, k) + if mongo.IsDuplicateKeyError(err) { + return ErrAlreadyExists + } + if err != nil { + return err + } + k.ID = res.InsertedID.(primitive.ObjectID) + return nil +} + +func (s *AuthAPIKeyStore) GetByID(ctx context.Context, id primitive.ObjectID) (*APIKey, error) { + return s.findOne(ctx, bson.M{"_id": id}) +} + +func (s *AuthAPIKeyStore) GetByPrefix(ctx context.Context, prefix string) (*APIKey, error) { + return s.findOne(ctx, bson.M{"prefix": prefix}) +} + +func (s *AuthAPIKeyStore) findOne(ctx context.Context, filter bson.M) (*APIKey, error) { + var k APIKey + err := s.coll.FindOne(ctx, filter).Decode(&k) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &k, nil +} + +func (s *AuthAPIKeyStore) List(ctx context.Context) ([]*APIKey, error) { + cur, err := s.coll.Find(ctx, bson.M{}, options.Find().SetSort(bson.D{{Key: "createdAt", Value: -1}})) + if err != nil { + return nil, err + } + out := []*APIKey{} + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return out, nil +} + +func (s *AuthAPIKeyStore) Revoke(ctx context.Context, id primitive.ObjectID, at time.Time) error { + res, err := s.coll.UpdateOne(ctx, bson.M{"_id": id, "revokedAt": nil}, bson.M{"$set": bson.M{"revokedAt": at}}) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return ErrNotFound + } + return nil +} + +// RevokeByTeam revokes every active key of a team, when the team is deleted. +func (s *AuthAPIKeyStore) RevokeByTeam(ctx context.Context, teamID primitive.ObjectID, at time.Time) error { + _, err := s.coll.UpdateMany(ctx, bson.M{"teamId": teamID, "revokedAt": nil}, bson.M{"$set": bson.M{"revokedAt": at}}) + return err +} + +func (s *AuthAPIKeyStore) TouchLastUsed(ctx context.Context, id primitive.ObjectID, at time.Time) error { + _, err := s.coll.UpdateByID(ctx, id, bson.M{"$set": bson.M{"lastUsedAt": at}}) + return err +} diff --git a/internal/stores/auth_apikeys_test.go b/internal/stores/auth_apikeys_test.go new file mode 100644 index 00000000..d6b5c13e --- /dev/null +++ b/internal/stores/auth_apikeys_test.go @@ -0,0 +1,52 @@ +package store + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func TestAuthAPIKeyStore(t *testing.T) { + db := testDatabase(t) + s := NewAuthAPIKeyStoreFromCollection(db.Collection(authAPIKeysCollection)) + ctx := context.Background() + team := primitive.NewObjectID() + + k := &APIKey{Prefix: "abcdefgh", Hash: "h1", Name: "ci", TeamID: &team, CreatedBy: primitive.NewObjectID()} + require.NoError(t, s.Create(ctx, k)) + assert.False(t, k.CreatedAt.IsZero()) + assert.ErrorIs(t, s.Create(ctx, &APIKey{Prefix: "abcdefgh", Hash: "h2", Name: "dup"}), ErrAlreadyExists) + + global := &APIKey{Prefix: "zzzzzzzz", Hash: "h3", Name: "global", CreatedBy: primitive.NewObjectID()} + require.NoError(t, s.Create(ctx, global)) + + got, err := s.GetByPrefix(ctx, "abcdefgh") + require.NoError(t, err) + assert.Equal(t, "ci", got.Name) + _, err = s.GetByPrefix(ctx, "nope") + assert.ErrorIs(t, err, ErrNotFound) + + at := time.Now().UTC().Truncate(time.Millisecond) + require.NoError(t, s.TouchLastUsed(ctx, k.ID, at)) + got, _ = s.GetByID(ctx, k.ID) + require.NotNil(t, got.LastUsedAt) + + require.NoError(t, s.RevokeByTeam(ctx, team, at)) + got, _ = s.GetByID(ctx, k.ID) + require.NotNil(t, got.RevokedAt) + g, _ := s.GetByID(ctx, global.ID) + assert.Nil(t, g.RevokedAt, "global keys are untouched") + + require.NoError(t, s.Revoke(ctx, global.ID, at)) + g, _ = s.GetByID(ctx, global.ID) + assert.NotNil(t, g.RevokedAt) + assert.ErrorIs(t, s.Revoke(ctx, primitive.NewObjectID(), at), ErrNotFound) + + list, err := s.List(ctx) + require.NoError(t, err) + assert.Len(t, list, 2) +} diff --git a/internal/stores/auth_settings.go b/internal/stores/auth_settings.go new file mode 100644 index 00000000..5967fdfa --- /dev/null +++ b/internal/stores/auth_settings.go @@ -0,0 +1,48 @@ +package store + +import ( + "context" + "crypto/rand" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// AuthSettingsStore holds server generated secrets that must survive restarts. +type AuthSettingsStore struct { + coll *mongo.Collection +} + +func NewAuthSettingsStore() *AuthSettingsStore { + return NewAuthSettingsStoreFromCollection(NewClient(authSettingsCollection)) +} + +func NewAuthSettingsStoreFromCollection(coll *mongo.Collection) *AuthSettingsStore { + return &AuthSettingsStore{coll: coll} +} + +type sessionSecretDoc struct { + Secret []byte `bson:"secret"` +} + +// SessionSecret returns the persisted session secret, generating it on first call. +// Concurrent first calls are safe: the upsert only inserts once. +func (s *AuthSettingsStore) SessionSecret(ctx context.Context) ([]byte, error) { + fresh := make([]byte, 32) + if _, err := rand.Read(fresh); err != nil { + return nil, fmt.Errorf("generate session secret: %w", err) + } + var doc sessionSecretDoc + err := s.coll.FindOneAndUpdate(ctx, + bson.M{"_id": "session"}, + bson.M{"$setOnInsert": bson.M{"secret": fresh, "createdAt": time.Now().UTC()}}, + options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After), + ).Decode(&doc) + if err != nil { + return nil, fmt.Errorf("load session secret: %w", err) + } + return doc.Secret, nil +} diff --git a/internal/stores/auth_settings_test.go b/internal/stores/auth_settings_test.go new file mode 100644 index 00000000..c68c6aab --- /dev/null +++ b/internal/stores/auth_settings_test.go @@ -0,0 +1,22 @@ +package store + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuthSettingsSessionSecretIsStable(t *testing.T) { + db := testDatabase(t) + s := NewAuthSettingsStoreFromCollection(db.Collection(authSettingsCollection)) + ctx := context.Background() + + first, err := s.SessionSecret(ctx) + require.NoError(t, err) + assert.Len(t, first, 32) + second, err := s.SessionSecret(ctx) + require.NoError(t, err) + assert.Equal(t, first, second) +} diff --git a/internal/stores/auth_teams.go b/internal/stores/auth_teams.go new file mode 100644 index 00000000..02f5d5d9 --- /dev/null +++ b/internal/stores/auth_teams.go @@ -0,0 +1,125 @@ +package store + +import ( + "context" + "errors" + "strings" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// AuthTeamStore persists teams. +type AuthTeamStore struct { + coll *mongo.Collection +} + +func NewAuthTeamStore() *AuthTeamStore { + return NewAuthTeamStoreFromCollection(NewClient(authTeamsCollection)) +} + +func NewAuthTeamStoreFromCollection(coll *mongo.Collection) *AuthTeamStore { + return &AuthTeamStore{coll: coll} +} + +func normalizeTeam(t *Team) { + t.NameLower = strings.ToLower(strings.TrimSpace(t.Name)) + if t.Permissions == nil { + t.Permissions = []string{} + } + if t.Scope.Services == nil { + t.Scope.Services = []string{} + } + if t.OIDCGroups == nil { + t.OIDCGroups = []string{} + } +} + +func (s *AuthTeamStore) Create(ctx context.Context, t *Team) error { + now := time.Now().UTC() + normalizeTeam(t) + t.CreatedAt, t.UpdatedAt = now, now + res, err := s.coll.InsertOne(ctx, t) + if mongo.IsDuplicateKeyError(err) { + return ErrAlreadyExists + } + if err != nil { + return err + } + t.ID = res.InsertedID.(primitive.ObjectID) + return nil +} + +func (s *AuthTeamStore) GetByID(ctx context.Context, id primitive.ObjectID) (*Team, error) { + return s.findOne(ctx, bson.M{"_id": id}) +} + +func (s *AuthTeamStore) GetByName(ctx context.Context, name string) (*Team, error) { + return s.findOne(ctx, bson.M{"nameLower": strings.ToLower(strings.TrimSpace(name))}) +} + +func (s *AuthTeamStore) findOne(ctx context.Context, filter bson.M) (*Team, error) { + var t Team + err := s.coll.FindOne(ctx, filter).Decode(&t) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &t, nil +} + +// GetByIDs returns the teams that exist among ids, in name order. +func (s *AuthTeamStore) GetByIDs(ctx context.Context, ids []primitive.ObjectID) ([]*Team, error) { + if len(ids) == 0 { + return []*Team{}, nil + } + return s.find(ctx, bson.M{"_id": bson.M{"$in": ids}}) +} + +func (s *AuthTeamStore) List(ctx context.Context) ([]*Team, error) { + return s.find(ctx, bson.M{}) +} + +func (s *AuthTeamStore) find(ctx context.Context, filter bson.M) ([]*Team, error) { + cur, err := s.coll.Find(ctx, filter, options.Find().SetSort(bson.D{{Key: "nameLower", Value: 1}})) + if err != nil { + return nil, err + } + out := []*Team{} + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return out, nil +} + +func (s *AuthTeamStore) Update(ctx context.Context, t *Team) error { + normalizeTeam(t) + t.UpdatedAt = time.Now().UTC() + res, err := s.coll.ReplaceOne(ctx, bson.M{"_id": t.ID}, t) + if mongo.IsDuplicateKeyError(err) { + return ErrAlreadyExists + } + if err != nil { + return err + } + if res.MatchedCount == 0 { + return ErrNotFound + } + return nil +} + +func (s *AuthTeamStore) Delete(ctx context.Context, id primitive.ObjectID) error { + res, err := s.coll.DeleteOne(ctx, bson.M{"_id": id}) + if err != nil { + return err + } + if res.DeletedCount == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/stores/auth_teams_test.go b/internal/stores/auth_teams_test.go new file mode 100644 index 00000000..25f3bdea --- /dev/null +++ b/internal/stores/auth_teams_test.go @@ -0,0 +1,49 @@ +package store + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func TestAuthTeamStoreCRUD(t *testing.T) { + db := testDatabase(t) + s := NewAuthTeamStoreFromCollection(db.Collection(authTeamsCollection)) + ctx := context.Background() + + team := &Team{Name: "Platform", Permissions: []string{"event:read"}, Scope: TeamScope{All: true}} + require.NoError(t, s.Create(ctx, team)) + assert.Equal(t, "platform", team.NameLower) + assert.ErrorIs(t, s.Create(ctx, &Team{Name: "PLATFORM"}), ErrAlreadyExists) + + got, err := s.GetByName(ctx, "platform") + require.NoError(t, err) + assert.Equal(t, team.ID, got.ID) + + other := &Team{Name: "Ops", Scope: TeamScope{Services: []string{"api"}}} + require.NoError(t, s.Create(ctx, other)) + byIDs, err := s.GetByIDs(ctx, []primitive.ObjectID{team.ID, other.ID, primitive.NewObjectID()}) + require.NoError(t, err) + assert.Len(t, byIDs, 2) + + empty, err := s.GetByIDs(ctx, nil) + require.NoError(t, err) + assert.Empty(t, empty) + + got.Description = "platform team" + require.NoError(t, s.Update(ctx, got)) + again, _ := s.GetByID(ctx, team.ID) + assert.Equal(t, "platform team", again.Description) + + require.NoError(t, s.Delete(ctx, other.ID)) + _, err = s.GetByID(ctx, other.ID) + assert.ErrorIs(t, err, ErrNotFound) + assert.ErrorIs(t, s.Delete(ctx, other.ID), ErrNotFound) + + list, err := s.List(ctx) + require.NoError(t, err) + assert.Len(t, list, 1) +} From c98efbaa9e05bd62608c00f5d4c4a726402c872f Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 12:04:43 +0200 Subject: [PATCH 15/39] fix(proto): regenerate event descriptor with waiting_approval status --- generated/proto/event/v1alpha1/event.pb.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/generated/proto/event/v1alpha1/event.pb.go b/generated/proto/event/v1alpha1/event.pb.go index ac08a9ff..85bddc27 100644 --- a/generated/proto/event/v1alpha1/event.pb.go +++ b/generated/proto/event/v1alpha1/event.pb.go @@ -2543,7 +2543,7 @@ const file_proto_event_v1alpha1_event_proto_rawDesc = "" + "\x02P2\x10\x02\x12\x06\n" + "\x02P3\x10\x03\x12\x06\n" + "\x02P4\x10\x04\x12\x06\n" + - "\x02P5\x10\x05*\xcd\x01\n" + + "\x02P5\x10\x05*\xe3\x01\n" + "\x06Status\x12\x16\n" + "\x12STATUS_UNSPECIFIED\x10\x00\x12\t\n" + "\x05start\x10\x01\x12\v\n" + @@ -2559,7 +2559,8 @@ const file_proto_event_v1alpha1_event_proto_rawDesc = "" + "\x12\b\n" + "\x04done\x10\v\x12\x0f\n" + "\vin_progress\x10\f\x12\v\n" + - "\aplanned\x10\r*\x97\x01\n" + + "\aplanned\x10\r\x12\x14\n" + + "\x10waiting_approval\x10\x0e*\x97\x01\n" + "\vEnvironment\x12\x1b\n" + "\x17ENVIRONMENT_UNSPECIFIED\x10\x00\x12\x0f\n" + "\vdevelopment\x10\x01\x12\x0f\n" + From 4f95fbc67fb3fd93bca750fa34f9e7f3cfa79aa4 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 12:05:19 +0200 Subject: [PATCH 16/39] feat(auth): add AuthService proto and generated code --- generated/openapiv2/apidocs.swagger.json | 776 +++- generated/proto/auth/v1alpha1/auth.pb.go | 1977 ++++++++++ generated/proto/auth/v1alpha1/auth.pb.gw.go | 913 +++++ .../proto/auth/v1alpha1/auth.pb.validate.go | 3456 +++++++++++++++++ generated/proto/auth/v1alpha1/auth_grpc.pb.go | 547 +++ internal/auth/authz/methods_test.go | 9 + proto/auth/v1alpha1/auth.proto | 227 ++ 7 files changed, 7896 insertions(+), 9 deletions(-) create mode 100644 generated/proto/auth/v1alpha1/auth.pb.go create mode 100644 generated/proto/auth/v1alpha1/auth.pb.gw.go create mode 100644 generated/proto/auth/v1alpha1/auth.pb.validate.go create mode 100644 generated/proto/auth/v1alpha1/auth_grpc.pb.go create mode 100644 proto/auth/v1alpha1/auth.proto diff --git a/generated/openapiv2/apidocs.swagger.json b/generated/openapiv2/apidocs.swagger.json index ae414566..65213605 100644 --- a/generated/openapiv2/apidocs.swagger.json +++ b/generated/openapiv2/apidocs.swagger.json @@ -1,10 +1,14 @@ { "swagger": "2.0", "info": { - "title": "proto/catalog/v1alpha1/catalog.proto", + "title": "proto/auth/v1alpha1/auth.proto", "version": "version not set" }, "tags": [ + { + "name": "AuthService", + "description": "AuthService exposes the current identity and the administration of users,\nteams and API keys. Login, logout and password change are plain HTTP\nhandlers because they set cookies." + }, { "name": "CatalogService" }, @@ -22,6 +26,340 @@ "application/json" ], "paths": { + "/api/v1alpha1/auth/api-keys": { + "get": { + "operationId": "AuthService_ListApiKeys", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1alpha1ListApiKeysResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googleRpcStatus" + } + } + }, + "tags": [ + "AuthService" + ] + }, + "post": { + "operationId": "AuthService_CreateApiKey", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1alpha1CreateApiKeyResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googleRpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1alpha1CreateApiKeyRequest" + } + } + ], + "tags": [ + "AuthService" + ] + } + }, + "/api/v1alpha1/auth/api-keys/{id}": { + "delete": { + "operationId": "AuthService_RevokeApiKey", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1alpha1RevokeApiKeyResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googleRpcStatus" + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + } + ], + "tags": [ + "AuthService" + ] + } + }, + "/api/v1alpha1/auth/config": { + "get": { + "operationId": "AuthService_GetAuthConfig", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1alpha1GetAuthConfigResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googleRpcStatus" + } + } + }, + "tags": [ + "AuthService" + ] + } + }, + "/api/v1alpha1/auth/me": { + "get": { + "operationId": "AuthService_Me", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1alpha1MeResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googleRpcStatus" + } + } + }, + "tags": [ + "AuthService" + ] + } + }, + "/api/v1alpha1/auth/teams": { + "get": { + "operationId": "AuthService_ListTeams", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1alpha1ListTeamsResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googleRpcStatus" + } + } + }, + "tags": [ + "AuthService" + ] + }, + "post": { + "operationId": "AuthService_CreateTeam", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1alpha1CreateTeamResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googleRpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1alpha1CreateTeamRequest" + } + } + ], + "tags": [ + "AuthService" + ] + } + }, + "/api/v1alpha1/auth/teams/{id}": { + "delete": { + "operationId": "AuthService_DeleteTeam", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1alpha1DeleteTeamResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googleRpcStatus" + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + } + ], + "tags": [ + "AuthService" + ] + }, + "put": { + "operationId": "AuthService_UpdateTeam", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1alpha1UpdateTeamResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googleRpcStatus" + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/AuthServiceUpdateTeamBody" + } + } + ], + "tags": [ + "AuthService" + ] + } + }, + "/api/v1alpha1/auth/users": { + "get": { + "operationId": "AuthService_ListUsers", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1alpha1ListUsersResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googleRpcStatus" + } + } + }, + "tags": [ + "AuthService" + ] + }, + "post": { + "operationId": "AuthService_CreateUser", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1alpha1CreateUserResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googleRpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1alpha1CreateUserRequest" + } + } + ], + "tags": [ + "AuthService" + ] + } + }, + "/api/v1alpha1/auth/users/{id}": { + "put": { + "operationId": "AuthService_UpdateUser", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1alpha1UpdateUserResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googleRpcStatus" + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/AuthServiceUpdateUserBody" + } + } + ], + "tags": [ + "AuthService" + ] + } + }, "/api/v1alpha1/catalog": { "get": { "operationId": "CatalogService_GetCatalog", @@ -634,7 +972,8 @@ "close", "done", "in_progress", - "planned" + "planned", + "waiting_approval" ], "default": "STATUS_UNSPECIFIED" }, @@ -810,7 +1149,8 @@ "close", "done", "in_progress", - "planned" + "planned", + "waiting_approval" ] }, "collectionFormat": "multi" @@ -951,7 +1291,8 @@ "close", "done", "in_progress", - "planned" + "planned", + "waiting_approval" ] }, "collectionFormat": "multi" @@ -1187,6 +1528,62 @@ } }, "definitions": { + "AuthServiceUpdateTeamBody": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "scope_all": { + "type": "boolean" + }, + "scope_services": { + "type": "array", + "items": { + "type": "string" + } + }, + "oidc_groups": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "AuthServiceUpdateUserBody": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "team_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "disabled": { + "type": "boolean" + }, + "new_password": { + "type": "string" + } + }, + "description": "UpdateUserRequest replaces email, display_name, team_ids and disabled.\nnew_password, when set, resets the password and invalidates sessions." + }, "CatalogServiceUpdateDependenciesBody": { "type": "object", "properties": { @@ -1284,7 +1681,8 @@ "close", "done", "in_progress", - "planned" + "planned", + "waiting_approval" ], "default": "STATUS_UNSPECIFIED" }, @@ -1357,8 +1755,45 @@ "event": { "$ref": "#/definitions/v1alpha1Event" } - }, - "title": "Response returns the updated event" + }, + "title": "Response returns the updated event" + }, + "v1alpha1ApiKey": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "prefix": { + "type": "string" + }, + "name": { + "type": "string" + }, + "team_id": { + "type": "string", + "description": "Empty for a global key." + }, + "created_by": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "last_used_at": { + "type": "string", + "format": "date-time" + }, + "revoked_at": { + "type": "string", + "format": "date-time" + } + } }, "v1alpha1Catalog": { "type": "object", @@ -1545,7 +1980,7 @@ "telegram" ], "default": "COMMUNICATION_TYPE_UNSPECIFIED", - "title": "- slack: Slack channel\n - teams: Microsoft Teams channel\n - email: Email address\n - discord: Discord channel\n - mattermost: Mattermost channel\n - telegram: Telegram group/channel" + "description": "- slack: Slack channel\n - teams: Microsoft Teams channel\n - email: Email address\n - discord: Discord channel\n - mattermost: Mattermost channel\n - telegram: Telegram group/channel" }, "v1alpha1ComplianceSummary": { "type": "object", @@ -1575,6 +2010,34 @@ } } }, + "v1alpha1CreateApiKeyRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "team_id": { + "type": "string", + "description": "Empty creates a global key, allowed only for Administrators members." + }, + "expires_at": { + "type": "string", + "format": "date-time" + } + } + }, + "v1alpha1CreateApiKeyResponse": { + "type": "object", + "properties": { + "api_key": { + "$ref": "#/definitions/v1alpha1ApiKey" + }, + "secret": { + "type": "string", + "description": "Shown once, never stored." + } + } + }, "v1alpha1CreateEventRequest": { "type": "object", "properties": { @@ -1628,6 +2091,46 @@ } } }, + "v1alpha1CreateTeamRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "scope_all": { + "type": "boolean" + }, + "scope_services": { + "type": "array", + "items": { + "type": "string" + } + }, + "oidc_groups": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "v1alpha1CreateTeamResponse": { + "type": "object", + "properties": { + "team": { + "$ref": "#/definitions/v1alpha1Team" + } + } + }, "v1alpha1CreateUpdateCatalogRequest": { "type": "object", "properties": { @@ -1729,6 +2232,38 @@ } } }, + "v1alpha1CreateUserRequest": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "password": { + "type": "string", + "description": "Temporary password, the user must change it at first login." + }, + "team_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "v1alpha1CreateUserResponse": { + "type": "object", + "properties": { + "user": { + "$ref": "#/definitions/v1alpha1User" + } + } + }, "v1alpha1DashboardLink": { "type": "object", "properties": { @@ -1766,7 +2301,7 @@ "custom" ], "default": "DASHBOARD_TYPE_UNSPECIFIED", - "title": "- grafana: Grafana dashboard\n - datadog: Datadog dashboard\n - newrelic: New Relic dashboard\n - prometheus: Prometheus dashboard\n - kibana: Kibana dashboard\n - splunk: Splunk dashboard\n - dynatrace: Dynatrace dashboard\n - appdynamics: AppDynamics dashboard\n - custom: Custom dashboard platform" + "description": "- grafana: Grafana dashboard\n - datadog: Datadog dashboard\n - newrelic: New Relic dashboard\n - prometheus: Prometheus dashboard\n - kibana: Kibana dashboard\n - splunk: Splunk dashboard\n - dynatrace: Dynatrace dashboard\n - appdynamics: AppDynamics dashboard\n - custom: Custom dashboard platform" }, "v1alpha1DeleteCatalogResponse": { "type": "object", @@ -1790,6 +2325,9 @@ } } }, + "v1alpha1DeleteTeamResponse": { + "type": "object" + }, "v1alpha1DeliverableComplianceStats": { "type": "object", "properties": { @@ -1972,6 +2510,29 @@ } } }, + "v1alpha1GetAuthConfigResponse": { + "type": "object", + "properties": { + "local_login_enabled": { + "type": "boolean" + }, + "oidc_enabled": { + "type": "boolean" + }, + "oidc_button_label": { + "type": "string" + }, + "anonymous_permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "demo_mode": { + "type": "boolean" + } + } + }, "v1alpha1GetCatalogResponse": { "type": "object", "properties": { @@ -2174,6 +2735,18 @@ ], "default": "LANGUAGES_UNSPECIFIED" }, + "v1alpha1ListApiKeysResponse": { + "type": "object", + "properties": { + "api_keys": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1alpha1ApiKey" + } + } + } + }, "v1alpha1ListCatalogsResponse": { "type": "object", "properties": { @@ -2222,6 +2795,30 @@ } } }, + "v1alpha1ListTeamsResponse": { + "type": "object", + "properties": { + "teams": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1alpha1Team" + } + } + } + }, + "v1alpha1ListUsersResponse": { + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1alpha1User" + } + } + } + }, "v1alpha1Lock": { "type": "object", "properties": { @@ -2252,6 +2849,59 @@ } } }, + "v1alpha1MeResponse": { + "type": "object", + "properties": { + "authenticated": { + "type": "boolean" + }, + "kind": { + "type": "string", + "title": "\"anonymous\", \"user\" or \"apikey\"" + }, + "user_id": { + "type": "string" + }, + "username": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "source": { + "type": "string", + "title": "\"local\" or \"oidc\", empty for API keys and anonymous" + }, + "teams": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1alpha1TeamRef" + } + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "scope_all": { + "type": "boolean" + }, + "scope_services": { + "type": "array", + "items": { + "type": "string" + } + }, + "must_change_password": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + } + } + }, "v1alpha1MonthlyStats": { "type": "object", "properties": { @@ -2340,6 +2990,9 @@ } } }, + "v1alpha1RevokeApiKeyResponse": { + "type": "object" + }, "v1alpha1SLA": { "type": "object", "properties": { @@ -2386,6 +3039,55 @@ } } }, + "v1alpha1Team": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "scope_all": { + "type": "boolean" + }, + "scope_services": { + "type": "array", + "items": { + "type": "string" + } + }, + "oidc_groups": { + "type": "array", + "items": { + "type": "string" + } + }, + "builtin": { + "type": "boolean" + } + } + }, + "v1alpha1TeamRef": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "v1alpha1TodayEventsResponse": { "type": "object", "properties": { @@ -2462,6 +3164,22 @@ } } }, + "v1alpha1UpdateTeamResponse": { + "type": "object", + "properties": { + "team": { + "$ref": "#/definitions/v1alpha1Team" + } + } + }, + "v1alpha1UpdateUserResponse": { + "type": "object", + "properties": { + "user": { + "$ref": "#/definitions/v1alpha1User" + } + } + }, "v1alpha1UpdateVersionsResponse": { "type": "object", "properties": { @@ -2493,6 +3211,46 @@ }, "title": "Used deliverable in a project" }, + "v1alpha1User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "source": { + "type": "string" + }, + "team_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "disabled": { + "type": "boolean" + }, + "must_change_password": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "last_login_at": { + "type": "string", + "format": "date-time" + } + } + }, "v1alpha1VulnerabilitySource": { "type": "object", "properties": { diff --git a/generated/proto/auth/v1alpha1/auth.pb.go b/generated/proto/auth/v1alpha1/auth.pb.go new file mode 100644 index 00000000..ac5fcfb5 --- /dev/null +++ b/generated/proto/auth/v1alpha1/auth.pb.go @@ -0,0 +1,1977 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc (unknown) +// source: proto/auth/v1alpha1/auth.proto + +package v1alpha1 + +import ( + _ "google.golang.org/genproto/googleapis/api/annotations" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetAuthConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetAuthConfigRequest) Reset() { + *x = GetAuthConfigRequest{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetAuthConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAuthConfigRequest) ProtoMessage() {} + +func (x *GetAuthConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetAuthConfigRequest.ProtoReflect.Descriptor instead. +func (*GetAuthConfigRequest) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{0} +} + +type GetAuthConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + LocalLoginEnabled bool `protobuf:"varint,1,opt,name=local_login_enabled,json=localLoginEnabled,proto3" json:"local_login_enabled,omitempty"` + OidcEnabled bool `protobuf:"varint,2,opt,name=oidc_enabled,json=oidcEnabled,proto3" json:"oidc_enabled,omitempty"` + OidcButtonLabel string `protobuf:"bytes,3,opt,name=oidc_button_label,json=oidcButtonLabel,proto3" json:"oidc_button_label,omitempty"` + AnonymousPermissions []string `protobuf:"bytes,4,rep,name=anonymous_permissions,json=anonymousPermissions,proto3" json:"anonymous_permissions,omitempty"` + DemoMode bool `protobuf:"varint,5,opt,name=demo_mode,json=demoMode,proto3" json:"demo_mode,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetAuthConfigResponse) Reset() { + *x = GetAuthConfigResponse{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetAuthConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAuthConfigResponse) ProtoMessage() {} + +func (x *GetAuthConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetAuthConfigResponse.ProtoReflect.Descriptor instead. +func (*GetAuthConfigResponse) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{1} +} + +func (x *GetAuthConfigResponse) GetLocalLoginEnabled() bool { + if x != nil { + return x.LocalLoginEnabled + } + return false +} + +func (x *GetAuthConfigResponse) GetOidcEnabled() bool { + if x != nil { + return x.OidcEnabled + } + return false +} + +func (x *GetAuthConfigResponse) GetOidcButtonLabel() string { + if x != nil { + return x.OidcButtonLabel + } + return "" +} + +func (x *GetAuthConfigResponse) GetAnonymousPermissions() []string { + if x != nil { + return x.AnonymousPermissions + } + return nil +} + +func (x *GetAuthConfigResponse) GetDemoMode() bool { + if x != nil { + return x.DemoMode + } + return false +} + +type MeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MeRequest) Reset() { + *x = MeRequest{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MeRequest) ProtoMessage() {} + +func (x *MeRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MeRequest.ProtoReflect.Descriptor instead. +func (*MeRequest) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{2} +} + +type TeamRef struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TeamRef) Reset() { + *x = TeamRef{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TeamRef) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TeamRef) ProtoMessage() {} + +func (x *TeamRef) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TeamRef.ProtoReflect.Descriptor instead. +func (*TeamRef) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{3} +} + +func (x *TeamRef) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *TeamRef) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type MeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Authenticated bool `protobuf:"varint,1,opt,name=authenticated,proto3" json:"authenticated,omitempty"` + // "anonymous", "user" or "apikey" + Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` + UserId string `protobuf:"bytes,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Username string `protobuf:"bytes,4,opt,name=username,proto3" json:"username,omitempty"` + DisplayName string `protobuf:"bytes,5,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` + // "local" or "oidc", empty for API keys and anonymous + Source string `protobuf:"bytes,6,opt,name=source,proto3" json:"source,omitempty"` + Teams []*TeamRef `protobuf:"bytes,7,rep,name=teams,proto3" json:"teams,omitempty"` + Permissions []string `protobuf:"bytes,8,rep,name=permissions,proto3" json:"permissions,omitempty"` + ScopeAll bool `protobuf:"varint,9,opt,name=scope_all,json=scopeAll,proto3" json:"scope_all,omitempty"` + ScopeServices []string `protobuf:"bytes,10,rep,name=scope_services,json=scopeServices,proto3" json:"scope_services,omitempty"` + MustChangePassword bool `protobuf:"varint,11,opt,name=must_change_password,json=mustChangePassword,proto3" json:"must_change_password,omitempty"` + IsAdmin bool `protobuf:"varint,12,opt,name=is_admin,json=isAdmin,proto3" json:"is_admin,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MeResponse) Reset() { + *x = MeResponse{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MeResponse) ProtoMessage() {} + +func (x *MeResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MeResponse.ProtoReflect.Descriptor instead. +func (*MeResponse) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{4} +} + +func (x *MeResponse) GetAuthenticated() bool { + if x != nil { + return x.Authenticated + } + return false +} + +func (x *MeResponse) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *MeResponse) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *MeResponse) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *MeResponse) GetDisplayName() string { + if x != nil { + return x.DisplayName + } + return "" +} + +func (x *MeResponse) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *MeResponse) GetTeams() []*TeamRef { + if x != nil { + return x.Teams + } + return nil +} + +func (x *MeResponse) GetPermissions() []string { + if x != nil { + return x.Permissions + } + return nil +} + +func (x *MeResponse) GetScopeAll() bool { + if x != nil { + return x.ScopeAll + } + return false +} + +func (x *MeResponse) GetScopeServices() []string { + if x != nil { + return x.ScopeServices + } + return nil +} + +func (x *MeResponse) GetMustChangePassword() bool { + if x != nil { + return x.MustChangePassword + } + return false +} + +func (x *MeResponse) GetIsAdmin() bool { + if x != nil { + return x.IsAdmin + } + return false +} + +type User struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` + Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"` + DisplayName string `protobuf:"bytes,4,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` + Source string `protobuf:"bytes,5,opt,name=source,proto3" json:"source,omitempty"` + TeamIds []string `protobuf:"bytes,6,rep,name=team_ids,json=teamIds,proto3" json:"team_ids,omitempty"` + Disabled bool `protobuf:"varint,7,opt,name=disabled,proto3" json:"disabled,omitempty"` + MustChangePassword bool `protobuf:"varint,8,opt,name=must_change_password,json=mustChangePassword,proto3" json:"must_change_password,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + LastLoginAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=last_login_at,json=lastLoginAt,proto3" json:"last_login_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *User) Reset() { + *x = User{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *User) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*User) ProtoMessage() {} + +func (x *User) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use User.ProtoReflect.Descriptor instead. +func (*User) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{5} +} + +func (x *User) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *User) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *User) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *User) GetDisplayName() string { + if x != nil { + return x.DisplayName + } + return "" +} + +func (x *User) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *User) GetTeamIds() []string { + if x != nil { + return x.TeamIds + } + return nil +} + +func (x *User) GetDisabled() bool { + if x != nil { + return x.Disabled + } + return false +} + +func (x *User) GetMustChangePassword() bool { + if x != nil { + return x.MustChangePassword + } + return false +} + +func (x *User) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *User) GetLastLoginAt() *timestamppb.Timestamp { + if x != nil { + return x.LastLoginAt + } + return nil +} + +type ListUsersRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListUsersRequest) Reset() { + *x = ListUsersRequest{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListUsersRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListUsersRequest) ProtoMessage() {} + +func (x *ListUsersRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListUsersRequest.ProtoReflect.Descriptor instead. +func (*ListUsersRequest) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{6} +} + +type ListUsersResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Users []*User `protobuf:"bytes,1,rep,name=users,proto3" json:"users,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListUsersResponse) Reset() { + *x = ListUsersResponse{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListUsersResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListUsersResponse) ProtoMessage() {} + +func (x *ListUsersResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListUsersResponse.ProtoReflect.Descriptor instead. +func (*ListUsersResponse) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{7} +} + +func (x *ListUsersResponse) GetUsers() []*User { + if x != nil { + return x.Users + } + return nil +} + +type CreateUserRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + Email string `protobuf:"bytes,2,opt,name=email,proto3" json:"email,omitempty"` + DisplayName string `protobuf:"bytes,3,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` + // Temporary password, the user must change it at first login. + Password string `protobuf:"bytes,4,opt,name=password,proto3" json:"password,omitempty"` + TeamIds []string `protobuf:"bytes,5,rep,name=team_ids,json=teamIds,proto3" json:"team_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateUserRequest) Reset() { + *x = CreateUserRequest{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateUserRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateUserRequest) ProtoMessage() {} + +func (x *CreateUserRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateUserRequest.ProtoReflect.Descriptor instead. +func (*CreateUserRequest) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{8} +} + +func (x *CreateUserRequest) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *CreateUserRequest) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *CreateUserRequest) GetDisplayName() string { + if x != nil { + return x.DisplayName + } + return "" +} + +func (x *CreateUserRequest) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +func (x *CreateUserRequest) GetTeamIds() []string { + if x != nil { + return x.TeamIds + } + return nil +} + +type CreateUserResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateUserResponse) Reset() { + *x = CreateUserResponse{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateUserResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateUserResponse) ProtoMessage() {} + +func (x *CreateUserResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateUserResponse.ProtoReflect.Descriptor instead. +func (*CreateUserResponse) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{9} +} + +func (x *CreateUserResponse) GetUser() *User { + if x != nil { + return x.User + } + return nil +} + +// UpdateUserRequest replaces email, display_name, team_ids and disabled. +// new_password, when set, resets the password and invalidates sessions. +type UpdateUserRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Email string `protobuf:"bytes,2,opt,name=email,proto3" json:"email,omitempty"` + DisplayName string `protobuf:"bytes,3,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` + TeamIds []string `protobuf:"bytes,4,rep,name=team_ids,json=teamIds,proto3" json:"team_ids,omitempty"` + Disabled bool `protobuf:"varint,5,opt,name=disabled,proto3" json:"disabled,omitempty"` + NewPassword string `protobuf:"bytes,6,opt,name=new_password,json=newPassword,proto3" json:"new_password,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateUserRequest) Reset() { + *x = UpdateUserRequest{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateUserRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateUserRequest) ProtoMessage() {} + +func (x *UpdateUserRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateUserRequest.ProtoReflect.Descriptor instead. +func (*UpdateUserRequest) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{10} +} + +func (x *UpdateUserRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *UpdateUserRequest) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *UpdateUserRequest) GetDisplayName() string { + if x != nil { + return x.DisplayName + } + return "" +} + +func (x *UpdateUserRequest) GetTeamIds() []string { + if x != nil { + return x.TeamIds + } + return nil +} + +func (x *UpdateUserRequest) GetDisabled() bool { + if x != nil { + return x.Disabled + } + return false +} + +func (x *UpdateUserRequest) GetNewPassword() string { + if x != nil { + return x.NewPassword + } + return "" +} + +type UpdateUserResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateUserResponse) Reset() { + *x = UpdateUserResponse{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateUserResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateUserResponse) ProtoMessage() {} + +func (x *UpdateUserResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateUserResponse.ProtoReflect.Descriptor instead. +func (*UpdateUserResponse) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{11} +} + +func (x *UpdateUserResponse) GetUser() *User { + if x != nil { + return x.User + } + return nil +} + +type Team struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + Permissions []string `protobuf:"bytes,4,rep,name=permissions,proto3" json:"permissions,omitempty"` + ScopeAll bool `protobuf:"varint,5,opt,name=scope_all,json=scopeAll,proto3" json:"scope_all,omitempty"` + ScopeServices []string `protobuf:"bytes,6,rep,name=scope_services,json=scopeServices,proto3" json:"scope_services,omitempty"` + OidcGroups []string `protobuf:"bytes,7,rep,name=oidc_groups,json=oidcGroups,proto3" json:"oidc_groups,omitempty"` + Builtin bool `protobuf:"varint,8,opt,name=builtin,proto3" json:"builtin,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Team) Reset() { + *x = Team{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Team) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Team) ProtoMessage() {} + +func (x *Team) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Team.ProtoReflect.Descriptor instead. +func (*Team) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{12} +} + +func (x *Team) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Team) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Team) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *Team) GetPermissions() []string { + if x != nil { + return x.Permissions + } + return nil +} + +func (x *Team) GetScopeAll() bool { + if x != nil { + return x.ScopeAll + } + return false +} + +func (x *Team) GetScopeServices() []string { + if x != nil { + return x.ScopeServices + } + return nil +} + +func (x *Team) GetOidcGroups() []string { + if x != nil { + return x.OidcGroups + } + return nil +} + +func (x *Team) GetBuiltin() bool { + if x != nil { + return x.Builtin + } + return false +} + +type ListTeamsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListTeamsRequest) Reset() { + *x = ListTeamsRequest{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListTeamsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTeamsRequest) ProtoMessage() {} + +func (x *ListTeamsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTeamsRequest.ProtoReflect.Descriptor instead. +func (*ListTeamsRequest) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{13} +} + +type ListTeamsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Teams []*Team `protobuf:"bytes,1,rep,name=teams,proto3" json:"teams,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListTeamsResponse) Reset() { + *x = ListTeamsResponse{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListTeamsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTeamsResponse) ProtoMessage() {} + +func (x *ListTeamsResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTeamsResponse.ProtoReflect.Descriptor instead. +func (*ListTeamsResponse) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{14} +} + +func (x *ListTeamsResponse) GetTeams() []*Team { + if x != nil { + return x.Teams + } + return nil +} + +type CreateTeamRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` + Permissions []string `protobuf:"bytes,3,rep,name=permissions,proto3" json:"permissions,omitempty"` + ScopeAll bool `protobuf:"varint,4,opt,name=scope_all,json=scopeAll,proto3" json:"scope_all,omitempty"` + ScopeServices []string `protobuf:"bytes,5,rep,name=scope_services,json=scopeServices,proto3" json:"scope_services,omitempty"` + OidcGroups []string `protobuf:"bytes,6,rep,name=oidc_groups,json=oidcGroups,proto3" json:"oidc_groups,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateTeamRequest) Reset() { + *x = CreateTeamRequest{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateTeamRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateTeamRequest) ProtoMessage() {} + +func (x *CreateTeamRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateTeamRequest.ProtoReflect.Descriptor instead. +func (*CreateTeamRequest) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{15} +} + +func (x *CreateTeamRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CreateTeamRequest) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *CreateTeamRequest) GetPermissions() []string { + if x != nil { + return x.Permissions + } + return nil +} + +func (x *CreateTeamRequest) GetScopeAll() bool { + if x != nil { + return x.ScopeAll + } + return false +} + +func (x *CreateTeamRequest) GetScopeServices() []string { + if x != nil { + return x.ScopeServices + } + return nil +} + +func (x *CreateTeamRequest) GetOidcGroups() []string { + if x != nil { + return x.OidcGroups + } + return nil +} + +type CreateTeamResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Team *Team `protobuf:"bytes,1,opt,name=team,proto3" json:"team,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateTeamResponse) Reset() { + *x = CreateTeamResponse{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateTeamResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateTeamResponse) ProtoMessage() {} + +func (x *CreateTeamResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateTeamResponse.ProtoReflect.Descriptor instead. +func (*CreateTeamResponse) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{16} +} + +func (x *CreateTeamResponse) GetTeam() *Team { + if x != nil { + return x.Team + } + return nil +} + +type UpdateTeamRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + Permissions []string `protobuf:"bytes,4,rep,name=permissions,proto3" json:"permissions,omitempty"` + ScopeAll bool `protobuf:"varint,5,opt,name=scope_all,json=scopeAll,proto3" json:"scope_all,omitempty"` + ScopeServices []string `protobuf:"bytes,6,rep,name=scope_services,json=scopeServices,proto3" json:"scope_services,omitempty"` + OidcGroups []string `protobuf:"bytes,7,rep,name=oidc_groups,json=oidcGroups,proto3" json:"oidc_groups,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateTeamRequest) Reset() { + *x = UpdateTeamRequest{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateTeamRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateTeamRequest) ProtoMessage() {} + +func (x *UpdateTeamRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateTeamRequest.ProtoReflect.Descriptor instead. +func (*UpdateTeamRequest) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{17} +} + +func (x *UpdateTeamRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *UpdateTeamRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *UpdateTeamRequest) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *UpdateTeamRequest) GetPermissions() []string { + if x != nil { + return x.Permissions + } + return nil +} + +func (x *UpdateTeamRequest) GetScopeAll() bool { + if x != nil { + return x.ScopeAll + } + return false +} + +func (x *UpdateTeamRequest) GetScopeServices() []string { + if x != nil { + return x.ScopeServices + } + return nil +} + +func (x *UpdateTeamRequest) GetOidcGroups() []string { + if x != nil { + return x.OidcGroups + } + return nil +} + +type UpdateTeamResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Team *Team `protobuf:"bytes,1,opt,name=team,proto3" json:"team,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateTeamResponse) Reset() { + *x = UpdateTeamResponse{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateTeamResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateTeamResponse) ProtoMessage() {} + +func (x *UpdateTeamResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateTeamResponse.ProtoReflect.Descriptor instead. +func (*UpdateTeamResponse) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{18} +} + +func (x *UpdateTeamResponse) GetTeam() *Team { + if x != nil { + return x.Team + } + return nil +} + +type DeleteTeamRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteTeamRequest) Reset() { + *x = DeleteTeamRequest{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteTeamRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteTeamRequest) ProtoMessage() {} + +func (x *DeleteTeamRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteTeamRequest.ProtoReflect.Descriptor instead. +func (*DeleteTeamRequest) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{19} +} + +func (x *DeleteTeamRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type DeleteTeamResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteTeamResponse) Reset() { + *x = DeleteTeamResponse{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteTeamResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteTeamResponse) ProtoMessage() {} + +func (x *DeleteTeamResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteTeamResponse.ProtoReflect.Descriptor instead. +func (*DeleteTeamResponse) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{20} +} + +type ApiKey struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Prefix string `protobuf:"bytes,2,opt,name=prefix,proto3" json:"prefix,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + // Empty for a global key. + TeamId string `protobuf:"bytes,4,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"` + CreatedBy string `protobuf:"bytes,5,opt,name=created_by,json=createdBy,proto3" json:"created_by,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + LastUsedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=last_used_at,json=lastUsedAt,proto3" json:"last_used_at,omitempty"` + RevokedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=revoked_at,json=revokedAt,proto3" json:"revoked_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApiKey) Reset() { + *x = ApiKey{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApiKey) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApiKey) ProtoMessage() {} + +func (x *ApiKey) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ApiKey.ProtoReflect.Descriptor instead. +func (*ApiKey) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{21} +} + +func (x *ApiKey) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ApiKey) GetPrefix() string { + if x != nil { + return x.Prefix + } + return "" +} + +func (x *ApiKey) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ApiKey) GetTeamId() string { + if x != nil { + return x.TeamId + } + return "" +} + +func (x *ApiKey) GetCreatedBy() string { + if x != nil { + return x.CreatedBy + } + return "" +} + +func (x *ApiKey) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *ApiKey) GetExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +func (x *ApiKey) GetLastUsedAt() *timestamppb.Timestamp { + if x != nil { + return x.LastUsedAt + } + return nil +} + +func (x *ApiKey) GetRevokedAt() *timestamppb.Timestamp { + if x != nil { + return x.RevokedAt + } + return nil +} + +type ListApiKeysRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListApiKeysRequest) Reset() { + *x = ListApiKeysRequest{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListApiKeysRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListApiKeysRequest) ProtoMessage() {} + +func (x *ListApiKeysRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListApiKeysRequest.ProtoReflect.Descriptor instead. +func (*ListApiKeysRequest) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{22} +} + +type ListApiKeysResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ApiKeys []*ApiKey `protobuf:"bytes,1,rep,name=api_keys,json=apiKeys,proto3" json:"api_keys,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListApiKeysResponse) Reset() { + *x = ListApiKeysResponse{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListApiKeysResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListApiKeysResponse) ProtoMessage() {} + +func (x *ListApiKeysResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListApiKeysResponse.ProtoReflect.Descriptor instead. +func (*ListApiKeysResponse) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{23} +} + +func (x *ListApiKeysResponse) GetApiKeys() []*ApiKey { + if x != nil { + return x.ApiKeys + } + return nil +} + +type CreateApiKeyRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Empty creates a global key, allowed only for Administrators members. + TeamId string `protobuf:"bytes,2,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"` + ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateApiKeyRequest) Reset() { + *x = CreateApiKeyRequest{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateApiKeyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateApiKeyRequest) ProtoMessage() {} + +func (x *CreateApiKeyRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateApiKeyRequest.ProtoReflect.Descriptor instead. +func (*CreateApiKeyRequest) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{24} +} + +func (x *CreateApiKeyRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CreateApiKeyRequest) GetTeamId() string { + if x != nil { + return x.TeamId + } + return "" +} + +func (x *CreateApiKeyRequest) GetExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +type CreateApiKeyResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ApiKey *ApiKey `protobuf:"bytes,1,opt,name=api_key,json=apiKey,proto3" json:"api_key,omitempty"` + // Shown once, never stored. + Secret string `protobuf:"bytes,2,opt,name=secret,proto3" json:"secret,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateApiKeyResponse) Reset() { + *x = CreateApiKeyResponse{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateApiKeyResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateApiKeyResponse) ProtoMessage() {} + +func (x *CreateApiKeyResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateApiKeyResponse.ProtoReflect.Descriptor instead. +func (*CreateApiKeyResponse) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{25} +} + +func (x *CreateApiKeyResponse) GetApiKey() *ApiKey { + if x != nil { + return x.ApiKey + } + return nil +} + +func (x *CreateApiKeyResponse) GetSecret() string { + if x != nil { + return x.Secret + } + return "" +} + +type RevokeApiKeyRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RevokeApiKeyRequest) Reset() { + *x = RevokeApiKeyRequest{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RevokeApiKeyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevokeApiKeyRequest) ProtoMessage() {} + +func (x *RevokeApiKeyRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RevokeApiKeyRequest.ProtoReflect.Descriptor instead. +func (*RevokeApiKeyRequest) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{26} +} + +func (x *RevokeApiKeyRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type RevokeApiKeyResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RevokeApiKeyResponse) Reset() { + *x = RevokeApiKeyResponse{} + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RevokeApiKeyResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RevokeApiKeyResponse) ProtoMessage() {} + +func (x *RevokeApiKeyResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_auth_v1alpha1_auth_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RevokeApiKeyResponse.ProtoReflect.Descriptor instead. +func (*RevokeApiKeyResponse) Descriptor() ([]byte, []int) { + return file_proto_auth_v1alpha1_auth_proto_rawDescGZIP(), []int{27} +} + +var File_proto_auth_v1alpha1_auth_proto protoreflect.FileDescriptor + +const file_proto_auth_v1alpha1_auth_proto_rawDesc = "" + + "\n" + + "\x1eproto/auth/v1alpha1/auth.proto\x12\x15tracker.auth.v1alpha1\x1a\x1cgoogle/api/annotations.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x16\n" + + "\x14GetAuthConfigRequest\"\xe8\x01\n" + + "\x15GetAuthConfigResponse\x12.\n" + + "\x13local_login_enabled\x18\x01 \x01(\bR\x11localLoginEnabled\x12!\n" + + "\foidc_enabled\x18\x02 \x01(\bR\voidcEnabled\x12*\n" + + "\x11oidc_button_label\x18\x03 \x01(\tR\x0foidcButtonLabel\x123\n" + + "\x15anonymous_permissions\x18\x04 \x03(\tR\x14anonymousPermissions\x12\x1b\n" + + "\tdemo_mode\x18\x05 \x01(\bR\bdemoMode\"\v\n" + + "\tMeRequest\"-\n" + + "\aTeamRef\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\"\x9f\x03\n" + + "\n" + + "MeResponse\x12$\n" + + "\rauthenticated\x18\x01 \x01(\bR\rauthenticated\x12\x12\n" + + "\x04kind\x18\x02 \x01(\tR\x04kind\x12\x17\n" + + "\auser_id\x18\x03 \x01(\tR\x06userId\x12\x1a\n" + + "\busername\x18\x04 \x01(\tR\busername\x12!\n" + + "\fdisplay_name\x18\x05 \x01(\tR\vdisplayName\x12\x16\n" + + "\x06source\x18\x06 \x01(\tR\x06source\x124\n" + + "\x05teams\x18\a \x03(\v2\x1e.tracker.auth.v1alpha1.TeamRefR\x05teams\x12 \n" + + "\vpermissions\x18\b \x03(\tR\vpermissions\x12\x1b\n" + + "\tscope_all\x18\t \x01(\bR\bscopeAll\x12%\n" + + "\x0escope_services\x18\n" + + " \x03(\tR\rscopeServices\x120\n" + + "\x14must_change_password\x18\v \x01(\bR\x12mustChangePassword\x12\x19\n" + + "\bis_admin\x18\f \x01(\bR\aisAdmin\"\xe7\x02\n" + + "\x04User\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1a\n" + + "\busername\x18\x02 \x01(\tR\busername\x12\x14\n" + + "\x05email\x18\x03 \x01(\tR\x05email\x12!\n" + + "\fdisplay_name\x18\x04 \x01(\tR\vdisplayName\x12\x16\n" + + "\x06source\x18\x05 \x01(\tR\x06source\x12\x19\n" + + "\bteam_ids\x18\x06 \x03(\tR\ateamIds\x12\x1a\n" + + "\bdisabled\x18\a \x01(\bR\bdisabled\x120\n" + + "\x14must_change_password\x18\b \x01(\bR\x12mustChangePassword\x129\n" + + "\n" + + "created_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12>\n" + + "\rlast_login_at\x18\n" + + " \x01(\v2\x1a.google.protobuf.TimestampR\vlastLoginAt\"\x12\n" + + "\x10ListUsersRequest\"F\n" + + "\x11ListUsersResponse\x121\n" + + "\x05users\x18\x01 \x03(\v2\x1b.tracker.auth.v1alpha1.UserR\x05users\"\x9f\x01\n" + + "\x11CreateUserRequest\x12\x1a\n" + + "\busername\x18\x01 \x01(\tR\busername\x12\x14\n" + + "\x05email\x18\x02 \x01(\tR\x05email\x12!\n" + + "\fdisplay_name\x18\x03 \x01(\tR\vdisplayName\x12\x1a\n" + + "\bpassword\x18\x04 \x01(\tR\bpassword\x12\x19\n" + + "\bteam_ids\x18\x05 \x03(\tR\ateamIds\"E\n" + + "\x12CreateUserResponse\x12/\n" + + "\x04user\x18\x01 \x01(\v2\x1b.tracker.auth.v1alpha1.UserR\x04user\"\xb6\x01\n" + + "\x11UpdateUserRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + + "\x05email\x18\x02 \x01(\tR\x05email\x12!\n" + + "\fdisplay_name\x18\x03 \x01(\tR\vdisplayName\x12\x19\n" + + "\bteam_ids\x18\x04 \x03(\tR\ateamIds\x12\x1a\n" + + "\bdisabled\x18\x05 \x01(\bR\bdisabled\x12!\n" + + "\fnew_password\x18\x06 \x01(\tR\vnewPassword\"E\n" + + "\x12UpdateUserResponse\x12/\n" + + "\x04user\x18\x01 \x01(\v2\x1b.tracker.auth.v1alpha1.UserR\x04user\"\xed\x01\n" + + "\x04Team\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12 \n" + + "\vdescription\x18\x03 \x01(\tR\vdescription\x12 \n" + + "\vpermissions\x18\x04 \x03(\tR\vpermissions\x12\x1b\n" + + "\tscope_all\x18\x05 \x01(\bR\bscopeAll\x12%\n" + + "\x0escope_services\x18\x06 \x03(\tR\rscopeServices\x12\x1f\n" + + "\voidc_groups\x18\a \x03(\tR\n" + + "oidcGroups\x12\x18\n" + + "\abuiltin\x18\b \x01(\bR\abuiltin\"\x12\n" + + "\x10ListTeamsRequest\"F\n" + + "\x11ListTeamsResponse\x121\n" + + "\x05teams\x18\x01 \x03(\v2\x1b.tracker.auth.v1alpha1.TeamR\x05teams\"\xd0\x01\n" + + "\x11CreateTeamRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + + "\vdescription\x18\x02 \x01(\tR\vdescription\x12 \n" + + "\vpermissions\x18\x03 \x03(\tR\vpermissions\x12\x1b\n" + + "\tscope_all\x18\x04 \x01(\bR\bscopeAll\x12%\n" + + "\x0escope_services\x18\x05 \x03(\tR\rscopeServices\x12\x1f\n" + + "\voidc_groups\x18\x06 \x03(\tR\n" + + "oidcGroups\"E\n" + + "\x12CreateTeamResponse\x12/\n" + + "\x04team\x18\x01 \x01(\v2\x1b.tracker.auth.v1alpha1.TeamR\x04team\"\xe0\x01\n" + + "\x11UpdateTeamRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12 \n" + + "\vdescription\x18\x03 \x01(\tR\vdescription\x12 \n" + + "\vpermissions\x18\x04 \x03(\tR\vpermissions\x12\x1b\n" + + "\tscope_all\x18\x05 \x01(\bR\bscopeAll\x12%\n" + + "\x0escope_services\x18\x06 \x03(\tR\rscopeServices\x12\x1f\n" + + "\voidc_groups\x18\a \x03(\tR\n" + + "oidcGroups\"E\n" + + "\x12UpdateTeamResponse\x12/\n" + + "\x04team\x18\x01 \x01(\v2\x1b.tracker.auth.v1alpha1.TeamR\x04team\"#\n" + + "\x11DeleteTeamRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\x14\n" + + "\x12DeleteTeamResponse\"\xeb\x02\n" + + "\x06ApiKey\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" + + "\x06prefix\x18\x02 \x01(\tR\x06prefix\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\x12\x17\n" + + "\ateam_id\x18\x04 \x01(\tR\x06teamId\x12\x1d\n" + + "\n" + + "created_by\x18\x05 \x01(\tR\tcreatedBy\x129\n" + + "\n" + + "created_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" + + "\n" + + "expires_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x12<\n" + + "\flast_used_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "lastUsedAt\x129\n" + + "\n" + + "revoked_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\trevokedAt\"\x14\n" + + "\x12ListApiKeysRequest\"O\n" + + "\x13ListApiKeysResponse\x128\n" + + "\bapi_keys\x18\x01 \x03(\v2\x1d.tracker.auth.v1alpha1.ApiKeyR\aapiKeys\"}\n" + + "\x13CreateApiKeyRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x17\n" + + "\ateam_id\x18\x02 \x01(\tR\x06teamId\x129\n" + + "\n" + + "expires_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"f\n" + + "\x14CreateApiKeyResponse\x126\n" + + "\aapi_key\x18\x01 \x01(\v2\x1d.tracker.auth.v1alpha1.ApiKeyR\x06apiKey\x12\x16\n" + + "\x06secret\x18\x02 \x01(\tR\x06secret\"%\n" + + "\x13RevokeApiKeyRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\x16\n" + + "\x14RevokeApiKeyResponse2\xf8\f\n" + + "\vAuthService\x12\x8d\x01\n" + + "\rGetAuthConfig\x12+.tracker.auth.v1alpha1.GetAuthConfigRequest\x1a,.tracker.auth.v1alpha1.GetAuthConfigResponse\"!\x82\xd3\xe4\x93\x02\x1b\x12\x19/api/v1alpha1/auth/config\x12h\n" + + "\x02Me\x12 .tracker.auth.v1alpha1.MeRequest\x1a!.tracker.auth.v1alpha1.MeResponse\"\x1d\x82\xd3\xe4\x93\x02\x17\x12\x15/api/v1alpha1/auth/me\x12\x80\x01\n" + + "\tListUsers\x12'.tracker.auth.v1alpha1.ListUsersRequest\x1a(.tracker.auth.v1alpha1.ListUsersResponse\" \x82\xd3\xe4\x93\x02\x1a\x12\x18/api/v1alpha1/auth/users\x12\x86\x01\n" + + "\n" + + "CreateUser\x12(.tracker.auth.v1alpha1.CreateUserRequest\x1a).tracker.auth.v1alpha1.CreateUserResponse\"#\x82\xd3\xe4\x93\x02\x1d:\x01*\"\x18/api/v1alpha1/auth/users\x12\x8b\x01\n" + + "\n" + + "UpdateUser\x12(.tracker.auth.v1alpha1.UpdateUserRequest\x1a).tracker.auth.v1alpha1.UpdateUserResponse\"(\x82\xd3\xe4\x93\x02\":\x01*\x1a\x1d/api/v1alpha1/auth/users/{id}\x12\x80\x01\n" + + "\tListTeams\x12'.tracker.auth.v1alpha1.ListTeamsRequest\x1a(.tracker.auth.v1alpha1.ListTeamsResponse\" \x82\xd3\xe4\x93\x02\x1a\x12\x18/api/v1alpha1/auth/teams\x12\x86\x01\n" + + "\n" + + "CreateTeam\x12(.tracker.auth.v1alpha1.CreateTeamRequest\x1a).tracker.auth.v1alpha1.CreateTeamResponse\"#\x82\xd3\xe4\x93\x02\x1d:\x01*\"\x18/api/v1alpha1/auth/teams\x12\x8b\x01\n" + + "\n" + + "UpdateTeam\x12(.tracker.auth.v1alpha1.UpdateTeamRequest\x1a).tracker.auth.v1alpha1.UpdateTeamResponse\"(\x82\xd3\xe4\x93\x02\":\x01*\x1a\x1d/api/v1alpha1/auth/teams/{id}\x12\x88\x01\n" + + "\n" + + "DeleteTeam\x12(.tracker.auth.v1alpha1.DeleteTeamRequest\x1a).tracker.auth.v1alpha1.DeleteTeamResponse\"%\x82\xd3\xe4\x93\x02\x1f*\x1d/api/v1alpha1/auth/teams/{id}\x12\x89\x01\n" + + "\vListApiKeys\x12).tracker.auth.v1alpha1.ListApiKeysRequest\x1a*.tracker.auth.v1alpha1.ListApiKeysResponse\"#\x82\xd3\xe4\x93\x02\x1d\x12\x1b/api/v1alpha1/auth/api-keys\x12\x8f\x01\n" + + "\fCreateApiKey\x12*.tracker.auth.v1alpha1.CreateApiKeyRequest\x1a+.tracker.auth.v1alpha1.CreateApiKeyResponse\"&\x82\xd3\xe4\x93\x02 :\x01*\"\x1b/api/v1alpha1/auth/api-keys\x12\x91\x01\n" + + "\fRevokeApiKey\x12*.tracker.auth.v1alpha1.RevokeApiKeyRequest\x1a+.tracker.auth.v1alpha1.RevokeApiKeyResponse\"(\x82\xd3\xe4\x93\x02\"* /api/v1alpha1/auth/api-keys/{id}B\x15Z\x13proto/auth/v1alpha1b\x06proto3" + +var ( + file_proto_auth_v1alpha1_auth_proto_rawDescOnce sync.Once + file_proto_auth_v1alpha1_auth_proto_rawDescData []byte +) + +func file_proto_auth_v1alpha1_auth_proto_rawDescGZIP() []byte { + file_proto_auth_v1alpha1_auth_proto_rawDescOnce.Do(func() { + file_proto_auth_v1alpha1_auth_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_auth_v1alpha1_auth_proto_rawDesc), len(file_proto_auth_v1alpha1_auth_proto_rawDesc))) + }) + return file_proto_auth_v1alpha1_auth_proto_rawDescData +} + +var file_proto_auth_v1alpha1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 28) +var file_proto_auth_v1alpha1_auth_proto_goTypes = []any{ + (*GetAuthConfigRequest)(nil), // 0: tracker.auth.v1alpha1.GetAuthConfigRequest + (*GetAuthConfigResponse)(nil), // 1: tracker.auth.v1alpha1.GetAuthConfigResponse + (*MeRequest)(nil), // 2: tracker.auth.v1alpha1.MeRequest + (*TeamRef)(nil), // 3: tracker.auth.v1alpha1.TeamRef + (*MeResponse)(nil), // 4: tracker.auth.v1alpha1.MeResponse + (*User)(nil), // 5: tracker.auth.v1alpha1.User + (*ListUsersRequest)(nil), // 6: tracker.auth.v1alpha1.ListUsersRequest + (*ListUsersResponse)(nil), // 7: tracker.auth.v1alpha1.ListUsersResponse + (*CreateUserRequest)(nil), // 8: tracker.auth.v1alpha1.CreateUserRequest + (*CreateUserResponse)(nil), // 9: tracker.auth.v1alpha1.CreateUserResponse + (*UpdateUserRequest)(nil), // 10: tracker.auth.v1alpha1.UpdateUserRequest + (*UpdateUserResponse)(nil), // 11: tracker.auth.v1alpha1.UpdateUserResponse + (*Team)(nil), // 12: tracker.auth.v1alpha1.Team + (*ListTeamsRequest)(nil), // 13: tracker.auth.v1alpha1.ListTeamsRequest + (*ListTeamsResponse)(nil), // 14: tracker.auth.v1alpha1.ListTeamsResponse + (*CreateTeamRequest)(nil), // 15: tracker.auth.v1alpha1.CreateTeamRequest + (*CreateTeamResponse)(nil), // 16: tracker.auth.v1alpha1.CreateTeamResponse + (*UpdateTeamRequest)(nil), // 17: tracker.auth.v1alpha1.UpdateTeamRequest + (*UpdateTeamResponse)(nil), // 18: tracker.auth.v1alpha1.UpdateTeamResponse + (*DeleteTeamRequest)(nil), // 19: tracker.auth.v1alpha1.DeleteTeamRequest + (*DeleteTeamResponse)(nil), // 20: tracker.auth.v1alpha1.DeleteTeamResponse + (*ApiKey)(nil), // 21: tracker.auth.v1alpha1.ApiKey + (*ListApiKeysRequest)(nil), // 22: tracker.auth.v1alpha1.ListApiKeysRequest + (*ListApiKeysResponse)(nil), // 23: tracker.auth.v1alpha1.ListApiKeysResponse + (*CreateApiKeyRequest)(nil), // 24: tracker.auth.v1alpha1.CreateApiKeyRequest + (*CreateApiKeyResponse)(nil), // 25: tracker.auth.v1alpha1.CreateApiKeyResponse + (*RevokeApiKeyRequest)(nil), // 26: tracker.auth.v1alpha1.RevokeApiKeyRequest + (*RevokeApiKeyResponse)(nil), // 27: tracker.auth.v1alpha1.RevokeApiKeyResponse + (*timestamppb.Timestamp)(nil), // 28: google.protobuf.Timestamp +} +var file_proto_auth_v1alpha1_auth_proto_depIdxs = []int32{ + 3, // 0: tracker.auth.v1alpha1.MeResponse.teams:type_name -> tracker.auth.v1alpha1.TeamRef + 28, // 1: tracker.auth.v1alpha1.User.created_at:type_name -> google.protobuf.Timestamp + 28, // 2: tracker.auth.v1alpha1.User.last_login_at:type_name -> google.protobuf.Timestamp + 5, // 3: tracker.auth.v1alpha1.ListUsersResponse.users:type_name -> tracker.auth.v1alpha1.User + 5, // 4: tracker.auth.v1alpha1.CreateUserResponse.user:type_name -> tracker.auth.v1alpha1.User + 5, // 5: tracker.auth.v1alpha1.UpdateUserResponse.user:type_name -> tracker.auth.v1alpha1.User + 12, // 6: tracker.auth.v1alpha1.ListTeamsResponse.teams:type_name -> tracker.auth.v1alpha1.Team + 12, // 7: tracker.auth.v1alpha1.CreateTeamResponse.team:type_name -> tracker.auth.v1alpha1.Team + 12, // 8: tracker.auth.v1alpha1.UpdateTeamResponse.team:type_name -> tracker.auth.v1alpha1.Team + 28, // 9: tracker.auth.v1alpha1.ApiKey.created_at:type_name -> google.protobuf.Timestamp + 28, // 10: tracker.auth.v1alpha1.ApiKey.expires_at:type_name -> google.protobuf.Timestamp + 28, // 11: tracker.auth.v1alpha1.ApiKey.last_used_at:type_name -> google.protobuf.Timestamp + 28, // 12: tracker.auth.v1alpha1.ApiKey.revoked_at:type_name -> google.protobuf.Timestamp + 21, // 13: tracker.auth.v1alpha1.ListApiKeysResponse.api_keys:type_name -> tracker.auth.v1alpha1.ApiKey + 28, // 14: tracker.auth.v1alpha1.CreateApiKeyRequest.expires_at:type_name -> google.protobuf.Timestamp + 21, // 15: tracker.auth.v1alpha1.CreateApiKeyResponse.api_key:type_name -> tracker.auth.v1alpha1.ApiKey + 0, // 16: tracker.auth.v1alpha1.AuthService.GetAuthConfig:input_type -> tracker.auth.v1alpha1.GetAuthConfigRequest + 2, // 17: tracker.auth.v1alpha1.AuthService.Me:input_type -> tracker.auth.v1alpha1.MeRequest + 6, // 18: tracker.auth.v1alpha1.AuthService.ListUsers:input_type -> tracker.auth.v1alpha1.ListUsersRequest + 8, // 19: tracker.auth.v1alpha1.AuthService.CreateUser:input_type -> tracker.auth.v1alpha1.CreateUserRequest + 10, // 20: tracker.auth.v1alpha1.AuthService.UpdateUser:input_type -> tracker.auth.v1alpha1.UpdateUserRequest + 13, // 21: tracker.auth.v1alpha1.AuthService.ListTeams:input_type -> tracker.auth.v1alpha1.ListTeamsRequest + 15, // 22: tracker.auth.v1alpha1.AuthService.CreateTeam:input_type -> tracker.auth.v1alpha1.CreateTeamRequest + 17, // 23: tracker.auth.v1alpha1.AuthService.UpdateTeam:input_type -> tracker.auth.v1alpha1.UpdateTeamRequest + 19, // 24: tracker.auth.v1alpha1.AuthService.DeleteTeam:input_type -> tracker.auth.v1alpha1.DeleteTeamRequest + 22, // 25: tracker.auth.v1alpha1.AuthService.ListApiKeys:input_type -> tracker.auth.v1alpha1.ListApiKeysRequest + 24, // 26: tracker.auth.v1alpha1.AuthService.CreateApiKey:input_type -> tracker.auth.v1alpha1.CreateApiKeyRequest + 26, // 27: tracker.auth.v1alpha1.AuthService.RevokeApiKey:input_type -> tracker.auth.v1alpha1.RevokeApiKeyRequest + 1, // 28: tracker.auth.v1alpha1.AuthService.GetAuthConfig:output_type -> tracker.auth.v1alpha1.GetAuthConfigResponse + 4, // 29: tracker.auth.v1alpha1.AuthService.Me:output_type -> tracker.auth.v1alpha1.MeResponse + 7, // 30: tracker.auth.v1alpha1.AuthService.ListUsers:output_type -> tracker.auth.v1alpha1.ListUsersResponse + 9, // 31: tracker.auth.v1alpha1.AuthService.CreateUser:output_type -> tracker.auth.v1alpha1.CreateUserResponse + 11, // 32: tracker.auth.v1alpha1.AuthService.UpdateUser:output_type -> tracker.auth.v1alpha1.UpdateUserResponse + 14, // 33: tracker.auth.v1alpha1.AuthService.ListTeams:output_type -> tracker.auth.v1alpha1.ListTeamsResponse + 16, // 34: tracker.auth.v1alpha1.AuthService.CreateTeam:output_type -> tracker.auth.v1alpha1.CreateTeamResponse + 18, // 35: tracker.auth.v1alpha1.AuthService.UpdateTeam:output_type -> tracker.auth.v1alpha1.UpdateTeamResponse + 20, // 36: tracker.auth.v1alpha1.AuthService.DeleteTeam:output_type -> tracker.auth.v1alpha1.DeleteTeamResponse + 23, // 37: tracker.auth.v1alpha1.AuthService.ListApiKeys:output_type -> tracker.auth.v1alpha1.ListApiKeysResponse + 25, // 38: tracker.auth.v1alpha1.AuthService.CreateApiKey:output_type -> tracker.auth.v1alpha1.CreateApiKeyResponse + 27, // 39: tracker.auth.v1alpha1.AuthService.RevokeApiKey:output_type -> tracker.auth.v1alpha1.RevokeApiKeyResponse + 28, // [28:40] is the sub-list for method output_type + 16, // [16:28] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name +} + +func init() { file_proto_auth_v1alpha1_auth_proto_init() } +func file_proto_auth_v1alpha1_auth_proto_init() { + if File_proto_auth_v1alpha1_auth_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_auth_v1alpha1_auth_proto_rawDesc), len(file_proto_auth_v1alpha1_auth_proto_rawDesc)), + NumEnums: 0, + NumMessages: 28, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_proto_auth_v1alpha1_auth_proto_goTypes, + DependencyIndexes: file_proto_auth_v1alpha1_auth_proto_depIdxs, + MessageInfos: file_proto_auth_v1alpha1_auth_proto_msgTypes, + }.Build() + File_proto_auth_v1alpha1_auth_proto = out.File + file_proto_auth_v1alpha1_auth_proto_goTypes = nil + file_proto_auth_v1alpha1_auth_proto_depIdxs = nil +} diff --git a/generated/proto/auth/v1alpha1/auth.pb.gw.go b/generated/proto/auth/v1alpha1/auth.pb.gw.go new file mode 100644 index 00000000..292f5078 --- /dev/null +++ b/generated/proto/auth/v1alpha1/auth.pb.gw.go @@ -0,0 +1,913 @@ +// Code generated by protoc-gen-grpc-gateway. DO NOT EDIT. +// source: proto/auth/v1alpha1/auth.proto + +/* +Package v1alpha1 is a reverse proxy. + +It translates gRPC into RESTful JSON APIs. +*/ +package v1alpha1 + +import ( + "context" + "errors" + "io" + "net/http" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/grpc-ecosystem/grpc-gateway/v2/utilities" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/grpclog" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +// Suppress "imported and not used" errors +var ( + _ codes.Code + _ io.Reader + _ status.Status + _ = errors.New + _ = runtime.String + _ = utilities.NewDoubleArray + _ = metadata.Join +) + +func request_AuthService_GetAuthConfig_0(ctx context.Context, marshaler runtime.Marshaler, client AuthServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetAuthConfigRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.GetAuthConfig(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_AuthService_GetAuthConfig_0(ctx context.Context, marshaler runtime.Marshaler, server AuthServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetAuthConfigRequest + metadata runtime.ServerMetadata + ) + msg, err := server.GetAuthConfig(ctx, &protoReq) + return msg, metadata, err +} + +func request_AuthService_Me_0(ctx context.Context, marshaler runtime.Marshaler, client AuthServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq MeRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.Me(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_AuthService_Me_0(ctx context.Context, marshaler runtime.Marshaler, server AuthServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq MeRequest + metadata runtime.ServerMetadata + ) + msg, err := server.Me(ctx, &protoReq) + return msg, metadata, err +} + +func request_AuthService_ListUsers_0(ctx context.Context, marshaler runtime.Marshaler, client AuthServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListUsersRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.ListUsers(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_AuthService_ListUsers_0(ctx context.Context, marshaler runtime.Marshaler, server AuthServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListUsersRequest + metadata runtime.ServerMetadata + ) + msg, err := server.ListUsers(ctx, &protoReq) + return msg, metadata, err +} + +func request_AuthService_CreateUser_0(ctx context.Context, marshaler runtime.Marshaler, client AuthServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CreateUserRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.CreateUser(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_AuthService_CreateUser_0(ctx context.Context, marshaler runtime.Marshaler, server AuthServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CreateUserRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.CreateUser(ctx, &protoReq) + return msg, metadata, err +} + +func request_AuthService_UpdateUser_0(ctx context.Context, marshaler runtime.Marshaler, client AuthServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq UpdateUserRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id") + } + protoReq.Id, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err) + } + msg, err := client.UpdateUser(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_AuthService_UpdateUser_0(ctx context.Context, marshaler runtime.Marshaler, server AuthServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq UpdateUserRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + val, ok := pathParams["id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id") + } + protoReq.Id, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err) + } + msg, err := server.UpdateUser(ctx, &protoReq) + return msg, metadata, err +} + +func request_AuthService_ListTeams_0(ctx context.Context, marshaler runtime.Marshaler, client AuthServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListTeamsRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.ListTeams(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_AuthService_ListTeams_0(ctx context.Context, marshaler runtime.Marshaler, server AuthServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListTeamsRequest + metadata runtime.ServerMetadata + ) + msg, err := server.ListTeams(ctx, &protoReq) + return msg, metadata, err +} + +func request_AuthService_CreateTeam_0(ctx context.Context, marshaler runtime.Marshaler, client AuthServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CreateTeamRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.CreateTeam(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_AuthService_CreateTeam_0(ctx context.Context, marshaler runtime.Marshaler, server AuthServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CreateTeamRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.CreateTeam(ctx, &protoReq) + return msg, metadata, err +} + +func request_AuthService_UpdateTeam_0(ctx context.Context, marshaler runtime.Marshaler, client AuthServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq UpdateTeamRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id") + } + protoReq.Id, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err) + } + msg, err := client.UpdateTeam(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_AuthService_UpdateTeam_0(ctx context.Context, marshaler runtime.Marshaler, server AuthServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq UpdateTeamRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + val, ok := pathParams["id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id") + } + protoReq.Id, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err) + } + msg, err := server.UpdateTeam(ctx, &protoReq) + return msg, metadata, err +} + +func request_AuthService_DeleteTeam_0(ctx context.Context, marshaler runtime.Marshaler, client AuthServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DeleteTeamRequest + metadata runtime.ServerMetadata + err error + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id") + } + protoReq.Id, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err) + } + msg, err := client.DeleteTeam(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_AuthService_DeleteTeam_0(ctx context.Context, marshaler runtime.Marshaler, server AuthServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DeleteTeamRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id") + } + protoReq.Id, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err) + } + msg, err := server.DeleteTeam(ctx, &protoReq) + return msg, metadata, err +} + +func request_AuthService_ListApiKeys_0(ctx context.Context, marshaler runtime.Marshaler, client AuthServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListApiKeysRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.ListApiKeys(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_AuthService_ListApiKeys_0(ctx context.Context, marshaler runtime.Marshaler, server AuthServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListApiKeysRequest + metadata runtime.ServerMetadata + ) + msg, err := server.ListApiKeys(ctx, &protoReq) + return msg, metadata, err +} + +func request_AuthService_CreateApiKey_0(ctx context.Context, marshaler runtime.Marshaler, client AuthServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CreateApiKeyRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.CreateApiKey(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_AuthService_CreateApiKey_0(ctx context.Context, marshaler runtime.Marshaler, server AuthServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CreateApiKeyRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.CreateApiKey(ctx, &protoReq) + return msg, metadata, err +} + +func request_AuthService_RevokeApiKey_0(ctx context.Context, marshaler runtime.Marshaler, client AuthServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RevokeApiKeyRequest + metadata runtime.ServerMetadata + err error + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id") + } + protoReq.Id, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err) + } + msg, err := client.RevokeApiKey(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_AuthService_RevokeApiKey_0(ctx context.Context, marshaler runtime.Marshaler, server AuthServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq RevokeApiKeyRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id") + } + protoReq.Id, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err) + } + msg, err := server.RevokeApiKey(ctx, &protoReq) + return msg, metadata, err +} + +// RegisterAuthServiceHandlerServer registers the http handlers for service AuthService to "mux". +// UnaryRPC :call AuthServiceServer directly. +// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. +// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterAuthServiceHandlerFromEndpoint instead. +// GRPC interceptors will not work for this type of registration. To use interceptors, you must use the "runtime.WithMiddlewares" option in the "runtime.NewServeMux" call. +func RegisterAuthServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux, server AuthServiceServer) error { + mux.Handle(http.MethodGet, pattern_AuthService_GetAuthConfig_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/GetAuthConfig", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/config")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthService_GetAuthConfig_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_GetAuthConfig_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_AuthService_Me_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/Me", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/me")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthService_Me_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_Me_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_AuthService_ListUsers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/ListUsers", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/users")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthService_ListUsers_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_ListUsers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_AuthService_CreateUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/CreateUser", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/users")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthService_CreateUser_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_CreateUser_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPut, pattern_AuthService_UpdateUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/UpdateUser", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/users/{id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthService_UpdateUser_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_UpdateUser_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_AuthService_ListTeams_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/ListTeams", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/teams")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthService_ListTeams_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_ListTeams_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_AuthService_CreateTeam_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/CreateTeam", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/teams")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthService_CreateTeam_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_CreateTeam_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPut, pattern_AuthService_UpdateTeam_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/UpdateTeam", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/teams/{id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthService_UpdateTeam_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_UpdateTeam_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodDelete, pattern_AuthService_DeleteTeam_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/DeleteTeam", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/teams/{id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthService_DeleteTeam_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_DeleteTeam_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_AuthService_ListApiKeys_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/ListApiKeys", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/api-keys")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthService_ListApiKeys_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_ListApiKeys_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_AuthService_CreateApiKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/CreateApiKey", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/api-keys")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthService_CreateApiKey_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_CreateApiKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodDelete, pattern_AuthService_RevokeApiKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/RevokeApiKey", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/api-keys/{id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthService_RevokeApiKey_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_RevokeApiKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + + return nil +} + +// RegisterAuthServiceHandlerFromEndpoint is same as RegisterAuthServiceHandler but +// automatically dials to "endpoint" and closes the connection when "ctx" gets done. +func RegisterAuthServiceHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) { + conn, err := grpc.NewClient(endpoint, opts...) + if err != nil { + return err + } + defer func() { + if err != nil { + if cerr := conn.Close(); cerr != nil { + grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) + } + return + } + go func() { + <-ctx.Done() + if cerr := conn.Close(); cerr != nil { + grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) + } + }() + }() + return RegisterAuthServiceHandler(ctx, mux, conn) +} + +// RegisterAuthServiceHandler registers the http handlers for service AuthService to "mux". +// The handlers forward requests to the grpc endpoint over "conn". +func RegisterAuthServiceHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error { + return RegisterAuthServiceHandlerClient(ctx, mux, NewAuthServiceClient(conn)) +} + +// RegisterAuthServiceHandlerClient registers the http handlers for service AuthService +// to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "AuthServiceClient". +// Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "AuthServiceClient" +// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in +// "AuthServiceClient" to call the correct interceptors. This client ignores the HTTP middlewares. +func RegisterAuthServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, client AuthServiceClient) error { + mux.Handle(http.MethodGet, pattern_AuthService_GetAuthConfig_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/GetAuthConfig", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/config")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthService_GetAuthConfig_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_GetAuthConfig_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_AuthService_Me_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/Me", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/me")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthService_Me_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_Me_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_AuthService_ListUsers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/ListUsers", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/users")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthService_ListUsers_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_ListUsers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_AuthService_CreateUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/CreateUser", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/users")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthService_CreateUser_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_CreateUser_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPut, pattern_AuthService_UpdateUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/UpdateUser", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/users/{id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthService_UpdateUser_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_UpdateUser_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_AuthService_ListTeams_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/ListTeams", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/teams")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthService_ListTeams_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_ListTeams_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_AuthService_CreateTeam_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/CreateTeam", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/teams")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthService_CreateTeam_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_CreateTeam_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPut, pattern_AuthService_UpdateTeam_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/UpdateTeam", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/teams/{id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthService_UpdateTeam_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_UpdateTeam_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodDelete, pattern_AuthService_DeleteTeam_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/DeleteTeam", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/teams/{id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthService_DeleteTeam_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_DeleteTeam_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_AuthService_ListApiKeys_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/ListApiKeys", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/api-keys")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthService_ListApiKeys_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_ListApiKeys_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodPost, pattern_AuthService_CreateApiKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/CreateApiKey", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/api-keys")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthService_CreateApiKey_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_CreateApiKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodDelete, pattern_AuthService_RevokeApiKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/tracker.auth.v1alpha1.AuthService/RevokeApiKey", runtime.WithHTTPPathPattern("/api/v1alpha1/auth/api-keys/{id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthService_RevokeApiKey_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_AuthService_RevokeApiKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + return nil +} + +var ( + pattern_AuthService_GetAuthConfig_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1alpha1", "auth", "config"}, "")) + pattern_AuthService_Me_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1alpha1", "auth", "me"}, "")) + pattern_AuthService_ListUsers_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1alpha1", "auth", "users"}, "")) + pattern_AuthService_CreateUser_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1alpha1", "auth", "users"}, "")) + pattern_AuthService_UpdateUser_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 1, 0, 4, 1, 5, 4}, []string{"api", "v1alpha1", "auth", "users", "id"}, "")) + pattern_AuthService_ListTeams_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1alpha1", "auth", "teams"}, "")) + pattern_AuthService_CreateTeam_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1alpha1", "auth", "teams"}, "")) + pattern_AuthService_UpdateTeam_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 1, 0, 4, 1, 5, 4}, []string{"api", "v1alpha1", "auth", "teams", "id"}, "")) + pattern_AuthService_DeleteTeam_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 1, 0, 4, 1, 5, 4}, []string{"api", "v1alpha1", "auth", "teams", "id"}, "")) + pattern_AuthService_ListApiKeys_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1alpha1", "auth", "api-keys"}, "")) + pattern_AuthService_CreateApiKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1alpha1", "auth", "api-keys"}, "")) + pattern_AuthService_RevokeApiKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 1, 0, 4, 1, 5, 4}, []string{"api", "v1alpha1", "auth", "api-keys", "id"}, "")) +) + +var ( + forward_AuthService_GetAuthConfig_0 = runtime.ForwardResponseMessage + forward_AuthService_Me_0 = runtime.ForwardResponseMessage + forward_AuthService_ListUsers_0 = runtime.ForwardResponseMessage + forward_AuthService_CreateUser_0 = runtime.ForwardResponseMessage + forward_AuthService_UpdateUser_0 = runtime.ForwardResponseMessage + forward_AuthService_ListTeams_0 = runtime.ForwardResponseMessage + forward_AuthService_CreateTeam_0 = runtime.ForwardResponseMessage + forward_AuthService_UpdateTeam_0 = runtime.ForwardResponseMessage + forward_AuthService_DeleteTeam_0 = runtime.ForwardResponseMessage + forward_AuthService_ListApiKeys_0 = runtime.ForwardResponseMessage + forward_AuthService_CreateApiKey_0 = runtime.ForwardResponseMessage + forward_AuthService_RevokeApiKey_0 = runtime.ForwardResponseMessage +) diff --git a/generated/proto/auth/v1alpha1/auth.pb.validate.go b/generated/proto/auth/v1alpha1/auth.pb.validate.go new file mode 100644 index 00000000..2498d84c --- /dev/null +++ b/generated/proto/auth/v1alpha1/auth.pb.validate.go @@ -0,0 +1,3456 @@ +// Code generated by protoc-gen-validate. DO NOT EDIT. +// source: proto/auth/v1alpha1/auth.proto + +package v1alpha1 + +import ( + "bytes" + "errors" + "fmt" + "net" + "net/mail" + "net/url" + "regexp" + "sort" + "strings" + "time" + "unicode/utf8" + + "google.golang.org/protobuf/types/known/anypb" +) + +// ensure the imports are used +var ( + _ = bytes.MinRead + _ = errors.New("") + _ = fmt.Print + _ = utf8.UTFMax + _ = (*regexp.Regexp)(nil) + _ = (*strings.Reader)(nil) + _ = net.IPv4len + _ = time.Duration(0) + _ = (*url.URL)(nil) + _ = (*mail.Address)(nil) + _ = anypb.Any{} + _ = sort.Sort +) + +// Validate checks the field values on GetAuthConfigRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *GetAuthConfigRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on GetAuthConfigRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// GetAuthConfigRequestMultiError, or nil if none found. +func (m *GetAuthConfigRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *GetAuthConfigRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if len(errors) > 0 { + return GetAuthConfigRequestMultiError(errors) + } + + return nil +} + +// GetAuthConfigRequestMultiError is an error wrapping multiple validation +// errors returned by GetAuthConfigRequest.ValidateAll() if the designated +// constraints aren't met. +type GetAuthConfigRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m GetAuthConfigRequestMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m GetAuthConfigRequestMultiError) AllErrors() []error { return m } + +// GetAuthConfigRequestValidationError is the validation error returned by +// GetAuthConfigRequest.Validate if the designated constraints aren't met. +type GetAuthConfigRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e GetAuthConfigRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e GetAuthConfigRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e GetAuthConfigRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e GetAuthConfigRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e GetAuthConfigRequestValidationError) ErrorName() string { + return "GetAuthConfigRequestValidationError" +} + +// Error satisfies the builtin error interface +func (e GetAuthConfigRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sGetAuthConfigRequest.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = GetAuthConfigRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = GetAuthConfigRequestValidationError{} + +// Validate checks the field values on GetAuthConfigResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *GetAuthConfigResponse) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on GetAuthConfigResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// GetAuthConfigResponseMultiError, or nil if none found. +func (m *GetAuthConfigResponse) ValidateAll() error { + return m.validate(true) +} + +func (m *GetAuthConfigResponse) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for LocalLoginEnabled + + // no validation rules for OidcEnabled + + // no validation rules for OidcButtonLabel + + // no validation rules for DemoMode + + if len(errors) > 0 { + return GetAuthConfigResponseMultiError(errors) + } + + return nil +} + +// GetAuthConfigResponseMultiError is an error wrapping multiple validation +// errors returned by GetAuthConfigResponse.ValidateAll() if the designated +// constraints aren't met. +type GetAuthConfigResponseMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m GetAuthConfigResponseMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m GetAuthConfigResponseMultiError) AllErrors() []error { return m } + +// GetAuthConfigResponseValidationError is the validation error returned by +// GetAuthConfigResponse.Validate if the designated constraints aren't met. +type GetAuthConfigResponseValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e GetAuthConfigResponseValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e GetAuthConfigResponseValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e GetAuthConfigResponseValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e GetAuthConfigResponseValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e GetAuthConfigResponseValidationError) ErrorName() string { + return "GetAuthConfigResponseValidationError" +} + +// Error satisfies the builtin error interface +func (e GetAuthConfigResponseValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sGetAuthConfigResponse.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = GetAuthConfigResponseValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = GetAuthConfigResponseValidationError{} + +// Validate checks the field values on MeRequest with the rules defined in the +// proto definition for this message. If any rules are violated, the first +// error encountered is returned, or nil if there are no violations. +func (m *MeRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on MeRequest with the rules defined in +// the proto definition for this message. If any rules are violated, the +// result is a list of violation errors wrapped in MeRequestMultiError, or nil +// if none found. +func (m *MeRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *MeRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if len(errors) > 0 { + return MeRequestMultiError(errors) + } + + return nil +} + +// MeRequestMultiError is an error wrapping multiple validation errors returned +// by MeRequest.ValidateAll() if the designated constraints aren't met. +type MeRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m MeRequestMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m MeRequestMultiError) AllErrors() []error { return m } + +// MeRequestValidationError is the validation error returned by +// MeRequest.Validate if the designated constraints aren't met. +type MeRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e MeRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e MeRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e MeRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e MeRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e MeRequestValidationError) ErrorName() string { return "MeRequestValidationError" } + +// Error satisfies the builtin error interface +func (e MeRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sMeRequest.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = MeRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = MeRequestValidationError{} + +// Validate checks the field values on TeamRef with the rules defined in the +// proto definition for this message. If any rules are violated, the first +// error encountered is returned, or nil if there are no violations. +func (m *TeamRef) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on TeamRef with the rules defined in the +// proto definition for this message. If any rules are violated, the result is +// a list of violation errors wrapped in TeamRefMultiError, or nil if none found. +func (m *TeamRef) ValidateAll() error { + return m.validate(true) +} + +func (m *TeamRef) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for Id + + // no validation rules for Name + + if len(errors) > 0 { + return TeamRefMultiError(errors) + } + + return nil +} + +// TeamRefMultiError is an error wrapping multiple validation errors returned +// by TeamRef.ValidateAll() if the designated constraints aren't met. +type TeamRefMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m TeamRefMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m TeamRefMultiError) AllErrors() []error { return m } + +// TeamRefValidationError is the validation error returned by TeamRef.Validate +// if the designated constraints aren't met. +type TeamRefValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e TeamRefValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e TeamRefValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e TeamRefValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e TeamRefValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e TeamRefValidationError) ErrorName() string { return "TeamRefValidationError" } + +// Error satisfies the builtin error interface +func (e TeamRefValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sTeamRef.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = TeamRefValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = TeamRefValidationError{} + +// Validate checks the field values on MeResponse with the rules defined in the +// proto definition for this message. If any rules are violated, the first +// error encountered is returned, or nil if there are no violations. +func (m *MeResponse) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on MeResponse with the rules defined in +// the proto definition for this message. If any rules are violated, the +// result is a list of violation errors wrapped in MeResponseMultiError, or +// nil if none found. +func (m *MeResponse) ValidateAll() error { + return m.validate(true) +} + +func (m *MeResponse) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for Authenticated + + // no validation rules for Kind + + // no validation rules for UserId + + // no validation rules for Username + + // no validation rules for DisplayName + + // no validation rules for Source + + for idx, item := range m.GetTeams() { + _, _ = idx, item + + if all { + switch v := interface{}(item).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, MeResponseValidationError{ + field: fmt.Sprintf("Teams[%v]", idx), + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, MeResponseValidationError{ + field: fmt.Sprintf("Teams[%v]", idx), + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(item).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return MeResponseValidationError{ + field: fmt.Sprintf("Teams[%v]", idx), + reason: "embedded message failed validation", + cause: err, + } + } + } + + } + + // no validation rules for ScopeAll + + // no validation rules for MustChangePassword + + // no validation rules for IsAdmin + + if len(errors) > 0 { + return MeResponseMultiError(errors) + } + + return nil +} + +// MeResponseMultiError is an error wrapping multiple validation errors +// returned by MeResponse.ValidateAll() if the designated constraints aren't met. +type MeResponseMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m MeResponseMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m MeResponseMultiError) AllErrors() []error { return m } + +// MeResponseValidationError is the validation error returned by +// MeResponse.Validate if the designated constraints aren't met. +type MeResponseValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e MeResponseValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e MeResponseValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e MeResponseValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e MeResponseValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e MeResponseValidationError) ErrorName() string { return "MeResponseValidationError" } + +// Error satisfies the builtin error interface +func (e MeResponseValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sMeResponse.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = MeResponseValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = MeResponseValidationError{} + +// Validate checks the field values on User with the rules defined in the proto +// definition for this message. If any rules are violated, the first error +// encountered is returned, or nil if there are no violations. +func (m *User) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on User with the rules defined in the +// proto definition for this message. If any rules are violated, the result is +// a list of violation errors wrapped in UserMultiError, or nil if none found. +func (m *User) ValidateAll() error { + return m.validate(true) +} + +func (m *User) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for Id + + // no validation rules for Username + + // no validation rules for Email + + // no validation rules for DisplayName + + // no validation rules for Source + + // no validation rules for Disabled + + // no validation rules for MustChangePassword + + if all { + switch v := interface{}(m.GetCreatedAt()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, UserValidationError{ + field: "CreatedAt", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, UserValidationError{ + field: "CreatedAt", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetCreatedAt()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return UserValidationError{ + field: "CreatedAt", + reason: "embedded message failed validation", + cause: err, + } + } + } + + if all { + switch v := interface{}(m.GetLastLoginAt()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, UserValidationError{ + field: "LastLoginAt", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, UserValidationError{ + field: "LastLoginAt", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetLastLoginAt()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return UserValidationError{ + field: "LastLoginAt", + reason: "embedded message failed validation", + cause: err, + } + } + } + + if len(errors) > 0 { + return UserMultiError(errors) + } + + return nil +} + +// UserMultiError is an error wrapping multiple validation errors returned by +// User.ValidateAll() if the designated constraints aren't met. +type UserMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m UserMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m UserMultiError) AllErrors() []error { return m } + +// UserValidationError is the validation error returned by User.Validate if the +// designated constraints aren't met. +type UserValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e UserValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e UserValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e UserValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e UserValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e UserValidationError) ErrorName() string { return "UserValidationError" } + +// Error satisfies the builtin error interface +func (e UserValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sUser.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = UserValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = UserValidationError{} + +// Validate checks the field values on ListUsersRequest with the rules defined +// in the proto definition for this message. If any rules are violated, the +// first error encountered is returned, or nil if there are no violations. +func (m *ListUsersRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on ListUsersRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// ListUsersRequestMultiError, or nil if none found. +func (m *ListUsersRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *ListUsersRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if len(errors) > 0 { + return ListUsersRequestMultiError(errors) + } + + return nil +} + +// ListUsersRequestMultiError is an error wrapping multiple validation errors +// returned by ListUsersRequest.ValidateAll() if the designated constraints +// aren't met. +type ListUsersRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ListUsersRequestMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ListUsersRequestMultiError) AllErrors() []error { return m } + +// ListUsersRequestValidationError is the validation error returned by +// ListUsersRequest.Validate if the designated constraints aren't met. +type ListUsersRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ListUsersRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ListUsersRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ListUsersRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ListUsersRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ListUsersRequestValidationError) ErrorName() string { return "ListUsersRequestValidationError" } + +// Error satisfies the builtin error interface +func (e ListUsersRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sListUsersRequest.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = ListUsersRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ListUsersRequestValidationError{} + +// Validate checks the field values on ListUsersResponse with the rules defined +// in the proto definition for this message. If any rules are violated, the +// first error encountered is returned, or nil if there are no violations. +func (m *ListUsersResponse) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on ListUsersResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// ListUsersResponseMultiError, or nil if none found. +func (m *ListUsersResponse) ValidateAll() error { + return m.validate(true) +} + +func (m *ListUsersResponse) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + for idx, item := range m.GetUsers() { + _, _ = idx, item + + if all { + switch v := interface{}(item).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, ListUsersResponseValidationError{ + field: fmt.Sprintf("Users[%v]", idx), + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, ListUsersResponseValidationError{ + field: fmt.Sprintf("Users[%v]", idx), + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(item).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return ListUsersResponseValidationError{ + field: fmt.Sprintf("Users[%v]", idx), + reason: "embedded message failed validation", + cause: err, + } + } + } + + } + + if len(errors) > 0 { + return ListUsersResponseMultiError(errors) + } + + return nil +} + +// ListUsersResponseMultiError is an error wrapping multiple validation errors +// returned by ListUsersResponse.ValidateAll() if the designated constraints +// aren't met. +type ListUsersResponseMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ListUsersResponseMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ListUsersResponseMultiError) AllErrors() []error { return m } + +// ListUsersResponseValidationError is the validation error returned by +// ListUsersResponse.Validate if the designated constraints aren't met. +type ListUsersResponseValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ListUsersResponseValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ListUsersResponseValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ListUsersResponseValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ListUsersResponseValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ListUsersResponseValidationError) ErrorName() string { + return "ListUsersResponseValidationError" +} + +// Error satisfies the builtin error interface +func (e ListUsersResponseValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sListUsersResponse.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = ListUsersResponseValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ListUsersResponseValidationError{} + +// Validate checks the field values on CreateUserRequest with the rules defined +// in the proto definition for this message. If any rules are violated, the +// first error encountered is returned, or nil if there are no violations. +func (m *CreateUserRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on CreateUserRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// CreateUserRequestMultiError, or nil if none found. +func (m *CreateUserRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *CreateUserRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for Username + + // no validation rules for Email + + // no validation rules for DisplayName + + // no validation rules for Password + + if len(errors) > 0 { + return CreateUserRequestMultiError(errors) + } + + return nil +} + +// CreateUserRequestMultiError is an error wrapping multiple validation errors +// returned by CreateUserRequest.ValidateAll() if the designated constraints +// aren't met. +type CreateUserRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m CreateUserRequestMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m CreateUserRequestMultiError) AllErrors() []error { return m } + +// CreateUserRequestValidationError is the validation error returned by +// CreateUserRequest.Validate if the designated constraints aren't met. +type CreateUserRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e CreateUserRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e CreateUserRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e CreateUserRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e CreateUserRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e CreateUserRequestValidationError) ErrorName() string { + return "CreateUserRequestValidationError" +} + +// Error satisfies the builtin error interface +func (e CreateUserRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sCreateUserRequest.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = CreateUserRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = CreateUserRequestValidationError{} + +// Validate checks the field values on CreateUserResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *CreateUserResponse) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on CreateUserResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// CreateUserResponseMultiError, or nil if none found. +func (m *CreateUserResponse) ValidateAll() error { + return m.validate(true) +} + +func (m *CreateUserResponse) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if all { + switch v := interface{}(m.GetUser()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, CreateUserResponseValidationError{ + field: "User", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, CreateUserResponseValidationError{ + field: "User", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetUser()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return CreateUserResponseValidationError{ + field: "User", + reason: "embedded message failed validation", + cause: err, + } + } + } + + if len(errors) > 0 { + return CreateUserResponseMultiError(errors) + } + + return nil +} + +// CreateUserResponseMultiError is an error wrapping multiple validation errors +// returned by CreateUserResponse.ValidateAll() if the designated constraints +// aren't met. +type CreateUserResponseMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m CreateUserResponseMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m CreateUserResponseMultiError) AllErrors() []error { return m } + +// CreateUserResponseValidationError is the validation error returned by +// CreateUserResponse.Validate if the designated constraints aren't met. +type CreateUserResponseValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e CreateUserResponseValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e CreateUserResponseValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e CreateUserResponseValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e CreateUserResponseValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e CreateUserResponseValidationError) ErrorName() string { + return "CreateUserResponseValidationError" +} + +// Error satisfies the builtin error interface +func (e CreateUserResponseValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sCreateUserResponse.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = CreateUserResponseValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = CreateUserResponseValidationError{} + +// Validate checks the field values on UpdateUserRequest with the rules defined +// in the proto definition for this message. If any rules are violated, the +// first error encountered is returned, or nil if there are no violations. +func (m *UpdateUserRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on UpdateUserRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// UpdateUserRequestMultiError, or nil if none found. +func (m *UpdateUserRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *UpdateUserRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for Id + + // no validation rules for Email + + // no validation rules for DisplayName + + // no validation rules for Disabled + + // no validation rules for NewPassword + + if len(errors) > 0 { + return UpdateUserRequestMultiError(errors) + } + + return nil +} + +// UpdateUserRequestMultiError is an error wrapping multiple validation errors +// returned by UpdateUserRequest.ValidateAll() if the designated constraints +// aren't met. +type UpdateUserRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m UpdateUserRequestMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m UpdateUserRequestMultiError) AllErrors() []error { return m } + +// UpdateUserRequestValidationError is the validation error returned by +// UpdateUserRequest.Validate if the designated constraints aren't met. +type UpdateUserRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e UpdateUserRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e UpdateUserRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e UpdateUserRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e UpdateUserRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e UpdateUserRequestValidationError) ErrorName() string { + return "UpdateUserRequestValidationError" +} + +// Error satisfies the builtin error interface +func (e UpdateUserRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sUpdateUserRequest.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = UpdateUserRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = UpdateUserRequestValidationError{} + +// Validate checks the field values on UpdateUserResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *UpdateUserResponse) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on UpdateUserResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// UpdateUserResponseMultiError, or nil if none found. +func (m *UpdateUserResponse) ValidateAll() error { + return m.validate(true) +} + +func (m *UpdateUserResponse) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if all { + switch v := interface{}(m.GetUser()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, UpdateUserResponseValidationError{ + field: "User", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, UpdateUserResponseValidationError{ + field: "User", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetUser()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return UpdateUserResponseValidationError{ + field: "User", + reason: "embedded message failed validation", + cause: err, + } + } + } + + if len(errors) > 0 { + return UpdateUserResponseMultiError(errors) + } + + return nil +} + +// UpdateUserResponseMultiError is an error wrapping multiple validation errors +// returned by UpdateUserResponse.ValidateAll() if the designated constraints +// aren't met. +type UpdateUserResponseMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m UpdateUserResponseMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m UpdateUserResponseMultiError) AllErrors() []error { return m } + +// UpdateUserResponseValidationError is the validation error returned by +// UpdateUserResponse.Validate if the designated constraints aren't met. +type UpdateUserResponseValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e UpdateUserResponseValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e UpdateUserResponseValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e UpdateUserResponseValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e UpdateUserResponseValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e UpdateUserResponseValidationError) ErrorName() string { + return "UpdateUserResponseValidationError" +} + +// Error satisfies the builtin error interface +func (e UpdateUserResponseValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sUpdateUserResponse.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = UpdateUserResponseValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = UpdateUserResponseValidationError{} + +// Validate checks the field values on Team with the rules defined in the proto +// definition for this message. If any rules are violated, the first error +// encountered is returned, or nil if there are no violations. +func (m *Team) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on Team with the rules defined in the +// proto definition for this message. If any rules are violated, the result is +// a list of violation errors wrapped in TeamMultiError, or nil if none found. +func (m *Team) ValidateAll() error { + return m.validate(true) +} + +func (m *Team) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for Id + + // no validation rules for Name + + // no validation rules for Description + + // no validation rules for ScopeAll + + // no validation rules for Builtin + + if len(errors) > 0 { + return TeamMultiError(errors) + } + + return nil +} + +// TeamMultiError is an error wrapping multiple validation errors returned by +// Team.ValidateAll() if the designated constraints aren't met. +type TeamMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m TeamMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m TeamMultiError) AllErrors() []error { return m } + +// TeamValidationError is the validation error returned by Team.Validate if the +// designated constraints aren't met. +type TeamValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e TeamValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e TeamValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e TeamValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e TeamValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e TeamValidationError) ErrorName() string { return "TeamValidationError" } + +// Error satisfies the builtin error interface +func (e TeamValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sTeam.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = TeamValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = TeamValidationError{} + +// Validate checks the field values on ListTeamsRequest with the rules defined +// in the proto definition for this message. If any rules are violated, the +// first error encountered is returned, or nil if there are no violations. +func (m *ListTeamsRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on ListTeamsRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// ListTeamsRequestMultiError, or nil if none found. +func (m *ListTeamsRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *ListTeamsRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if len(errors) > 0 { + return ListTeamsRequestMultiError(errors) + } + + return nil +} + +// ListTeamsRequestMultiError is an error wrapping multiple validation errors +// returned by ListTeamsRequest.ValidateAll() if the designated constraints +// aren't met. +type ListTeamsRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ListTeamsRequestMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ListTeamsRequestMultiError) AllErrors() []error { return m } + +// ListTeamsRequestValidationError is the validation error returned by +// ListTeamsRequest.Validate if the designated constraints aren't met. +type ListTeamsRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ListTeamsRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ListTeamsRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ListTeamsRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ListTeamsRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ListTeamsRequestValidationError) ErrorName() string { return "ListTeamsRequestValidationError" } + +// Error satisfies the builtin error interface +func (e ListTeamsRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sListTeamsRequest.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = ListTeamsRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ListTeamsRequestValidationError{} + +// Validate checks the field values on ListTeamsResponse with the rules defined +// in the proto definition for this message. If any rules are violated, the +// first error encountered is returned, or nil if there are no violations. +func (m *ListTeamsResponse) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on ListTeamsResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// ListTeamsResponseMultiError, or nil if none found. +func (m *ListTeamsResponse) ValidateAll() error { + return m.validate(true) +} + +func (m *ListTeamsResponse) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + for idx, item := range m.GetTeams() { + _, _ = idx, item + + if all { + switch v := interface{}(item).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, ListTeamsResponseValidationError{ + field: fmt.Sprintf("Teams[%v]", idx), + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, ListTeamsResponseValidationError{ + field: fmt.Sprintf("Teams[%v]", idx), + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(item).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return ListTeamsResponseValidationError{ + field: fmt.Sprintf("Teams[%v]", idx), + reason: "embedded message failed validation", + cause: err, + } + } + } + + } + + if len(errors) > 0 { + return ListTeamsResponseMultiError(errors) + } + + return nil +} + +// ListTeamsResponseMultiError is an error wrapping multiple validation errors +// returned by ListTeamsResponse.ValidateAll() if the designated constraints +// aren't met. +type ListTeamsResponseMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ListTeamsResponseMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ListTeamsResponseMultiError) AllErrors() []error { return m } + +// ListTeamsResponseValidationError is the validation error returned by +// ListTeamsResponse.Validate if the designated constraints aren't met. +type ListTeamsResponseValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ListTeamsResponseValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ListTeamsResponseValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ListTeamsResponseValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ListTeamsResponseValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ListTeamsResponseValidationError) ErrorName() string { + return "ListTeamsResponseValidationError" +} + +// Error satisfies the builtin error interface +func (e ListTeamsResponseValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sListTeamsResponse.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = ListTeamsResponseValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ListTeamsResponseValidationError{} + +// Validate checks the field values on CreateTeamRequest with the rules defined +// in the proto definition for this message. If any rules are violated, the +// first error encountered is returned, or nil if there are no violations. +func (m *CreateTeamRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on CreateTeamRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// CreateTeamRequestMultiError, or nil if none found. +func (m *CreateTeamRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *CreateTeamRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for Name + + // no validation rules for Description + + // no validation rules for ScopeAll + + if len(errors) > 0 { + return CreateTeamRequestMultiError(errors) + } + + return nil +} + +// CreateTeamRequestMultiError is an error wrapping multiple validation errors +// returned by CreateTeamRequest.ValidateAll() if the designated constraints +// aren't met. +type CreateTeamRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m CreateTeamRequestMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m CreateTeamRequestMultiError) AllErrors() []error { return m } + +// CreateTeamRequestValidationError is the validation error returned by +// CreateTeamRequest.Validate if the designated constraints aren't met. +type CreateTeamRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e CreateTeamRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e CreateTeamRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e CreateTeamRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e CreateTeamRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e CreateTeamRequestValidationError) ErrorName() string { + return "CreateTeamRequestValidationError" +} + +// Error satisfies the builtin error interface +func (e CreateTeamRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sCreateTeamRequest.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = CreateTeamRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = CreateTeamRequestValidationError{} + +// Validate checks the field values on CreateTeamResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *CreateTeamResponse) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on CreateTeamResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// CreateTeamResponseMultiError, or nil if none found. +func (m *CreateTeamResponse) ValidateAll() error { + return m.validate(true) +} + +func (m *CreateTeamResponse) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if all { + switch v := interface{}(m.GetTeam()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, CreateTeamResponseValidationError{ + field: "Team", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, CreateTeamResponseValidationError{ + field: "Team", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetTeam()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return CreateTeamResponseValidationError{ + field: "Team", + reason: "embedded message failed validation", + cause: err, + } + } + } + + if len(errors) > 0 { + return CreateTeamResponseMultiError(errors) + } + + return nil +} + +// CreateTeamResponseMultiError is an error wrapping multiple validation errors +// returned by CreateTeamResponse.ValidateAll() if the designated constraints +// aren't met. +type CreateTeamResponseMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m CreateTeamResponseMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m CreateTeamResponseMultiError) AllErrors() []error { return m } + +// CreateTeamResponseValidationError is the validation error returned by +// CreateTeamResponse.Validate if the designated constraints aren't met. +type CreateTeamResponseValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e CreateTeamResponseValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e CreateTeamResponseValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e CreateTeamResponseValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e CreateTeamResponseValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e CreateTeamResponseValidationError) ErrorName() string { + return "CreateTeamResponseValidationError" +} + +// Error satisfies the builtin error interface +func (e CreateTeamResponseValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sCreateTeamResponse.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = CreateTeamResponseValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = CreateTeamResponseValidationError{} + +// Validate checks the field values on UpdateTeamRequest with the rules defined +// in the proto definition for this message. If any rules are violated, the +// first error encountered is returned, or nil if there are no violations. +func (m *UpdateTeamRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on UpdateTeamRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// UpdateTeamRequestMultiError, or nil if none found. +func (m *UpdateTeamRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *UpdateTeamRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for Id + + // no validation rules for Name + + // no validation rules for Description + + // no validation rules for ScopeAll + + if len(errors) > 0 { + return UpdateTeamRequestMultiError(errors) + } + + return nil +} + +// UpdateTeamRequestMultiError is an error wrapping multiple validation errors +// returned by UpdateTeamRequest.ValidateAll() if the designated constraints +// aren't met. +type UpdateTeamRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m UpdateTeamRequestMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m UpdateTeamRequestMultiError) AllErrors() []error { return m } + +// UpdateTeamRequestValidationError is the validation error returned by +// UpdateTeamRequest.Validate if the designated constraints aren't met. +type UpdateTeamRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e UpdateTeamRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e UpdateTeamRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e UpdateTeamRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e UpdateTeamRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e UpdateTeamRequestValidationError) ErrorName() string { + return "UpdateTeamRequestValidationError" +} + +// Error satisfies the builtin error interface +func (e UpdateTeamRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sUpdateTeamRequest.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = UpdateTeamRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = UpdateTeamRequestValidationError{} + +// Validate checks the field values on UpdateTeamResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *UpdateTeamResponse) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on UpdateTeamResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// UpdateTeamResponseMultiError, or nil if none found. +func (m *UpdateTeamResponse) ValidateAll() error { + return m.validate(true) +} + +func (m *UpdateTeamResponse) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if all { + switch v := interface{}(m.GetTeam()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, UpdateTeamResponseValidationError{ + field: "Team", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, UpdateTeamResponseValidationError{ + field: "Team", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetTeam()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return UpdateTeamResponseValidationError{ + field: "Team", + reason: "embedded message failed validation", + cause: err, + } + } + } + + if len(errors) > 0 { + return UpdateTeamResponseMultiError(errors) + } + + return nil +} + +// UpdateTeamResponseMultiError is an error wrapping multiple validation errors +// returned by UpdateTeamResponse.ValidateAll() if the designated constraints +// aren't met. +type UpdateTeamResponseMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m UpdateTeamResponseMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m UpdateTeamResponseMultiError) AllErrors() []error { return m } + +// UpdateTeamResponseValidationError is the validation error returned by +// UpdateTeamResponse.Validate if the designated constraints aren't met. +type UpdateTeamResponseValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e UpdateTeamResponseValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e UpdateTeamResponseValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e UpdateTeamResponseValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e UpdateTeamResponseValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e UpdateTeamResponseValidationError) ErrorName() string { + return "UpdateTeamResponseValidationError" +} + +// Error satisfies the builtin error interface +func (e UpdateTeamResponseValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sUpdateTeamResponse.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = UpdateTeamResponseValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = UpdateTeamResponseValidationError{} + +// Validate checks the field values on DeleteTeamRequest with the rules defined +// in the proto definition for this message. If any rules are violated, the +// first error encountered is returned, or nil if there are no violations. +func (m *DeleteTeamRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on DeleteTeamRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// DeleteTeamRequestMultiError, or nil if none found. +func (m *DeleteTeamRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *DeleteTeamRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for Id + + if len(errors) > 0 { + return DeleteTeamRequestMultiError(errors) + } + + return nil +} + +// DeleteTeamRequestMultiError is an error wrapping multiple validation errors +// returned by DeleteTeamRequest.ValidateAll() if the designated constraints +// aren't met. +type DeleteTeamRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m DeleteTeamRequestMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m DeleteTeamRequestMultiError) AllErrors() []error { return m } + +// DeleteTeamRequestValidationError is the validation error returned by +// DeleteTeamRequest.Validate if the designated constraints aren't met. +type DeleteTeamRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e DeleteTeamRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e DeleteTeamRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e DeleteTeamRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e DeleteTeamRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e DeleteTeamRequestValidationError) ErrorName() string { + return "DeleteTeamRequestValidationError" +} + +// Error satisfies the builtin error interface +func (e DeleteTeamRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sDeleteTeamRequest.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = DeleteTeamRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = DeleteTeamRequestValidationError{} + +// Validate checks the field values on DeleteTeamResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *DeleteTeamResponse) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on DeleteTeamResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// DeleteTeamResponseMultiError, or nil if none found. +func (m *DeleteTeamResponse) ValidateAll() error { + return m.validate(true) +} + +func (m *DeleteTeamResponse) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if len(errors) > 0 { + return DeleteTeamResponseMultiError(errors) + } + + return nil +} + +// DeleteTeamResponseMultiError is an error wrapping multiple validation errors +// returned by DeleteTeamResponse.ValidateAll() if the designated constraints +// aren't met. +type DeleteTeamResponseMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m DeleteTeamResponseMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m DeleteTeamResponseMultiError) AllErrors() []error { return m } + +// DeleteTeamResponseValidationError is the validation error returned by +// DeleteTeamResponse.Validate if the designated constraints aren't met. +type DeleteTeamResponseValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e DeleteTeamResponseValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e DeleteTeamResponseValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e DeleteTeamResponseValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e DeleteTeamResponseValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e DeleteTeamResponseValidationError) ErrorName() string { + return "DeleteTeamResponseValidationError" +} + +// Error satisfies the builtin error interface +func (e DeleteTeamResponseValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sDeleteTeamResponse.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = DeleteTeamResponseValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = DeleteTeamResponseValidationError{} + +// Validate checks the field values on ApiKey with the rules defined in the +// proto definition for this message. If any rules are violated, the first +// error encountered is returned, or nil if there are no violations. +func (m *ApiKey) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on ApiKey with the rules defined in the +// proto definition for this message. If any rules are violated, the result is +// a list of violation errors wrapped in ApiKeyMultiError, or nil if none found. +func (m *ApiKey) ValidateAll() error { + return m.validate(true) +} + +func (m *ApiKey) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for Id + + // no validation rules for Prefix + + // no validation rules for Name + + // no validation rules for TeamId + + // no validation rules for CreatedBy + + if all { + switch v := interface{}(m.GetCreatedAt()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, ApiKeyValidationError{ + field: "CreatedAt", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, ApiKeyValidationError{ + field: "CreatedAt", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetCreatedAt()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return ApiKeyValidationError{ + field: "CreatedAt", + reason: "embedded message failed validation", + cause: err, + } + } + } + + if all { + switch v := interface{}(m.GetExpiresAt()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, ApiKeyValidationError{ + field: "ExpiresAt", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, ApiKeyValidationError{ + field: "ExpiresAt", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetExpiresAt()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return ApiKeyValidationError{ + field: "ExpiresAt", + reason: "embedded message failed validation", + cause: err, + } + } + } + + if all { + switch v := interface{}(m.GetLastUsedAt()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, ApiKeyValidationError{ + field: "LastUsedAt", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, ApiKeyValidationError{ + field: "LastUsedAt", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetLastUsedAt()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return ApiKeyValidationError{ + field: "LastUsedAt", + reason: "embedded message failed validation", + cause: err, + } + } + } + + if all { + switch v := interface{}(m.GetRevokedAt()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, ApiKeyValidationError{ + field: "RevokedAt", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, ApiKeyValidationError{ + field: "RevokedAt", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetRevokedAt()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return ApiKeyValidationError{ + field: "RevokedAt", + reason: "embedded message failed validation", + cause: err, + } + } + } + + if len(errors) > 0 { + return ApiKeyMultiError(errors) + } + + return nil +} + +// ApiKeyMultiError is an error wrapping multiple validation errors returned by +// ApiKey.ValidateAll() if the designated constraints aren't met. +type ApiKeyMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ApiKeyMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ApiKeyMultiError) AllErrors() []error { return m } + +// ApiKeyValidationError is the validation error returned by ApiKey.Validate if +// the designated constraints aren't met. +type ApiKeyValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ApiKeyValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ApiKeyValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ApiKeyValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ApiKeyValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ApiKeyValidationError) ErrorName() string { return "ApiKeyValidationError" } + +// Error satisfies the builtin error interface +func (e ApiKeyValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sApiKey.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = ApiKeyValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ApiKeyValidationError{} + +// Validate checks the field values on ListApiKeysRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *ListApiKeysRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on ListApiKeysRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// ListApiKeysRequestMultiError, or nil if none found. +func (m *ListApiKeysRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *ListApiKeysRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if len(errors) > 0 { + return ListApiKeysRequestMultiError(errors) + } + + return nil +} + +// ListApiKeysRequestMultiError is an error wrapping multiple validation errors +// returned by ListApiKeysRequest.ValidateAll() if the designated constraints +// aren't met. +type ListApiKeysRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ListApiKeysRequestMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ListApiKeysRequestMultiError) AllErrors() []error { return m } + +// ListApiKeysRequestValidationError is the validation error returned by +// ListApiKeysRequest.Validate if the designated constraints aren't met. +type ListApiKeysRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ListApiKeysRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ListApiKeysRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ListApiKeysRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ListApiKeysRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ListApiKeysRequestValidationError) ErrorName() string { + return "ListApiKeysRequestValidationError" +} + +// Error satisfies the builtin error interface +func (e ListApiKeysRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sListApiKeysRequest.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = ListApiKeysRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ListApiKeysRequestValidationError{} + +// Validate checks the field values on ListApiKeysResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *ListApiKeysResponse) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on ListApiKeysResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// ListApiKeysResponseMultiError, or nil if none found. +func (m *ListApiKeysResponse) ValidateAll() error { + return m.validate(true) +} + +func (m *ListApiKeysResponse) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + for idx, item := range m.GetApiKeys() { + _, _ = idx, item + + if all { + switch v := interface{}(item).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, ListApiKeysResponseValidationError{ + field: fmt.Sprintf("ApiKeys[%v]", idx), + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, ListApiKeysResponseValidationError{ + field: fmt.Sprintf("ApiKeys[%v]", idx), + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(item).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return ListApiKeysResponseValidationError{ + field: fmt.Sprintf("ApiKeys[%v]", idx), + reason: "embedded message failed validation", + cause: err, + } + } + } + + } + + if len(errors) > 0 { + return ListApiKeysResponseMultiError(errors) + } + + return nil +} + +// ListApiKeysResponseMultiError is an error wrapping multiple validation +// errors returned by ListApiKeysResponse.ValidateAll() if the designated +// constraints aren't met. +type ListApiKeysResponseMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m ListApiKeysResponseMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m ListApiKeysResponseMultiError) AllErrors() []error { return m } + +// ListApiKeysResponseValidationError is the validation error returned by +// ListApiKeysResponse.Validate if the designated constraints aren't met. +type ListApiKeysResponseValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e ListApiKeysResponseValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e ListApiKeysResponseValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e ListApiKeysResponseValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e ListApiKeysResponseValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e ListApiKeysResponseValidationError) ErrorName() string { + return "ListApiKeysResponseValidationError" +} + +// Error satisfies the builtin error interface +func (e ListApiKeysResponseValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sListApiKeysResponse.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = ListApiKeysResponseValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = ListApiKeysResponseValidationError{} + +// Validate checks the field values on CreateApiKeyRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *CreateApiKeyRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on CreateApiKeyRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// CreateApiKeyRequestMultiError, or nil if none found. +func (m *CreateApiKeyRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *CreateApiKeyRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for Name + + // no validation rules for TeamId + + if all { + switch v := interface{}(m.GetExpiresAt()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, CreateApiKeyRequestValidationError{ + field: "ExpiresAt", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, CreateApiKeyRequestValidationError{ + field: "ExpiresAt", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetExpiresAt()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return CreateApiKeyRequestValidationError{ + field: "ExpiresAt", + reason: "embedded message failed validation", + cause: err, + } + } + } + + if len(errors) > 0 { + return CreateApiKeyRequestMultiError(errors) + } + + return nil +} + +// CreateApiKeyRequestMultiError is an error wrapping multiple validation +// errors returned by CreateApiKeyRequest.ValidateAll() if the designated +// constraints aren't met. +type CreateApiKeyRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m CreateApiKeyRequestMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m CreateApiKeyRequestMultiError) AllErrors() []error { return m } + +// CreateApiKeyRequestValidationError is the validation error returned by +// CreateApiKeyRequest.Validate if the designated constraints aren't met. +type CreateApiKeyRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e CreateApiKeyRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e CreateApiKeyRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e CreateApiKeyRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e CreateApiKeyRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e CreateApiKeyRequestValidationError) ErrorName() string { + return "CreateApiKeyRequestValidationError" +} + +// Error satisfies the builtin error interface +func (e CreateApiKeyRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sCreateApiKeyRequest.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = CreateApiKeyRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = CreateApiKeyRequestValidationError{} + +// Validate checks the field values on CreateApiKeyResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *CreateApiKeyResponse) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on CreateApiKeyResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// CreateApiKeyResponseMultiError, or nil if none found. +func (m *CreateApiKeyResponse) ValidateAll() error { + return m.validate(true) +} + +func (m *CreateApiKeyResponse) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if all { + switch v := interface{}(m.GetApiKey()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, CreateApiKeyResponseValidationError{ + field: "ApiKey", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, CreateApiKeyResponseValidationError{ + field: "ApiKey", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetApiKey()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return CreateApiKeyResponseValidationError{ + field: "ApiKey", + reason: "embedded message failed validation", + cause: err, + } + } + } + + // no validation rules for Secret + + if len(errors) > 0 { + return CreateApiKeyResponseMultiError(errors) + } + + return nil +} + +// CreateApiKeyResponseMultiError is an error wrapping multiple validation +// errors returned by CreateApiKeyResponse.ValidateAll() if the designated +// constraints aren't met. +type CreateApiKeyResponseMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m CreateApiKeyResponseMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m CreateApiKeyResponseMultiError) AllErrors() []error { return m } + +// CreateApiKeyResponseValidationError is the validation error returned by +// CreateApiKeyResponse.Validate if the designated constraints aren't met. +type CreateApiKeyResponseValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e CreateApiKeyResponseValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e CreateApiKeyResponseValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e CreateApiKeyResponseValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e CreateApiKeyResponseValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e CreateApiKeyResponseValidationError) ErrorName() string { + return "CreateApiKeyResponseValidationError" +} + +// Error satisfies the builtin error interface +func (e CreateApiKeyResponseValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sCreateApiKeyResponse.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = CreateApiKeyResponseValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = CreateApiKeyResponseValidationError{} + +// Validate checks the field values on RevokeApiKeyRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *RevokeApiKeyRequest) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on RevokeApiKeyRequest with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// RevokeApiKeyRequestMultiError, or nil if none found. +func (m *RevokeApiKeyRequest) ValidateAll() error { + return m.validate(true) +} + +func (m *RevokeApiKeyRequest) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for Id + + if len(errors) > 0 { + return RevokeApiKeyRequestMultiError(errors) + } + + return nil +} + +// RevokeApiKeyRequestMultiError is an error wrapping multiple validation +// errors returned by RevokeApiKeyRequest.ValidateAll() if the designated +// constraints aren't met. +type RevokeApiKeyRequestMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m RevokeApiKeyRequestMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m RevokeApiKeyRequestMultiError) AllErrors() []error { return m } + +// RevokeApiKeyRequestValidationError is the validation error returned by +// RevokeApiKeyRequest.Validate if the designated constraints aren't met. +type RevokeApiKeyRequestValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e RevokeApiKeyRequestValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e RevokeApiKeyRequestValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e RevokeApiKeyRequestValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e RevokeApiKeyRequestValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e RevokeApiKeyRequestValidationError) ErrorName() string { + return "RevokeApiKeyRequestValidationError" +} + +// Error satisfies the builtin error interface +func (e RevokeApiKeyRequestValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sRevokeApiKeyRequest.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = RevokeApiKeyRequestValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = RevokeApiKeyRequestValidationError{} + +// Validate checks the field values on RevokeApiKeyResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the first error encountered is returned, or nil if there are no violations. +func (m *RevokeApiKeyResponse) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on RevokeApiKeyResponse with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// RevokeApiKeyResponseMultiError, or nil if none found. +func (m *RevokeApiKeyResponse) ValidateAll() error { + return m.validate(true) +} + +func (m *RevokeApiKeyResponse) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if len(errors) > 0 { + return RevokeApiKeyResponseMultiError(errors) + } + + return nil +} + +// RevokeApiKeyResponseMultiError is an error wrapping multiple validation +// errors returned by RevokeApiKeyResponse.ValidateAll() if the designated +// constraints aren't met. +type RevokeApiKeyResponseMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m RevokeApiKeyResponseMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m RevokeApiKeyResponseMultiError) AllErrors() []error { return m } + +// RevokeApiKeyResponseValidationError is the validation error returned by +// RevokeApiKeyResponse.Validate if the designated constraints aren't met. +type RevokeApiKeyResponseValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e RevokeApiKeyResponseValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e RevokeApiKeyResponseValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e RevokeApiKeyResponseValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e RevokeApiKeyResponseValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e RevokeApiKeyResponseValidationError) ErrorName() string { + return "RevokeApiKeyResponseValidationError" +} + +// Error satisfies the builtin error interface +func (e RevokeApiKeyResponseValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sRevokeApiKeyResponse.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = RevokeApiKeyResponseValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = RevokeApiKeyResponseValidationError{} diff --git a/generated/proto/auth/v1alpha1/auth_grpc.pb.go b/generated/proto/auth/v1alpha1/auth_grpc.pb.go new file mode 100644 index 00000000..e4d2761b --- /dev/null +++ b/generated/proto/auth/v1alpha1/auth_grpc.pb.go @@ -0,0 +1,547 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc (unknown) +// source: proto/auth/v1alpha1/auth.proto + +package v1alpha1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + AuthService_GetAuthConfig_FullMethodName = "/tracker.auth.v1alpha1.AuthService/GetAuthConfig" + AuthService_Me_FullMethodName = "/tracker.auth.v1alpha1.AuthService/Me" + AuthService_ListUsers_FullMethodName = "/tracker.auth.v1alpha1.AuthService/ListUsers" + AuthService_CreateUser_FullMethodName = "/tracker.auth.v1alpha1.AuthService/CreateUser" + AuthService_UpdateUser_FullMethodName = "/tracker.auth.v1alpha1.AuthService/UpdateUser" + AuthService_ListTeams_FullMethodName = "/tracker.auth.v1alpha1.AuthService/ListTeams" + AuthService_CreateTeam_FullMethodName = "/tracker.auth.v1alpha1.AuthService/CreateTeam" + AuthService_UpdateTeam_FullMethodName = "/tracker.auth.v1alpha1.AuthService/UpdateTeam" + AuthService_DeleteTeam_FullMethodName = "/tracker.auth.v1alpha1.AuthService/DeleteTeam" + AuthService_ListApiKeys_FullMethodName = "/tracker.auth.v1alpha1.AuthService/ListApiKeys" + AuthService_CreateApiKey_FullMethodName = "/tracker.auth.v1alpha1.AuthService/CreateApiKey" + AuthService_RevokeApiKey_FullMethodName = "/tracker.auth.v1alpha1.AuthService/RevokeApiKey" +) + +// AuthServiceClient is the client API for AuthService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// AuthService exposes the current identity and the administration of users, +// teams and API keys. Login, logout and password change are plain HTTP +// handlers because they set cookies. +type AuthServiceClient interface { + GetAuthConfig(ctx context.Context, in *GetAuthConfigRequest, opts ...grpc.CallOption) (*GetAuthConfigResponse, error) + Me(ctx context.Context, in *MeRequest, opts ...grpc.CallOption) (*MeResponse, error) + ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (*ListUsersResponse, error) + CreateUser(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (*CreateUserResponse, error) + UpdateUser(ctx context.Context, in *UpdateUserRequest, opts ...grpc.CallOption) (*UpdateUserResponse, error) + ListTeams(ctx context.Context, in *ListTeamsRequest, opts ...grpc.CallOption) (*ListTeamsResponse, error) + CreateTeam(ctx context.Context, in *CreateTeamRequest, opts ...grpc.CallOption) (*CreateTeamResponse, error) + UpdateTeam(ctx context.Context, in *UpdateTeamRequest, opts ...grpc.CallOption) (*UpdateTeamResponse, error) + DeleteTeam(ctx context.Context, in *DeleteTeamRequest, opts ...grpc.CallOption) (*DeleteTeamResponse, error) + ListApiKeys(ctx context.Context, in *ListApiKeysRequest, opts ...grpc.CallOption) (*ListApiKeysResponse, error) + CreateApiKey(ctx context.Context, in *CreateApiKeyRequest, opts ...grpc.CallOption) (*CreateApiKeyResponse, error) + RevokeApiKey(ctx context.Context, in *RevokeApiKeyRequest, opts ...grpc.CallOption) (*RevokeApiKeyResponse, error) +} + +type authServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewAuthServiceClient(cc grpc.ClientConnInterface) AuthServiceClient { + return &authServiceClient{cc} +} + +func (c *authServiceClient) GetAuthConfig(ctx context.Context, in *GetAuthConfigRequest, opts ...grpc.CallOption) (*GetAuthConfigResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetAuthConfigResponse) + err := c.cc.Invoke(ctx, AuthService_GetAuthConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authServiceClient) Me(ctx context.Context, in *MeRequest, opts ...grpc.CallOption) (*MeResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(MeResponse) + err := c.cc.Invoke(ctx, AuthService_Me_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authServiceClient) ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (*ListUsersResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListUsersResponse) + err := c.cc.Invoke(ctx, AuthService_ListUsers_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authServiceClient) CreateUser(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (*CreateUserResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CreateUserResponse) + err := c.cc.Invoke(ctx, AuthService_CreateUser_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authServiceClient) UpdateUser(ctx context.Context, in *UpdateUserRequest, opts ...grpc.CallOption) (*UpdateUserResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UpdateUserResponse) + err := c.cc.Invoke(ctx, AuthService_UpdateUser_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authServiceClient) ListTeams(ctx context.Context, in *ListTeamsRequest, opts ...grpc.CallOption) (*ListTeamsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListTeamsResponse) + err := c.cc.Invoke(ctx, AuthService_ListTeams_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authServiceClient) CreateTeam(ctx context.Context, in *CreateTeamRequest, opts ...grpc.CallOption) (*CreateTeamResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CreateTeamResponse) + err := c.cc.Invoke(ctx, AuthService_CreateTeam_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authServiceClient) UpdateTeam(ctx context.Context, in *UpdateTeamRequest, opts ...grpc.CallOption) (*UpdateTeamResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UpdateTeamResponse) + err := c.cc.Invoke(ctx, AuthService_UpdateTeam_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authServiceClient) DeleteTeam(ctx context.Context, in *DeleteTeamRequest, opts ...grpc.CallOption) (*DeleteTeamResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteTeamResponse) + err := c.cc.Invoke(ctx, AuthService_DeleteTeam_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authServiceClient) ListApiKeys(ctx context.Context, in *ListApiKeysRequest, opts ...grpc.CallOption) (*ListApiKeysResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListApiKeysResponse) + err := c.cc.Invoke(ctx, AuthService_ListApiKeys_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authServiceClient) CreateApiKey(ctx context.Context, in *CreateApiKeyRequest, opts ...grpc.CallOption) (*CreateApiKeyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CreateApiKeyResponse) + err := c.cc.Invoke(ctx, AuthService_CreateApiKey_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authServiceClient) RevokeApiKey(ctx context.Context, in *RevokeApiKeyRequest, opts ...grpc.CallOption) (*RevokeApiKeyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RevokeApiKeyResponse) + err := c.cc.Invoke(ctx, AuthService_RevokeApiKey_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// AuthServiceServer is the server API for AuthService service. +// All implementations must embed UnimplementedAuthServiceServer +// for forward compatibility. +// +// AuthService exposes the current identity and the administration of users, +// teams and API keys. Login, logout and password change are plain HTTP +// handlers because they set cookies. +type AuthServiceServer interface { + GetAuthConfig(context.Context, *GetAuthConfigRequest) (*GetAuthConfigResponse, error) + Me(context.Context, *MeRequest) (*MeResponse, error) + ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error) + CreateUser(context.Context, *CreateUserRequest) (*CreateUserResponse, error) + UpdateUser(context.Context, *UpdateUserRequest) (*UpdateUserResponse, error) + ListTeams(context.Context, *ListTeamsRequest) (*ListTeamsResponse, error) + CreateTeam(context.Context, *CreateTeamRequest) (*CreateTeamResponse, error) + UpdateTeam(context.Context, *UpdateTeamRequest) (*UpdateTeamResponse, error) + DeleteTeam(context.Context, *DeleteTeamRequest) (*DeleteTeamResponse, error) + ListApiKeys(context.Context, *ListApiKeysRequest) (*ListApiKeysResponse, error) + CreateApiKey(context.Context, *CreateApiKeyRequest) (*CreateApiKeyResponse, error) + RevokeApiKey(context.Context, *RevokeApiKeyRequest) (*RevokeApiKeyResponse, error) + mustEmbedUnimplementedAuthServiceServer() +} + +// UnimplementedAuthServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedAuthServiceServer struct{} + +func (UnimplementedAuthServiceServer) GetAuthConfig(context.Context, *GetAuthConfigRequest) (*GetAuthConfigResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetAuthConfig not implemented") +} +func (UnimplementedAuthServiceServer) Me(context.Context, *MeRequest) (*MeResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Me not implemented") +} +func (UnimplementedAuthServiceServer) ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListUsers not implemented") +} +func (UnimplementedAuthServiceServer) CreateUser(context.Context, *CreateUserRequest) (*CreateUserResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CreateUser not implemented") +} +func (UnimplementedAuthServiceServer) UpdateUser(context.Context, *UpdateUserRequest) (*UpdateUserResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method UpdateUser not implemented") +} +func (UnimplementedAuthServiceServer) ListTeams(context.Context, *ListTeamsRequest) (*ListTeamsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListTeams not implemented") +} +func (UnimplementedAuthServiceServer) CreateTeam(context.Context, *CreateTeamRequest) (*CreateTeamResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CreateTeam not implemented") +} +func (UnimplementedAuthServiceServer) UpdateTeam(context.Context, *UpdateTeamRequest) (*UpdateTeamResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method UpdateTeam not implemented") +} +func (UnimplementedAuthServiceServer) DeleteTeam(context.Context, *DeleteTeamRequest) (*DeleteTeamResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeleteTeam not implemented") +} +func (UnimplementedAuthServiceServer) ListApiKeys(context.Context, *ListApiKeysRequest) (*ListApiKeysResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListApiKeys not implemented") +} +func (UnimplementedAuthServiceServer) CreateApiKey(context.Context, *CreateApiKeyRequest) (*CreateApiKeyResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CreateApiKey not implemented") +} +func (UnimplementedAuthServiceServer) RevokeApiKey(context.Context, *RevokeApiKeyRequest) (*RevokeApiKeyResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RevokeApiKey not implemented") +} +func (UnimplementedAuthServiceServer) mustEmbedUnimplementedAuthServiceServer() {} +func (UnimplementedAuthServiceServer) testEmbeddedByValue() {} + +// UnsafeAuthServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to AuthServiceServer will +// result in compilation errors. +type UnsafeAuthServiceServer interface { + mustEmbedUnimplementedAuthServiceServer() +} + +func RegisterAuthServiceServer(s grpc.ServiceRegistrar, srv AuthServiceServer) { + // If the following call pancis, it indicates UnimplementedAuthServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&AuthService_ServiceDesc, srv) +} + +func _AuthService_GetAuthConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetAuthConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServiceServer).GetAuthConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthService_GetAuthConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServiceServer).GetAuthConfig(ctx, req.(*GetAuthConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AuthService_Me_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MeRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServiceServer).Me(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthService_Me_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServiceServer).Me(ctx, req.(*MeRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AuthService_ListUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListUsersRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServiceServer).ListUsers(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthService_ListUsers_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServiceServer).ListUsers(ctx, req.(*ListUsersRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AuthService_CreateUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateUserRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServiceServer).CreateUser(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthService_CreateUser_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServiceServer).CreateUser(ctx, req.(*CreateUserRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AuthService_UpdateUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateUserRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServiceServer).UpdateUser(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthService_UpdateUser_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServiceServer).UpdateUser(ctx, req.(*UpdateUserRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AuthService_ListTeams_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListTeamsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServiceServer).ListTeams(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthService_ListTeams_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServiceServer).ListTeams(ctx, req.(*ListTeamsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AuthService_CreateTeam_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateTeamRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServiceServer).CreateTeam(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthService_CreateTeam_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServiceServer).CreateTeam(ctx, req.(*CreateTeamRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AuthService_UpdateTeam_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateTeamRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServiceServer).UpdateTeam(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthService_UpdateTeam_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServiceServer).UpdateTeam(ctx, req.(*UpdateTeamRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AuthService_DeleteTeam_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteTeamRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServiceServer).DeleteTeam(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthService_DeleteTeam_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServiceServer).DeleteTeam(ctx, req.(*DeleteTeamRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AuthService_ListApiKeys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListApiKeysRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServiceServer).ListApiKeys(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthService_ListApiKeys_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServiceServer).ListApiKeys(ctx, req.(*ListApiKeysRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AuthService_CreateApiKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateApiKeyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServiceServer).CreateApiKey(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthService_CreateApiKey_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServiceServer).CreateApiKey(ctx, req.(*CreateApiKeyRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AuthService_RevokeApiKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RevokeApiKeyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServiceServer).RevokeApiKey(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthService_RevokeApiKey_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServiceServer).RevokeApiKey(ctx, req.(*RevokeApiKeyRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// AuthService_ServiceDesc is the grpc.ServiceDesc for AuthService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var AuthService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "tracker.auth.v1alpha1.AuthService", + HandlerType: (*AuthServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetAuthConfig", + Handler: _AuthService_GetAuthConfig_Handler, + }, + { + MethodName: "Me", + Handler: _AuthService_Me_Handler, + }, + { + MethodName: "ListUsers", + Handler: _AuthService_ListUsers_Handler, + }, + { + MethodName: "CreateUser", + Handler: _AuthService_CreateUser_Handler, + }, + { + MethodName: "UpdateUser", + Handler: _AuthService_UpdateUser_Handler, + }, + { + MethodName: "ListTeams", + Handler: _AuthService_ListTeams_Handler, + }, + { + MethodName: "CreateTeam", + Handler: _AuthService_CreateTeam_Handler, + }, + { + MethodName: "UpdateTeam", + Handler: _AuthService_UpdateTeam_Handler, + }, + { + MethodName: "DeleteTeam", + Handler: _AuthService_DeleteTeam_Handler, + }, + { + MethodName: "ListApiKeys", + Handler: _AuthService_ListApiKeys_Handler, + }, + { + MethodName: "CreateApiKey", + Handler: _AuthService_CreateApiKey_Handler, + }, + { + MethodName: "RevokeApiKey", + Handler: _AuthService_RevokeApiKey_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "proto/auth/v1alpha1/auth.proto", +} diff --git a/internal/auth/authz/methods_test.go b/internal/auth/authz/methods_test.go index 8cd7de94..4df4e68c 100644 --- a/internal/auth/authz/methods_test.go +++ b/internal/auth/authz/methods_test.go @@ -10,6 +10,7 @@ import ( "google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/reflect/protoregistry" + _ "github.com/bananaops/tracker/generated/proto/auth/v1alpha1" _ "github.com/bananaops/tracker/generated/proto/catalog/v1alpha1" _ "github.com/bananaops/tracker/generated/proto/event/v1alpha1" _ "github.com/bananaops/tracker/generated/proto/lock/v1alpha1" @@ -50,6 +51,14 @@ func TestEveryRPCMethodIsMapped(t *testing.T) { } } +func TestEveryTableEntryIsARegisteredRPC(t *testing.T) { + registered := registeredMethods() + for name := range MethodPermissions { + _, ok := registered[name] + assert.True(t, ok, "authz.MethodPermissions entry %s does not match any RPC (typo?)", name) + } +} + func TestTableOnlyContainsGrantableOrPseudoPermissions(t *testing.T) { for name, perm := range MethodPermissions { if perm == PermPublicAlias || perm == PermAuthenticatedAlias { diff --git a/proto/auth/v1alpha1/auth.proto b/proto/auth/v1alpha1/auth.proto new file mode 100644 index 00000000..9781ef9f --- /dev/null +++ b/proto/auth/v1alpha1/auth.proto @@ -0,0 +1,227 @@ +syntax = "proto3"; + +package tracker.auth.v1alpha1; + +import "google/api/annotations.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "proto/auth/v1alpha1"; + +// AuthService exposes the current identity and the administration of users, +// teams and API keys. Login, logout and password change are plain HTTP +// handlers because they set cookies. +service AuthService { + rpc GetAuthConfig(GetAuthConfigRequest) returns (GetAuthConfigResponse) { + option (google.api.http) = {get: "/api/v1alpha1/auth/config"}; + } + rpc Me(MeRequest) returns (MeResponse) { + option (google.api.http) = {get: "/api/v1alpha1/auth/me"}; + } + + rpc ListUsers(ListUsersRequest) returns (ListUsersResponse) { + option (google.api.http) = {get: "/api/v1alpha1/auth/users"}; + } + rpc CreateUser(CreateUserRequest) returns (CreateUserResponse) { + option (google.api.http) = { + post: "/api/v1alpha1/auth/users" + body: "*" + }; + } + rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse) { + option (google.api.http) = { + put: "/api/v1alpha1/auth/users/{id}" + body: "*" + }; + } + + rpc ListTeams(ListTeamsRequest) returns (ListTeamsResponse) { + option (google.api.http) = {get: "/api/v1alpha1/auth/teams"}; + } + rpc CreateTeam(CreateTeamRequest) returns (CreateTeamResponse) { + option (google.api.http) = { + post: "/api/v1alpha1/auth/teams" + body: "*" + }; + } + rpc UpdateTeam(UpdateTeamRequest) returns (UpdateTeamResponse) { + option (google.api.http) = { + put: "/api/v1alpha1/auth/teams/{id}" + body: "*" + }; + } + rpc DeleteTeam(DeleteTeamRequest) returns (DeleteTeamResponse) { + option (google.api.http) = {delete: "/api/v1alpha1/auth/teams/{id}"}; + } + + rpc ListApiKeys(ListApiKeysRequest) returns (ListApiKeysResponse) { + option (google.api.http) = {get: "/api/v1alpha1/auth/api-keys"}; + } + rpc CreateApiKey(CreateApiKeyRequest) returns (CreateApiKeyResponse) { + option (google.api.http) = { + post: "/api/v1alpha1/auth/api-keys" + body: "*" + }; + } + rpc RevokeApiKey(RevokeApiKeyRequest) returns (RevokeApiKeyResponse) { + option (google.api.http) = {delete: "/api/v1alpha1/auth/api-keys/{id}"}; + } +} + +message GetAuthConfigRequest {} + +message GetAuthConfigResponse { + bool local_login_enabled = 1; + bool oidc_enabled = 2; + string oidc_button_label = 3; + repeated string anonymous_permissions = 4; + bool demo_mode = 5; +} + +message MeRequest {} + +message TeamRef { + string id = 1; + string name = 2; +} + +message MeResponse { + bool authenticated = 1; + // "anonymous", "user" or "apikey" + string kind = 2; + string user_id = 3; + string username = 4; + string display_name = 5; + // "local" or "oidc", empty for API keys and anonymous + string source = 6; + repeated TeamRef teams = 7; + repeated string permissions = 8; + bool scope_all = 9; + repeated string scope_services = 10; + bool must_change_password = 11; + bool is_admin = 12; +} + +message User { + string id = 1; + string username = 2; + string email = 3; + string display_name = 4; + string source = 5; + repeated string team_ids = 6; + bool disabled = 7; + bool must_change_password = 8; + google.protobuf.Timestamp created_at = 9; + google.protobuf.Timestamp last_login_at = 10; +} + +message ListUsersRequest {} +message ListUsersResponse { + repeated User users = 1; +} + +message CreateUserRequest { + string username = 1; + string email = 2; + string display_name = 3; + // Temporary password, the user must change it at first login. + string password = 4; + repeated string team_ids = 5; +} +message CreateUserResponse { + User user = 1; +} + +// UpdateUserRequest replaces email, display_name, team_ids and disabled. +// new_password, when set, resets the password and invalidates sessions. +message UpdateUserRequest { + string id = 1; + string email = 2; + string display_name = 3; + repeated string team_ids = 4; + bool disabled = 5; + string new_password = 6; +} +message UpdateUserResponse { + User user = 1; +} + +message Team { + string id = 1; + string name = 2; + string description = 3; + repeated string permissions = 4; + bool scope_all = 5; + repeated string scope_services = 6; + repeated string oidc_groups = 7; + bool builtin = 8; +} + +message ListTeamsRequest {} +message ListTeamsResponse { + repeated Team teams = 1; +} + +message CreateTeamRequest { + string name = 1; + string description = 2; + repeated string permissions = 3; + bool scope_all = 4; + repeated string scope_services = 5; + repeated string oidc_groups = 6; +} +message CreateTeamResponse { + Team team = 1; +} + +message UpdateTeamRequest { + string id = 1; + string name = 2; + string description = 3; + repeated string permissions = 4; + bool scope_all = 5; + repeated string scope_services = 6; + repeated string oidc_groups = 7; +} +message UpdateTeamResponse { + Team team = 1; +} + +message DeleteTeamRequest { + string id = 1; +} +message DeleteTeamResponse {} + +message ApiKey { + string id = 1; + string prefix = 2; + string name = 3; + // Empty for a global key. + string team_id = 4; + string created_by = 5; + google.protobuf.Timestamp created_at = 6; + google.protobuf.Timestamp expires_at = 7; + google.protobuf.Timestamp last_used_at = 8; + google.protobuf.Timestamp revoked_at = 9; +} + +message ListApiKeysRequest {} +message ListApiKeysResponse { + repeated ApiKey api_keys = 1; +} + +message CreateApiKeyRequest { + string name = 1; + // Empty creates a global key, allowed only for Administrators members. + string team_id = 2; + google.protobuf.Timestamp expires_at = 3; +} +message CreateApiKeyResponse { + ApiKey api_key = 1; + // Shown once, never stored. + string secret = 2; +} + +message RevokeApiKeyRequest { + string id = 1; +} +message RevokeApiKeyResponse {} From 156f9f90e257f7c4e29347735575136531e8b768 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 12:09:16 +0200 Subject: [PATCH 17/39] feat(auth): resolve principals from sessions and API keys --- internal/auth/identity/effective.go | 33 ++++ internal/auth/identity/effective_test.go | 33 ++++ internal/auth/identity/resolver.go | 189 +++++++++++++++++++++++ internal/auth/identity/resolver_test.go | 150 ++++++++++++++++++ 4 files changed, 405 insertions(+) create mode 100644 internal/auth/identity/effective.go create mode 100644 internal/auth/identity/effective_test.go create mode 100644 internal/auth/identity/resolver.go create mode 100644 internal/auth/identity/resolver_test.go diff --git a/internal/auth/identity/effective.go b/internal/auth/identity/effective.go new file mode 100644 index 00000000..b384bd6f --- /dev/null +++ b/internal/auth/identity/effective.go @@ -0,0 +1,33 @@ +// Package identity resolves principals from the persisted users, teams and API keys. +package identity + +import ( + "github.com/bananaops/tracker/internal/auth" + store "github.com/bananaops/tracker/internal/stores" +) + +// Effective computes the rights granted by a set of teams: the union of their +// permissions, the union of their scopes and whether one of them is the +// built-in Administrators team. +func Effective(teams []*store.Team) (auth.PermissionSet, auth.Scope, bool) { + perms := auth.NewPermissionSet() + scope := auth.ScopeOf() + admin := false + for _, t := range teams { + for _, raw := range t.Permissions { + p := auth.Permission(raw) + if auth.IsValidPermission(p) { + perms.Add(p) + } + } + if t.Scope.All { + scope = scope.Union(auth.ScopeAll()) + } else { + scope = scope.Union(auth.ScopeOf(t.Scope.Services...)) + } + if t.Builtin { + admin = true + } + } + return perms, scope, admin +} diff --git a/internal/auth/identity/effective_test.go b/internal/auth/identity/effective_test.go new file mode 100644 index 00000000..6498d880 --- /dev/null +++ b/internal/auth/identity/effective_test.go @@ -0,0 +1,33 @@ +package identity + +import ( + "testing" + + "github.com/bananaops/tracker/internal/auth" + store "github.com/bananaops/tracker/internal/stores" + "github.com/stretchr/testify/assert" +) + +func TestEffective(t *testing.T) { + perms, scope, admin := Effective(nil) + assert.Empty(t, perms) + assert.False(t, scope.All) + assert.False(t, admin) + + teams := []*store.Team{ + {Name: "readers", Permissions: []string{"event:read", "bogus"}, Scope: store.TeamScope{Services: []string{"api"}}}, + {Name: "ops", Permissions: []string{"lock:write"}, Scope: store.TeamScope{Services: []string{"web"}}}, + } + perms, scope, admin = Effective(teams) + assert.True(t, perms.Has(auth.PermEventRead)) + assert.True(t, perms.Has(auth.PermLockWrite)) + assert.False(t, perms.Has("bogus"), "unknown permissions stored in the database are ignored") + assert.Equal(t, []string{"api", "web"}, scope.ServiceList()) + assert.False(t, admin) + + teams = append(teams, &store.Team{Name: "Administrators", Builtin: true, Permissions: []string{"access:manage"}, Scope: store.TeamScope{All: true}}) + perms, scope, admin = Effective(teams) + assert.True(t, scope.All) + assert.True(t, admin) + assert.True(t, perms.Has(auth.PermAccessManage)) +} diff --git a/internal/auth/identity/resolver.go b/internal/auth/identity/resolver.go new file mode 100644 index 00000000..7548ac50 --- /dev/null +++ b/internal/auth/identity/resolver.go @@ -0,0 +1,189 @@ +package identity + +import ( + "context" + "errors" + "log/slog" + "time" + + "github.com/bananaops/tracker/internal/auth" + store "github.com/bananaops/tracker/internal/stores" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// UserStore is the subset of store.AuthUserStore used to resolve sessions. +type UserStore interface { + GetByID(ctx context.Context, id primitive.ObjectID) (*store.User, error) +} + +// TeamStore is the subset of store.AuthTeamStore used to compute rights. +type TeamStore interface { + GetByIDs(ctx context.Context, ids []primitive.ObjectID) ([]*store.Team, error) +} + +// APIKeyStore is the subset of store.AuthAPIKeyStore used to resolve keys. +type APIKeyStore interface { + GetByPrefix(ctx context.Context, prefix string) (*store.APIKey, error) + TouchLastUsed(ctx context.Context, id primitive.ObjectID, at time.Time) error +} + +// Resolver implements auth.Resolver on top of the stores. +type Resolver struct { + Users UserStore + Teams TeamStore + Keys APIKeyStore + Sessions *auth.SessionManager + AnonymousPermissions []auth.Permission + Now func() time.Time + Logger *slog.Logger +} + +const lastUsedGranularity = time.Minute + +func (r *Resolver) logger() *slog.Logger { + if r.Logger != nil { + return r.Logger + } + return slog.Default() +} + +func (r *Resolver) now() time.Time { + if r.Now != nil { + return r.Now() + } + return time.Now() +} + +func (r *Resolver) anonymous() auth.Principal { + return auth.Anonymous(r.AnonymousPermissions) +} + +// Resolve never fails: any invalid credential yields the anonymous principal. +func (r *Resolver) Resolve(ctx context.Context, creds auth.Credentials) auth.Principal { + if creds.APIKey != "" { + if p, ok := r.resolveAPIKey(ctx, creds.APIKey); ok { + return p + } + return r.anonymous() + } + if creds.SessionToken != "" { + if p, ok := r.resolveSession(ctx, creds.SessionToken); ok { + return p + } + return r.anonymous() + } + return r.anonymous() +} + +func (r *Resolver) resolveAPIKey(ctx context.Context, secret string) (auth.Principal, bool) { + prefix, ok := auth.ParseAPIKeyPrefix(secret) + if !ok { + r.logger().Warn("auth: malformed api key") + return auth.Principal{}, false + } + key, err := r.Keys.GetByPrefix(ctx, prefix) + if err != nil { + if !errors.Is(err, store.ErrNotFound) { + r.logger().Error("auth: api key lookup failed", "error", err) + } else { + r.logger().Warn("auth: unknown api key", "prefix", prefix) + } + return auth.Principal{}, false + } + if !auth.APIKeyMatches(key.Hash, secret) { + r.logger().Warn("auth: api key secret mismatch", "prefix", prefix) + return auth.Principal{}, false + } + now := r.now() + if key.RevokedAt != nil { + r.logger().Warn("auth: revoked api key used", "prefix", prefix) + return auth.Principal{}, false + } + if key.ExpiresAt != nil && now.After(*key.ExpiresAt) { + r.logger().Warn("auth: expired api key used", "prefix", prefix) + return auth.Principal{}, false + } + if key.LastUsedAt == nil || now.Sub(*key.LastUsedAt) > lastUsedGranularity { + if err := r.Keys.TouchLastUsed(ctx, key.ID, now); err != nil { + r.logger().Error("auth: touch api key failed", "error", err) + } + } + + if key.TeamID == nil { + return auth.Principal{ + Kind: auth.KindAPIKey, + Username: "apikey:" + prefix, + Permissions: auth.NewPermissionSet(auth.AllPermissions()...), + Scope: auth.ScopeAll(), + IsAdmin: true, + KeyPrefix: prefix, + }, true + } + teams, err := r.Teams.GetByIDs(ctx, []primitive.ObjectID{*key.TeamID}) + if err != nil { + r.logger().Error("auth: team lookup failed", "error", err) + return auth.Principal{}, false + } + if len(teams) == 0 { + r.logger().Warn("auth: api key belongs to a deleted team", "prefix", prefix) + return auth.Principal{}, false + } + perms, scope, _ := Effective(teams) + return auth.Principal{ + Kind: auth.KindAPIKey, + Username: "apikey:" + prefix, + TeamIDs: []string{key.TeamID.Hex()}, + Permissions: perms, + Scope: scope, + KeyPrefix: prefix, + }, true +} + +func (r *Resolver) resolveSession(ctx context.Context, token string) (auth.Principal, bool) { + sess, err := r.Sessions.Verify(token) + if err != nil { + return auth.Principal{}, false + } + id, err := primitive.ObjectIDFromHex(sess.UserID) + if err != nil { + return auth.Principal{}, false + } + user, err := r.Users.GetByID(ctx, id) + if err != nil { + if !errors.Is(err, store.ErrNotFound) { + r.logger().Error("auth: user lookup failed", "error", err) + } + return auth.Principal{}, false + } + if user.Disabled || user.SessionVersion != sess.SessionVersion { + return auth.Principal{}, false + } + p, err := r.PrincipalForUser(ctx, user) + if err != nil { + r.logger().Error("auth: team lookup failed", "error", err) + return auth.Principal{}, false + } + return p, true +} + +// PrincipalForUser builds the principal of a user from its teams. +func (r *Resolver) PrincipalForUser(ctx context.Context, user *store.User) (auth.Principal, error) { + teams, err := r.Teams.GetByIDs(ctx, user.Teams) + if err != nil { + return auth.Principal{}, err + } + perms, scope, admin := Effective(teams) + teamIDs := make([]string, 0, len(teams)) + for _, t := range teams { + teamIDs = append(teamIDs, t.ID.Hex()) + } + return auth.Principal{ + Kind: auth.KindUser, + UserID: user.ID.Hex(), + Username: user.Username, + TeamIDs: teamIDs, + Permissions: perms, + Scope: scope, + IsAdmin: admin, + }, nil +} diff --git a/internal/auth/identity/resolver_test.go b/internal/auth/identity/resolver_test.go new file mode 100644 index 00000000..5df3899e --- /dev/null +++ b/internal/auth/identity/resolver_test.go @@ -0,0 +1,150 @@ +package identity + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/bananaops/tracker/internal/auth" + store "github.com/bananaops/tracker/internal/stores" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type fakeUsers struct { + byID map[primitive.ObjectID]*store.User +} + +func (f *fakeUsers) GetByID(_ context.Context, id primitive.ObjectID) (*store.User, error) { + if u, ok := f.byID[id]; ok { + return u, nil + } + return nil, store.ErrNotFound +} + +type fakeTeams struct { + byID map[primitive.ObjectID]*store.Team +} + +func (f *fakeTeams) GetByIDs(_ context.Context, ids []primitive.ObjectID) ([]*store.Team, error) { + var out []*store.Team + for _, id := range ids { + if t, ok := f.byID[id]; ok { + out = append(out, t) + } + } + return out, nil +} + +type fakeKeys struct { + byPrefix map[string]*store.APIKey + touched int +} + +func (f *fakeKeys) GetByPrefix(_ context.Context, prefix string) (*store.APIKey, error) { + if k, ok := f.byPrefix[prefix]; ok { + return k, nil + } + return nil, store.ErrNotFound +} + +func (f *fakeKeys) TouchLastUsed(_ context.Context, _ primitive.ObjectID, _ time.Time) error { + f.touched++ + return nil +} + +func newFixture(t *testing.T) (*Resolver, *store.User, *store.Team, *fakeKeys) { + t.Helper() + team := &store.Team{ID: primitive.NewObjectID(), Name: "ops", Permissions: []string{"event:read"}, Scope: store.TeamScope{Services: []string{"api"}}} + admins := &store.Team{ID: primitive.NewObjectID(), Name: "Administrators", Builtin: true, Permissions: []string{"access:manage"}, Scope: store.TeamScope{All: true}} + user := &store.User{ID: primitive.NewObjectID(), Username: "alice", Teams: []primitive.ObjectID{team.ID}, SessionVersion: 2} + sessions, err := auth.NewSessionManager(bytes.Repeat([]byte{3}, 32), time.Hour) + require.NoError(t, err) + keys := &fakeKeys{byPrefix: map[string]*store.APIKey{}} + r := &Resolver{ + Users: &fakeUsers{byID: map[primitive.ObjectID]*store.User{user.ID: user}}, + Teams: &fakeTeams{byID: map[primitive.ObjectID]*store.Team{team.ID: team, admins.ID: admins}}, + Keys: keys, + Sessions: sessions, + AnonymousPermissions: []auth.Permission{auth.PermLinksRead}, + Now: time.Now, + } + return r, user, team, keys +} + +func TestResolveAnonymous(t *testing.T) { + r, _, _, _ := newFixture(t) + p := r.Resolve(context.Background(), auth.Credentials{}) + assert.Equal(t, auth.KindAnonymous, p.Kind) + assert.True(t, p.Has(auth.PermLinksRead)) + assert.False(t, p.Has(auth.PermEventRead)) +} + +func TestResolveSession(t *testing.T) { + r, user, team, _ := newFixture(t) + token, _, _ := r.Sessions.Issue(user.ID.Hex(), user.SessionVersion) + + p := r.Resolve(context.Background(), auth.Credentials{SessionToken: token}) + assert.Equal(t, auth.KindUser, p.Kind) + assert.Equal(t, "alice", p.Username) + assert.Equal(t, []string{team.ID.Hex()}, p.TeamIDs) + assert.True(t, p.Has(auth.PermEventRead)) + assert.True(t, p.Scope.Allows("api")) + assert.False(t, p.Scope.Allows("web")) + assert.False(t, p.IsAdmin) + + stale, _, _ := r.Sessions.Issue(user.ID.Hex(), user.SessionVersion-1) + assert.Equal(t, auth.KindAnonymous, r.Resolve(context.Background(), auth.Credentials{SessionToken: stale}).Kind) + + user.Disabled = true + assert.Equal(t, auth.KindAnonymous, r.Resolve(context.Background(), auth.Credentials{SessionToken: token}).Kind) + + assert.Equal(t, auth.KindAnonymous, r.Resolve(context.Background(), auth.Credentials{SessionToken: "garbage"}).Kind) +} + +func TestResolveAPIKey(t *testing.T) { + r, _, team, keys := newFixture(t) + gen, _ := auth.GenerateAPIKey() + keys.byPrefix[gen.Prefix] = &store.APIKey{ID: primitive.NewObjectID(), Prefix: gen.Prefix, Hash: gen.Hash, TeamID: &team.ID} + + p := r.Resolve(context.Background(), auth.Credentials{APIKey: gen.Secret}) + assert.Equal(t, auth.KindAPIKey, p.Kind) + assert.Equal(t, "apikey:"+gen.Prefix, p.Username) + assert.Equal(t, gen.Prefix, p.KeyPrefix) + assert.True(t, p.Has(auth.PermEventRead)) + assert.False(t, p.IsAdmin) + assert.Equal(t, 1, keys.touched) + + // Second use within a minute does not touch again. + now := time.Now() + keys.byPrefix[gen.Prefix].LastUsedAt = &now + r.Resolve(context.Background(), auth.Credentials{APIKey: gen.Secret}) + assert.Equal(t, 1, keys.touched) + + wrong := gen.Secret[:len(gen.Secret)-1] + "x" + assert.Equal(t, auth.KindAnonymous, r.Resolve(context.Background(), auth.Credentials{APIKey: wrong}).Kind) + + revoked := now + keys.byPrefix[gen.Prefix].RevokedAt = &revoked + assert.Equal(t, auth.KindAnonymous, r.Resolve(context.Background(), auth.Credentials{APIKey: gen.Secret}).Kind) + keys.byPrefix[gen.Prefix].RevokedAt = nil + + past := now.Add(-time.Minute) + keys.byPrefix[gen.Prefix].ExpiresAt = &past + assert.Equal(t, auth.KindAnonymous, r.Resolve(context.Background(), auth.Credentials{APIKey: gen.Secret}).Kind) + keys.byPrefix[gen.Prefix].ExpiresAt = nil + + // Global key. + global, _ := auth.GenerateAPIKey() + keys.byPrefix[global.Prefix] = &store.APIKey{ID: primitive.NewObjectID(), Prefix: global.Prefix, Hash: global.Hash} + p = r.Resolve(context.Background(), auth.Credentials{APIKey: global.Secret}) + assert.True(t, p.IsAdmin) + assert.True(t, p.Scope.All) + assert.True(t, p.Has(auth.PermAccessManage)) + + // Unknown prefix and malformed key. + assert.Equal(t, auth.KindAnonymous, r.Resolve(context.Background(), auth.Credentials{APIKey: "trk_zzzzzzzz_nothing"}).Kind) + assert.Equal(t, auth.KindAnonymous, r.Resolve(context.Background(), auth.Credentials{APIKey: "trk_bad"}).Kind) +} From 14eaf2cd78a1c9d037b9377b4d175b661673a477 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 12:09:55 +0200 Subject: [PATCH 18/39] feat(auth): bootstrap administrators team and initial admin --- internal/auth/identity/bootstrap.go | 115 +++++++++++++++++++++++ internal/auth/identity/bootstrap_test.go | 74 +++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 internal/auth/identity/bootstrap.go create mode 100644 internal/auth/identity/bootstrap_test.go diff --git a/internal/auth/identity/bootstrap.go b/internal/auth/identity/bootstrap.go new file mode 100644 index 00000000..7b839603 --- /dev/null +++ b/internal/auth/identity/bootstrap.go @@ -0,0 +1,115 @@ +package identity + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "math/big" + + "github.com/bananaops/tracker/internal/auth" + store "github.com/bananaops/tracker/internal/stores" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// BootstrapUserStore is the subset of store.AuthUserStore used at startup. +type BootstrapUserStore interface { + Count(ctx context.Context) (int64, error) + Create(ctx context.Context, u *store.User) error +} + +// BootstrapTeamStore is the subset of store.AuthTeamStore used at startup. +type BootstrapTeamStore interface { + GetByName(ctx context.Context, name string) (*store.Team, error) + Create(ctx context.Context, t *store.Team) error +} + +// BootstrapResult tells the caller what was created. +type BootstrapResult struct { + AdminCreated bool + // GeneratedPassword is set only when no AUTH_ADMIN_PASSWORD was provided. + // The caller must log it once. + GeneratedPassword string + AdminsTeamID primitive.ObjectID +} + +const generatedPasswordLength = 24 + +// Bootstrap makes sure the Administrators team exists and creates the first +// admin user when the user collection is empty. +func Bootstrap(ctx context.Context, users BootstrapUserStore, teams BootstrapTeamStore, adminPassword string) (BootstrapResult, error) { + admins, err := teams.GetByName(ctx, store.AdministratorsTeamName) + if errors.Is(err, store.ErrNotFound) { + admins = &store.Team{ + Name: store.AdministratorsTeamName, + Description: "Built-in team holding every permission", + Permissions: permissionStrings(auth.AllPermissions()), + Scope: store.TeamScope{All: true, Services: []string{}}, + OIDCGroups: []string{}, + Builtin: true, + } + if err := teams.Create(ctx, admins); err != nil { + return BootstrapResult{}, fmt.Errorf("create administrators team: %w", err) + } + } else if err != nil { + return BootstrapResult{}, fmt.Errorf("lookup administrators team: %w", err) + } + result := BootstrapResult{AdminsTeamID: admins.ID} + + count, err := users.Count(ctx) + if err != nil { + return BootstrapResult{}, fmt.Errorf("count users: %w", err) + } + if count > 0 { + return result, nil + } + + password := adminPassword + if password == "" { + password, err = GeneratePassword(generatedPasswordLength) + if err != nil { + return BootstrapResult{}, err + } + result.GeneratedPassword = password + } + hash, err := auth.HashPassword(password) + if err != nil { + return BootstrapResult{}, fmt.Errorf("hash admin password: %w", err) + } + admin := &store.User{ + Username: "admin", + DisplayName: "Administrator", + Source: store.UserSourceLocal, + PasswordHash: hash, + Teams: []primitive.ObjectID{admins.ID}, + MustChangePassword: true, + } + if err := users.Create(ctx, admin); err != nil { + return BootstrapResult{}, fmt.Errorf("create admin user: %w", err) + } + result.AdminCreated = true + return result, nil +} + +const passwordAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +// GeneratePassword returns a random alphanumeric password of n characters. +func GeneratePassword(n int) (string, error) { + out := make([]byte, n) + for i := range out { + idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(passwordAlphabet)))) + if err != nil { + return "", fmt.Errorf("generate password: %w", err) + } + out[i] = passwordAlphabet[idx.Int64()] + } + return string(out), nil +} + +func permissionStrings(perms []auth.Permission) []string { + out := make([]string, len(perms)) + for i, p := range perms { + out[i] = string(p) + } + return out +} diff --git a/internal/auth/identity/bootstrap_test.go b/internal/auth/identity/bootstrap_test.go new file mode 100644 index 00000000..4958dc27 --- /dev/null +++ b/internal/auth/identity/bootstrap_test.go @@ -0,0 +1,74 @@ +package identity + +import ( + "context" + "testing" + + "github.com/bananaops/tracker/internal/auth" + store "github.com/bananaops/tracker/internal/stores" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type memUsers struct{ users []*store.User } + +func (m *memUsers) Count(context.Context) (int64, error) { return int64(len(m.users)), nil } +func (m *memUsers) Create(_ context.Context, u *store.User) error { + u.ID = primitive.NewObjectID() + m.users = append(m.users, u) + return nil +} + +type memTeams struct{ teams map[string]*store.Team } + +func (m *memTeams) GetByName(_ context.Context, name string) (*store.Team, error) { + if t, ok := m.teams[name]; ok { + return t, nil + } + return nil, store.ErrNotFound +} +func (m *memTeams) Create(_ context.Context, t *store.Team) error { + t.ID = primitive.NewObjectID() + m.teams[t.Name] = t + return nil +} + +func TestBootstrapCreatesAdminWithGivenPassword(t *testing.T) { + users, teams := &memUsers{}, &memTeams{teams: map[string]*store.Team{}} + res, err := Bootstrap(context.Background(), users, teams, "initial-admin-password") + require.NoError(t, err) + assert.True(t, res.AdminCreated) + assert.Empty(t, res.GeneratedPassword) + + admins := teams.teams[store.AdministratorsTeamName] + require.NotNil(t, admins) + assert.True(t, admins.Builtin) + assert.True(t, admins.Scope.All) + assert.Len(t, admins.Permissions, len(auth.AllPermissions())) + assert.Equal(t, admins.ID, res.AdminsTeamID) + + require.Len(t, users.users, 1) + admin := users.users[0] + assert.Equal(t, "admin", admin.Username) + assert.Equal(t, store.UserSourceLocal, admin.Source) + assert.True(t, admin.MustChangePassword) + assert.Equal(t, []primitive.ObjectID{admins.ID}, admin.Teams) + ok, _ := auth.VerifyPassword(admin.PasswordHash, "initial-admin-password") + assert.True(t, ok) +} + +func TestBootstrapGeneratesPasswordAndIsIdempotent(t *testing.T) { + users, teams := &memUsers{}, &memTeams{teams: map[string]*store.Team{}} + res, err := Bootstrap(context.Background(), users, teams, "") + require.NoError(t, err) + assert.Len(t, res.GeneratedPassword, 24) + ok, _ := auth.VerifyPassword(users.users[0].PasswordHash, res.GeneratedPassword) + assert.True(t, ok) + + again, err := Bootstrap(context.Background(), users, teams, "") + require.NoError(t, err) + assert.False(t, again.AdminCreated) + assert.Len(t, users.users, 1) + assert.Len(t, teams.teams, 1) +} From 34f3b573bd3027f80d4c95a654016dee1be100be Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 12:13:30 +0200 Subject: [PATCH 19/39] feat(auth): add login, logout and password change endpoints --- server/auth_http.go | 203 ++++++++++++++++++++++++++++++++++++ server/auth_http_test.go | 136 ++++++++++++++++++++++++ server/auth_testing_test.go | 90 ++++++++++++++++ 3 files changed, 429 insertions(+) create mode 100644 server/auth_http.go create mode 100644 server/auth_http_test.go create mode 100644 server/auth_testing_test.go diff --git a/server/auth_http.go b/server/auth_http.go new file mode 100644 index 00000000..ccb24e2e --- /dev/null +++ b/server/auth_http.go @@ -0,0 +1,203 @@ +package server + +import ( + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/bananaops/tracker/internal/auth" + store "github.com/bananaops/tracker/internal/stores" + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +const ( + loginMaxFailures = 5 + loginWindow = time.Minute + maxAuthBodyBytes = 4096 +) + +// AuthHTTP serves the cookie based endpoints that cannot be gRPC methods. +type AuthHTTP struct { + users *store.AuthUserStore + sessions *auth.SessionManager + limiter *auth.LoginLimiter + cfg auth.Config + logger *slog.Logger +} + +func NewAuthHTTP(users *store.AuthUserStore, sessions *auth.SessionManager, cfg auth.Config) *AuthHTTP { + return &AuthHTTP{ + users: users, + sessions: sessions, + limiter: auth.NewLoginLimiter(loginMaxFailures, loginWindow), + cfg: cfg, + logger: slog.Default(), + } +} + +// Register mounts the endpoints on the gateway mux. +func (h *AuthHTTP) Register(mux *runtime.ServeMux) { + routes := []struct { + path string + handler runtime.HandlerFunc + }{ + {"/api/v1alpha1/auth/login", h.handleLogin}, + {"/api/v1alpha1/auth/logout", h.handleLogout}, + {"/api/v1alpha1/auth/password", h.handleChangePassword}, + } + for _, r := range routes { + if err := mux.HandlePath(http.MethodPost, r.path, r.handler); err != nil { + h.logger.Error("Failed to register auth route", "path", r.path, "error", err) + } + } +} + +func writeJSONError(w http.ResponseWriter, code int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +func decodeJSON(r *http.Request, v any) error { + return json.NewDecoder(io.LimitReader(r.Body, maxAuthBodyBytes)).Decode(v) +} + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (h *AuthHTTP) handleLogin(w http.ResponseWriter, r *http.Request, _ map[string]string) { + var req loginRequest + if err := decodeJSON(r, &req); err != nil || req.Username == "" || req.Password == "" { + writeJSONError(w, http.StatusBadRequest, "username and password are required") + return + } + ip := auth.ClientIP(r, h.cfg.TrustProxy) + limitKey := strings.ToLower(req.Username) + "|" + ip + if h.limiter.Blocked(limitKey) { + h.logger.Warn("auth.login", "result", "rate_limited", "username", req.Username, "ip", ip) + writeJSONError(w, http.StatusTooManyRequests, "too many failed attempts, retry later") + return + } + + fail := func(reason string) { + h.limiter.RecordFailure(limitKey) + h.logger.Warn("auth.login", "result", "failure", "reason", reason, "username", req.Username, "ip", ip) + writeJSONError(w, http.StatusUnauthorized, "invalid credentials") + } + + user, err := h.users.GetByUsername(r.Context(), req.Username) + if err != nil { + if !errors.Is(err, store.ErrNotFound) { + h.logger.Error("auth.login lookup failed", "error", err) + writeJSONError(w, http.StatusInternalServerError, "internal error") + return + } + auth.DummyVerify(req.Password) + fail("unknown_user") + return + } + if user.Source != store.UserSourceLocal || user.Disabled || user.PasswordHash == "" { + auth.DummyVerify(req.Password) + fail("not_eligible") + return + } + ok, err := auth.VerifyPassword(user.PasswordHash, req.Password) + if err != nil || !ok { + fail("bad_password") + return + } + + h.limiter.Reset(limitKey) + if err := h.issueSession(w, user); err != nil { + h.logger.Error("auth.login issue session failed", "error", err) + writeJSONError(w, http.StatusInternalServerError, "internal error") + return + } + if err := h.users.TouchLogin(r.Context(), user.ID, time.Now().UTC()); err != nil { + h.logger.Error("auth.login touch failed", "error", err) + } + h.logger.Info("auth.login", "result", "success", "username", user.Username, "ip", ip) + w.WriteHeader(http.StatusNoContent) +} + +func (h *AuthHTTP) issueSession(w http.ResponseWriter, user *store.User) error { + token, expires, err := h.sessions.Issue(user.ID.Hex(), user.SessionVersion) + if err != nil { + return err + } + http.SetCookie(w, auth.SessionCookie(token, expires, h.cfg.CookieSecure)) + return nil +} + +func (h *AuthHTTP) handleLogout(w http.ResponseWriter, _ *http.Request, _ map[string]string) { + http.SetCookie(w, auth.ClearSessionCookie(h.cfg.CookieSecure)) + w.WriteHeader(http.StatusNoContent) +} + +type changePasswordRequest struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` +} + +func (h *AuthHTTP) handleChangePassword(w http.ResponseWriter, r *http.Request, _ map[string]string) { + p, ok := auth.FromContext(r.Context()) + if !ok || p.Kind != auth.KindUser { + writeJSONError(w, http.StatusUnauthorized, "authentication required") + return + } + var req changePasswordRequest + if err := decodeJSON(r, &req); err != nil || req.CurrentPassword == "" || req.NewPassword == "" { + writeJSONError(w, http.StatusBadRequest, "currentPassword and newPassword are required") + return + } + id, err := primitive.ObjectIDFromHex(p.UserID) + if err != nil { + writeJSONError(w, http.StatusUnauthorized, "authentication required") + return + } + user, err := h.users.GetByID(r.Context(), id) + if err != nil { + writeJSONError(w, http.StatusUnauthorized, "authentication required") + return + } + if user.Source != store.UserSourceLocal { + writeJSONError(w, http.StatusBadRequest, "password is managed by the identity provider") + return + } + ok, err = auth.VerifyPassword(user.PasswordHash, req.CurrentPassword) + if err != nil || !ok { + h.logger.Warn("auth.password", "result", "failure", "username", user.Username) + writeJSONError(w, http.StatusUnauthorized, "current password is incorrect") + return + } + if err := auth.ValidatePasswordPolicy(req.NewPassword); err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) + return + } + hash, err := auth.HashPassword(req.NewPassword) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "internal error") + return + } + user.PasswordHash = hash + user.MustChangePassword = false + user.SessionVersion++ + if err := h.users.Update(r.Context(), user); err != nil { + h.logger.Error("auth.password update failed", "error", err) + writeJSONError(w, http.StatusInternalServerError, "internal error") + return + } + if err := h.issueSession(w, user); err != nil { + writeJSONError(w, http.StatusInternalServerError, "internal error") + return + } + h.logger.Info("auth.password", "result", "success", "username", user.Username) + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/auth_http_test.go b/server/auth_http_test.go new file mode 100644 index 00000000..ba9b8ea1 --- /dev/null +++ b/server/auth_http_test.go @@ -0,0 +1,136 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/bananaops/tracker/internal/auth" + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newAuthHTTPServer(t *testing.T, f *authFixture) http.Handler { + t.Helper() + mux := runtime.NewServeMux() + NewAuthHTTP(f.users, f.sessions, f.cfg).Register(mux) + return auth.HTTPMiddleware(f.resolver)(mux) +} + +func post(h http.Handler, path, body string, cookie *http.Cookie) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if cookie != nil { + req.AddCookie(cookie) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func sessionCookie(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie { + t.Helper() + for _, c := range rec.Result().Cookies() { + if c.Name == auth.SessionCookieName { + return c + } + } + t.Fatal("no session cookie in response") + return nil +} + +func TestLoginSuccessSetsCookie(t *testing.T) { + f := newAuthFixture(t) + h := newAuthHTTPServer(t, f) + + rec := post(h, "/api/v1alpha1/auth/login", `{"username":"Admin","password":"admin-password-123"}`, nil) + require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String()) + c := sessionCookie(t, rec) + assert.True(t, c.HttpOnly) + assert.Equal(t, http.SameSiteLaxMode, c.SameSite) + assert.False(t, c.Secure, "no https public url in this fixture") + + sess, err := f.sessions.Verify(c.Value) + require.NoError(t, err) + assert.Equal(t, f.admin.ID.Hex(), sess.UserID) + + again, _ := f.users.GetByID(context.Background(), f.admin.ID) + assert.NotNil(t, again.LastLoginAt) +} + +func TestLoginFailures(t *testing.T) { + f := newAuthFixture(t) + h := newAuthHTTPServer(t, f) + + rec := post(h, "/api/v1alpha1/auth/login", `{"username":"admin","password":"wrong-password-1"}`, nil) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Empty(t, rec.Result().Cookies()) + + rec = post(h, "/api/v1alpha1/auth/login", `{"username":"ghost","password":"wrong-password-1"}`, nil) + assert.Equal(t, http.StatusUnauthorized, rec.Code, "unknown user gets the same answer") + + rec = post(h, "/api/v1alpha1/auth/login", `{"username":"admin"}`, nil) + assert.Equal(t, http.StatusBadRequest, rec.Code) + + rec = post(h, "/api/v1alpha1/auth/login", `not json`, nil) + assert.Equal(t, http.StatusBadRequest, rec.Code) + + f.admin.Disabled = true + require.NoError(t, f.users.Update(context.Background(), f.admin)) + rec = post(h, "/api/v1alpha1/auth/login", `{"username":"admin","password":"admin-password-123"}`, nil) + assert.Equal(t, http.StatusUnauthorized, rec.Code, "disabled user cannot log in") +} + +func TestLoginRateLimited(t *testing.T) { + f := newAuthFixture(t) + h := newAuthHTTPServer(t, f) + for i := 0; i < 5; i++ { + rec := post(h, "/api/v1alpha1/auth/login", `{"username":"admin","password":"wrong-password-1"}`, nil) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + } + rec := post(h, "/api/v1alpha1/auth/login", `{"username":"admin","password":"admin-password-123"}`, nil) + assert.Equal(t, http.StatusTooManyRequests, rec.Code, "even the right password is blocked") +} + +func TestLogoutClearsCookie(t *testing.T) { + f := newAuthFixture(t) + h := newAuthHTTPServer(t, f) + rec := post(h, "/api/v1alpha1/auth/logout", "", nil) + assert.Equal(t, http.StatusNoContent, rec.Code) + c := sessionCookie(t, rec) + assert.Equal(t, -1, c.MaxAge) +} + +func TestChangePassword(t *testing.T) { + f := newAuthFixture(t) + h := newAuthHTTPServer(t, f) + + rec := post(h, "/api/v1alpha1/auth/password", `{"currentPassword":"admin-password-123","newPassword":"brand-new-password-1"}`, nil) + assert.Equal(t, http.StatusUnauthorized, rec.Code, "anonymous") + + login := post(h, "/api/v1alpha1/auth/login", `{"username":"admin","password":"admin-password-123"}`, nil) + cookie := sessionCookie(t, login) + + rec = post(h, "/api/v1alpha1/auth/password", `{"currentPassword":"nope-nope-nope","newPassword":"brand-new-password-1"}`, cookie) + assert.Equal(t, http.StatusUnauthorized, rec.Code, "wrong current password") + + rec = post(h, "/api/v1alpha1/auth/password", `{"currentPassword":"admin-password-123","newPassword":"short"}`, cookie) + assert.Equal(t, http.StatusBadRequest, rec.Code, "policy") + + rec = post(h, "/api/v1alpha1/auth/password", `{"currentPassword":"admin-password-123","newPassword":"brand-new-password-1"}`, cookie) + require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String()) + fresh := sessionCookie(t, rec) + + // The old session is invalidated, the new one works. + rec = post(h, "/api/v1alpha1/auth/password", `{"currentPassword":"brand-new-password-1","newPassword":"another-new-password-2"}`, cookie) + assert.Equal(t, http.StatusUnauthorized, rec.Code, "old session version rejected") + rec = post(h, "/api/v1alpha1/auth/password", `{"currentPassword":"brand-new-password-1","newPassword":"another-new-password-2"}`, fresh) + assert.Equal(t, http.StatusNoContent, rec.Code) + + u, _ := f.users.GetByID(context.Background(), f.admin.ID) + assert.False(t, u.MustChangePassword) + assert.Equal(t, 2, u.SessionVersion) +} diff --git a/server/auth_testing_test.go b/server/auth_testing_test.go new file mode 100644 index 00000000..7854c78d --- /dev/null +++ b/server/auth_testing_test.go @@ -0,0 +1,90 @@ +package server + +import ( + "bytes" + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/bananaops/tracker/internal/auth" + "github.com/bananaops/tracker/internal/auth/identity" + store "github.com/bananaops/tracker/internal/stores" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +// authFixture wires real stores on a throwaway database. +type authFixture struct { + users *store.AuthUserStore + teams *store.AuthTeamStore + keys *store.AuthAPIKeyStore + sessions *auth.SessionManager + resolver *identity.Resolver + cfg auth.Config + adminsID string + admin *store.User +} + +func newAuthFixture(t *testing.T) *authFixture { + t.Helper() + uri := os.Getenv("MONGO_TEST_URI") + if uri == "" { + t.Skip("MONGO_TEST_URI not set") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri)) + require.NoError(t, err) + db := client.Database(fmt.Sprintf("tracker_test_%d", time.Now().UnixNano())) + t.Cleanup(func() { + c, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = db.Drop(c) + _ = client.Disconnect(c) + }) + require.NoError(t, store.EnsureIndexes(ctx, db)) + + f := &authFixture{ + users: store.NewAuthUserStoreFromCollection(db.Collection("auth_users")), + teams: store.NewAuthTeamStoreFromCollection(db.Collection("auth_teams")), + keys: store.NewAuthAPIKeyStoreFromCollection(db.Collection("auth_api_keys")), + } + f.sessions, err = auth.NewSessionManager(bytes.Repeat([]byte{9}, 32), time.Hour) + require.NoError(t, err) + f.cfg = auth.Config{SessionTTL: time.Hour, AnonymousPermissions: []auth.Permission{}} + f.resolver = &identity.Resolver{Users: f.users, Teams: f.teams, Keys: f.keys, Sessions: f.sessions} + + res, err := identity.Bootstrap(ctx, f.users, f.teams, "admin-password-123") + require.NoError(t, err) + f.adminsID = res.AdminsTeamID.Hex() + f.admin, err = f.users.GetByUsername(ctx, "admin") + require.NoError(t, err) + return f +} + +// principalOf resolves the principal of a stored user through the real resolver. +func (f *authFixture) principalOf(t *testing.T, u *store.User) auth.Principal { + t.Helper() + p, err := f.resolver.PrincipalForUser(context.Background(), u) + require.NoError(t, err) + return p +} + +type fakeTransportStream struct{ method string } + +func (s fakeTransportStream) Method() string { return s.method } +func (fakeTransportStream) SetHeader(metadata.MD) error { return nil } +func (fakeTransportStream) SendHeader(metadata.MD) error { return nil } +func (fakeTransportStream) SetTrailer(metadata.MD) error { return nil } + +// rpcCtx builds a context as the gRPC server would: principal plus full method name. +func rpcCtx(p auth.Principal, method string) context.Context { + ctx := grpc.NewContextWithServerTransportStream(context.Background(), + fakeTransportStream{method: "/tracker.auth.v1alpha1.AuthService/" + method}) + return auth.WithPrincipal(ctx, p) +} From 23360f122ece3359d10d7bb02dfe73816a6832f6 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 12:17:27 +0200 Subject: [PATCH 20/39] feat(auth): add AuthService with config, identity and user management --- server/auth.go | 217 ++++++++++++++++++++++++++++++++++++++ server/auth_apikeys.go | 23 ++++ server/auth_teams.go | 23 ++++ server/auth_test.go | 73 +++++++++++++ server/auth_users.go | 144 +++++++++++++++++++++++++ server/auth_users_test.go | 93 ++++++++++++++++ 6 files changed, 573 insertions(+) create mode 100644 server/auth.go create mode 100644 server/auth_apikeys.go create mode 100644 server/auth_teams.go create mode 100644 server/auth_test.go create mode 100644 server/auth_users.go create mode 100644 server/auth_users_test.go diff --git a/server/auth.go b/server/auth.go new file mode 100644 index 00000000..ae92516a --- /dev/null +++ b/server/auth.go @@ -0,0 +1,217 @@ +package server + +import ( + "context" + "errors" + "log/slog" + "time" + + authv1 "github.com/bananaops/tracker/generated/proto/auth/v1alpha1" + "github.com/bananaops/tracker/internal/auth" + "github.com/bananaops/tracker/internal/auth/authz" + store "github.com/bananaops/tracker/internal/stores" + "go.mongodb.org/mongo-driver/bson/primitive" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// Auth implements tracker.auth.v1alpha1.AuthService. +type Auth struct { + authv1.UnimplementedAuthServiceServer + users *store.AuthUserStore + teams *store.AuthTeamStore + keys *store.AuthAPIKeyStore + cfg auth.Config + logger *slog.Logger + now func() time.Time +} + +// NewAuth builds the AuthService implementation. +func NewAuth(users *store.AuthUserStore, teams *store.AuthTeamStore, keys *store.AuthAPIKeyStore, cfg auth.Config) *Auth { + return &Auth{ + users: users, + teams: teams, + keys: keys, + cfg: cfg, + logger: slog.Default(), + now: time.Now, + } +} + +func (a *Auth) GetAuthConfig(ctx context.Context, _ *authv1.GetAuthConfigRequest) (*authv1.GetAuthConfigResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } + return &authv1.GetAuthConfigResponse{ + LocalLoginEnabled: true, + OidcEnabled: false, + AnonymousPermissions: permissionStrings(a.cfg.AnonymousPermissions), + DemoMode: a.cfg.DemoMode, + }, nil +} + +func (a *Auth) Me(ctx context.Context, _ *authv1.MeRequest) (*authv1.MeResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } + p := currentPrincipal(ctx) + resp := &authv1.MeResponse{ + Authenticated: p.IsAuthenticated(), + Kind: string(p.Kind), + UserId: p.UserID, + Username: p.Username, + Permissions: permissionStrings(p.Permissions.Slice()), + ScopeAll: p.Scope.All, + ScopeServices: p.Scope.ServiceList(), + IsAdmin: p.IsAdmin, + } + if p.Kind == auth.KindUser { + if id, err := primitive.ObjectIDFromHex(p.UserID); err == nil { + if user, err := a.users.GetByID(ctx, id); err == nil { + resp.DisplayName = user.DisplayName + resp.Source = user.Source + resp.MustChangePassword = user.MustChangePassword + } + } + } + ids := make([]primitive.ObjectID, 0, len(p.TeamIDs)) + for _, raw := range p.TeamIDs { + if id, err := primitive.ObjectIDFromHex(raw); err == nil { + ids = append(ids, id) + } + } + teams, err := a.teams.GetByIDs(ctx, ids) + if err != nil { + return nil, storeError(err, "teams") + } + for _, t := range teams { + resp.Teams = append(resp.Teams, &authv1.TeamRef{Id: t.ID.Hex(), Name: t.Name}) + } + return resp, nil +} + +// currentPrincipal never fails: a missing principal is anonymous without rights. +func currentPrincipal(ctx context.Context) auth.Principal { + if p, ok := auth.FromContext(ctx); ok { + return p + } + return auth.Anonymous(nil) +} + +func permissionStrings(perms []auth.Permission) []string { + out := make([]string, 0, len(perms)) + for _, p := range perms { + out = append(out, string(p)) + } + return out +} + +func parseObjectID(id, what string) (primitive.ObjectID, error) { + oid, err := primitive.ObjectIDFromHex(id) + if err != nil { + return primitive.NilObjectID, status.Errorf(codes.InvalidArgument, "invalid %s id", what) + } + return oid, nil +} + +// parseTeamIDs validates ids and checks that every team exists. +func (a *Auth) parseTeamIDs(ctx context.Context, raw []string) ([]primitive.ObjectID, error) { + ids := make([]primitive.ObjectID, 0, len(raw)) + seen := map[primitive.ObjectID]struct{}{} + for _, r := range raw { + id, err := parseObjectID(r, "team") + if err != nil { + return nil, err + } + if _, dup := seen[id]; dup { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + teams, err := a.teams.GetByIDs(ctx, ids) + if err != nil { + return nil, storeError(err, "teams") + } + if len(teams) != len(ids) { + return nil, status.Error(codes.NotFound, "one of the teams does not exist") + } + return ids, nil +} + +// storeError maps store errors to gRPC statuses. +func storeError(err error, what string) error { + switch { + case errors.Is(err, store.ErrNotFound): + return status.Errorf(codes.NotFound, "%s not found", what) + case errors.Is(err, store.ErrAlreadyExists): + return status.Errorf(codes.AlreadyExists, "%s already exists", what) + default: + slog.Error("auth store error", "what", what, "error", err) + return status.Error(codes.Internal, "internal error") + } +} + +func tsOrNil(t *time.Time) *timestamppb.Timestamp { + if t == nil { + return nil + } + return timestamppb.New(*t) +} + +func objectIDStrings(ids []primitive.ObjectID) []string { + out := make([]string, 0, len(ids)) + for _, id := range ids { + out = append(out, id.Hex()) + } + return out +} + +func toProtoUser(u *store.User) *authv1.User { + return &authv1.User{ + Id: u.ID.Hex(), + Username: u.Username, + Email: u.Email, + DisplayName: u.DisplayName, + Source: u.Source, + TeamIds: objectIDStrings(u.Teams), + Disabled: u.Disabled, + MustChangePassword: u.MustChangePassword, + CreatedAt: timestamppb.New(u.CreatedAt), + LastLoginAt: tsOrNil(u.LastLoginAt), + } +} + +func toProtoTeam(t *store.Team) *authv1.Team { + return &authv1.Team{ + Id: t.ID.Hex(), + Name: t.Name, + Description: t.Description, + Permissions: t.Permissions, + ScopeAll: t.Scope.All, + ScopeServices: t.Scope.Services, + OidcGroups: t.OIDCGroups, + Builtin: t.Builtin, + } +} + +func toProtoAPIKey(k *store.APIKey) *authv1.ApiKey { + out := &authv1.ApiKey{ + Id: k.ID.Hex(), + Prefix: k.Prefix, + Name: k.Name, + CreatedBy: k.CreatedBy.Hex(), + CreatedAt: timestamppb.New(k.CreatedAt), + ExpiresAt: tsOrNil(k.ExpiresAt), + LastUsedAt: tsOrNil(k.LastUsedAt), + RevokedAt: tsOrNil(k.RevokedAt), + } + if k.TeamID != nil { + out.TeamId = k.TeamID.Hex() + } + if k.CreatedBy.IsZero() { + out.CreatedBy = "" + } + return out +} diff --git a/server/auth_apikeys.go b/server/auth_apikeys.go new file mode 100644 index 00000000..0dca9936 --- /dev/null +++ b/server/auth_apikeys.go @@ -0,0 +1,23 @@ +package server + +import ( + "context" + + authv1 "github.com/bananaops/tracker/generated/proto/auth/v1alpha1" + "github.com/bananaops/tracker/internal/auth/authz" +) + +func (a *Auth) ListApiKeys(ctx context.Context, _ *authv1.ListApiKeysRequest) (*authv1.ListApiKeysResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } + keys, err := a.keys.List(ctx) + if err != nil { + return nil, storeError(err, "api keys") + } + resp := &authv1.ListApiKeysResponse{ApiKeys: make([]*authv1.ApiKey, 0, len(keys))} + for _, k := range keys { + resp.ApiKeys = append(resp.ApiKeys, toProtoAPIKey(k)) + } + return resp, nil +} diff --git a/server/auth_teams.go b/server/auth_teams.go new file mode 100644 index 00000000..ece8b4cf --- /dev/null +++ b/server/auth_teams.go @@ -0,0 +1,23 @@ +package server + +import ( + "context" + + authv1 "github.com/bananaops/tracker/generated/proto/auth/v1alpha1" + "github.com/bananaops/tracker/internal/auth/authz" +) + +func (a *Auth) ListTeams(ctx context.Context, _ *authv1.ListTeamsRequest) (*authv1.ListTeamsResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } + teams, err := a.teams.List(ctx) + if err != nil { + return nil, storeError(err, "teams") + } + resp := &authv1.ListTeamsResponse{Teams: make([]*authv1.Team, 0, len(teams))} + for _, t := range teams { + resp.Teams = append(resp.Teams, toProtoTeam(t)) + } + return resp, nil +} diff --git a/server/auth_test.go b/server/auth_test.go new file mode 100644 index 00000000..c99a18f9 --- /dev/null +++ b/server/auth_test.go @@ -0,0 +1,73 @@ +package server + +import ( + "context" + "testing" + + authv1 "github.com/bananaops/tracker/generated/proto/auth/v1alpha1" + "github.com/bananaops/tracker/internal/auth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func newAuthService(f *authFixture) *Auth { + return NewAuth(f.users, f.teams, f.keys, f.cfg) +} + +func TestGetAuthConfigIsPublic(t *testing.T) { + f := newAuthFixture(t) + f.cfg.AnonymousPermissions = []auth.Permission{auth.PermEventRead} + f.cfg.DemoMode = true + svc := newAuthService(f) + + resp, err := svc.GetAuthConfig(rpcCtx(auth.Anonymous(nil), "GetAuthConfig"), &authv1.GetAuthConfigRequest{}) + require.NoError(t, err) + assert.True(t, resp.LocalLoginEnabled) + assert.False(t, resp.OidcEnabled) + assert.Equal(t, []string{"event:read"}, resp.AnonymousPermissions) + assert.True(t, resp.DemoMode) +} + +func TestMe(t *testing.T) { + f := newAuthFixture(t) + svc := newAuthService(f) + + resp, err := svc.Me(rpcCtx(auth.Anonymous([]auth.Permission{auth.PermEventRead}), "Me"), &authv1.MeRequest{}) + require.NoError(t, err) + assert.False(t, resp.Authenticated) + assert.Equal(t, "anonymous", resp.Kind) + assert.Equal(t, []string{"event:read"}, resp.Permissions) + + resp, err = svc.Me(rpcCtx(f.principalOf(t, f.admin), "Me"), &authv1.MeRequest{}) + require.NoError(t, err) + assert.True(t, resp.Authenticated) + assert.Equal(t, "user", resp.Kind) + assert.Equal(t, "admin", resp.Username) + assert.Equal(t, "Administrator", resp.DisplayName) + assert.Equal(t, "local", resp.Source) + assert.True(t, resp.MustChangePassword) + assert.True(t, resp.IsAdmin) + assert.True(t, resp.ScopeAll) + require.Len(t, resp.Teams, 1) + assert.Equal(t, "Administrators", resp.Teams[0].Name) + assert.Contains(t, resp.Permissions, "access:manage") +} + +func TestAuthServiceRequiresAccessManage(t *testing.T) { + f := newAuthFixture(t) + svc := newAuthService(f) + reader := auth.Principal{Kind: auth.KindUser, UserID: f.admin.ID.Hex(), Permissions: auth.NewPermissionSet(auth.PermEventRead)} + + _, err := svc.ListUsers(rpcCtx(auth.Anonymous(nil), "ListUsers"), &authv1.ListUsersRequest{}) + assert.Equal(t, codes.Unauthenticated, status.Code(err)) + _, err = svc.ListUsers(rpcCtx(reader, "ListUsers"), &authv1.ListUsersRequest{}) + assert.Equal(t, codes.PermissionDenied, status.Code(err)) + _, err = svc.ListTeams(rpcCtx(reader, "ListTeams"), &authv1.ListTeamsRequest{}) + assert.Equal(t, codes.PermissionDenied, status.Code(err)) + _, err = svc.ListApiKeys(rpcCtx(reader, "ListApiKeys"), &authv1.ListApiKeysRequest{}) + assert.Equal(t, codes.PermissionDenied, status.Code(err)) + _, err = svc.ListUsers(context.Background(), &authv1.ListUsersRequest{}) + assert.Equal(t, codes.PermissionDenied, status.Code(err), "no method in context is denied") +} diff --git a/server/auth_users.go b/server/auth_users.go new file mode 100644 index 00000000..c5fd9796 --- /dev/null +++ b/server/auth_users.go @@ -0,0 +1,144 @@ +package server + +import ( + "context" + "regexp" + "strings" + + authv1 "github.com/bananaops/tracker/generated/proto/auth/v1alpha1" + "github.com/bananaops/tracker/internal/auth" + "github.com/bananaops/tracker/internal/auth/authz" + store "github.com/bananaops/tracker/internal/stores" + "go.mongodb.org/mongo-driver/bson/primitive" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var usernamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{1,63}$`) + +func (a *Auth) ListUsers(ctx context.Context, _ *authv1.ListUsersRequest) (*authv1.ListUsersResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } + users, err := a.users.List(ctx) + if err != nil { + return nil, storeError(err, "users") + } + resp := &authv1.ListUsersResponse{Users: make([]*authv1.User, 0, len(users))} + for _, u := range users { + resp.Users = append(resp.Users, toProtoUser(u)) + } + return resp, nil +} + +func (a *Auth) CreateUser(ctx context.Context, req *authv1.CreateUserRequest) (*authv1.CreateUserResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } + username := strings.TrimSpace(req.Username) + if !usernamePattern.MatchString(username) { + return nil, status.Error(codes.InvalidArgument, "username must be 2 to 64 characters of letters, digits, dot, underscore or dash") + } + if err := auth.ValidatePasswordPolicy(req.Password); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + teamIDs, err := a.parseTeamIDs(ctx, req.TeamIds) + if err != nil { + return nil, err + } + hash, err := auth.HashPassword(req.Password) + if err != nil { + return nil, status.Error(codes.Internal, "internal error") + } + user := &store.User{ + Username: username, + Email: strings.TrimSpace(req.Email), + DisplayName: strings.TrimSpace(req.DisplayName), + Source: store.UserSourceLocal, + PasswordHash: hash, + Teams: teamIDs, + MustChangePassword: true, + } + if err := a.users.Create(ctx, user); err != nil { + return nil, storeError(err, "user") + } + a.logger.Info("auth.user.create", "username", user.Username, "by", currentPrincipal(ctx).Username) + return &authv1.CreateUserResponse{User: toProtoUser(user)}, nil +} + +func (a *Auth) UpdateUser(ctx context.Context, req *authv1.UpdateUserRequest) (*authv1.UpdateUserResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } + id, err := parseObjectID(req.Id, "user") + if err != nil { + return nil, err + } + user, err := a.users.GetByID(ctx, id) + if err != nil { + return nil, storeError(err, "user") + } + caller := currentPrincipal(ctx) + if req.Disabled && caller.UserID == user.ID.Hex() { + return nil, status.Error(codes.FailedPrecondition, "you cannot disable your own account") + } + teamIDs, err := a.parseTeamIDs(ctx, req.TeamIds) + if err != nil { + return nil, err + } + if req.NewPassword != "" { + if user.Source != store.UserSourceLocal { + return nil, status.Error(codes.InvalidArgument, "password is managed by the identity provider") + } + if err := auth.ValidatePasswordPolicy(req.NewPassword); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + } + + admins, err := a.teams.GetByName(ctx, store.AdministratorsTeamName) + if err != nil { + return nil, storeError(err, "administrators team") + } + wasAdmin := !user.Disabled && containsID(user.Teams, admins.ID) + staysAdmin := !req.Disabled && containsID(teamIDs, admins.ID) + if wasAdmin && !staysAdmin { + others, err := a.users.CountEnabledInTeam(ctx, admins.ID, user.ID) + if err != nil { + return nil, storeError(err, "users") + } + if others == 0 { + return nil, status.Error(codes.FailedPrecondition, "cannot remove the last administrator") + } + } + + user.Email = strings.TrimSpace(req.Email) + user.DisplayName = strings.TrimSpace(req.DisplayName) + user.Teams = teamIDs + if req.Disabled && !user.Disabled { + user.SessionVersion++ + } + user.Disabled = req.Disabled + if req.NewPassword != "" { + hash, err := auth.HashPassword(req.NewPassword) + if err != nil { + return nil, status.Error(codes.Internal, "internal error") + } + user.PasswordHash = hash + user.MustChangePassword = true + user.SessionVersion++ + } + if err := a.users.Update(ctx, user); err != nil { + return nil, storeError(err, "user") + } + a.logger.Info("auth.user.update", "username", user.Username, "disabled", user.Disabled, "by", caller.Username) + return &authv1.UpdateUserResponse{User: toProtoUser(user)}, nil +} + +func containsID(ids []primitive.ObjectID, id primitive.ObjectID) bool { + for _, x := range ids { + if x == id { + return true + } + } + return false +} diff --git a/server/auth_users_test.go b/server/auth_users_test.go new file mode 100644 index 00000000..aef66cd7 --- /dev/null +++ b/server/auth_users_test.go @@ -0,0 +1,93 @@ +package server + +import ( + "context" + "testing" + + authv1 "github.com/bananaops/tracker/generated/proto/auth/v1alpha1" + "github.com/bananaops/tracker/internal/auth" + store "github.com/bananaops/tracker/internal/stores" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestCreateAndListUsers(t *testing.T) { + f := newAuthFixture(t) + svc := newAuthService(f) + ctx := rpcCtx(f.principalOf(t, f.admin), "CreateUser") + + resp, err := svc.CreateUser(ctx, &authv1.CreateUserRequest{ + Username: "bob", Email: "bob@example.com", DisplayName: "Bob", Password: "bob-initial-pass-1", TeamIds: []string{f.adminsID}, + }) + require.NoError(t, err) + assert.Equal(t, "bob", resp.User.Username) + assert.Equal(t, "local", resp.User.Source) + assert.True(t, resp.User.MustChangePassword) + assert.Equal(t, []string{f.adminsID}, resp.User.TeamIds) + + _, err = svc.CreateUser(ctx, &authv1.CreateUserRequest{Username: "BOB", Password: "bob-initial-pass-1"}) + assert.Equal(t, codes.AlreadyExists, status.Code(err)) + _, err = svc.CreateUser(ctx, &authv1.CreateUserRequest{Username: "bad name!", Password: "bob-initial-pass-1"}) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + _, err = svc.CreateUser(ctx, &authv1.CreateUserRequest{Username: "carol", Password: "short"}) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + _, err = svc.CreateUser(ctx, &authv1.CreateUserRequest{Username: "carol", Password: "carol-initial-pass-1", TeamIds: []string{"not-an-id"}}) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + _, err = svc.CreateUser(ctx, &authv1.CreateUserRequest{Username: "carol", Password: "carol-initial-pass-1", TeamIds: []string{"000000000000000000000000"}}) + assert.Equal(t, codes.NotFound, status.Code(err), "unknown team") + + list, err := svc.ListUsers(rpcCtx(f.principalOf(t, f.admin), "ListUsers"), &authv1.ListUsersRequest{}) + require.NoError(t, err) + assert.Len(t, list.Users, 2) +} + +func TestUpdateUserGuards(t *testing.T) { + f := newAuthFixture(t) + svc := newAuthService(f) + admin := f.principalOf(t, f.admin) + + created, err := svc.CreateUser(rpcCtx(admin, "CreateUser"), &authv1.CreateUserRequest{Username: "bob", Password: "bob-initial-pass-1"}) + require.NoError(t, err) + + // Admin cannot disable itself. + _, err = svc.UpdateUser(rpcCtx(admin, "UpdateUser"), &authv1.UpdateUserRequest{Id: f.admin.ID.Hex(), TeamIds: []string{f.adminsID}, Disabled: true}) + assert.Equal(t, codes.FailedPrecondition, status.Code(err)) + + // Admin cannot leave the Administrators team while being the last one. + _, err = svc.UpdateUser(rpcCtx(admin, "UpdateUser"), &authv1.UpdateUserRequest{Id: f.admin.ID.Hex(), TeamIds: []string{}}) + assert.Equal(t, codes.FailedPrecondition, status.Code(err)) + + // Promote bob, then admin may leave. + _, err = svc.UpdateUser(rpcCtx(admin, "UpdateUser"), &authv1.UpdateUserRequest{Id: created.User.Id, TeamIds: []string{f.adminsID}, Email: "bob@example.com"}) + require.NoError(t, err) + _, err = svc.UpdateUser(rpcCtx(admin, "UpdateUser"), &authv1.UpdateUserRequest{Id: f.admin.ID.Hex(), TeamIds: []string{}}) + require.NoError(t, err) + + // Admin joins back, so that bob is no longer the last administrator. + _, err = svc.UpdateUser(rpcCtx(admin, "UpdateUser"), &authv1.UpdateUserRequest{Id: f.admin.ID.Hex(), TeamIds: []string{f.adminsID}, DisplayName: "Administrator"}) + require.NoError(t, err) + + // Disabling bob bumps its session version; a password reset too. + before, _ := f.users.GetByUsername(context.Background(), "bob") + resp, err := svc.UpdateUser(rpcCtx(admin, "UpdateUser"), &authv1.UpdateUserRequest{Id: created.User.Id, TeamIds: []string{f.adminsID}, Disabled: true, NewPassword: "bob-reset-pass-22"}) + require.NoError(t, err) + assert.True(t, resp.User.Disabled) + after, _ := f.users.GetByUsername(context.Background(), "bob") + assert.Equal(t, before.SessionVersion+2, after.SessionVersion) + assert.True(t, after.MustChangePassword) + ok, _ := auth.VerifyPassword(after.PasswordHash, "bob-reset-pass-22") + assert.True(t, ok) + + _, err = svc.UpdateUser(rpcCtx(admin, "UpdateUser"), &authv1.UpdateUserRequest{Id: "000000000000000000000000"}) + assert.Equal(t, codes.NotFound, status.Code(err)) + _, err = svc.UpdateUser(rpcCtx(admin, "UpdateUser"), &authv1.UpdateUserRequest{Id: "zzz"}) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + + // Password reset is refused for OIDC accounts. + oidc := &store.User{Username: "sso-user", Source: store.UserSourceOIDC, OIDCIssuer: "https://idp", OIDCSubject: "abc"} + require.NoError(t, f.users.Create(context.Background(), oidc)) + _, err = svc.UpdateUser(rpcCtx(admin, "UpdateUser"), &authv1.UpdateUserRequest{Id: oidc.ID.Hex(), NewPassword: "whatever-pass-123"}) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) +} From cd1f91e427747df330bf8bde9fa8a31ddb685d9b Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 12:21:11 +0200 Subject: [PATCH 21/39] feat(auth): manage teams and API keys through AuthService --- server/auth_apikeys.go | 71 +++++++++++++++++++ server/auth_apikeys_test.go | 74 +++++++++++++++++++ server/auth_teams.go | 138 ++++++++++++++++++++++++++++++++++++ server/auth_teams_test.go | 80 +++++++++++++++++++++ 4 files changed, 363 insertions(+) create mode 100644 server/auth_apikeys_test.go create mode 100644 server/auth_teams_test.go diff --git a/server/auth_apikeys.go b/server/auth_apikeys.go index 0dca9936..d46b8e21 100644 --- a/server/auth_apikeys.go +++ b/server/auth_apikeys.go @@ -2,9 +2,15 @@ package server import ( "context" + "strings" authv1 "github.com/bananaops/tracker/generated/proto/auth/v1alpha1" + "github.com/bananaops/tracker/internal/auth" "github.com/bananaops/tracker/internal/auth/authz" + store "github.com/bananaops/tracker/internal/stores" + "go.mongodb.org/mongo-driver/bson/primitive" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) func (a *Auth) ListApiKeys(ctx context.Context, _ *authv1.ListApiKeysRequest) (*authv1.ListApiKeysResponse, error) { @@ -21,3 +27,68 @@ func (a *Auth) ListApiKeys(ctx context.Context, _ *authv1.ListApiKeysRequest) (* } return resp, nil } + +func (a *Auth) CreateApiKey(ctx context.Context, req *authv1.CreateApiKeyRequest) (*authv1.CreateApiKeyResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } + name := strings.TrimSpace(req.Name) + if name == "" || len(name) > teamNameMaxLength { + return nil, status.Error(codes.InvalidArgument, "api key name is required") + } + caller := currentPrincipal(ctx) + + var teamID *primitive.ObjectID + if req.TeamId == "" { + if !caller.IsAdmin { + return nil, status.Error(codes.PermissionDenied, "only administrators can create global api keys") + } + } else { + id, err := parseObjectID(req.TeamId, "team") + if err != nil { + return nil, err + } + if _, err := a.teams.GetByID(ctx, id); err != nil { + return nil, storeError(err, "team") + } + teamID = &id + } + + key := &store.APIKey{Name: name, TeamID: teamID} + if req.ExpiresAt != nil { + exp := req.ExpiresAt.AsTime().UTC() + if !exp.After(a.now()) { + return nil, status.Error(codes.InvalidArgument, "expiresAt must be in the future") + } + key.ExpiresAt = &exp + } + if id, err := primitive.ObjectIDFromHex(caller.UserID); err == nil { + key.CreatedBy = id + } + + gen, err := auth.GenerateAPIKey() + if err != nil { + return nil, status.Error(codes.Internal, "internal error") + } + key.Prefix, key.Hash = gen.Prefix, gen.Hash + if err := a.keys.Create(ctx, key); err != nil { + return nil, storeError(err, "api key") + } + a.logger.Info("auth.apikey.create", "prefix", key.Prefix, "name", key.Name, "global", teamID == nil, "by", caller.Username) + return &authv1.CreateApiKeyResponse{ApiKey: toProtoAPIKey(key), Secret: gen.Secret}, nil +} + +func (a *Auth) RevokeApiKey(ctx context.Context, req *authv1.RevokeApiKeyRequest) (*authv1.RevokeApiKeyResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } + id, err := parseObjectID(req.Id, "api key") + if err != nil { + return nil, err + } + if err := a.keys.Revoke(ctx, id, a.now().UTC()); err != nil { + return nil, storeError(err, "active api key") + } + a.logger.Info("auth.apikey.revoke", "id", id.Hex(), "by", currentPrincipal(ctx).Username) + return &authv1.RevokeApiKeyResponse{}, nil +} diff --git a/server/auth_apikeys_test.go b/server/auth_apikeys_test.go new file mode 100644 index 00000000..af5e60f4 --- /dev/null +++ b/server/auth_apikeys_test.go @@ -0,0 +1,74 @@ +package server + +import ( + "context" + "strings" + "testing" + "time" + + authv1 "github.com/bananaops/tracker/generated/proto/auth/v1alpha1" + "github.com/bananaops/tracker/internal/auth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestAPIKeyLifecycle(t *testing.T) { + f := newAuthFixture(t) + svc := newAuthService(f) + admin := f.principalOf(t, f.admin) + + team, err := svc.CreateTeam(rpcCtx(admin, "CreateTeam"), &authv1.CreateTeamRequest{Name: "Ops", Permissions: []string{"lock:write"}}) + require.NoError(t, err) + + created, err := svc.CreateApiKey(rpcCtx(admin, "CreateApiKey"), &authv1.CreateApiKeyRequest{Name: "deploy", TeamId: team.Team.Id}) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(created.Secret, "trk_")) + assert.Equal(t, created.ApiKey.Prefix, strings.Split(created.Secret, "_")[1]) + assert.Equal(t, team.Team.Id, created.ApiKey.TeamId) + assert.Equal(t, f.admin.ID.Hex(), created.ApiKey.CreatedBy) + + // The secret really authenticates with the team rights. + p := f.resolver.Resolve(context.Background(), auth.Credentials{APIKey: created.Secret}) + assert.Equal(t, auth.KindAPIKey, p.Kind) + assert.True(t, p.Has(auth.PermLockWrite)) + assert.False(t, p.IsAdmin) + + // Global key: admins only. + manager := auth.Principal{Kind: auth.KindUser, UserID: f.admin.ID.Hex(), Username: "mgr", Permissions: auth.NewPermissionSet(auth.PermAccessManage)} + _, err = svc.CreateApiKey(rpcCtx(manager, "CreateApiKey"), &authv1.CreateApiKeyRequest{Name: "global"}) + assert.Equal(t, codes.PermissionDenied, status.Code(err)) + global, err := svc.CreateApiKey(rpcCtx(admin, "CreateApiKey"), &authv1.CreateApiKeyRequest{Name: "global"}) + require.NoError(t, err) + assert.Empty(t, global.ApiKey.TeamId) + p = f.resolver.Resolve(context.Background(), auth.Credentials{APIKey: global.Secret}) + assert.True(t, p.IsAdmin) + + // Validation. + _, err = svc.CreateApiKey(rpcCtx(admin, "CreateApiKey"), &authv1.CreateApiKeyRequest{Name: ""}) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + _, err = svc.CreateApiKey(rpcCtx(admin, "CreateApiKey"), &authv1.CreateApiKeyRequest{Name: "x", TeamId: "000000000000000000000000"}) + assert.Equal(t, codes.NotFound, status.Code(err)) + _, err = svc.CreateApiKey(rpcCtx(admin, "CreateApiKey"), &authv1.CreateApiKeyRequest{Name: "x", ExpiresAt: timestamppb.New(time.Now().Add(-time.Hour))}) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + future := time.Now().Add(time.Hour) + exp, err := svc.CreateApiKey(rpcCtx(admin, "CreateApiKey"), &authv1.CreateApiKeyRequest{Name: "temp", ExpiresAt: timestamppb.New(future)}) + require.NoError(t, err) + assert.WithinDuration(t, future, exp.ApiKey.ExpiresAt.AsTime(), time.Second) + + // Revocation. + _, err = svc.RevokeApiKey(rpcCtx(admin, "RevokeApiKey"), &authv1.RevokeApiKeyRequest{Id: created.ApiKey.Id}) + require.NoError(t, err) + p = f.resolver.Resolve(context.Background(), auth.Credentials{APIKey: created.Secret}) + assert.Equal(t, auth.KindAnonymous, p.Kind) + _, err = svc.RevokeApiKey(rpcCtx(admin, "RevokeApiKey"), &authv1.RevokeApiKeyRequest{Id: created.ApiKey.Id}) + assert.Equal(t, codes.NotFound, status.Code(err), "already revoked") + _, err = svc.RevokeApiKey(rpcCtx(admin, "RevokeApiKey"), &authv1.RevokeApiKeyRequest{Id: "bad"}) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + + list, err := svc.ListApiKeys(rpcCtx(admin, "ListApiKeys"), &authv1.ListApiKeysRequest{}) + require.NoError(t, err) + assert.Len(t, list.ApiKeys, 3) +} diff --git a/server/auth_teams.go b/server/auth_teams.go index ece8b4cf..2ce137ea 100644 --- a/server/auth_teams.go +++ b/server/auth_teams.go @@ -2,11 +2,19 @@ package server import ( "context" + "sort" + "strings" authv1 "github.com/bananaops/tracker/generated/proto/auth/v1alpha1" + "github.com/bananaops/tracker/internal/auth" "github.com/bananaops/tracker/internal/auth/authz" + store "github.com/bananaops/tracker/internal/stores" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) +const teamNameMaxLength = 64 + func (a *Auth) ListTeams(ctx context.Context, _ *authv1.ListTeamsRequest) (*authv1.ListTeamsResponse, error) { if err := authz.Authorize(ctx); err != nil { return nil, err @@ -21,3 +29,133 @@ func (a *Auth) ListTeams(ctx context.Context, _ *authv1.ListTeamsRequest) (*auth } return resp, nil } + +// teamFromRequest validates the editable fields of a team. +func teamFromRequest(name, description string, perms []string, scopeAll bool, services, groups []string) (*store.Team, error) { + name = strings.TrimSpace(name) + if name == "" || len(name) > teamNameMaxLength { + return nil, status.Errorf(codes.InvalidArgument, "team name must be 1 to %d characters", teamNameMaxLength) + } + cleanPerms := make([]string, 0, len(perms)) + seenPerm := map[string]struct{}{} + for _, raw := range perms { + p := auth.Permission(strings.TrimSpace(raw)) + if !auth.IsValidPermission(p) { + return nil, status.Errorf(codes.InvalidArgument, "unknown permission %q", raw) + } + if _, dup := seenPerm[string(p)]; dup { + continue + } + seenPerm[string(p)] = struct{}{} + cleanPerms = append(cleanPerms, string(p)) + } + sort.Strings(cleanPerms) + + cleanServices := dedupeTrimmed(services) + scope := store.TeamScope{All: scopeAll || len(cleanServices) == 0, Services: cleanServices} + if scope.All { + scope.Services = []string{} + } + return &store.Team{ + Name: name, + Description: strings.TrimSpace(description), + Permissions: cleanPerms, + Scope: scope, + OIDCGroups: dedupeTrimmed(groups), + }, nil +} + +func dedupeTrimmed(in []string) []string { + out := make([]string, 0, len(in)) + seen := map[string]struct{}{} + for _, raw := range in { + s := strings.TrimSpace(raw) + if s == "" { + continue + } + if _, dup := seen[s]; dup { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + sort.Strings(out) + return out +} + +func (a *Auth) CreateTeam(ctx context.Context, req *authv1.CreateTeamRequest) (*authv1.CreateTeamResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } + team, err := teamFromRequest(req.Name, req.Description, req.Permissions, req.ScopeAll, req.ScopeServices, req.OidcGroups) + if err != nil { + return nil, err + } + if err := a.teams.Create(ctx, team); err != nil { + return nil, storeError(err, "team") + } + a.logger.Info("auth.team.create", "team", team.Name, "by", currentPrincipal(ctx).Username) + return &authv1.CreateTeamResponse{Team: toProtoTeam(team)}, nil +} + +func (a *Auth) UpdateTeam(ctx context.Context, req *authv1.UpdateTeamRequest) (*authv1.UpdateTeamResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } + id, err := parseObjectID(req.Id, "team") + if err != nil { + return nil, err + } + existing, err := a.teams.GetByID(ctx, id) + if err != nil { + return nil, storeError(err, "team") + } + if existing.Builtin { + // Name, permissions and scope of the built-in team are immutable. + existing.Description = strings.TrimSpace(req.Description) + existing.OIDCGroups = dedupeTrimmed(req.OidcGroups) + } else { + edited, err := teamFromRequest(req.Name, req.Description, req.Permissions, req.ScopeAll, req.ScopeServices, req.OidcGroups) + if err != nil { + return nil, err + } + existing.Name = edited.Name + existing.Description = edited.Description + existing.Permissions = edited.Permissions + existing.Scope = edited.Scope + existing.OIDCGroups = edited.OIDCGroups + } + if err := a.teams.Update(ctx, existing); err != nil { + return nil, storeError(err, "team") + } + a.logger.Info("auth.team.update", "team", existing.Name, "by", currentPrincipal(ctx).Username) + return &authv1.UpdateTeamResponse{Team: toProtoTeam(existing)}, nil +} + +func (a *Auth) DeleteTeam(ctx context.Context, req *authv1.DeleteTeamRequest) (*authv1.DeleteTeamResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } + id, err := parseObjectID(req.Id, "team") + if err != nil { + return nil, err + } + team, err := a.teams.GetByID(ctx, id) + if err != nil { + return nil, storeError(err, "team") + } + if team.Builtin { + return nil, status.Error(codes.FailedPrecondition, "the built-in team cannot be deleted") + } + if err := a.users.RemoveTeam(ctx, id); err != nil { + return nil, storeError(err, "users") + } + if err := a.keys.RevokeByTeam(ctx, id, a.now().UTC()); err != nil { + return nil, storeError(err, "api keys") + } + if err := a.teams.Delete(ctx, id); err != nil { + return nil, storeError(err, "team") + } + a.logger.Info("auth.team.delete", "team", team.Name, "by", currentPrincipal(ctx).Username) + return &authv1.DeleteTeamResponse{}, nil +} diff --git a/server/auth_teams_test.go b/server/auth_teams_test.go new file mode 100644 index 00000000..f0d9e33b --- /dev/null +++ b/server/auth_teams_test.go @@ -0,0 +1,80 @@ +package server + +import ( + "context" + "testing" + + authv1 "github.com/bananaops/tracker/generated/proto/auth/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestTeamLifecycle(t *testing.T) { + f := newAuthFixture(t) + svc := newAuthService(f) + admin := f.principalOf(t, f.admin) + + created, err := svc.CreateTeam(rpcCtx(admin, "CreateTeam"), &authv1.CreateTeamRequest{ + Name: "Platform", Description: "platform team", Permissions: []string{"event:read", "event:write"}, + ScopeServices: []string{"api", "api", " web "}, + }) + require.NoError(t, err) + assert.Equal(t, "Platform", created.Team.Name) + assert.False(t, created.Team.ScopeAll) + assert.Equal(t, []string{"api", "web"}, created.Team.ScopeServices) + assert.False(t, created.Team.Builtin) + + all, err := svc.CreateTeam(rpcCtx(admin, "CreateTeam"), &authv1.CreateTeamRequest{Name: "Everyone"}) + require.NoError(t, err) + assert.True(t, all.Team.ScopeAll, "no services means every service") + + _, err = svc.CreateTeam(rpcCtx(admin, "CreateTeam"), &authv1.CreateTeamRequest{Name: "platform"}) + assert.Equal(t, codes.AlreadyExists, status.Code(err)) + _, err = svc.CreateTeam(rpcCtx(admin, "CreateTeam"), &authv1.CreateTeamRequest{Name: " "}) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + _, err = svc.CreateTeam(rpcCtx(admin, "CreateTeam"), &authv1.CreateTeamRequest{Name: "X", Permissions: []string{"public"}}) + assert.Equal(t, codes.InvalidArgument, status.Code(err), "pseudo permissions are not grantable") + _, err = svc.CreateTeam(rpcCtx(admin, "CreateTeam"), &authv1.CreateTeamRequest{Name: "X", Permissions: []string{"nope:read"}}) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + + updated, err := svc.UpdateTeam(rpcCtx(admin, "UpdateTeam"), &authv1.UpdateTeamRequest{ + Id: created.Team.Id, Name: "Platform2", Description: "d", Permissions: []string{"lock:read"}, ScopeAll: true, + }) + require.NoError(t, err) + assert.Equal(t, "Platform2", updated.Team.Name) + assert.True(t, updated.Team.ScopeAll) + assert.Equal(t, []string{"lock:read"}, updated.Team.Permissions) + + // Builtin team: only description and oidc groups change. + builtin, err := svc.UpdateTeam(rpcCtx(admin, "UpdateTeam"), &authv1.UpdateTeamRequest{ + Id: f.adminsID, Name: "Hackers", Description: "root", Permissions: []string{"event:read"}, OidcGroups: []string{"admins"}, + }) + require.NoError(t, err) + assert.Equal(t, "Administrators", builtin.Team.Name) + assert.Equal(t, "root", builtin.Team.Description) + assert.Equal(t, []string{"admins"}, builtin.Team.OidcGroups) + assert.Contains(t, builtin.Team.Permissions, "access:manage") + + _, err = svc.DeleteTeam(rpcCtx(admin, "DeleteTeam"), &authv1.DeleteTeamRequest{Id: f.adminsID}) + assert.Equal(t, codes.FailedPrecondition, status.Code(err)) + + // Deleting a team detaches users and revokes its keys. + _, err = svc.CreateUser(rpcCtx(admin, "CreateUser"), &authv1.CreateUserRequest{Username: "bob", Password: "bob-initial-pass-1", TeamIds: []string{created.Team.Id}}) + require.NoError(t, err) + key, err := svc.CreateApiKey(rpcCtx(admin, "CreateApiKey"), &authv1.CreateApiKeyRequest{Name: "ci", TeamId: created.Team.Id}) + require.NoError(t, err) + + _, err = svc.DeleteTeam(rpcCtx(admin, "DeleteTeam"), &authv1.DeleteTeamRequest{Id: created.Team.Id}) + require.NoError(t, err) + bob, _ := f.users.GetByUsername(context.Background(), "bob") + assert.Empty(t, bob.Teams) + keys, _ := svc.ListApiKeys(rpcCtx(admin, "ListApiKeys"), &authv1.ListApiKeysRequest{}) + require.Len(t, keys.ApiKeys, 1) + assert.Equal(t, key.ApiKey.Id, keys.ApiKeys[0].Id) + assert.NotNil(t, keys.ApiKeys[0].RevokedAt) + + _, err = svc.DeleteTeam(rpcCtx(admin, "DeleteTeam"), &authv1.DeleteTeamRequest{Id: created.Team.Id}) + assert.Equal(t, codes.NotFound, status.Code(err)) +} From 14f91166b2e730c3300d1411952666e058de63c6 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 12:29:54 +0200 Subject: [PATCH 22/39] feat(auth): wire authentication middleware and guard every endpoint --- cmd/serv.go | 76 +++++++++++++++++++++++++++++++++++-- server/authz_wiring_test.go | 48 +++++++++++++++++++++++ server/catalog.go | 22 +++++++++++ server/event.go | 43 ++++++++++++++++++++- server/homer.go | 6 ++- server/links.go | 10 +++-- server/lock.go | 16 ++++++++ 7 files changed, 210 insertions(+), 11 deletions(-) create mode 100644 server/authz_wiring_test.go diff --git a/cmd/serv.go b/cmd/serv.go index 6283c76b..e9b98276 100644 --- a/cmd/serv.go +++ b/cmd/serv.go @@ -16,9 +16,13 @@ import ( "syscall" "time" + authv1 "github.com/bananaops/tracker/generated/proto/auth/v1alpha1" catalog "github.com/bananaops/tracker/generated/proto/catalog/v1alpha1" event "github.com/bananaops/tracker/generated/proto/event/v1alpha1" lock "github.com/bananaops/tracker/generated/proto/lock/v1alpha1" + "github.com/bananaops/tracker/internal/auth" + "github.com/bananaops/tracker/internal/auth/identity" + store "github.com/bananaops/tracker/internal/stores" "github.com/bananaops/tracker/server" "github.com/go-openapi/runtime/middleware" "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" @@ -34,9 +38,60 @@ var serv = &cobra.Command{ Short: "Run tracker server", Run: func(cmd *cobra.Command, args []string) { + ctx := context.TODO() + + // Authentication: configuration, stores, bootstrap and principal resolver. + authCfg, err := auth.LoadConfig(os.LookupEnv) + if err != nil { + log.Fatalf("invalid authentication configuration: %v", err) + } + if authCfg.AnonymousDefaulted { + slog.Warn("AUTH_ANONYMOUS_PERMISSIONS is not set: anonymous callers keep every permission except access:manage. This default becomes empty in the next major release.") + } + userStore := store.NewAuthUserStore() + teamStore := store.NewAuthTeamStore() + keyStore := store.NewAuthAPIKeyStore() + settingsStore := store.NewAuthSettingsStore() + + sessionSecret := authCfg.SessionSecret + if sessionSecret == nil { + sessionSecret, err = settingsStore.SessionSecret(ctx) + if err != nil { + log.Fatalf("cannot load session secret: %v", err) + } + slog.Info("AUTH_SESSION_SECRET is not set, using the secret persisted in MongoDB") + } + sessions, err := auth.NewSessionManager(sessionSecret, authCfg.SessionTTL) + if err != nil { + log.Fatalf("cannot create session manager: %v", err) + } + + bootstrap, err := identity.Bootstrap(ctx, userStore, teamStore, authCfg.AdminPassword) + if err != nil { + log.Fatalf("authentication bootstrap failed: %v", err) + } + if bootstrap.AdminCreated { + if bootstrap.GeneratedPassword != "" { + slog.Warn("Initial admin account created with a generated password. Change it at first login.", "username", "admin", "password", bootstrap.GeneratedPassword) + } else { + slog.Info("Initial admin account created from AUTH_ADMIN_PASSWORD", "username", "admin") + } + } + + resolver := &identity.Resolver{ + Users: userStore, + Teams: teamStore, + Keys: keyStore, + Sessions: sessions, + AnonymousPermissions: authCfg.AnonymousPermissions, + } + // Set up gRPC server grpcServerEndpoint := "localhost:8765" - grpcServer := grpc.NewServer() + grpcServer := grpc.NewServer( + grpc.ChainUnaryInterceptor(auth.UnaryInterceptor(resolver)), + grpc.ChainStreamInterceptor(auth.StreamInterceptor(resolver)), + ) // register reflection API https://github.com/grpc/grpc/blob/master/doc/server-reflection.md reflection.Register(grpcServer) @@ -53,12 +108,14 @@ var serv = &cobra.Command{ catalogs := server.NewCatalog() catalog.RegisterCatalogServiceServer(grpcServer, catalogs) + // register auth service + authService := server.NewAuth(userStore, teamStore, keyStore, authCfg) + authv1.RegisterAuthServiceServer(grpcServer, authService) + // register health checK service //healthCheckService := &server.HealthCheckService{} //health.RegisterHealthServer(grpcServer, healthCheckService) - ctx := context.TODO() - // Initialiser les index MongoDB après la première connexion db := server.GetDatabaseConnection() if db != nil { @@ -69,7 +126,7 @@ var serv = &cobra.Command{ mux := runtime.NewServeMux() // Register generated routes to mux - err := event.RegisterEventServiceHandlerServer(ctx, mux, events) + err = event.RegisterEventServiceHandlerServer(ctx, mux, events) if err != nil { panic(err) } @@ -84,6 +141,14 @@ var serv = &cobra.Command{ panic(err) } + err = authv1.RegisterAuthServiceHandlerServer(ctx, mux, authService) + if err != nil { + panic(err) + } + + // Cookie based auth endpoints (login, logout, password change) + server.NewAuthHTTP(userStore, sessions, authCfg).Register(mux) + // Register Homer proxy endpoint server.RegisterHomerHandler(mux, os.Getenv("HOMER_URL")) @@ -228,6 +293,9 @@ var serv = &cobra.Command{ httpHandler = mux } + // Resolve the principal of every HTTP request (SPA, API and custom handlers) + httpHandler = auth.HTTPMiddleware(resolver)(httpHandler) + httpServer := &http.Server{ Addr: "0.0.0.0:8080", ReadHeaderTimeout: 2 * time.Second, // Fix CWE-400 Potential Slowloris Attack because ReadHeaderTimeout is not configured in the http.Server diff --git a/server/authz_wiring_test.go b/server/authz_wiring_test.go new file mode 100644 index 00000000..ad7f1a0e --- /dev/null +++ b/server/authz_wiring_test.go @@ -0,0 +1,48 @@ +package server + +import ( + "context" + "reflect" + "testing" + + catalogv1 "github.com/bananaops/tracker/generated/proto/catalog/v1alpha1" + eventv1 "github.com/bananaops/tracker/generated/proto/event/v1alpha1" + lockv1 "github.com/bananaops/tracker/generated/proto/lock/v1alpha1" + "github.com/bananaops/tracker/internal/auth" + "github.com/stretchr/testify/assert" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Every RPC of the three existing services must refuse an anonymous caller +// without permissions before touching the database. The services are built +// with nil stores on purpose: reaching the store would panic. +func TestExistingServicesAreGuarded(t *testing.T) { + services := []struct { + name string + impl any + desc grpc.ServiceDesc + }{ + {"EventService", &Event{}, eventv1.EventService_ServiceDesc}, + {"CatalogService", &Catalog{}, catalogv1.CatalogService_ServiceDesc}, + {"LockService", &Lock{}, lockv1.LockService_ServiceDesc}, + } + for _, svc := range services { + v := reflect.ValueOf(svc.impl) + for _, m := range svc.desc.Methods { + method := v.MethodByName(m.MethodName) + if !method.IsValid() { + t.Errorf("%s.%s not implemented", svc.name, m.MethodName) + continue + } + full := "/" + svc.desc.ServiceName + "/" + m.MethodName + ctx := grpc.NewContextWithServerTransportStream(context.Background(), fakeTransportStream{method: full}) + ctx = auth.WithPrincipal(ctx, auth.Anonymous(nil)) + req := reflect.New(method.Type().In(1).Elem()) + out := method.Call([]reflect.Value{reflect.ValueOf(ctx), req}) + err, _ := out[1].Interface().(error) + assert.Equal(t, codes.Unauthenticated, status.Code(err), "%s.%s must call authz.Authorize first", svc.name, m.MethodName) + } + } +} diff --git a/server/catalog.go b/server/catalog.go index 6edf0f43..0ba1e356 100644 --- a/server/catalog.go +++ b/server/catalog.go @@ -7,6 +7,7 @@ import ( "os" v1alpha1 "github.com/bananaops/tracker/generated/proto/catalog/v1alpha1" + "github.com/bananaops/tracker/internal/auth/authz" "github.com/bananaops/tracker/internal/config" store "github.com/bananaops/tracker/internal/stores" "google.golang.org/protobuf/types/known/timestamppb" @@ -30,6 +31,9 @@ func (e *Catalog) CreateUpdateCatalog( ctx context.Context, i *v1alpha1.CreateUpdateCatalogRequest, ) (*v1alpha1.CreateUpdateCatalogResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } // Validation des champs requis if i.Name == "" { @@ -117,6 +121,9 @@ func (e *Catalog) GetCatalog( ctx context.Context, i *v1alpha1.GetCatalogRequest, ) (*v1alpha1.GetCatalogResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } var catalogResult = &v1alpha1.GetCatalogResponse{} var err error @@ -132,6 +139,9 @@ func (e *Catalog) ListCatalogs( ctx context.Context, i *v1alpha1.ListCatalogsRequest, ) (*v1alpha1.ListCatalogsResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } var catalogsResult = &v1alpha1.ListCatalogsResponse{} var err error @@ -149,6 +159,9 @@ func (e *Catalog) DeleteCatalog( ctx context.Context, i *v1alpha1.DeleteCatalogRequest, ) (*v1alpha1.DeleteCatalogResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } var catalogResult = &v1alpha1.DeleteCatalogResponse{} @@ -164,6 +177,9 @@ func (e *Catalog) GetVersionCompliance( ctx context.Context, i *v1alpha1.GetVersionComplianceRequest, ) (*v1alpha1.GetVersionComplianceResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } var response = &v1alpha1.GetVersionComplianceResponse{} var projectCompliances []*v1alpha1.ProjectCompliance @@ -304,6 +320,9 @@ func (e *Catalog) UpdateVersions( ctx context.Context, i *v1alpha1.UpdateVersionsRequest, ) (*v1alpha1.UpdateVersionsResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } e.logger.Info("🔧 Updating versions for service", "name", i.Name, @@ -348,6 +367,9 @@ func (e *Catalog) UpdateDependencies( ctx context.Context, i *v1alpha1.UpdateDependenciesRequest, ) (*v1alpha1.UpdateDependenciesResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } // Validation if i.Name == "" { diff --git a/server/event.go b/server/event.go index e17ed180..94dee12d 100644 --- a/server/event.go +++ b/server/event.go @@ -10,6 +10,7 @@ import ( v1alpha1 "github.com/bananaops/tracker/generated/proto/event/v1alpha1" lock "github.com/bananaops/tracker/generated/proto/lock/v1alpha1" + "github.com/bananaops/tracker/internal/auth/authz" "github.com/bananaops/tracker/internal/config" store "github.com/bananaops/tracker/internal/stores" "github.com/bananaops/tracker/internal/utils" @@ -102,6 +103,9 @@ func (e *Event) CreateEvent( ctx context.Context, i *v1alpha1.CreateEventRequest, ) (*v1alpha1.CreateEventResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } var event = &v1alpha1.Event{ Title: i.Title, @@ -254,6 +258,9 @@ func (e *Event) GetEvent( ctx context.Context, i *v1alpha1.GetEventRequest, ) (*v1alpha1.GetEventResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } var eventResult = &v1alpha1.GetEventResponse{} var err error @@ -277,6 +284,9 @@ func (e *Event) SearchEvents( ctx context.Context, i *v1alpha1.SearchEventsRequest, ) (*v1alpha1.SearchEventsResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } filter, err := utils.CreateFilter(i) if err != nil { @@ -297,6 +307,9 @@ func (e *Event) ListEvents( ctx context.Context, i *v1alpha1.ListEventsRequest, ) (*v1alpha1.ListEventsResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } var eventsResult = &v1alpha1.ListEventsResponse{} var err error @@ -314,6 +327,9 @@ func (e *Event) TodayEvents( ctx context.Context, i *v1alpha1.TodayEventsRequest, ) (*v1alpha1.TodayEventsResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } today := time.Now().Format("2006-01-02") @@ -341,6 +357,9 @@ func (e *Event) UpdateEvent( ctx context.Context, i *v1alpha1.UpdateEventRequest, ) (*v1alpha1.UpdateEventResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } var eventResult = &v1alpha1.UpdateEventResponse{} var eventDatabase = &v1alpha1.GetEventResponse{} @@ -529,10 +548,17 @@ func (e *Event) UpdateEvent( return eventResult, nil } -func (e *Event) DeleteEvent( +// DeleteEvents implements the EventService.DeleteEvents RPC. The method is +// named DeleteEvents (plural) to match the generated EventServiceServer +// interface; the previous DeleteEvent (singular) name never satisfied that +// interface, so the RPC always fell back to the embedded unimplemented stub. +func (e *Event) DeleteEvents( ctx context.Context, i *v1alpha1.DeleteEventRequest, ) (*v1alpha1.DeleteEventResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } var eventResult = &v1alpha1.DeleteEventResponse{} @@ -548,6 +574,9 @@ func (e *Event) AddChangelogEntry( ctx context.Context, i *v1alpha1.AddChangelogEntryRequest, ) (*v1alpha1.AddChangelogEntryResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } // Retrieve the existing event eventDatabase, err := e.store.Get(ctx, map[string]interface{}{"metadata.id": i.Id}) @@ -595,6 +624,9 @@ func (e *Event) GetEventChangelog( ctx context.Context, i *v1alpha1.GetEventChangelogRequest, ) (*v1alpha1.GetEventChangelogResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } // Retrieve the existing event eventDatabase, err := e.store.Get(ctx, map[string]interface{}{"metadata.id": i.Id}) @@ -655,6 +687,9 @@ func (e *Event) AddSlackId( ctx context.Context, i *v1alpha1.AddSlackIdRequest, ) (*v1alpha1.AddSlackIdResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } // Retrieve the existing event eventDatabase, err := e.store.Get(ctx, map[string]interface{}{"metadata.id": i.Id}) @@ -721,6 +756,9 @@ func (e *Event) GetEventStats( ctx context.Context, i *v1alpha1.GetEventStatsRequest, ) (*v1alpha1.GetEventStatsResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } // Build filter from request statsFilter := &utils.StatsFilter{ @@ -801,6 +839,9 @@ func (e *Event) GetEventStatsByMonth( ctx context.Context, i *v1alpha1.GetEventStatsByMonthRequest, ) (*v1alpha1.GetEventStatsByMonthResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } // Build filter from request statsFilter := &utils.StatsFilter{ diff --git a/server/homer.go b/server/homer.go index 7ed0f2f0..123ab73e 100644 --- a/server/homer.go +++ b/server/homer.go @@ -8,6 +8,8 @@ import ( "net/http" "time" + "github.com/bananaops/tracker/internal/auth" + "github.com/bananaops/tracker/internal/auth/authz" "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" "gopkg.in/yaml.v3" ) @@ -90,7 +92,7 @@ func RegisterHomerHandler(mux *runtime.ServeMux, homerURL string) { return } - err := mux.HandlePath("GET", "/api/homer-links", func(w http.ResponseWriter, r *http.Request, _ map[string]string) { + err := mux.HandlePath("GET", "/api/homer-links", authz.RequireHTTP(auth.PermLinksRead, func(w http.ResponseWriter, r *http.Request, _ map[string]string) { result, fetchErr := FetchHomerLinks(homerURL) if fetchErr != nil { slog.Error("Failed to fetch Homer links", "error", fetchErr) @@ -103,7 +105,7 @@ func RegisterHomerHandler(mux *runtime.ServeMux, homerURL string) { if encErr := json.NewEncoder(w).Encode(result); encErr != nil { slog.Error("Failed to encode homer links response", "error", encErr) } - }) + })) if err != nil { slog.Error("Failed to register /api/homer-links handler", "error", err) } diff --git a/server/links.go b/server/links.go index 025c1a81..f7a71d80 100644 --- a/server/links.go +++ b/server/links.go @@ -6,6 +6,8 @@ import ( "net/http" "strings" + "github.com/bananaops/tracker/internal/auth" + "github.com/bananaops/tracker/internal/auth/authz" store "github.com/bananaops/tracker/internal/stores" "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" ) @@ -23,19 +25,19 @@ func RegisterLinksHandler(mux *runtime.ServeMux) { initLinksStore() // GET /api/links - if err := mux.HandlePath("GET", "/api/links", handleListLinks); err != nil { + if err := mux.HandlePath("GET", "/api/links", authz.RequireHTTP(auth.PermLinksRead, handleListLinks)); err != nil { slog.Error("Failed to register GET /api/links", "error", err) } // POST /api/links - if err := mux.HandlePath("POST", "/api/links", handleCreateLink); err != nil { + if err := mux.HandlePath("POST", "/api/links", authz.RequireHTTP(auth.PermLinksWrite, handleCreateLink)); err != nil { slog.Error("Failed to register POST /api/links", "error", err) } // PUT /api/links/{id} - if err := mux.HandlePath("PUT", "/api/links/{id}", handleUpdateLink); err != nil { + if err := mux.HandlePath("PUT", "/api/links/{id}", authz.RequireHTTP(auth.PermLinksWrite, handleUpdateLink)); err != nil { slog.Error("Failed to register PUT /api/links/{id}", "error", err) } // DELETE /api/links/{id} - if err := mux.HandlePath("DELETE", "/api/links/{id}", handleDeleteLink); err != nil { + if err := mux.HandlePath("DELETE", "/api/links/{id}", authz.RequireHTTP(auth.PermLinksWrite, handleDeleteLink)); err != nil { slog.Error("Failed to register DELETE /api/links/{id}", "error", err) } } diff --git a/server/lock.go b/server/lock.go index 4e4f5284..18b0492d 100644 --- a/server/lock.go +++ b/server/lock.go @@ -8,6 +8,7 @@ import ( eventv1alpha1 "github.com/bananaops/tracker/generated/proto/event/v1alpha1" v1alpha1 "github.com/bananaops/tracker/generated/proto/lock/v1alpha1" + "github.com/bananaops/tracker/internal/auth/authz" "github.com/bananaops/tracker/internal/config" store "github.com/bananaops/tracker/internal/stores" "google.golang.org/protobuf/types/known/timestamppb" @@ -33,6 +34,9 @@ func (e *Lock) CreateLock( ctx context.Context, i *v1alpha1.CreateLockRequest, ) (*v1alpha1.CreateLockResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } var lock = &v1alpha1.Lock{ Service: i.Service, @@ -118,6 +122,9 @@ func (e *Lock) GetLock( ctx context.Context, i *v1alpha1.GetLockRequest, ) (*v1alpha1.GetLockResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } var lockResult = &v1alpha1.GetLockResponse{} var err error @@ -133,6 +140,9 @@ func (e *Lock) UpdateLock( ctx context.Context, i *v1alpha1.UpdateLockRequest, ) (*v1alpha1.UpdateLockResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } // Retrieve existing lock by id existing, err := e.store.Get(ctx, map[string]interface{}{"id": i.Id}) @@ -179,6 +189,9 @@ func (e *Lock) UnLock( ctx context.Context, i *v1alpha1.UnLockRequest, ) (*v1alpha1.UnLockResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } var lockResult = &v1alpha1.GetLockResponse{} var err error @@ -240,6 +253,9 @@ func (e *Lock) ListLocks( ctx context.Context, i *v1alpha1.ListLocksRequest, ) (*v1alpha1.ListLocksResponse, error) { + if err := authz.Authorize(ctx); err != nil { + return nil, err + } var LocksResult = &v1alpha1.ListLocksResponse{} var err error From 7609076a375487c04b1f706183d04b8475af3524 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 12:34:24 +0200 Subject: [PATCH 23/39] fix(auth): resolve RPC method name through the grpc-gateway --- internal/auth/authz/authz.go | 14 +++++++--- internal/auth/authz/authz_test.go | 44 +++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/internal/auth/authz/authz.go b/internal/auth/authz/authz.go index 3809e685..1ae90e26 100644 --- a/internal/auth/authz/authz.go +++ b/internal/auth/authz/authz.go @@ -27,12 +27,20 @@ func init() { prometheus.MustRegister(authRequests) } -// MethodFromContext returns the full RPC method name, on gRPC or through the gateway. +// MethodFromContext returns the full RPC method name, on gRPC or through the +// gateway. runtime.RPCMethod is tried first: the generated *.pb.gw.go code +// (HandlerServer mode) injects a dummy runtime.ServerTransportStream into the +// context purely to let grpc.SendHeader/SetTrailer work outside of a real +// gRPC server, and that stream's Method() always returns "". Since +// grpc.Method(ctx) reports ok=true as soon as any transport stream is +// present, checking it first would silently treat every gateway request as +// having no method, denying it as unauthorized. grpc.Method is used only as +// a fallback, and only when it actually returns a non-empty name. func MethodFromContext(ctx context.Context) string { - if m, ok := grpc.Method(ctx); ok { + if m, ok := runtime.RPCMethod(ctx); ok { return m } - if m, ok := runtime.RPCMethod(ctx); ok { + if m, ok := grpc.Method(ctx); ok && m != "" { return m } return "" diff --git a/internal/auth/authz/authz_test.go b/internal/auth/authz/authz_test.go index 69dfced9..6ebd25cb 100644 --- a/internal/auth/authz/authz_test.go +++ b/internal/auth/authz/authz_test.go @@ -7,13 +7,57 @@ import ( "testing" "github.com/bananaops/tracker/internal/auth" + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) const listEvents = "/tracker.event.v1alpha1.EventService/ListEvents" +const getAuthConfig = "/tracker.auth.v1alpha1.AuthService/GetAuthConfig" + +// fakeTransportStream is a minimal grpc.ServerTransportStream, as used by a +// real gRPC server for a direct (non-gateway) call. +type fakeTransportStream struct{ method string } + +func (s fakeTransportStream) Method() string { return s.method } +func (fakeTransportStream) SetHeader(metadata.MD) error { return nil } +func (fakeTransportStream) SendHeader(metadata.MD) error { return nil } +func (fakeTransportStream) SetTrailer(metadata.MD) error { return nil } + +// TestMethodFromContextThroughGRPC covers a direct gRPC call: the real +// transport stream reports the real method name. +func TestMethodFromContextThroughGRPC(t *testing.T) { + ctx := grpc.NewContextWithServerTransportStream(context.Background(), fakeTransportStream{method: listEvents}) + assert.Equal(t, listEvents, MethodFromContext(ctx)) +} + +// TestMethodFromContextThroughGateway mirrors exactly what the generated +// *.pb.gw.go code does in HandlerServer mode (see for example +// RegisterAuthServiceHandlerServer in generated/proto/auth/v1alpha1/auth.pb.gw.go): +// it injects a dummy runtime.ServerTransportStream purely to let +// grpc.SendHeader/SetTrailer work outside of a real gRPC server, then +// annotates the context with the real RPC method name via +// runtime.AnnotateIncomingContext. runtime.ServerTransportStream.Method() +// always returns "", so grpc.Method(ctx) must not be trusted just because it +// reports ok=true. +func TestMethodFromContextThroughGateway(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1alpha1/auth/config", nil) + ctx := grpc.NewContextWithServerTransportStream(context.Background(), &runtime.ServerTransportStream{}) + mux := runtime.NewServeMux() + annotated, err := runtime.AnnotateIncomingContext(ctx, mux, req, getAuthConfig, runtime.WithHTTPPathPattern("/api/v1alpha1/auth/config")) + require.NoError(t, err) + + assert.Equal(t, getAuthConfig, MethodFromContext(annotated)) + + err = Authorize(auth.WithPrincipal(annotated, auth.Anonymous(nil))) + assert.NoError(t, err, "GetAuthConfig is public") +} + func TestCheck(t *testing.T) { anon := auth.Anonymous(nil) reader := auth.Principal{Kind: auth.KindUser, Username: "r", Permissions: auth.NewPermissionSet(auth.PermEventRead)} From 70f65927038cb6cbbde54597cc4d6cbe38274b75 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 12:38:47 +0200 Subject: [PATCH 24/39] docs(auth): document authentication and run tests against MongoDB in CI --- .env.example | 18 +++++ .github/workflows/go-test.yml | 14 ++++ README.md | 1 + docs/AUTHENTICATION.md | 131 ++++++++++++++++++++++++++++++++++ docs/CONFIGURATION.md | 23 ++++++ docs/index.md | 1 + 6 files changed, 188 insertions(+) create mode 100644 docs/AUTHENTICATION.md diff --git a/.env.example b/.env.example index e230aefc..17228ba2 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,21 @@ SLACK_EVENTS_CHANNEL= # Homer Dashboard Integration (optional) # URL of your Homer dashboard to import links from HOMER_URL= + +# Authentication (see docs/AUTHENTICATION.md) +# Comma separated permissions granted to anonymous callers. +# Unset: every permission except access:manage (transitional default, will become empty). +# Recommended for production: empty value or a read-only set such as event:read,catalog:read +AUTH_ANONYMOUS_PERMISSIONS= +# Password of the initial "admin" account, used only when the user collection is empty. +# Unset: a random password is generated and printed once in the server logs. +AUTH_ADMIN_PASSWORD= +# Base64 encoded secret (32 bytes or more) signing session cookies. +# Unset: generated once and persisted in MongoDB. +AUTH_SESSION_SECRET= +AUTH_SESSION_TTL=12h +# Public URL of the UI, used to decide whether cookies are Secure. +AUTH_PUBLIC_URL= +AUTH_COOKIE_SECURE=false +# Trust X-Forwarded-For for login rate limiting, only behind a reverse proxy you control. +AUTH_TRUST_PROXY=false diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index 7617eebc..29bec51a 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -11,6 +11,8 @@ on: - go.mod - go.sum - main.go + - server/** + - proto/** jobs: linting: @@ -47,6 +49,16 @@ jobs: test: name: Go test runs-on: ubuntu-latest + services: + mongo: + image: mongo:7 + ports: + - 27017:27017 + options: >- + --health-cmd "mongosh --quiet --eval 'db.runCommand({ ping: 1 })'" + --health-interval 5s + --health-timeout 5s + --health-retries 10 steps: - name: Check out code into the Go module directory @@ -61,4 +73,6 @@ jobs: run: go mod download -x - name: Test + env: + MONGO_TEST_URI: mongodb://127.0.0.1:27017 run: go test -v ./... diff --git a/README.md b/README.md index e23370cd..c6abfa7c 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,7 @@ npm run dev - [🚀 Installation Guide](./docs/INSTALLATION.md) - Complete installation instructions - [⚙️ Configuration Guide](./docs/CONFIGURATION.md) - Environment variables and settings - [🔧 Development Guide](./docs/DEVELOPMENT.md) - Set up development environment +- [Authentication](./docs/AUTHENTICATION.md) - Users, teams, permissions and API keys ### User Guides - [📖 User Guide](./docs/USER_GUIDE.md) - How to use Tracker diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md new file mode 100644 index 00000000..bfa1de95 --- /dev/null +++ b/docs/AUTHENTICATION.md @@ -0,0 +1,131 @@ +# Authentication and Access Control + +Tracker authenticates every request and authorizes it against a small set of +permissions. Rights are granted to teams; users and API keys inherit the +rights of their teams. + +## Permissions + +| Permission | Grants | +|------------|--------| +| `event:read` | Read events, stats and changelogs | +| `event:write` | Create, update and delete events | +| `catalog:read` | Read the service catalog | +| `catalog:write` | Create, update and delete catalog entries | +| `lock:read` | List and read locks | +| `lock:write` | Create, update and release locks | +| `links:read` | Read custom links and Homer links | +| `links:write` | Manage custom links | +| `access:manage` | Manage users, teams and API keys | + +Every gRPC method and REST route maps to exactly one permission. A method +missing from the mapping is refused. An anonymous caller lacking the +permission receives `401 Unauthorized` (gRPC `UNAUTHENTICATED`); an +authenticated caller lacking it receives `403 Forbidden` +(`PERMISSION_DENIED`). + +## Anonymous access + +`AUTH_ANONYMOUS_PERMISSIONS` lists the permissions granted without +credentials. During the transition period the default is every permission +except `access:manage`, so existing installations keep working. The server +logs a warning at startup when the variable is not set. Set it to an empty +value to require authentication everywhere: + +```bash +AUTH_ANONYMOUS_PERMISSIONS= +``` + +`DEMO_MODE=true` overrides this and grants the read-only set +`event:read,catalog:read,lock:read,links:read` instead. + +## Initial administrator + +On first start with an empty user collection, Tracker creates the built-in +team `Administrators` (every permission, every service) and a local user +`admin` in it. The password comes from `AUTH_ADMIN_PASSWORD`, or is generated +and printed once in the logs: + +``` +WARN Initial admin account created with a generated password. Change it at first login. username=admin password=... +``` + +The account is flagged `mustChangePassword`. Change it right away: + +```bash +curl -c jar -X POST http://localhost:8080/api/v1alpha1/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":""}' +curl -b jar -c jar -X POST http://localhost:8080/api/v1alpha1/auth/password \ + -H 'Content-Type: application/json' \ + -d '{"currentPassword":"","newPassword":""}' +``` + +Passwords are hashed with Argon2id and must be 12 to 128 characters long. + +## Sessions + +Login sets an `HttpOnly`, `SameSite=Lax` cookie named `tracker_session` +valid for `AUTH_SESSION_TTL` (default 12 hours). The cookie is `Secure` when +`AUTH_PUBLIC_URL` starts with `https://` or `AUTH_COOKIE_SECURE=true`. +Changing a password, disabling a user or resetting its password invalidates +existing sessions. Five failed logins for the same username and IP within a +minute block further attempts for a minute. + +| Endpoint | Description | +|----------|-------------| +| `POST /api/v1alpha1/auth/login` | Body `{"username","password"}`. `204` and cookie on success, `401` otherwise, `429` when rate limited. | +| `POST /api/v1alpha1/auth/logout` | Clears the cookie. | +| `POST /api/v1alpha1/auth/password` | Body `{"currentPassword","newPassword"}`. Requires a session. | +| `GET /api/v1alpha1/auth/me` | Identity, teams and effective permissions of the caller. Public. | +| `GET /api/v1alpha1/auth/config` | Login options and anonymous permissions. Public. | + +## Teams + +A team carries a list of permissions, an optional list of catalog services +(empty means every service; per-service filtering is enforced in a later +release) and optional OIDC group names (used once OIDC lands). Users belong +to any number of teams and get the union of their rights. The built-in +`Administrators` team cannot be renamed, deleted or stripped of permissions. + +| Endpoint | Permission | +|----------|------------| +| `GET/POST /api/v1alpha1/auth/teams` | `access:manage` | +| `PUT/DELETE /api/v1alpha1/auth/teams/{id}` | `access:manage` | +| `GET/POST /api/v1alpha1/auth/users` | `access:manage` | +| `PUT /api/v1alpha1/auth/users/{id}` | `access:manage` | + +Deleting a team detaches its users and revokes its API keys. The last +enabled member of `Administrators` cannot be disabled or removed from the +team, and nobody can disable their own account. + +## API keys + +API keys are meant for automation (CI, the MCP server, scripts). A key +belongs to a team and inherits its rights, or is global (every permission) +when created without a team, which only members of `Administrators` may do. +A global API key is a full administrator credential. + +```bash +curl -b jar -X POST http://localhost:8080/api/v1alpha1/auth/api-keys \ + -H 'Content-Type: application/json' \ + -d '{"name":"ci","teamId":"","expiresAt":"2027-01-01T00:00:00Z"}' +``` + +The response contains the secret exactly once. Keys look like +`trk__`; only a SHA-256 hash is stored. Present the key in +either header: + +``` +X-Api-Key: trk_... +Authorization: Bearer trk_... +``` + +For gRPC, send the same value in the `x-api-key` or `authorization` +metadata. Revoke a key with `DELETE /api/v1alpha1/auth/api-keys/{id}`. + +## Metrics + +`tracker_auth_requests_total{principal,result}` counts authorization +decisions, with `principal` in `anonymous`, `user`, `apikey` and `result` +in `allowed`, `unauthenticated`, `denied`. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 6370be46..c4436aa4 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -58,6 +58,29 @@ DEMO_MODE=true BUY_ME_COFFEE_URL=https://www.buymeacoffee.com/yourname ``` +### Authentication + +| Variable | Default | Description | +|----------|---------|-------------| +| `AUTH_ANONYMOUS_PERMISSIONS` | all except `access:manage` | Comma separated permissions granted to unauthenticated callers. Set it to an empty value to require authentication everywhere. The default becomes empty in the next major release. | +| `AUTH_ADMIN_PASSWORD` | generated | Password of the initial `admin` account. Only used when no user exists yet. When unset, a random password is printed once in the logs. | +| `AUTH_SESSION_SECRET` | persisted in MongoDB | Base64 secret (32 bytes minimum) signing session cookies. Set it explicitly when running several replicas without a shared database secret. | +| `AUTH_SESSION_TTL` | `12h` | Session lifetime. | +| `AUTH_PUBLIC_URL` | - | Public URL of the UI. An `https` URL makes cookies `Secure`. | +| `AUTH_COOKIE_SECURE` | `false` | Force the `Secure` flag on cookies. | +| `AUTH_TRUST_PROXY` | `false` | Use `X-Forwarded-For` as client IP for login rate limiting. | + +`DEMO_MODE=true` overrides `AUTH_ANONYMOUS_PERMISSIONS` with the read-only set `event:read,catalog:read,lock:read,links:read`. + +See [AUTHENTICATION.md](AUTHENTICATION.md) for permissions, teams and API keys. + +**Example:** +```bash +AUTH_ANONYMOUS_PERMISSIONS=event:read,catalog:read +AUTH_ADMIN_PASSWORD=change-me-at-first-login +AUTH_PUBLIC_URL=https://tracker.example.com +``` + ### Slack Integration | Variable | Default | Description | diff --git a/docs/index.md b/docs/index.md index 121bfd2d..7057f1ca 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,6 +9,7 @@ Tracker est une API de gestion d'événements, de catalogues et de verrous const ### 📚 Documentation générale - [README](./README.md) - Vue d'ensemble et architecture - [Spécification API](./api-specification.md) - OpenAPI et Protobuf +- [Authentication](./AUTHENTICATION.md) - Users, teams, permissions and API keys ### 🔧 APIs par service - [Events API](./events.md) - Gestion des événements From df150f66752fe3da2fdc3b7f23c1d415d7da037f Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 12:44:49 +0200 Subject: [PATCH 25/39] docs(auth): clarify anonymous permission precedence and list API key routes --- README.md | 2 +- docs/AUTHENTICATION.md | 23 +++++++++++++++-------- docs/CONFIGURATION.md | 2 +- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c6abfa7c..1557927b 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ npm run dev - [🚀 Installation Guide](./docs/INSTALLATION.md) - Complete installation instructions - [⚙️ Configuration Guide](./docs/CONFIGURATION.md) - Environment variables and settings - [🔧 Development Guide](./docs/DEVELOPMENT.md) - Set up development environment -- [Authentication](./docs/AUTHENTICATION.md) - Users, teams, permissions and API keys +- [🔐 Authentication](./docs/AUTHENTICATION.md) - Users, teams, permissions and API keys ### User Guides - [📖 User Guide](./docs/USER_GUIDE.md) - How to use Tracker diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index bfa1de95..ae4ed6e6 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -27,18 +27,19 @@ authenticated caller lacking it receives `403 Forbidden` ## Anonymous access `AUTH_ANONYMOUS_PERMISSIONS` lists the permissions granted without -credentials. During the transition period the default is every permission -except `access:manage`, so existing installations keep working. The server -logs a warning at startup when the variable is not set. Set it to an empty -value to require authentication everywhere: +credentials. When it is set, its value is used as is, even when empty. When +it is unset, the default is the read-only set +`event:read,catalog:read,lock:read,links:read` if `DEMO_MODE=true`, otherwise +every permission except `access:manage` (transitional default, so existing +installations keep working; the server logs a warning at startup, and the +default becomes empty in the next major release). + +Set it to an empty value to require authentication everywhere: ```bash AUTH_ANONYMOUS_PERMISSIONS= ``` -`DEMO_MODE=true` overrides this and grants the read-only set -`event:read,catalog:read,lock:read,links:read` instead. - ## Initial administrator On first start with an empty user collection, Tracker creates the built-in @@ -106,6 +107,12 @@ belongs to a team and inherits its rights, or is global (every permission) when created without a team, which only members of `Administrators` may do. A global API key is a full administrator credential. +| Endpoint | Permission | +|----------|------------| +| `GET /api/v1alpha1/auth/api-keys` | `access:manage` | +| `POST /api/v1alpha1/auth/api-keys` | `access:manage` | +| `DELETE /api/v1alpha1/auth/api-keys/{id}` | `access:manage` | + ```bash curl -b jar -X POST http://localhost:8080/api/v1alpha1/auth/api-keys \ -H 'Content-Type: application/json' \ @@ -122,7 +129,7 @@ Authorization: Bearer trk_... ``` For gRPC, send the same value in the `x-api-key` or `authorization` -metadata. Revoke a key with `DELETE /api/v1alpha1/auth/api-keys/{id}`. +metadata. ## Metrics diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c4436aa4..7f85f4fd 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -70,7 +70,7 @@ BUY_ME_COFFEE_URL=https://www.buymeacoffee.com/yourname | `AUTH_COOKIE_SECURE` | `false` | Force the `Secure` flag on cookies. | | `AUTH_TRUST_PROXY` | `false` | Use `X-Forwarded-For` as client IP for login rate limiting. | -`DEMO_MODE=true` overrides `AUTH_ANONYMOUS_PERMISSIONS` with the read-only set `event:read,catalog:read,lock:read,links:read`. +When `AUTH_ANONYMOUS_PERMISSIONS` is set, its value is used as is, even when empty. When it is unset, the default is the read-only set `event:read,catalog:read,lock:read,links:read` if `DEMO_MODE=true`, otherwise every permission except `access:manage` (transitional default, with a startup warning). See [AUTHENTICATION.md](AUTHENTICATION.md) for permissions, teams and API keys. From d0fcad9626653ecaca9e8653786594b6639263c6 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 12:57:02 +0200 Subject: [PATCH 26/39] fix(auth): use the proxy-appended X-Forwarded-For entry for rate limiting The first entry of X-Forwarded-For is client controlled. Behind an ingress that appends the peer address (nginx, Traefik, HAProxy), an attacker could send an arbitrary first entry and get a new rate limiting key on every request, defeating the 5 failures per 60 s per (username, IP) budget. ClientIP now reads the last non empty entry, the one written by the trusted proxy. --- .env.example | 3 ++- docs/AUTHENTICATION.md | 5 ++++- docs/CONFIGURATION.md | 2 +- internal/auth/credentials.go | 14 +++++++++++++- internal/auth/credentials_test.go | 22 ++++++++++++++++++++-- 5 files changed, 40 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 17228ba2..0df40536 100644 --- a/.env.example +++ b/.env.example @@ -37,5 +37,6 @@ AUTH_SESSION_TTL=12h # Public URL of the UI, used to decide whether cookies are Secure. AUTH_PUBLIC_URL= AUTH_COOKIE_SECURE=false -# Trust X-Forwarded-For for login rate limiting, only behind a reverse proxy you control. +# Trust the last X-Forwarded-For entry for login rate limiting, only behind a +# reverse proxy you control that appends the peer address to the header. AUTH_TRUST_PROXY=false diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index ae4ed6e6..cac632d2 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -71,7 +71,10 @@ valid for `AUTH_SESSION_TTL` (default 12 hours). The cookie is `Secure` when `AUTH_PUBLIC_URL` starts with `https://` or `AUTH_COOKIE_SECURE=true`. Changing a password, disabling a user or resetting its password invalidates existing sessions. Five failed logins for the same username and IP within a -minute block further attempts for a minute. +minute block further attempts for a minute. The client IP is the peer address +of the connection, unless `AUTH_TRUST_PROXY=true`, in which case it is the last +entry of `X-Forwarded-For`, the one appended by the reverse proxy. The earlier +entries are client controlled and must not be trusted. | Endpoint | Description | |----------|-------------| diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7f85f4fd..2023cbbf 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -68,7 +68,7 @@ BUY_ME_COFFEE_URL=https://www.buymeacoffee.com/yourname | `AUTH_SESSION_TTL` | `12h` | Session lifetime. | | `AUTH_PUBLIC_URL` | - | Public URL of the UI. An `https` URL makes cookies `Secure`. | | `AUTH_COOKIE_SECURE` | `false` | Force the `Secure` flag on cookies. | -| `AUTH_TRUST_PROXY` | `false` | Use `X-Forwarded-For` as client IP for login rate limiting. | +| `AUTH_TRUST_PROXY` | `false` | Use the last entry of `X-Forwarded-For` as client IP for login rate limiting, and `X-Forwarded-Proto` to decide the request scheme. Only enable it behind a reverse proxy that appends the peer address to the header. | When `AUTH_ANONYMOUS_PERMISSIONS` is set, its value is used as is, even when empty. When it is unset, the default is the read-only set `event:read,catalog:read,lock:read,links:read` if `DEMO_MODE=true`, otherwise every permission except `access:manage` (transitional default, with a startup warning). diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go index dc51da57..f430459c 100644 --- a/internal/auth/credentials.go +++ b/internal/auth/credentials.go @@ -102,10 +102,22 @@ func ClearSessionCookie(secure bool) *http.Cookie { // ClientIP returns the peer address, honouring X-Forwarded-For only when the // deployment declares a trusted reverse proxy. +// +// The LAST entry of the header is used, not the first. Every mainstream +// ingress (nginx $proxy_add_x_forwarded_for, Traefik, HAProxy) appends the +// address it saw to whatever the client sent, so the last entry is the only +// one the trusted proxy wrote. Reading the first entry would let a client +// forge X-Forwarded-For and get a fresh rate limiting key on every request, +// which defeats the per (username, IP) login limit. func ClientIP(r *http.Request, trustProxy bool) string { if trustProxy { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - return strings.TrimSpace(strings.Split(xff, ",")[0]) + parts := strings.Split(xff, ",") + for i := len(parts) - 1; i >= 0; i-- { + if ip := strings.TrimSpace(parts[i]); ip != "" { + return ip + } + } } } host, _, err := net.SplitHostPort(r.RemoteAddr) diff --git a/internal/auth/credentials_test.go b/internal/auth/credentials_test.go index 0948a3e0..0766e695 100644 --- a/internal/auth/credentials_test.go +++ b/internal/auth/credentials_test.go @@ -61,6 +61,24 @@ func TestClientIP(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "/", nil) r.RemoteAddr = "10.0.0.5:4444" r.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1") - assert.Equal(t, "10.0.0.5", ClientIP(r, false)) - assert.Equal(t, "203.0.113.9", ClientIP(r, true)) + assert.Equal(t, "10.0.0.5", ClientIP(r, false), "the header is ignored without a trusted proxy") + assert.Equal(t, "10.0.0.1", ClientIP(r, true), "the last entry is the one appended by the trusted proxy") + + // A client that forges the header cannot change its rate limiting key: the + // trusted proxy appends the real peer address after whatever was sent. + spoofed := httptest.NewRequest(http.MethodGet, "/", nil) + spoofed.RemoteAddr = "10.0.0.5:4444" + spoofed.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8 , 198.51.100.7") + assert.Equal(t, "198.51.100.7", ClientIP(spoofed, true)) + + single := httptest.NewRequest(http.MethodGet, "/", nil) + single.RemoteAddr = "10.0.0.5:4444" + single.Header.Set("X-Forwarded-For", " 198.51.100.7 ") + assert.Equal(t, "198.51.100.7", ClientIP(single, true)) + + // An empty or blank header falls back to the peer address. + blank := httptest.NewRequest(http.MethodGet, "/", nil) + blank.RemoteAddr = "10.0.0.5:4444" + blank.Header.Set("X-Forwarded-For", " , ") + assert.Equal(t, "10.0.0.5", ClientIP(blank, true)) } From b79a85f9a57ce97232f4cc5c7e93a5b8451da48c Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 12:58:02 +0200 Subject: [PATCH 27/39] fix(auth): bound the login rate limiter memory The failures map was only pruned for the key being looked at, so a caller varying the username or the IP created keys that were never reclaimed. A full sweep now runs from the locked paths, every 1000 recorded failures or every 60 s of limiter clock, whichever comes first. --- internal/auth/ratelimit.go | 44 ++++++++++++++++++++++++++++++++- internal/auth/ratelimit_test.go | 28 +++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/internal/auth/ratelimit.go b/internal/auth/ratelimit.go index 859b6514..81792793 100644 --- a/internal/auth/ratelimit.go +++ b/internal/auth/ratelimit.go @@ -5,6 +5,13 @@ import ( "time" ) +const ( + // sweepEveryInsertions and sweepInterval bound the memory of the limiter: + // whichever comes first triggers a full pass over the map. + sweepEveryInsertions = 1000 + sweepInterval = time.Minute +) + // LoginLimiter blocks a key after too many failures inside a sliding window. // It is in-memory and per process, which is enough to slow down online guessing. type LoginLimiter struct { @@ -12,6 +19,10 @@ type LoginLimiter struct { maxFailures int window time.Duration failures map[string][]time.Time + // insertions counts the failures recorded since the last sweep, and + // lastSweep dates that sweep. See maybeSweep. + insertions int + lastSweep time.Time // Now is overridable in tests. Now func() time.Time } @@ -25,7 +36,9 @@ func NewLoginLimiter(maxFailures int, window time.Duration) *LoginLimiter { func (l *LoginLimiter) Blocked(key string) bool { l.mu.Lock() defer l.mu.Unlock() - l.prune(key, l.Now()) + now := l.Now() + l.prune(key, now) + l.maybeSweep(now) return len(l.failures[key]) >= l.maxFailures } @@ -36,6 +49,8 @@ func (l *LoginLimiter) RecordFailure(key string) { now := l.Now() l.prune(key, now) l.failures[key] = append(l.failures[key], now) + l.insertions++ + l.maybeSweep(now) } // Reset forgets the failures of the key, after a successful login. @@ -45,6 +60,15 @@ func (l *LoginLimiter) Reset(key string) { delete(l.failures, key) } +// size is the number of tracked keys. Test only. +func (l *LoginLimiter) size() int { + l.mu.Lock() + defer l.mu.Unlock() + return len(l.failures) +} + +// prune drops the failures of one key that left the window, and removes the +// key entirely when nothing is left. The caller holds the lock. func (l *LoginLimiter) prune(key string, now time.Time) { kept := l.failures[key][:0] for _, at := range l.failures[key] { @@ -58,3 +82,21 @@ func (l *LoginLimiter) prune(key string, now time.Time) { } l.failures[key] = kept } + +// maybeSweep prunes every key, not just the one being looked at. Without it +// the map only ever shrinks for keys somebody retries, so a caller varying +// the username or the IP grows it without bound. The caller holds the lock. +func (l *LoginLimiter) maybeSweep(now time.Time) { + if l.lastSweep.IsZero() { + l.lastSweep = now + return + } + if l.insertions < sweepEveryInsertions && now.Sub(l.lastSweep) < sweepInterval { + return + } + l.insertions = 0 + l.lastSweep = now + for key := range l.failures { + l.prune(key, now) + } +} diff --git a/internal/auth/ratelimit_test.go b/internal/auth/ratelimit_test.go index 085bd7e5..b7eeebbd 100644 --- a/internal/auth/ratelimit_test.go +++ b/internal/auth/ratelimit_test.go @@ -1,6 +1,7 @@ package auth import ( + "fmt" "testing" "time" @@ -30,3 +31,30 @@ func TestLoginLimiter(t *testing.T) { l.Reset("alice|1.1.1.1") assert.False(t, l.Blocked("alice|1.1.1.1")) } + +func TestLoginLimiterSweepsExpiredKeys(t *testing.T) { + l := NewLoginLimiter(3, time.Minute) + now := time.Now() + l.Now = func() time.Time { return now } + + // An attacker varying the username creates one key per attempt. + for i := 0; i < 500; i++ { + l.RecordFailure(fmt.Sprintf("user%d|203.0.113.9", i)) + } + assert.Equal(t, 500, l.size()) + + // Neither the insertion budget nor the sweep delay is reached yet. + now = now.Add(30 * time.Second) + l.RecordFailure("late|203.0.113.9") + assert.Equal(t, 501, l.size(), "no sweep before the sweep interval") + + // Past the sweep interval, every key whose failures left the window goes. + now = now.Add(2 * time.Minute) + l.RecordFailure("fresh|203.0.113.9") + assert.Equal(t, 1, l.size(), "only the key recorded just now survives") + + // Blocked sweeps too, so a read only workload cannot leak either. + now = now.Add(2 * time.Minute) + assert.False(t, l.Blocked("someone|203.0.113.9")) + assert.Equal(t, 0, l.size()) +} From 28b00f698c2082ad4c0365d098d03d9ae2bb3d53 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 13:00:34 +0200 Subject: [PATCH 28/39] fix(auth): ignore session cookies on cross-site browser requests The session cookie is SameSite=Lax, so a browser still sends it on a top level cross-site GET, and UnLock is bound to GET /api/v1alpha1/unlock/{id}. A third party link could therefore release a lock as the logged in user, and a cross-site form could POST to the login endpoint. IsCrossSite reads Sec-Fetch-Site, falling back to comparing Origin with AUTH_PUBLIC_URL or the request host. HTTPMiddleware drops a cookie credential on such a request, failing closed to anonymous rather than to 403 so public GET routes keep working. The login handler answers 403 before any password work. Explicit credentials (API key, bearer) are untouched, and non browser clients sending neither header are unaffected. --- cmd/serv.go | 2 +- docs/AUTHENTICATION.md | 17 ++++++++ internal/auth/credentials.go | 6 ++- internal/auth/credentials_test.go | 2 +- internal/auth/csrf.go | 70 ++++++++++++++++++++++++++++++ internal/auth/csrf_test.go | 72 +++++++++++++++++++++++++++++++ internal/auth/transport.go | 18 +++++++- internal/auth/transport_test.go | 45 ++++++++++++++++++- server/auth_http.go | 9 ++++ server/auth_http_test.go | 18 +++++++- 10 files changed, 252 insertions(+), 7 deletions(-) create mode 100644 internal/auth/csrf.go create mode 100644 internal/auth/csrf_test.go diff --git a/cmd/serv.go b/cmd/serv.go index e9b98276..95ab51d6 100644 --- a/cmd/serv.go +++ b/cmd/serv.go @@ -294,7 +294,7 @@ var serv = &cobra.Command{ } // Resolve the principal of every HTTP request (SPA, API and custom handlers) - httpHandler = auth.HTTPMiddleware(resolver)(httpHandler) + httpHandler = auth.HTTPMiddleware(resolver, authCfg)(httpHandler) httpServer := &http.Server{ Addr: "0.0.0.0:8080", diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index cac632d2..d491cad9 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -84,6 +84,23 @@ entries are client controlled and must not be trusted. | `GET /api/v1alpha1/auth/me` | Identity, teams and effective permissions of the caller. Public. | | `GET /api/v1alpha1/auth/config` | Login options and anonymous permissions. Public. | +### Browser cross-site requests + +`SameSite=Lax` still lets a browser attach the session cookie to a top level +cross-site `GET` navigation, and the API keeps a write behind a `GET` binding +(`GET /api/v1alpha1/unlock/{id}`). A request is therefore treated as +cross-site when `Sec-Fetch-Site` is anything other than `same-origin`, +`same-site` or `none`, or, when that header is missing, when the `Origin` +header does not match `AUTH_PUBLIC_URL` (or the request scheme and `Host` +when `AUTH_PUBLIC_URL` is unset). + +On such a request the session cookie is ignored and the caller is anonymous, +so it gets whatever `AUTH_ANONYMOUS_PERMISSIONS` grants and nothing more. +`POST /api/v1alpha1/auth/login` goes further and answers `403` before doing +any password work. API keys and `Authorization: Bearer` tokens are explicit +credentials, not ambient ones, and are never dropped. Requests carrying +neither header, which is every non browser client, are unaffected. + ## Teams A team carries a list of permissions, an optional list of catalog services diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go index f430459c..1273561e 100644 --- a/internal/auth/credentials.go +++ b/internal/auth/credentials.go @@ -20,6 +20,10 @@ const ( type Credentials struct { APIKey string SessionToken string + // FromCookie is true when the session token came from the browser cookie + // rather than an explicit header. Only that source is ambient, so only + // that source needs the cross-site guard. See IsCrossSite. + FromCookie bool } // Empty reports whether no credential was presented. @@ -35,7 +39,7 @@ func CredentialsFromHTTP(r *http.Request) Credentials { return c } if ck, err := r.Cookie(SessionCookieName); err == nil && ck.Value != "" { - return Credentials{SessionToken: ck.Value} + return Credentials{SessionToken: ck.Value, FromCookie: true} } return Credentials{} } diff --git a/internal/auth/credentials_test.go b/internal/auth/credentials_test.go index 0766e695..c510c3c0 100644 --- a/internal/auth/credentials_test.go +++ b/internal/auth/credentials_test.go @@ -27,7 +27,7 @@ func TestCredentialsFromHTTP(t *testing.T) { r = httptest.NewRequest(http.MethodGet, "/", nil) r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "cookie.jwt"}) - assert.Equal(t, Credentials{SessionToken: "cookie.jwt"}, CredentialsFromHTTP(r)) + assert.Equal(t, Credentials{SessionToken: "cookie.jwt", FromCookie: true}, CredentialsFromHTTP(r)) // Header wins over cookie. r.Header.Set("X-Api-Key", "trk_abcdefgh_secret") diff --git a/internal/auth/csrf.go b/internal/auth/csrf.go new file mode 100644 index 00000000..63c288b4 --- /dev/null +++ b/internal/auth/csrf.go @@ -0,0 +1,70 @@ +package auth + +import ( + "net/http" + "net/url" + "strings" +) + +// IsCrossSite reports whether a browser request was initiated from another +// site. It is the CSRF guard for the session cookie: that cookie is +// SameSite=Lax, so a browser still attaches it to a top level cross-site GET +// navigation, and the API exposes at least one write behind a GET binding +// (UnLock). A cross-site request must therefore not act as the logged in user. +// +// Sec-Fetch-Site is authoritative when present. Only same-origin, same-site +// and none are accepted; cross-site and any other value are treated as +// cross-site, because browsers are the only clients that send the header and +// an unrecognised value is safest handled as untrusted. +// +// Without Sec-Fetch-Site, an Origin header is compared to the expected origin: +// publicURL (AUTH_PUBLIC_URL) when configured, otherwise the request scheme +// and Host. X-Forwarded-Proto is honoured for the scheme only when trustProxy +// is on, since a client can set it otherwise. +// +// A request carrying neither header comes from a non browser client (curl, a +// script, the MCP server). Such a client cannot be tricked into replaying a +// cookie it does not hold, so it is not cross-site. +func IsCrossSite(r *http.Request, publicURL string, trustProxy bool) bool { + switch strings.ToLower(strings.TrimSpace(r.Header.Get("Sec-Fetch-Site"))) { + case "": + // No Sec-Fetch-Site, fall back to the Origin check below. + case "same-origin", "same-site", "none": + return false + default: + return true + } + + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin == "" { + return false + } + return !strings.EqualFold(strings.TrimRight(origin, "/"), expectedOrigin(r, publicURL, trustProxy)) +} + +// expectedOrigin is the scheme://host[:port] a same-origin request carries. +func expectedOrigin(r *http.Request, publicURL string, trustProxy bool) string { + if publicURL != "" { + if u, err := url.Parse(publicURL); err == nil && u.Scheme != "" && u.Host != "" { + return strings.ToLower(u.Scheme + "://" + u.Host) + } + } + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + if trustProxy { + if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { + // Same rule as X-Forwarded-For: a proxy chain appends, so the + // last entry is the one the trusted proxy wrote. + parts := strings.Split(proto, ",") + for i := len(parts) - 1; i >= 0; i-- { + if v := strings.TrimSpace(parts[i]); v != "" { + scheme = strings.ToLower(v) + break + } + } + } + } + return strings.ToLower(scheme + "://" + r.Host) +} diff --git a/internal/auth/csrf_test.go b/internal/auth/csrf_test.go new file mode 100644 index 00000000..b6d3f40f --- /dev/null +++ b/internal/auth/csrf_test.go @@ -0,0 +1,72 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsCrossSite(t *testing.T) { + cases := []struct { + name string + secFetch string + origin string + forwarded string + host string + publicURL string + trustProxy bool + want bool + }{ + {name: "no header at all is a non browser client", want: false}, + {name: "sec-fetch-site cross-site", secFetch: "cross-site", want: true}, + {name: "sec-fetch-site same-origin", secFetch: "same-origin", want: false}, + {name: "sec-fetch-site same-site", secFetch: "same-site", want: false}, + {name: "sec-fetch-site none", secFetch: "none", want: false}, + {name: "sec-fetch-site wins over a matching origin", secFetch: "cross-site", origin: "http://example.com", host: "example.com", want: true}, + {name: "unknown sec-fetch-site value fails closed", secFetch: "future-value", want: true}, + { + name: "origin matching the public url", origin: "https://tracker.example.com", + publicURL: "https://tracker.example.com", host: "internal:8080", want: false, + }, + { + name: "origin not matching the public url", origin: "https://evil.example.net", + publicURL: "https://tracker.example.com", host: "internal:8080", want: true, + }, + { + name: "public url with a trailing slash still matches", origin: "https://tracker.example.com", + publicURL: "https://tracker.example.com/", host: "internal:8080", want: false, + }, + {name: "origin matching the request host", origin: "http://example.com:8080", host: "example.com:8080", want: false}, + {name: "origin not matching the request host", origin: "http://evil.example.net", host: "example.com:8080", want: true}, + {name: "origin null is cross-site", origin: "null", host: "example.com", want: true}, + { + name: "forwarded proto is honoured behind a trusted proxy", origin: "https://example.com", + forwarded: "https", host: "example.com", trustProxy: true, want: false, + }, + { + name: "forwarded proto is ignored without a trusted proxy", origin: "https://example.com", + forwarded: "https", host: "example.com", want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/api/v1alpha1/unlock/1", nil) + if tc.host != "" { + r.Host = tc.host + } + if tc.secFetch != "" { + r.Header.Set("Sec-Fetch-Site", tc.secFetch) + } + if tc.origin != "" { + r.Header.Set("Origin", tc.origin) + } + if tc.forwarded != "" { + r.Header.Set("X-Forwarded-Proto", tc.forwarded) + } + assert.Equal(t, tc.want, IsCrossSite(r, tc.publicURL, tc.trustProxy)) + }) + } +} diff --git a/internal/auth/transport.go b/internal/auth/transport.go index b6bc4d32..eaea9e7a 100644 --- a/internal/auth/transport.go +++ b/internal/auth/transport.go @@ -2,6 +2,7 @@ package auth import ( "context" + "log/slog" "net/http" "google.golang.org/grpc" @@ -22,10 +23,23 @@ func (f ResolverFunc) Resolve(ctx context.Context, creds Credentials) Principal // HTTPMiddleware resolves the principal of every HTTP request and stores it in // the request context. It never rejects a request: authorization happens later. -func HTTPMiddleware(r Resolver) func(http.Handler) http.Handler { +// +// The configuration is only read for the cross-site guard: a session cookie +// presented on a cross-site browser request is dropped, so the request +// resolves to the anonymous principal instead of the logged in user. It fails +// closed to anonymous rather than to 403 so that a cross-site GET of a public +// route keeps working. +func HTTPMiddleware(r Resolver, cfg Config) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - p := r.Resolve(req.Context(), CredentialsFromHTTP(req)) + creds := CredentialsFromHTTP(req) + if creds.FromCookie && IsCrossSite(req, cfg.PublicURL, cfg.TrustProxy) { + slog.Debug("auth: ignoring the session cookie of a cross-site request", + "method", req.Method, "path", req.URL.Path, + "origin", req.Header.Get("Origin"), "sec_fetch_site", req.Header.Get("Sec-Fetch-Site")) + creds = Credentials{} + } + p := r.Resolve(req.Context(), creds) next.ServeHTTP(w, req.WithContext(WithPrincipal(req.Context(), p))) }) } diff --git a/internal/auth/transport_test.go b/internal/auth/transport_test.go index 75553df2..2a2ab5c1 100644 --- a/internal/auth/transport_test.go +++ b/internal/auth/transport_test.go @@ -17,13 +17,16 @@ func fakeResolver() Resolver { if c.APIKey == "trk_abcdefgh_ok" { return Principal{Kind: KindAPIKey, Username: "apikey:abcdefgh", Permissions: NewPermissionSet(PermEventRead)} } + if c.SessionToken == "session.ok" { + return Principal{Kind: KindUser, Username: "alice", Permissions: NewPermissionSet(PermEventRead)} + } return Anonymous(nil) }) } func TestHTTPMiddlewareStoresPrincipal(t *testing.T) { var seen Principal - h := HTTPMiddleware(fakeResolver())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := HTTPMiddleware(fakeResolver(), Config{})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen, _ = FromContext(r.Context()) })) r := httptest.NewRequest(http.MethodGet, "/", nil) @@ -65,3 +68,43 @@ func TestStreamInterceptorStoresPrincipal(t *testing.T) { require.NoError(t, err) assert.Equal(t, KindAPIKey, seen.Kind) } + +// A session cookie is SameSite=Lax, so a browser still sends it on a top level +// cross-site GET. The middleware must not turn that into an authenticated call. +func TestHTTPMiddlewareIgnoresCookieOnCrossSiteRequest(t *testing.T) { + var seen Principal + cfg := Config{PublicURL: "https://tracker.example.com"} + h := HTTPMiddleware(fakeResolver(), cfg)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen, _ = FromContext(r.Context()) + })) + + withCookie := func(secFetch string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/api/v1alpha1/unlock/abc", nil) + r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "session.ok"}) + if secFetch != "" { + r.Header.Set("Sec-Fetch-Site", secFetch) + } + return r + } + + h.ServeHTTP(httptest.NewRecorder(), withCookie("same-origin")) + assert.Equal(t, KindUser, seen.Kind, "same-origin cookie request stays authenticated") + + h.ServeHTTP(httptest.NewRecorder(), withCookie("cross-site")) + assert.Equal(t, KindAnonymous, seen.Kind, "cross-site cookie request falls back to anonymous") + assert.False(t, seen.Has(PermEventRead)) + + // An API key is not a browser credential and is never dropped. + r := httptest.NewRequest(http.MethodGet, "/api/v1alpha1/unlock/abc", nil) + r.Header.Set("X-Api-Key", "trk_abcdefgh_ok") + r.Header.Set("Sec-Fetch-Site", "cross-site") + h.ServeHTTP(httptest.NewRecorder(), r) + assert.Equal(t, KindAPIKey, seen.Kind, "an API key is not subject to the cookie CSRF guard") + + // A bearer session token is explicit, so it is not dropped either. + r = httptest.NewRequest(http.MethodGet, "/api/v1alpha1/unlock/abc", nil) + r.Header.Set("Authorization", "Bearer session.ok") + r.Header.Set("Sec-Fetch-Site", "cross-site") + h.ServeHTTP(httptest.NewRecorder(), r) + assert.Equal(t, KindUser, seen.Kind, "an explicit bearer token is not subject to the cookie CSRF guard") +} diff --git a/server/auth_http.go b/server/auth_http.go index ccb24e2e..f1825103 100644 --- a/server/auth_http.go +++ b/server/auth_http.go @@ -73,6 +73,15 @@ type loginRequest struct { } func (h *AuthHTTP) handleLogin(w http.ResponseWriter, r *http.Request, _ map[string]string) { + // A cross-site form can POST here (the JSON decoder does not require a + // preflighted Content-Type), which would let a third party site probe + // passwords and plant a session cookie in the victim's browser. Reject + // before any password work so the check costs nothing. + if auth.IsCrossSite(r, h.cfg.PublicURL, h.cfg.TrustProxy) { + h.logger.Warn("auth.login", "result", "cross_site", "origin", r.Header.Get("Origin")) + writeJSONError(w, http.StatusForbidden, "cross-site login requests are refused") + return + } var req loginRequest if err := decodeJSON(r, &req); err != nil || req.Username == "" || req.Password == "" { writeJSONError(w, http.StatusBadRequest, "username and password are required") diff --git a/server/auth_http_test.go b/server/auth_http_test.go index ba9b8ea1..828618f7 100644 --- a/server/auth_http_test.go +++ b/server/auth_http_test.go @@ -17,7 +17,7 @@ func newAuthHTTPServer(t *testing.T, f *authFixture) http.Handler { t.Helper() mux := runtime.NewServeMux() NewAuthHTTP(f.users, f.sessions, f.cfg).Register(mux) - return auth.HTTPMiddleware(f.resolver)(mux) + return auth.HTTPMiddleware(f.resolver, f.cfg)(mux) } func post(h http.Handler, path, body string, cookie *http.Cookie) *httptest.ResponseRecorder { @@ -134,3 +134,19 @@ func TestChangePassword(t *testing.T) { assert.False(t, u.MustChangePassword) assert.Equal(t, 2, u.SessionVersion) } + +func TestLoginRejectsCrossSiteRequest(t *testing.T) { + f := newAuthFixture(t) + h := newAuthHTTPServer(t, f) + + req := httptest.NewRequest(http.MethodPost, "/api/v1alpha1/auth/login", + strings.NewReader(`{"username":"admin","password":"admin-password-123"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Sec-Fetch-Site", "cross-site") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Empty(t, rec.Result().Cookies(), "no session is issued to a cross-site caller") + assert.Contains(t, rec.Body.String(), "cross-site") +} From bedf079c727930303a698081593dc7aaaf7ae849 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 13:02:08 +0200 Subject: [PATCH 29/39] feat(auth): add tracker_auth_logins_total metric Spec 5.4 asks for a login counter next to the authorization one. It is incremented at the three exits of the login handler: success, failure (unknown user, ineligible account, wrong password) and rate_limited. The method label is local, leaving room for oidc. --- docs/AUTHENTICATION.md | 5 +++++ go.mod | 1 + internal/auth/authz/authz.go | 24 +++++++++++++++++++++++- server/auth_http.go | 4 ++++ server/auth_http_test.go | 16 ++++++++++++++++ 5 files changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index d491cad9..c2558d65 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -156,3 +156,8 @@ metadata. `tracker_auth_requests_total{principal,result}` counts authorization decisions, with `principal` in `anonymous`, `user`, `apikey` and `result` in `allowed`, `unauthenticated`, `denied`. + +`tracker_auth_logins_total{method,result}` counts login attempts, with +`method` in `local` (`oidc` once it lands) and `result` in `success`, +`failure`, `rate_limited`. Malformed bodies, cross-site refusals and internal +errors are not login attempts and are not counted. diff --git a/go.mod b/go.mod index 6c9e0502..1e147ed1 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.18.7 // indirect github.com/kr/text v0.2.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/montanaflynn/stats v0.7.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect diff --git a/internal/auth/authz/authz.go b/internal/auth/authz/authz.go index 1ae90e26..03493b5e 100644 --- a/internal/auth/authz/authz.go +++ b/internal/auth/authz/authz.go @@ -23,8 +23,30 @@ var authRequests = prometheus.NewCounterVec( []string{"principal", "result"}, ) +// AuthLogins counts login attempts. The method label names the authentication +// method (local for now, oidc once it lands) and the result label is one of +// LoginSuccess, LoginFailure or LoginRateLimited. Malformed bodies, cross-site +// refusals and internal errors are not login attempts and are not counted. +// It is exported so the login handler, which lives in the server package, can +// increment it and tests can read it back. +var AuthLogins = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "tracker_auth_logins_total", + Help: "Login attempts by authentication method and result", + }, + []string{"method", "result"}, +) + +// Values of the AuthLogins labels. +const ( + LoginMethodLocal = "local" + LoginSuccess = "success" + LoginFailure = "failure" + LoginRateLimited = "rate_limited" +) + func init() { - prometheus.MustRegister(authRequests) + prometheus.MustRegister(authRequests, AuthLogins) } // MethodFromContext returns the full RPC method name, on gRPC or through the diff --git a/server/auth_http.go b/server/auth_http.go index f1825103..56e2bc13 100644 --- a/server/auth_http.go +++ b/server/auth_http.go @@ -10,6 +10,7 @@ import ( "time" "github.com/bananaops/tracker/internal/auth" + "github.com/bananaops/tracker/internal/auth/authz" store "github.com/bananaops/tracker/internal/stores" "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" "go.mongodb.org/mongo-driver/bson/primitive" @@ -91,6 +92,7 @@ func (h *AuthHTTP) handleLogin(w http.ResponseWriter, r *http.Request, _ map[str limitKey := strings.ToLower(req.Username) + "|" + ip if h.limiter.Blocked(limitKey) { h.logger.Warn("auth.login", "result", "rate_limited", "username", req.Username, "ip", ip) + authz.AuthLogins.WithLabelValues(authz.LoginMethodLocal, authz.LoginRateLimited).Inc() writeJSONError(w, http.StatusTooManyRequests, "too many failed attempts, retry later") return } @@ -98,6 +100,7 @@ func (h *AuthHTTP) handleLogin(w http.ResponseWriter, r *http.Request, _ map[str fail := func(reason string) { h.limiter.RecordFailure(limitKey) h.logger.Warn("auth.login", "result", "failure", "reason", reason, "username", req.Username, "ip", ip) + authz.AuthLogins.WithLabelValues(authz.LoginMethodLocal, authz.LoginFailure).Inc() writeJSONError(w, http.StatusUnauthorized, "invalid credentials") } @@ -133,6 +136,7 @@ func (h *AuthHTTP) handleLogin(w http.ResponseWriter, r *http.Request, _ map[str h.logger.Error("auth.login touch failed", "error", err) } h.logger.Info("auth.login", "result", "success", "username", user.Username, "ip", ip) + authz.AuthLogins.WithLabelValues(authz.LoginMethodLocal, authz.LoginSuccess).Inc() w.WriteHeader(http.StatusNoContent) } diff --git a/server/auth_http_test.go b/server/auth_http_test.go index 828618f7..f82afec7 100644 --- a/server/auth_http_test.go +++ b/server/auth_http_test.go @@ -8,11 +8,18 @@ import ( "testing" "github.com/bananaops/tracker/internal/auth" + "github.com/bananaops/tracker/internal/auth/authz" "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// loginCount reads tracker_auth_logins_total for one result label. +func loginCount(result string) float64 { + return testutil.ToFloat64(authz.AuthLogins.WithLabelValues("local", result)) +} + func newAuthHTTPServer(t *testing.T, f *authFixture) http.Handler { t.Helper() mux := runtime.NewServeMux() @@ -45,6 +52,7 @@ func sessionCookie(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie { func TestLoginSuccessSetsCookie(t *testing.T) { f := newAuthFixture(t) h := newAuthHTTPServer(t, f) + before := loginCount("success") rec := post(h, "/api/v1alpha1/auth/login", `{"username":"Admin","password":"admin-password-123"}`, nil) require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String()) @@ -59,11 +67,13 @@ func TestLoginSuccessSetsCookie(t *testing.T) { again, _ := f.users.GetByID(context.Background(), f.admin.ID) assert.NotNil(t, again.LastLoginAt) + assert.Equal(t, before+1, loginCount("success")) } func TestLoginFailures(t *testing.T) { f := newAuthFixture(t) h := newAuthHTTPServer(t, f) + before := loginCount("failure") rec := post(h, "/api/v1alpha1/auth/login", `{"username":"admin","password":"wrong-password-1"}`, nil) assert.Equal(t, http.StatusUnauthorized, rec.Code) @@ -82,17 +92,23 @@ func TestLoginFailures(t *testing.T) { require.NoError(t, f.users.Update(context.Background(), f.admin)) rec = post(h, "/api/v1alpha1/auth/login", `{"username":"admin","password":"admin-password-123"}`, nil) assert.Equal(t, http.StatusUnauthorized, rec.Code, "disabled user cannot log in") + + // Wrong password, unknown user and disabled user, the two 400 answers are + // not login attempts and are not counted. + assert.Equal(t, before+3, loginCount("failure")) } func TestLoginRateLimited(t *testing.T) { f := newAuthFixture(t) h := newAuthHTTPServer(t, f) + before := loginCount("rate_limited") for i := 0; i < 5; i++ { rec := post(h, "/api/v1alpha1/auth/login", `{"username":"admin","password":"wrong-password-1"}`, nil) assert.Equal(t, http.StatusUnauthorized, rec.Code) } rec := post(h, "/api/v1alpha1/auth/login", `{"username":"admin","password":"admin-password-123"}`, nil) assert.Equal(t, http.StatusTooManyRequests, rec.Code, "even the right password is blocked") + assert.Equal(t, before+1, loginCount("rate_limited")) } func TestLogoutClearsCookie(t *testing.T) { From ab762cfe0523d8d785fc41ebeac5ab945b1a4486 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 13:02:30 +0200 Subject: [PATCH 30/39] docs(auth): explain the double authz count in CreateEvent CreateEvent calls CreateLock and UpdateLock on the request context, so the method name authz resolves stays CreateEvent. The nested lock operation is authorized by event:write rather than lock:write, and tracker_auth_requests_total counts such a request twice. Comment only. --- server/event.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/server/event.go b/server/event.go index 94dee12d..b6b57094 100644 --- a/server/event.go +++ b/server/event.go @@ -168,6 +168,15 @@ func (e *Event) CreateEvent( EventId: "", // Sera mis à jour après la création de l'événement } + // This is an internal call: it reuses the request context, so the + // method name authz sees stays "/tracker.event.v1alpha1.EventService/ + // CreateEvent", not CreateLock. The lock is therefore authorized by + // event:write, not lock:write, even though spec 3.1 files CreateLock + // under lock:write. That is deliberate: creating an event that locks a + // service is one operation from the caller's point of view. + // Side effect: tracker_auth_requests_total counts such a request twice + // under the same method label, once for CreateEvent and once for the + // nested Authorize below. Same for the UpdateLock call further down. _, err := e.lockService.CreateLock(ctx, lockReq) if err != nil { e.logger.Error("failed to create lock", @@ -205,6 +214,9 @@ func (e *Event) CreateEvent( existingLock, err2 := e.lockService.store.Get(ctx, filter) if err2 == nil && existingLock != nil && existingLock.Id != "" { + // Internal call on the request context, see the comment above the + // CreateLock call: authorized by event:write and counted a second + // time in tracker_auth_requests_total under CreateEvent. _, errUpd := e.lockService.UpdateLock(ctx, &lock.UpdateLockRequest{ Id: existingLock.Id, EventId: eventResult.Event.Metadata.Id, From a2bc40dde586fd262fc9447744fe3cfc728a1cc8 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 13:03:30 +0200 Subject: [PATCH 31/39] fix(auth): tolerate concurrent bootstrap across replicas Two replicas starting on an empty database both tried to create the Administrators team and the admin user, and the loser died on log.Fatalf. The Helm chart allows several replicas. ErrAlreadyExists on the team now triggers a read back by name, and on the admin user it means a peer got there first: AdminCreated is false and no password is returned, so nothing is logged. --- internal/auth/identity/bootstrap.go | 22 +++++++++- internal/auth/identity/bootstrap_test.go | 56 ++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/internal/auth/identity/bootstrap.go b/internal/auth/identity/bootstrap.go index 7b839603..4d303fc3 100644 --- a/internal/auth/identity/bootstrap.go +++ b/internal/auth/identity/bootstrap.go @@ -37,6 +37,11 @@ const generatedPasswordLength = 24 // Bootstrap makes sure the Administrators team exists and creates the first // admin user when the user collection is empty. +// +// It is safe to run on several replicas at once. Both writes are guarded by a +// unique index, and losing either race is treated as success: the team is read +// back, and an admin created by a peer means this replica has nothing to +// report, so AdminCreated is false and no password is returned. func Bootstrap(ctx context.Context, users BootstrapUserStore, teams BootstrapTeamStore, adminPassword string) (BootstrapResult, error) { admins, err := teams.GetByName(ctx, store.AdministratorsTeamName) if errors.Is(err, store.ErrNotFound) { @@ -48,7 +53,14 @@ func Bootstrap(ctx context.Context, users BootstrapUserStore, teams BootstrapTea OIDCGroups: []string{}, Builtin: true, } - if err := teams.Create(ctx, admins); err != nil { + if err := teams.Create(ctx, admins); errors.Is(err, store.ErrAlreadyExists) { + // Another replica won the race between the lookup and the insert. + // The unique index did its job, read back what the peer wrote. + admins, err = teams.GetByName(ctx, store.AdministratorsTeamName) + if err != nil { + return BootstrapResult{}, fmt.Errorf("lookup administrators team created by a peer: %w", err) + } + } else if err != nil { return BootstrapResult{}, fmt.Errorf("create administrators team: %w", err) } } else if err != nil { @@ -84,7 +96,13 @@ func Bootstrap(ctx context.Context, users BootstrapUserStore, teams BootstrapTea Teams: []primitive.ObjectID{admins.ID}, MustChangePassword: true, } - if err := users.Create(ctx, admin); err != nil { + if err := users.Create(ctx, admin); errors.Is(err, store.ErrAlreadyExists) { + // A peer replica created the admin between the Count and the insert. + // Its password is the one that counts, so drop the one generated here + // and report nothing created: the caller must not log a password that + // does not open anything. + return BootstrapResult{AdminsTeamID: admins.ID}, nil + } else if err != nil { return BootstrapResult{}, fmt.Errorf("create admin user: %w", err) } result.AdminCreated = true diff --git a/internal/auth/identity/bootstrap_test.go b/internal/auth/identity/bootstrap_test.go index 4958dc27..538627d1 100644 --- a/internal/auth/identity/bootstrap_test.go +++ b/internal/auth/identity/bootstrap_test.go @@ -72,3 +72,59 @@ func TestBootstrapGeneratesPasswordAndIsIdempotent(t *testing.T) { assert.Len(t, users.users, 1) assert.Len(t, teams.teams, 1) } + +// raceTeams answers ErrNotFound on the first lookup, then loses the creation +// race and finally sees the team a peer replica created. +type raceTeams struct { + peer *store.Team + lookups int + attempts int +} + +func (m *raceTeams) GetByName(_ context.Context, name string) (*store.Team, error) { + m.lookups++ + if m.lookups == 1 { + return nil, store.ErrNotFound + } + return m.peer, nil +} + +func (m *raceTeams) Create(_ context.Context, _ *store.Team) error { + m.attempts++ + return store.ErrAlreadyExists +} + +func TestBootstrapReusesTeamCreatedByAPeer(t *testing.T) { + peer := &store.Team{ID: primitive.NewObjectID(), Name: store.AdministratorsTeamName, Builtin: true} + teams := &raceTeams{peer: peer} + users := &memUsers{} + + res, err := Bootstrap(context.Background(), users, teams, "initial-admin-password") + require.NoError(t, err, "losing the team creation race is not a startup failure") + assert.Equal(t, peer.ID, res.AdminsTeamID) + assert.Equal(t, 2, teams.lookups, "the team is read back after the unique index fires") + assert.True(t, res.AdminCreated) + require.Len(t, users.users, 1) + assert.Equal(t, []primitive.ObjectID{peer.ID}, users.users[0].Teams) +} + +// racingUsers reports an empty collection but loses the creation race. +type racingUsers struct{ attempts int } + +func (m *racingUsers) Count(context.Context) (int64, error) { return 0, nil } +func (m *racingUsers) Create(_ context.Context, _ *store.User) error { + m.attempts++ + return store.ErrAlreadyExists +} + +func TestBootstrapToleratesAdminCreatedByAPeer(t *testing.T) { + teams := &memTeams{teams: map[string]*store.Team{}} + users := &racingUsers{} + + res, err := Bootstrap(context.Background(), users, teams, "") + require.NoError(t, err, "a peer replica created the admin, this replica just carries on") + assert.False(t, res.AdminCreated, "this replica did not create it") + assert.Empty(t, res.GeneratedPassword, "nothing must be logged as a password") + assert.Equal(t, teams.teams[store.AdministratorsTeamName].ID, res.AdminsTeamID) + assert.Equal(t, 1, users.attempts) +} From d61a1af3cb4bf42612991e7f5072bc2a1e418e08 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 13:06:32 +0200 Subject: [PATCH 32/39] fix(auth): grant admin flag to API keys of the Administrators team resolveAPIKey dropped the admin flag returned by Effective, so a key attached to the built-in team held every permission but had IsAdmin false and could not mint a global key. That contradicted spec 4.1 and the comment on auth.Principal.IsAdmin. A team key now inherits the flag the same way a user of that team does. --- internal/auth/identity/resolver.go | 7 ++++- internal/auth/identity/resolver_test.go | 34 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/internal/auth/identity/resolver.go b/internal/auth/identity/resolver.go index 7548ac50..45ab69a4 100644 --- a/internal/auth/identity/resolver.go +++ b/internal/auth/identity/resolver.go @@ -128,13 +128,18 @@ func (r *Resolver) resolveAPIKey(ctx context.Context, secret string) (auth.Princ r.logger().Warn("auth: api key belongs to a deleted team", "prefix", prefix) return auth.Principal{}, false } - perms, scope, _ := Effective(teams) + // A key attached to the built-in Administrators team inherits its admin + // flag, exactly like a user of that team. Spec 4.1 and the comment on + // auth.Principal.IsAdmin both say so, and a key holding access:manage is + // an administrator credential in practice anyway. + perms, scope, admin := Effective(teams) return auth.Principal{ Kind: auth.KindAPIKey, Username: "apikey:" + prefix, TeamIDs: []string{key.TeamID.Hex()}, Permissions: perms, Scope: scope, + IsAdmin: admin, KeyPrefix: prefix, }, true } diff --git a/internal/auth/identity/resolver_test.go b/internal/auth/identity/resolver_test.go index 5df3899e..197b0c29 100644 --- a/internal/auth/identity/resolver_test.go +++ b/internal/auth/identity/resolver_test.go @@ -148,3 +148,37 @@ func TestResolveAPIKey(t *testing.T) { assert.Equal(t, auth.KindAnonymous, r.Resolve(context.Background(), auth.Credentials{APIKey: "trk_zzzzzzzz_nothing"}).Kind) assert.Equal(t, auth.KindAnonymous, r.Resolve(context.Background(), auth.Credentials{APIKey: "trk_bad"}).Kind) } + +// A key attached to the built-in Administrators team is an administrator +// credential, as spec 4.1 and the comment on auth.Principal.IsAdmin say. Only +// the built-in flag grants it, not the permissions the team happens to hold. +func TestResolveAPIKeyOnAdministratorsTeam(t *testing.T) { + r, _, team, keys := newFixture(t) + admins := &store.Team{ + ID: primitive.NewObjectID(), + Name: store.AdministratorsTeamName, + Builtin: true, + Permissions: []string{string(auth.PermAccessManage)}, + Scope: store.TeamScope{All: true}, + } + r.Teams.(*fakeTeams).byID[admins.ID] = admins + + adminKey, _ := auth.GenerateAPIKey() + keys.byPrefix[adminKey.Prefix] = &store.APIKey{ + ID: primitive.NewObjectID(), Prefix: adminKey.Prefix, Hash: adminKey.Hash, TeamID: &admins.ID, + } + + p := r.Resolve(context.Background(), auth.Credentials{APIKey: adminKey.Secret}) + require.Equal(t, auth.KindAPIKey, p.Kind) + assert.True(t, p.IsAdmin, "a key on the built-in team inherits the admin flag") + assert.Equal(t, []string{admins.ID.Hex()}, p.TeamIDs) + assert.True(t, p.Has(auth.PermAccessManage)) + assert.True(t, p.Scope.All) + + // A key on an ordinary team stays a plain credential. + teamKey, _ := auth.GenerateAPIKey() + keys.byPrefix[teamKey.Prefix] = &store.APIKey{ + ID: primitive.NewObjectID(), Prefix: teamKey.Prefix, Hash: teamKey.Hash, TeamID: &team.ID, + } + assert.False(t, r.Resolve(context.Background(), auth.Credentials{APIKey: teamKey.Secret}).IsAdmin) +} From e1ec28e46514610b38c13debb6a57b24e979f8a5 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 13:07:19 +0200 Subject: [PATCH 33/39] docs(auth): clarify access:manage scope, logout semantics and .env example access:manage is full administrative control, since it allows joining Administrators or minting a key on that team. Logout is stateless, so a stolen token lives until its expiry unless the session version is bumped. An invalid, revoked or expired API key silently falls back to anonymous, which under the transitional default hides revocation from the client. .env.example set AUTH_ANONYMOUS_PERMISSIONS to an empty value, and an explicitly set variable wins, so copying the file removed every anonymous permission while the login UI only lands in PR 2. The line is now commented out. --- .env.example | 8 +++++--- docs/AUTHENTICATION.md | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 0df40536..2129a746 100644 --- a/.env.example +++ b/.env.example @@ -24,9 +24,11 @@ HOMER_URL= # Authentication (see docs/AUTHENTICATION.md) # Comma separated permissions granted to anonymous callers. -# Unset: every permission except access:manage (transitional default, will become empty). -# Recommended for production: empty value or a read-only set such as event:read,catalog:read -AUTH_ANONYMOUS_PERMISSIONS= +# Leave the line commented out to keep the transitional default: every +# permission except access:manage. Setting it wins even when the value is +# empty, and an empty value means no anonymous access at all, which locks the +# UI out until the login screen ships. Uncomment it to pick a narrower set. +# AUTH_ANONYMOUS_PERMISSIONS=event:read,catalog:read,lock:read,links:read # Password of the initial "admin" account, used only when the user collection is empty. # Unset: a random password is generated and printed once in the server logs. AUTH_ADMIN_PASSWORD= diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index c2558d65..a9a03abe 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -18,6 +18,11 @@ rights of their teams. | `links:write` | Manage custom links | | `access:manage` | Manage users, teams and API keys | +`access:manage` is effectively full administrative control, not just user +management: a caller holding it can add itself to `Administrators` through +`UpdateUser`, or mint an API key on that team, and reach every permission +that way. Grant it as you would grant root. + Every gRPC method and REST route maps to exactly one permission. A method missing from the mapping is refused. An anonymous caller lacking the permission receives `401 Unauthorized` (gRPC `UNAUTHENTICATED`); an @@ -76,6 +81,12 @@ of the connection, unless `AUTH_TRUST_PROXY=true`, in which case it is the last entry of `X-Forwarded-For`, the one appended by the reverse proxy. The earlier entries are client controlled and must not be trusted. +Logout is stateless: it only clears the cookie, so a session token stolen +beforehand stays valid until its own expiry (`AUTH_SESSION_TTL`, 12 hours by +default). Changing the user's password, disabling the user or resetting its +password bumps the session version and is the only way to revoke a token +early. + | Endpoint | Description | |----------|-------------| | `POST /api/v1alpha1/auth/login` | Body `{"username","password"}`. `204` and cookie on success, `401` otherwise, `429` when rate limited. | @@ -151,6 +162,13 @@ Authorization: Bearer trk_... For gRPC, send the same value in the `x-api-key` or `authorization` metadata. +An API key that is malformed, unknown, revoked or expired does not produce an +error: the caller falls back to the anonymous principal. Under the +transitional default, where the anonymous principal holds every permission but +`access:manage`, a client whose key was revoked therefore keeps working on +those routes and never learns that its key is dead. Narrowing +`AUTH_ANONYMOUS_PERMISSIONS` makes revocation visible as a `401`. + ## Metrics `tracker_auth_requests_total{principal,result}` counts authorization From 7de689a70d9672e61fca3855dd09215ff8ff22ec Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 13:08:56 +0200 Subject: [PATCH 34/39] fix(auth): normalize default ports in the cross-site origin check Browsers omit the default port in Origin, but AUTH_PUBLIC_URL and the Host header may carry it. Comparing the raw strings made AUTH_PUBLIC_URL=https://tracker.example.com:443 mismatch Origin: https://tracker.example.com, which silently turned legitimate cookie requests anonymous. Both sides are now lowercased and stripped of an explicit :80 on http or :443 on https. Any other port still has to match. --- internal/auth/csrf.go | 18 +++++++++++++++++- internal/auth/csrf_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/internal/auth/csrf.go b/internal/auth/csrf.go index 63c288b4..e0129dad 100644 --- a/internal/auth/csrf.go +++ b/internal/auth/csrf.go @@ -39,7 +39,23 @@ func IsCrossSite(r *http.Request, publicURL string, trustProxy bool) bool { if origin == "" { return false } - return !strings.EqualFold(strings.TrimRight(origin, "/"), expectedOrigin(r, publicURL, trustProxy)) + return normalizeOrigin(origin) != normalizeOrigin(expectedOrigin(r, publicURL, trustProxy)) +} + +// normalizeOrigin lowercases an origin and drops an explicit default port, so +// that https://host and https://host:443 compare equal (same for http and 80). +// Browsers omit the default port in Origin, but AUTH_PUBLIC_URL and the Host +// header may carry it, and a spurious mismatch would silently turn legitimate +// requests anonymous. +func normalizeOrigin(origin string) string { + o := strings.ToLower(strings.TrimRight(strings.TrimSpace(origin), "/")) + if rest, ok := strings.CutPrefix(o, "http://"); ok { + return "http://" + strings.TrimSuffix(rest, ":80") + } + if rest, ok := strings.CutPrefix(o, "https://"); ok { + return "https://" + strings.TrimSuffix(rest, ":443") + } + return o } // expectedOrigin is the scheme://host[:port] a same-origin request carries. diff --git a/internal/auth/csrf_test.go b/internal/auth/csrf_test.go index b6d3f40f..297f397d 100644 --- a/internal/auth/csrf_test.go +++ b/internal/auth/csrf_test.go @@ -49,6 +49,36 @@ func TestIsCrossSite(t *testing.T) { name: "forwarded proto is ignored without a trusted proxy", origin: "https://example.com", forwarded: "https", host: "example.com", want: true, }, + + // An explicit default port means the same origin as no port at all. + { + name: "explicit 443 in the public url matches a bare origin", origin: "https://tracker.example.com", + publicURL: "https://tracker.example.com:443", host: "internal:8080", want: false, + }, + { + name: "explicit 443 in the origin matches a bare public url", origin: "https://tracker.example.com:443", + publicURL: "https://tracker.example.com", host: "internal:8080", want: false, + }, + { + name: "explicit 80 in the origin matches a bare host", origin: "http://example.com:80", + host: "example.com", want: false, + }, + { + name: "explicit 80 in the host matches a bare origin", origin: "http://example.com", + host: "example.com:80", want: false, + }, + { + name: "a non default port still has to match", origin: "https://tracker.example.com:8443", + publicURL: "https://tracker.example.com", host: "internal:8080", want: true, + }, + { + name: "443 on http is not a default port", origin: "http://example.com:443", + host: "example.com", want: true, + }, + { + name: "a port ending in 80 is not the default port", origin: "http://example.com:8080", + host: "example.com", want: true, + }, } for _, tc := range cases { From d334d0a9b9555bed3182e75be425139f351348b7 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 13:21:24 +0200 Subject: [PATCH 35/39] fix(auth): satisfy golangci-lint and gosec on the auth packages Move the test-only LoginLimiter.size helper into the test file so the unused linter no longer flags it (golangci-lint runs with tests: false), and annotate three gosec false positives: the X-Api-Key header name and the auth_api_keys collection name are not credentials (G101), and the session cookie Secure flag is configuration driven because a plain http deployment cannot set it (G124); HttpOnly and SameSite stay hard-coded. --- internal/auth/credentials.go | 8 +++++--- internal/auth/ratelimit.go | 7 ------- internal/auth/ratelimit_test.go | 8 ++++++++ internal/stores/auth_models.go | 2 +- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go index 1273561e..7a59112f 100644 --- a/internal/auth/credentials.go +++ b/internal/auth/credentials.go @@ -13,7 +13,7 @@ const ( // SessionCookieName carries the session token for the SPA. SessionCookieName = "tracker_session" // APIKeyHeader carries an API key. - APIKeyHeader = "X-Api-Key" + APIKeyHeader = "X-Api-Key" // #nosec G101 -- header name, not a credential ) // Credentials are the raw secrets found on a request, before any lookup. @@ -78,7 +78,9 @@ func fromBearer(header string) (Credentials, bool) { // SessionCookie builds the cookie carrying a session token. func SessionCookie(token string, expires time.Time, secure bool) *http.Cookie { - return &http.Cookie{ + // Secure follows AUTH_COOKIE_SECURE or an https AUTH_PUBLIC_URL; a plain + // http deployment cannot set it or the browser drops the cookie. + return &http.Cookie{ // #nosec G124 -- Secure is configuration driven, HttpOnly and SameSite are set Name: SessionCookieName, Value: token, Path: "/", @@ -92,7 +94,7 @@ func SessionCookie(token string, expires time.Time, secure bool) *http.Cookie { // ClearSessionCookie builds the cookie that removes the session. func ClearSessionCookie(secure bool) *http.Cookie { - return &http.Cookie{ + return &http.Cookie{ // #nosec G124 -- Secure is configuration driven, HttpOnly and SameSite are set Name: SessionCookieName, Value: "", Path: "/", diff --git a/internal/auth/ratelimit.go b/internal/auth/ratelimit.go index 81792793..4aeff455 100644 --- a/internal/auth/ratelimit.go +++ b/internal/auth/ratelimit.go @@ -60,13 +60,6 @@ func (l *LoginLimiter) Reset(key string) { delete(l.failures, key) } -// size is the number of tracked keys. Test only. -func (l *LoginLimiter) size() int { - l.mu.Lock() - defer l.mu.Unlock() - return len(l.failures) -} - // prune drops the failures of one key that left the window, and removes the // key entirely when nothing is left. The caller holds the lock. func (l *LoginLimiter) prune(key string, now time.Time) { diff --git a/internal/auth/ratelimit_test.go b/internal/auth/ratelimit_test.go index b7eeebbd..7c7af48f 100644 --- a/internal/auth/ratelimit_test.go +++ b/internal/auth/ratelimit_test.go @@ -58,3 +58,11 @@ func TestLoginLimiterSweepsExpiredKeys(t *testing.T) { assert.False(t, l.Blocked("someone|203.0.113.9")) assert.Equal(t, 0, l.size()) } + +// size is the number of tracked keys, exposed to the tests only so the +// production type carries no unused method. +func (l *LoginLimiter) size() int { + l.mu.Lock() + defer l.mu.Unlock() + return len(l.failures) +} diff --git a/internal/stores/auth_models.go b/internal/stores/auth_models.go index 36153a14..bf14d7e2 100644 --- a/internal/stores/auth_models.go +++ b/internal/stores/auth_models.go @@ -16,7 +16,7 @@ const ( authUsersCollection = "auth_users" authTeamsCollection = "auth_teams" - authAPIKeysCollection = "auth_api_keys" + authAPIKeysCollection = "auth_api_keys" // #nosec G101 -- collection name, not a credential authSettingsCollection = "auth_settings" ) From e4049190fce6b14772b08059db2fce2c7b4c0cb7 Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 13:21:24 +0200 Subject: [PATCH 36/39] ci: exclude generated code from the gosec scan protoc-gen-go-grpc emits *_FullMethodName constants whose names contain ApiKey, which gosec G101 reports as hardcoded credentials. Generated files cannot carry #nosec annotations, so skip them in the scanner. --- .github/workflows/go-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index 29bec51a..96867217 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -44,7 +44,7 @@ jobs: - name: Run Gosec Security Scanner uses: securego/gosec@master with: - args: -exclude=G103,G115 ./... + args: -exclude=G103,G115 -exclude-generated ./... test: name: Go test From a0d2f97ddb8171610a0093d95880fae11b0644fe Mon Sep 17 00:00:00 2001 From: umignon Date: Sat, 5 Sep 2026 13:21:24 +0200 Subject: [PATCH 37/39] fix(deps): bump google.golang.org/grpc to v1.83.2 v1.79.3 is affected by GO-2026-6061 (fixed in v1.82.1). The Snyk check on this PR reports it because the manifest changed; main carries the same version. --- go.mod | 4 ++-- go.sum | 32 ++++++++++++++++---------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/go.mod b/go.mod index 1e147ed1..3ea86349 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/go-openapi/runtime v0.29.5 github.com/golang-jwt/jwt/v5 v5.3.0 golang.org/x/crypto v0.56.0 - google.golang.org/grpc v1.79.3 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) @@ -63,7 +63,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 go.mongodb.org/mongo-driver v1.17.9 - golang.org/x/net v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d diff --git a/go.sum b/go.sum index fac28015..552d4208 100644 --- a/go.sum +++ b/go.sum @@ -118,16 +118,16 @@ go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5 go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= @@ -142,8 +142,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -167,14 +167,14 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d h1:xr2lwHI91bn3UiXcnyzRMQjp2LRiM8wEHzwUaE0YhTs= google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d/go.mod h1:O0ZOWSrfWfJ+Z5HbwZ+wNtHsg/vk1k2C/w67eww8PfQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 1441948f345094f3a0266444c45835b18cf18179 Mon Sep 17 00:00:00 2001 From: jplanckeel Date: Wed, 23 Sep 2026 14:44:05 +0200 Subject: [PATCH 38/39] fix(auth): refuse invalid credentials instead of downgrading to anonymous An API key or bearer token that is malformed, unknown, revoked or expired resolved to the anonymous principal, so the caller silently inherited AUTH_ANONYMOUS_PERMISSIONS. Under the transitional default that is wider than most credentials carry: revoking a key limited to event:read promoted it to event:write instead of shutting it down, and the client never learned its key was dead. Such a request now resolves to auth.RejectedCredential and authorization answers 401 on every route, public ones included. The check lives in authz.CheckPermission, so the gRPC services, the gateway and the hand-written /api/links and /api/homer-links routes are all covered, and the decision is still counted in tracker_auth_requests_total. The session cookie keeps its fallback to anonymous: it is ambient, a browser keeps sending a stale one on its own, and a 401 there would also cover the SPA and the login page the user needs to recover. The same token presented as an Authorization: Bearer header is refused. Co-Authored-By: Claude Opus 5 (1M context) --- docs/AUTHENTICATION.md | 23 +++++++++++---- internal/auth/authz/authz.go | 7 +++++ internal/auth/authz/authz_test.go | 38 ++++++++++++++++++++++++ internal/auth/identity/resolver.go | 16 ++++++++-- internal/auth/identity/resolver_test.go | 39 ++++++++++++++++++++----- internal/auth/principal.go | 28 ++++++++++++++++++ 6 files changed, 134 insertions(+), 17 deletions(-) diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index a9a03abe..19315127 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -81,6 +81,13 @@ of the connection, unless `AUTH_TRUST_PROXY=true`, in which case it is the last entry of `X-Forwarded-For`, the one appended by the reverse proxy. The earlier entries are client controlled and must not be trusted. +A session token that no longer resolves, because it expired, was signed with +another secret, or its user was disabled or bumped, is refused with `401` when +it arrives in an `Authorization: Bearer` header. In the `tracker_session` +cookie it falls back to anonymous instead: the cookie is ambient, a browser +keeps sending a stale one on its own, and a `401` there would also cover the +SPA and the login page the user needs to recover. + Logout is stateless: it only clears the cookie, so a session token stolen beforehand stays valid until its own expiry (`AUTH_SESSION_TTL`, 12 hours by default). Changing the user's password, disabling the user or resetting its @@ -162,12 +169,16 @@ Authorization: Bearer trk_... For gRPC, send the same value in the `x-api-key` or `authorization` metadata. -An API key that is malformed, unknown, revoked or expired does not produce an -error: the caller falls back to the anonymous principal. Under the -transitional default, where the anonymous principal holds every permission but -`access:manage`, a client whose key was revoked therefore keeps working on -those routes and never learns that its key is dead. Narrowing -`AUTH_ANONYMOUS_PERMISSIONS` makes revocation visible as a `401`. +An API key that is malformed, unknown, revoked or expired is refused with +`401 Unauthorized` (gRPC `UNAUTHENTICATED`) on every route, public ones +included. It does **not** fall back to the anonymous principal: that would +hand a dead credential whatever `AUTH_ANONYMOUS_PERMISSIONS` grants, which +under the transitional default is wider than most keys carry. Revoking a key +limited to `event:read` would then silently promote it to `event:write` +instead of shutting it down. + +Presenting no credential at all is unchanged: the caller is anonymous and gets +the anonymous permissions. ## Metrics diff --git a/internal/auth/authz/authz.go b/internal/auth/authz/authz.go index 03493b5e..586810a4 100644 --- a/internal/auth/authz/authz.go +++ b/internal/auth/authz/authz.go @@ -93,6 +93,13 @@ func Check(p auth.Principal, method string) error { // CheckPermission is the pure decision for a principal and a permission. func CheckPermission(p auth.Principal, perm auth.Permission) error { + // A credential that was presented and refused loses even public routes. + // The caller asked to be identified and could not be, so it must hear + // about it rather than be quietly served as an anonymous visitor: see + // auth.RejectedCredential. + if p.CredentialRejected { + return status.Error(codes.Unauthenticated, "invalid or expired credentials") + } switch perm { case auth.PermPublic: return nil diff --git a/internal/auth/authz/authz_test.go b/internal/auth/authz/authz_test.go index 6ebd25cb..709be2cc 100644 --- a/internal/auth/authz/authz_test.go +++ b/internal/auth/authz/authz_test.go @@ -102,3 +102,41 @@ func TestRequireHTTP(t *testing.T) { assert.Equal(t, http.StatusOK, rec.Code) assert.True(t, called) } + +// A credential that was presented and refused is denied everywhere, including +// on the public routes and on permissions the anonymous principal would hold. +// Otherwise revoking a narrow API key would widen it to +// AUTH_ANONYMOUS_PERMISSIONS instead of shutting it down. +func TestRejectedCredentialIsAlwaysUnauthenticated(t *testing.T) { + rejected := auth.RejectedCredential() + + for _, perm := range []auth.Permission{auth.PermPublic, auth.PermAuthenticated, auth.PermEventRead} { + err := CheckPermission(rejected, perm) + assert.Equal(t, codes.Unauthenticated, status.Code(err), "permission %s", perm) + } + + assert.Equal(t, codes.Unauthenticated, status.Code(Check(rejected, getAuthConfig))) + assert.Equal(t, codes.Unauthenticated, status.Code(Check(rejected, listEvents))) + + // The same principal carrying no rejection is served normally. + anon := auth.Anonymous([]auth.Permission{auth.PermEventRead}) + require.NoError(t, CheckPermission(anon, auth.PermPublic)) + require.NoError(t, CheckPermission(anon, auth.PermEventRead)) +} + +// RequireHTTP guards the hand-written routes (/api/links, /api/homer-links), +// so it must refuse a rejected credential with a 401 too. +func TestRequireHTTPRefusesRejectedCredential(t *testing.T) { + called := false + h := RequireHTTP(auth.PermLinksRead, func(w http.ResponseWriter, r *http.Request, _ map[string]string) { + called = true + }) + + r := httptest.NewRequest(http.MethodGet, "/api/links", nil) + r = r.WithContext(auth.WithPrincipal(r.Context(), auth.RejectedCredential())) + w := httptest.NewRecorder() + h(w, r, nil) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.False(t, called, "the handler must not run") +} diff --git a/internal/auth/identity/resolver.go b/internal/auth/identity/resolver.go index 45ab69a4..eb270388 100644 --- a/internal/auth/identity/resolver.go +++ b/internal/auth/identity/resolver.go @@ -58,19 +58,29 @@ func (r *Resolver) anonymous() auth.Principal { return auth.Anonymous(r.AnonymousPermissions) } -// Resolve never fails: any invalid credential yields the anonymous principal. +// Resolve never returns an error: a credential it cannot honour yields +// auth.RejectedCredential, which authorization refuses with a 401. +// +// The one exception is the session cookie. It is ambient, so a browser keeps +// sending it long after it went stale, and refusing it would serve a 401 for +// the SPA itself, including the login page that would let the user recover. +// A cookie that no longer resolves therefore falls back to anonymous, exactly +// as if it had not been sent. Explicit credentials get no such indulgence. func (r *Resolver) Resolve(ctx context.Context, creds auth.Credentials) auth.Principal { if creds.APIKey != "" { if p, ok := r.resolveAPIKey(ctx, creds.APIKey); ok { return p } - return r.anonymous() + return auth.RejectedCredential() } if creds.SessionToken != "" { if p, ok := r.resolveSession(ctx, creds.SessionToken); ok { return p } - return r.anonymous() + if creds.FromCookie { + return r.anonymous() + } + return auth.RejectedCredential() } return r.anonymous() } diff --git a/internal/auth/identity/resolver_test.go b/internal/auth/identity/resolver_test.go index 197b0c29..9567d199 100644 --- a/internal/auth/identity/resolver_test.go +++ b/internal/auth/identity/resolver_test.go @@ -95,13 +95,32 @@ func TestResolveSession(t *testing.T) { assert.False(t, p.Scope.Allows("web")) assert.False(t, p.IsAdmin) + // A bearer token is explicit: once it stops resolving it is refused. stale, _, _ := r.Sessions.Issue(user.ID.Hex(), user.SessionVersion-1) - assert.Equal(t, auth.KindAnonymous, r.Resolve(context.Background(), auth.Credentials{SessionToken: stale}).Kind) + assert.True(t, r.Resolve(context.Background(), auth.Credentials{SessionToken: stale}).CredentialRejected) user.Disabled = true - assert.Equal(t, auth.KindAnonymous, r.Resolve(context.Background(), auth.Credentials{SessionToken: token}).Kind) + assert.True(t, r.Resolve(context.Background(), auth.Credentials{SessionToken: token}).CredentialRejected) + user.Disabled = false - assert.Equal(t, auth.KindAnonymous, r.Resolve(context.Background(), auth.Credentials{SessionToken: "garbage"}).Kind) + assert.True(t, r.Resolve(context.Background(), auth.Credentials{SessionToken: "garbage"}).CredentialRejected) +} + +// The session cookie is ambient: a browser keeps sending a stale one, and +// refusing it would serve a 401 for the SPA itself, login page included. It +// must degrade to anonymous, unlike the same token sent as a bearer. +func TestResolveStaleCookieFallsBackToAnonymous(t *testing.T) { + r, user, _, _ := newFixture(t) + stale, _, _ := r.Sessions.Issue(user.ID.Hex(), user.SessionVersion-1) + + p := r.Resolve(context.Background(), auth.Credentials{SessionToken: stale, FromCookie: true}) + assert.Equal(t, auth.KindAnonymous, p.Kind) + assert.False(t, p.CredentialRejected) + assert.True(t, p.Has(auth.PermLinksRead), "anonymous permissions still apply") + + p = r.Resolve(context.Background(), auth.Credentials{SessionToken: "garbage", FromCookie: true}) + assert.Equal(t, auth.KindAnonymous, p.Kind) + assert.False(t, p.CredentialRejected) } func TestResolveAPIKey(t *testing.T) { @@ -124,16 +143,20 @@ func TestResolveAPIKey(t *testing.T) { assert.Equal(t, 1, keys.touched) wrong := gen.Secret[:len(gen.Secret)-1] + "x" - assert.Equal(t, auth.KindAnonymous, r.Resolve(context.Background(), auth.Credentials{APIKey: wrong}).Kind) + assert.True(t, r.Resolve(context.Background(), auth.Credentials{APIKey: wrong}).CredentialRejected) + // A revoked key must not inherit the anonymous permissions, which under + // the transitional default are wider than what the key itself carried. revoked := now keys.byPrefix[gen.Prefix].RevokedAt = &revoked - assert.Equal(t, auth.KindAnonymous, r.Resolve(context.Background(), auth.Credentials{APIKey: gen.Secret}).Kind) + rejected := r.Resolve(context.Background(), auth.Credentials{APIKey: gen.Secret}) + assert.True(t, rejected.CredentialRejected) + assert.False(t, rejected.Has(auth.PermLinksRead), "a dead key gains nothing from AUTH_ANONYMOUS_PERMISSIONS") keys.byPrefix[gen.Prefix].RevokedAt = nil past := now.Add(-time.Minute) keys.byPrefix[gen.Prefix].ExpiresAt = &past - assert.Equal(t, auth.KindAnonymous, r.Resolve(context.Background(), auth.Credentials{APIKey: gen.Secret}).Kind) + assert.True(t, r.Resolve(context.Background(), auth.Credentials{APIKey: gen.Secret}).CredentialRejected) keys.byPrefix[gen.Prefix].ExpiresAt = nil // Global key. @@ -145,8 +168,8 @@ func TestResolveAPIKey(t *testing.T) { assert.True(t, p.Has(auth.PermAccessManage)) // Unknown prefix and malformed key. - assert.Equal(t, auth.KindAnonymous, r.Resolve(context.Background(), auth.Credentials{APIKey: "trk_zzzzzzzz_nothing"}).Kind) - assert.Equal(t, auth.KindAnonymous, r.Resolve(context.Background(), auth.Credentials{APIKey: "trk_bad"}).Kind) + assert.True(t, r.Resolve(context.Background(), auth.Credentials{APIKey: "trk_zzzzzzzz_nothing"}).CredentialRejected) + assert.True(t, r.Resolve(context.Background(), auth.Credentials{APIKey: "trk_bad"}).CredentialRejected) } // A key attached to the built-in Administrators team is an administrator diff --git a/internal/auth/principal.go b/internal/auth/principal.go index 841d7d99..17cb491c 100644 --- a/internal/auth/principal.go +++ b/internal/auth/principal.go @@ -23,6 +23,10 @@ type Principal struct { IsAdmin bool // KeyPrefix is set when Kind is KindAPIKey, for logging. KeyPrefix string + // CredentialRejected is true when the request presented an explicit + // credential that could not be honoured. Authorization turns it into a + // 401 whatever the permission asked for, including a public one. + CredentialRejected bool } // Anonymous returns the principal used for unauthenticated requests. @@ -35,6 +39,30 @@ func Anonymous(perms []Permission) Principal { } } +// RejectedCredential is the principal of a request that presented an explicit +// credential, an API key or a bearer token, which could not be honoured: +// malformed, unknown, revoked or expired. +// +// It is deliberately not the anonymous principal. Falling back to anonymous +// would hand the caller whatever AUTH_ANONYMOUS_PERMISSIONS grants, which +// under the transitional default is more than most credentials carry: a key +// restricted to event:read would silently gain event:write the moment it is +// revoked. A dead credential must be refused, not upgraded. +// +// An absent credential is not rejected, and neither is a session cookie that +// no longer resolves: the cookie is ambient, and a browser holding a stale one +// must still be able to load the SPA and its login page. See +// Credentials.FromCookie. +func RejectedCredential() Principal { + return Principal{ + Kind: KindAnonymous, + Username: "anonymous", + Permissions: NewPermissionSet(), + Scope: ScopeAll(), + CredentialRejected: true, + } +} + // IsAuthenticated reports whether the principal is a user or an API key. func (p Principal) IsAuthenticated() bool { return p.Kind == KindUser || p.Kind == KindAPIKey From 2e2d1a5245115ff272c4b656c2cd4ef0c91dbfe1 Mon Sep 17 00:00:00 2001 From: jplanckeel Date: Wed, 23 Sep 2026 14:44:11 +0200 Subject: [PATCH 39/39] docs(events): flag that DELETE /event/{id} now deletes for real Up to 0.21.x the route answered 501: the server method was named DeleteEvent while the generated EventServiceServer interface declares DeleteEvents, so the implementation never satisfied it and the embedded unimplemented stub replied to every call. The rename in this branch makes the route destructive, which nothing in the branch announced. BREAKING CHANGE: DELETE /api/v1alpha1/event/{id} used to answer 501 Unimplemented and delete nothing. It now deletes the event. Callers that relied on the no-op, cleanup scripts, CI jobs or crawlers, must be checked before upgrading. Deletion requires event:write, which the transitional AUTH_ANONYMOUS_PERMISSIONS default grants to anonymous callers. Co-Authored-By: Claude Opus 5 (1M context) --- docs/EVENTS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/EVENTS.md b/docs/EVENTS.md index 5272f837..85dd17c3 100644 --- a/docs/EVENTS.md +++ b/docs/EVENTS.md @@ -177,6 +177,19 @@ DELETE /api/v1alpha1/event/{id} curl -X DELETE http://localhost:8080/api/v1alpha1/event/507f1f77bcf86cd799439011 ``` +> **Behaviour change.** Up to and including `0.21.x` this route never deleted +> anything. The server method was named `DeleteEvent` while the generated +> `EventServiceServer` interface declares `DeleteEvents`, so the implementation +> never satisfied the interface and the embedded unimplemented stub answered +> every call with `501 Unimplemented`. The method is now named `DeleteEvents` +> and the route deletes the event for real. +> +> Check anything that calls it, a cleanup script, a CI job, a crawler, before +> upgrading: a request that used to be a harmless no-op now destroys data. +> Deletion requires `event:write`, which the transitional +> `AUTH_ANONYMOUS_PERMISSIONS` default grants to anonymous callers; set that +> variable to restrict it. See [Authentication](./AUTHENTICATION.md). + ### List Events ```bash