diff --git a/go.mod b/go.mod index eeb73cd68..1d6dccd75 100644 --- a/go.mod +++ b/go.mod @@ -61,7 +61,7 @@ require ( github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-ole/go-ole v1.3.0 github.com/gobwas/ws v1.4.0 - github.com/goccy/go-yaml v1.18.0 + github.com/goccy/go-yaml v1.19.2 github.com/godbus/dbus/v5 v5.2.2 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-cmp v0.7.0 @@ -92,6 +92,8 @@ require ( github.com/ory/dockertest/v4 v4.0.0 github.com/oschwald/maxminddb-golang v1.12.0 github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/pb33f/libopenapi v0.41.2 + github.com/pb33f/libopenapi-validator v0.15.0 github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 github.com/pion/ice/v4 v4.0.0-00010101000000-000000000000 github.com/pion/logging v0.2.4 @@ -135,7 +137,7 @@ require ( golang.org/x/mod v0.39.0 golang.org/x/net v0.58.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/sync v0.22.0 + golang.org/x/sync v0.23.0 golang.org/x/term v0.45.0 golang.org/x/time v0.15.0 google.golang.org/api v0.276.0 @@ -183,9 +185,12 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 // indirect github.com/aws/smithy-go v1.23.0 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad // indirect github.com/beevik/etree v1.6.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect + github.com/buger/jsonparser v1.1.2 // indirect github.com/caddyserver/zerossl v0.1.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -213,12 +218,13 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.23.0 // indirect github.com/go-openapi/errors v0.22.2 // indirect - github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonpointer v0.23.2 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/loads v0.22.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/strfmt v0.23.0 // indirect github.com/go-openapi/swag v0.23.1 // indirect + github.com/go-openapi/swag/jsonname v0.26.1 // indirect github.com/go-openapi/validate v0.24.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect @@ -297,6 +303,9 @@ require ( github.com/openbao/openbao/api/v2 v2.5.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pb33f/go-yaml v0.1.1 // indirect + github.com/pb33f/jsonpath v0.8.4 // indirect + github.com/pb33f/ordered-map/v2 v2.3.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pion/dtls/v2 v2.2.10 // indirect @@ -314,6 +323,7 @@ require ( github.com/russellhaering/goxmldsig v1.6.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/skeema/knownhosts v1.3.2 // indirect diff --git a/go.sum b/go.sum index 115bb3373..2a0b10d4e 100644 --- a/go.sum +++ b/go.sum @@ -93,10 +93,16 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 h1:YZPjhyaGzhDQEvsffDEcpycq49nl github.com/aws/aws-sdk-go-v2/service/sts v1.38.2/go.mod h1:2dIN8qhQfv37BdUYGgEC8Q3tteM3zFxTI1MLO2O3J3c= github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad h1:3swAvbzgfaI6nKuDDU7BiKfZRdF+h2ZwKgMHd8Ha4t8= +github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad/go.mod h1:9+nBLYNWkvPcq9ep0owWUsPTLgL9ZXTsZWcCSVGGLJ0= github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE= github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow= +github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= @@ -104,6 +110,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/c-robinson/iplib v1.0.3 h1:NG0UF0GoEsrC1/vyfX1Lx2Ss7CySWl3KqqXh3q4DdPU= github.com/c-robinson/iplib v1.0.3/go.mod h1:i3LuuFL1hRT5gFpBRnEydzw8R6yhGkF4szNDIbF8pgo= github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= @@ -155,6 +163,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0= github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -220,8 +230,8 @@ github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC0 github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= github.com/go-openapi/errors v0.22.2 h1:rdxhzcBUazEcGccKqbY1Y7NS8FDcMyIRr0934jrYnZg= github.com/go-openapi/errors v0.22.2/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0= -github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= -github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +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.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco= @@ -232,6 +242,10 @@ github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMg github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +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/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= @@ -259,8 +273,8 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -561,6 +575,18 @@ github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq5 github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pb33f/go-yaml v0.1.1 h1:yWGmhVdwzionRnSc/hEJwcfq6PaZL2QdYzyReXBbX7Q= +github.com/pb33f/go-yaml v0.1.1/go.mod h1:QvWdkHP5VjFYAeHMQUkaPQqREaQnBNOXmWzAjNrnSdo= +github.com/pb33f/jsonpath v0.8.4 h1:Yx7fxsKl8scgERL22WhKDs2c6gYNNUonPSWEG8Y194s= +github.com/pb33f/jsonpath v0.8.4/go.mod h1:eO6mgdhw5RgzCxm/bXTi75VUs0/cCYJzdGjMCCUEI6M= +github.com/pb33f/libopenapi v0.41.2 h1:7/KNzJJ0o5fC/tYxDYcHjziKfg0bkBpFUfI2UDbChAo= +github.com/pb33f/libopenapi v0.41.2/go.mod h1:mUldESjO6ownR1TCcywvoYp7e4v/bCdQyiiIqsgWne0= +github.com/pb33f/libopenapi-validator v0.15.0 h1:5wl7/tpyewqzZ7Y5/M9CZjojlaaK2DYKPZYGIvPq7SI= +github.com/pb33f/libopenapi-validator v0.15.0/go.mod h1:P52RfZsY/+oWjRL52mpQ8Icwual6rtkJt1qOWdAe4eM= +github.com/pb33f/ordered-map/v2 v2.3.2 h1:wDyaZ2Pv9QLh64X4utCeD5Zoi9nIv2aW3lwv172O5PQ= +github.com/pb33f/ordered-map/v2 v2.3.2/go.mod h1:1OhFrXu3OYw3kM+FXF+Ug3ugmmZ9LE8z0JfQMLvtV0w= +github.com/pb33f/testify v0.1.1 h1:mnHe7uxKt8dyNYEGUspow72VjD264DB5rOJq4bz5tJw= +github.com/pb33f/testify v0.1.1/go.mod h1:keghMqOLECF1ENzESnfM+cwPcKN5uhTOpvbtjgL6aZg= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 h1:E7Kmf11E4K7B5hDti2K2NqPb1nlYlGYsu02S1JNd/Bs= @@ -638,6 +664,8 @@ github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkB github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970= @@ -790,6 +818,7 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= @@ -828,6 +857,7 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= @@ -849,8 +879,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -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/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -897,6 +927,7 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= @@ -912,6 +943,7 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= diff --git a/magefiles/openapi.go b/magefiles/openapi.go new file mode 100644 index 000000000..e590b3ddf --- /dev/null +++ b/magefiles/openapi.go @@ -0,0 +1,31 @@ +package main + +import ( + "os" + "path/filepath" + + "github.com/magefile/mage/mg" + "github.com/netbirdio/netbird/shared/management/http/apiv1alpha1" +) + +var apiPath = filepath.Join("shared", "management", "http", "apiv1alpha1") + +type Openapi mg.Namespace + +func (Openapi) GenerateV1Bindings(generateflags *string) error { + bundle, model, err := apiv1alpha1.GenerateV1ApiBindings(apiv1alpha1.ApiPath) + if err != nil { + return err + } + err = os.WriteFile(filepath.Join(apiPath, "bundle.yaml"), bundle, 0644) //nolint:gosec + if err != nil { + return err + } + + generated, err := apiv1alpha1.GenerateV1Schema(model) + if err != nil { + return err + } + + return os.WriteFile(filepath.Join(apiPath, "types.gen.go"), generated.Source, 0644) //nolint:gosec +} diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index 34c8363f1..4ced64a16 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -36,9 +36,11 @@ import ( nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" "github.com/netbirdio/netbird/management/server/activity" activitystore "github.com/netbirdio/netbird/management/server/activity/store" + "github.com/netbirdio/netbird/management/server/api/v1alpha1" nbcache "github.com/netbirdio/netbird/management/server/cache" nbContext "github.com/netbirdio/netbird/management/server/context" nbhttp "github.com/netbirdio/netbird/management/server/http" + "github.com/netbirdio/netbird/management/server/http/middleware" "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/telemetry" @@ -47,7 +49,10 @@ import ( "github.com/netbirdio/netbird/util/crypt" ) -const apiPrefix = "/api" +const ( + apiPrefix = "/api" + apiV1Prefix = "/api/v1alpha1" +) var ( kaep = keepalive.EnforcementPolicy{ @@ -158,13 +163,31 @@ func (s *BaseServer) EventStore() activity.Store { } func (s *BaseServer) APIHandler() http.Handler { - return Create(s, func() http.Handler { - httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.Router(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.PermissionsManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ServiceManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager(), s.ReverseProxyGRPCServer(), s.Config.ReverseProxy.TrustedHTTPProxies, s.RateLimiter(), s.IsValidChildAccount, s.AgentNetworkManager(), nil) + _ = CreateNamed(s, "http_v1api", func() http.Handler { + apiv1Router := s.ApiV1Router() + _, err := v1alpha1.NewAPIV1Handler(context.Background(), apiv1Router, s.AccountManager(), s.NetworkMapController(), s.PermissionsManager()) if err != nil { log.Fatalf("failed to create API handler: %v", err) } - return httpAPIHandler + + return apiv1Router }) + + _ = CreateNamed(s, "http_v0api", func() http.Handler { + apiRouter := s.ApiRouter() + _, err := nbhttp.NewAPIHandler( + context.Background(), apiRouter, s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), + s.GroupsManager(), s.GeoLocationManager(), s.PermissionsManager(), s.SettingsManager(), s.ZonesManager(), + s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ServiceManager(), s.ReverseProxyDomainManager(), + s.AccessLogsManager(), s.ReverseProxyGRPCServer(), s.Config.ReverseProxy.TrustedHTTPProxies, + s.AgentNetworkManager(), nil) + if err != nil { + log.Fatalf("failed to create API handler: %v", err) + } + return apiRouter + }) + + return s.Router() } // IDPHandler returns the HTTP handler for the embedded IdP (Dex), or nil if @@ -177,9 +200,28 @@ func (s *BaseServer) IDPHandler() http.Handler { return cors.AllowAll().Handler(embeddedIdP.Handler()) } +// Router returns the root HTTP router with the shared API middleware applied. func (s *BaseServer) Router() *mux.Router { return Create(s, func() *mux.Router { - return mux.NewRouter().PathPrefix(apiPrefix).Subrouter() + router := mux.NewRouter() + router.Use(middleware.BuildMiddleware(s.RateLimiter(), s.AuthManager(), s.AccountManager(), s.Metrics(), s.IsValidChildAccount)...) + return router + }) +} + +// ApiV1Router returns the subrouter for the versioned API under apiV1Prefix. +func (s *BaseServer) ApiV1Router() *mux.Router { + return CreateNamed(s, "apiv1_router", func() *mux.Router { + return s.Router().PathPrefix(apiV1Prefix).Subrouter() + }) +} + +// ApiRouter returns the subrouter for the unversioned API under apiPrefix. +func (s *BaseServer) ApiRouter() *mux.Router { + return CreateNamed(s, "apiv0_router", func() *mux.Router { + // The nested versioned prefix must be registered before the broader apiPrefix. + s.ApiV1Router() + return s.Router().PathPrefix(apiPrefix).Subrouter() }) } diff --git a/management/internals/shared/grpc/validate_session_test.go b/management/internals/shared/grpc/validate_session_test.go index 4b36e74cf..8b4090dcc 100644 --- a/management/internals/shared/grpc/validate_session_test.go +++ b/management/internals/shared/grpc/validate_session_test.go @@ -1,5 +1,3 @@ -//go:build integration - package grpc import ( @@ -195,7 +193,7 @@ func TestValidateSession_UserAllowed(t *testing.T) { resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ Domain: "test-proxy.example.com", - SessionToken: token, + SessionToken: token, //nolint:staticcheck }) require.NoError(t, err) @@ -216,7 +214,7 @@ func TestValidateSession_UserNotInAllowedGroup(t *testing.T) { resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ Domain: "restricted-proxy.example.com", - SessionToken: token, + SessionToken: token, //nolint:staticcheck }) require.NoError(t, err) @@ -240,7 +238,7 @@ func TestValidateSession_PendingApprovalUserDenied(t *testing.T) { resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ Domain: "restricted-proxy.example.com", - SessionToken: token, + SessionToken: token, //nolint:staticcheck }) require.NoError(t, err) @@ -265,7 +263,7 @@ func TestValidateSession_PendingApprovalUserInAllUsersGroupDenied(t *testing.T) resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ Domain: "all-users-proxy.example.com", - SessionToken: token, + SessionToken: token, //nolint:staticcheck }) require.NoError(t, err) @@ -288,7 +286,7 @@ func TestValidateSession_BlockedUserDenied(t *testing.T) { resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ Domain: "restricted-proxy.example.com", - SessionToken: token, + SessionToken: token, //nolint:staticcheck }) require.NoError(t, err) @@ -312,7 +310,7 @@ func TestValidateSession_UserAllowedAfterApproval(t *testing.T) { token := createSessionToken(t, proxy.SessionPrivateKey, pendingUserID, "restricted-proxy.example.com") req := &proto.ValidateSessionRequest{ Domain: "restricted-proxy.example.com", - SessionToken: token, + SessionToken: token, //nolint:staticcheck, } resp, err := setup.proxyService.ValidateSession(ctx, req) @@ -345,7 +343,7 @@ func TestValidateSession_UserInDifferentAccount(t *testing.T) { resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ Domain: "test-proxy.example.com", - SessionToken: token, + SessionToken: token, //nolint:staticcheck, }) require.NoError(t, err) @@ -364,7 +362,7 @@ func TestValidateSession_UserNotFound(t *testing.T) { resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ Domain: "test-proxy.example.com", - SessionToken: token, + SessionToken: token, //nolint:staticcheck, }) require.NoError(t, err) @@ -383,7 +381,7 @@ func TestValidateSession_ProxyNotFound(t *testing.T) { resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ Domain: "unknown-proxy.example.com", - SessionToken: token, + SessionToken: token, //nolint:staticcheck, }) require.NoError(t, err) @@ -397,7 +395,7 @@ func TestValidateSession_InvalidToken(t *testing.T) { resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ Domain: "test-proxy.example.com", - SessionToken: "invalid-token", + SessionToken: "invalid-token", //nolint:staticcheck, }) require.NoError(t, err) @@ -410,7 +408,7 @@ func TestValidateSession_MissingDomain(t *testing.T) { defer setup.cleanup() resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ - SessionToken: "some-token", + SessionToken: "some-token", //nolint:staticcheck, }) require.NoError(t, err) @@ -491,15 +489,15 @@ func (m *testValidateSessionServiceManager) GetAllServices(_ context.Context, _, } func (m *testValidateSessionServiceManager) GetService(_ context.Context, _, _, _ string) (*service.Service, error) { - return nil, nil + return nil, nil //nolint:nilnil } func (m *testValidateSessionServiceManager) CreateService(_ context.Context, _, _ string, _ *service.Service) (*service.Service, error) { - return nil, nil + return nil, nil //nolint:nilnil } func (m *testValidateSessionServiceManager) UpdateService(_ context.Context, _, _ string, _ *service.Service) (*service.Service, error) { - return nil, nil + return nil, nil //nolint:nilnil } func (m *testValidateSessionServiceManager) DeleteService(_ context.Context, _, _, _ string) error { @@ -543,7 +541,7 @@ func (m *testValidateSessionServiceManager) GetServiceIDByTargetID(_ context.Con } func (m *testValidateSessionServiceManager) CreateServiceFromPeer(_ context.Context, _, _ string, _ *service.ExposeServiceRequest) (*service.ExposeServiceResponse, error) { - return nil, nil + return nil, nil //nolint:nilnil } func (m *testValidateSessionServiceManager) RenewServiceFromPeer(_ context.Context, _, _, _ string) error { @@ -571,7 +569,7 @@ func (m *testValidateSessionServiceManager) DeleteAccountCluster(_ context.Conte type testValidateSessionProxyManager struct{} func (m *testValidateSessionProxyManager) Connect(_ context.Context, _, _, _, _, _ string, _ *string, _ *proxy.Capabilities) (*proxy.Proxy, error) { - return nil, nil + return nil, nil //nolint:nilnil } func (m *testValidateSessionProxyManager) Disconnect(_ context.Context, _, _ string) error { @@ -603,7 +601,7 @@ func (m *testValidateSessionProxyManager) CleanupStale(_ context.Context, _ time } func (m *testValidateSessionProxyManager) GetAccountProxy(_ context.Context, _ string) (*proxy.Proxy, error) { - return nil, nil + return nil, nil //nolint:nilnil } func (m *testValidateSessionProxyManager) CountAccountProxies(_ context.Context, _ string) (int64, error) { @@ -634,6 +632,10 @@ func (m *testValidateSessionProxyManager) ClusterSupportsPrivate(_ context.Conte return nil } +func (m *testValidateSessionProxyManager) ClusterAllProxiesPrivate(_ context.Context, _ string) *bool { + return nil +} + func (m *testValidateSessionProxyManager) ClusterSupportsSessionCode(_ context.Context, _ string) bool { return false } diff --git a/management/server/api/v1alpha1/groups/helpers.go b/management/server/api/v1alpha1/groups/helpers.go new file mode 100644 index 000000000..4b15fc9ec --- /dev/null +++ b/management/server/api/v1alpha1/groups/helpers.go @@ -0,0 +1,38 @@ +package groups + +import ( + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/http/apiv1alpha1" +) + +func ToGroupsInfoMap(groups []*types.Group, idCount int) map[string][]apiv1alpha1.GroupMinimum { + groupsInfoMap := make(map[string][]apiv1alpha1.GroupMinimum, idCount) + groupsChecked := make(map[string]struct{}, len(groups)) // not sure why this is needed (left over from old implementation) + for _, group := range groups { + _, ok := groupsChecked[group.ID] + if ok { + continue + } + + groupsChecked[group.ID] = struct{}{} + for _, pk := range group.Peers { + info := apiv1alpha1.GroupMinimum{ + Id: group.ID, + Name: group.Name, + PeersCount: len(group.Peers), + ResourcesCount: len(group.Resources), + } + groupsInfoMap[pk] = append(groupsInfoMap[pk], info) + } + for _, rk := range group.Resources { + info := apiv1alpha1.GroupMinimum{ + Id: group.ID, + Name: group.Name, + PeersCount: len(group.Peers), + ResourcesCount: len(group.Resources), + } + groupsInfoMap[rk.ID] = append(groupsInfoMap[rk.ID], info) + } + } + return groupsInfoMap +} diff --git a/management/server/api/v1alpha1/handler.go b/management/server/api/v1alpha1/handler.go new file mode 100644 index 000000000..3af8f4871 --- /dev/null +++ b/management/server/api/v1alpha1/handler.go @@ -0,0 +1,38 @@ +package v1alpha1 + +import ( + "context" + "net/http" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/shared/management/http/apiv1alpha1" + + "github.com/netbirdio/netbird/management/server/permissions" + + "github.com/netbirdio/netbird/management/server/api/v1alpha1/peers" + "github.com/netbirdio/netbird/management/server/api/v1alpha1/users" +) + +func NewAPIV1Handler( + ctx context.Context, + router *mux.Router, + accountManager account.Manager, + networkMapController network_map.Controller, + permissionsManager permissions.Manager) (http.Handler, error) { + + v1validatorMiddleware, err := apiv1alpha1.CreateV1ApiValidatingMiddleware() + if err != nil { + return nil, err + } + + router.Use(v1validatorMiddleware.Handler) + + peersHandler := peers.NewHandler(accountManager, networkMapController, permissionsManager) + _ = peersHandler.WithEndpointsForRouter(router) + + usersHandler := users.NewHandler(accountManager) + return usersHandler.WithEndpointsForRouter(router), nil +} diff --git a/management/server/api/v1alpha1/peers/peers_handler.go b/management/server/api/v1alpha1/peers/peers_handler.go new file mode 100644 index 000000000..8e0593723 --- /dev/null +++ b/management/server/api/v1alpha1/peers/peers_handler.go @@ -0,0 +1,418 @@ +package peers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/netip" + + "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/api/v1alpha1/groups" + nbcontext "github.com/netbirdio/netbird/management/server/context" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/shared/management/http/apiv1alpha1" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +type Handler struct { + accountManager account.Manager + permissionsManager permissions.Manager + networkMapController network_map.Controller +} + +// NewHandler creates a new peers Handler +func NewHandler(accountManager account.Manager, networkMapController network_map.Controller, permissionsManager permissions.Manager) *Handler { + return &Handler{ + accountManager: accountManager, + networkMapController: networkMapController, + permissionsManager: permissionsManager, + } +} + +func (h *Handler) WithEndpointsForRouter(router *mux.Router) *mux.Router { + router.HandleFunc("/peers", h.GetAllPeers).Methods("GET", "OPTIONS") + router.HandleFunc("/peers/{peerId}", h.HandlePeer).Methods("GET", "PUT", "DELETE", "OPTIONS") + return router +} + +func (h *Handler) getPeer(ctx context.Context, accountID, peerID, userID string, w http.ResponseWriter) { + peer, err := h.accountManager.GetPeer(ctx, accountID, peerID, userID) + if err != nil { + util.WriteError(ctx, err, w) + return + } + + if peer.ProxyMeta.Embedded { + util.WriteError(ctx, status.Errorf(status.InvalidArgument, "not allowed to read peer"), w) + return + } + + settings, err := h.accountManager.GetAccountSettings(ctx, accountID, activity.SystemInitiator) + if err != nil { + util.WriteError(ctx, err, w) + return + } + + dnsDomain := h.networkMapController.GetDNSDomain(settings) + + grps, _ := h.accountManager.GetPeerGroups(ctx, accountID, peerID) + grpsInfoMap := groups.ToGroupsInfoMap(grps, 0) + + validPeers, invalidPeers, err := h.accountManager.GetValidatedPeers(ctx, accountID) + if err != nil { + log.WithContext(ctx).Errorf("failed to list approved peers: %v", err) + util.WriteError(ctx, fmt.Errorf("internal error"), w) + return + } + + _, valid := validPeers[peer.ID] + reason := invalidPeers[peer.ID] + + util.WriteJSONObject(ctx, w, toSinglePeerResponse(peer, grpsInfoMap[peerID], dnsDomain, valid, reason)) +} + +func (h *Handler) updatePeer(ctx context.Context, accountID, userID, peerID string, w http.ResponseWriter, r *http.Request) { + req := &apiv1alpha1.PeerRequest{} + err := json.NewDecoder(r.Body).Decode(req) + if err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + update := &nbpeer.Peer{ + ID: peerID, + SSHEnabled: req.SshEnabled, + Name: req.Name, + LoginExpirationEnabled: req.LoginExpirationEnabled, + InactivityExpirationEnabled: req.InactivityExpirationEnabled, + } + + if req.ApprovalRequired != nil { + // todo: looks like that we reset all status property, is it right? + update.Status = &nbpeer.PeerStatus{ + RequiresApproval: *req.ApprovalRequired, + } + } + + if req.Ip != nil { + addr, err := netip.ParseAddr(*req.Ip) + if err != nil { + util.WriteError(ctx, status.Errorf(status.InvalidArgument, "invalid IP address %s: %v", *req.Ip, err), w) + return + } + + if err = h.accountManager.UpdatePeerIP(ctx, accountID, userID, peerID, addr); err != nil { + util.WriteError(ctx, err, w) + return + } + } + + if req.Ipv6 != nil { + v6Addr, err := parseIPv6(req.Ipv6) + if err != nil { + util.WriteError(ctx, status.Errorf(status.InvalidArgument, "%v", err), w) + return + } + if err = h.accountManager.UpdatePeerIPv6(ctx, accountID, userID, peerID, v6Addr); err != nil { + util.WriteError(ctx, err, w) + return + } + } + + peer, err := h.accountManager.UpdatePeer(ctx, accountID, userID, update) + if err != nil { + util.WriteError(ctx, err, w) + return + } + + settings, err := h.accountManager.GetAccountSettings(ctx, accountID, activity.SystemInitiator) + if err != nil { + util.WriteError(ctx, err, w) + return + } + dnsDomain := h.networkMapController.GetDNSDomain(settings) + + peerGroups, err := h.accountManager.GetPeerGroups(ctx, accountID, peer.ID) + if err != nil { + util.WriteError(ctx, err, w) + return + } + + grpsInfoMap := groups.ToGroupsInfoMap(peerGroups, 0) + + validPeers, invalidPeers, err := h.accountManager.GetValidatedPeers(ctx, accountID) + if err != nil { + log.WithContext(ctx).Errorf("failed to get validated peers: %v", err) + util.WriteError(ctx, fmt.Errorf("internal error"), w) + return + } + + _, valid := validPeers[peer.ID] + reason := invalidPeers[peer.ID] + + util.WriteJSONObject(r.Context(), w, toSinglePeerResponse(peer, grpsInfoMap[peerID], dnsDomain, valid, reason)) +} + +func (h *Handler) deletePeer(ctx context.Context, accountID, userID string, peerID string, w http.ResponseWriter) { + err := h.accountManager.DeletePeer(ctx, accountID, peerID, userID) + if err != nil { + log.WithContext(ctx).Errorf("failed to delete peer: %v", err) + util.WriteError(ctx, err, w) + return + } + util.WriteJSONObject(ctx, w, util.EmptyObject{}) +} + +// HandlePeer handles all peer requests for GET, PUT and DELETE operations +func (h *Handler) HandlePeer(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + accountID, userID := userAuth.AccountId, userAuth.UserId + vars := mux.Vars(r) + peerID := vars["peerId"] + if len(peerID) == 0 { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid peer ID"), w) + return + } + + switch r.Method { + case http.MethodDelete: + h.deletePeer(r.Context(), accountID, userID, peerID, w) + return + case http.MethodGet: + h.getPeer(r.Context(), accountID, peerID, userID, w) + return + case http.MethodPut: + h.updatePeer(r.Context(), accountID, userID, peerID, w, r) + return + default: + util.WriteError(r.Context(), status.Errorf(status.NotFound, "unknown METHOD"), w) + } +} + +// GetAllPeers returns a list of all peers associated with a provided account +func (h *Handler) GetAllPeers(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + nameFilter := r.URL.Query().Get("name") + ipFilter := r.URL.Query().Get("ip") + macFilter := r.URL.Query().Get("mac") + + accountID, userID := userAuth.AccountId, userAuth.UserId + + peers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, nameFilter, ipFilter, macFilter) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + settings, err := h.accountManager.GetAccountSettings(r.Context(), accountID, activity.SystemInitiator) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + dnsDomain := h.networkMapController.GetDNSDomain(settings) + + grps, _ := h.accountManager.GetAllGroups(r.Context(), accountID, userID) + + grpsInfoMap := groups.ToGroupsInfoMap(grps, len(peers)) + respBody := make([]*apiv1alpha1.PeerBatch, 0, len(peers)) + for _, peer := range peers { + if peer.ProxyMeta.Embedded { + continue + } + respBody = append(respBody, toPeerListItemResponse(peer, grpsInfoMap[peer.ID], dnsDomain, 0)) + } + + validPeersMap, invalidPeersMap, err := h.accountManager.GetValidatedPeers(r.Context(), accountID) + if err != nil { + log.WithContext(r.Context()).Errorf("failed to get validated peers: %v", err) + util.WriteError(r.Context(), fmt.Errorf("internal error"), w) + return + } + h.setApprovalRequiredFlag(respBody, validPeersMap, invalidPeersMap) + + util.WriteJSONObject(r.Context(), w, respBody) +} + +func (h *Handler) setApprovalRequiredFlag(respBody []*apiv1alpha1.PeerBatch, validPeersMap map[string]struct{}, invalidPeersMap map[string]string) { + for _, peer := range respBody { + _, ok := validPeersMap[peer.Id] + if !ok { + peer.ApprovalRequired = true + + reason := invalidPeersMap[peer.Id] + peer.DisapprovalReason = &reason + } + } +} + +func parseIPv6(s *string) (netip.Addr, error) { + if s == nil { + return netip.Addr{}, fmt.Errorf("IPv6 address is nil") + } + addr, err := netip.ParseAddr(*s) + if err != nil { + return netip.Addr{}, fmt.Errorf("invalid IPv6 address %s: %w", *s, err) + } + addr = addr.Unmap() + if !addr.Is6() { + return netip.Addr{}, fmt.Errorf("address %s is not IPv6", *s) + } + return addr, nil +} + +func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []apiv1alpha1.GroupMinimum, dnsDomain string, approved bool, reason string) *apiv1alpha1.Peer { + osVersion := peer.Meta.OSVersion + if osVersion == "" { + osVersion = peer.Meta.Core + } + + apiPeer := &apiv1alpha1.Peer{ + PeerMinimum: apiv1alpha1.PeerMinimum{ + Id: peer.ID, + Name: peer.Name, + }, + CreatedAt: peer.CreatedAt, + Ip: peer.IP.String(), + Ipv6: peerIPv6String(peer), + ConnectionIp: peer.Location.ConnectionIP.String(), + Connected: peer.Status.Connected, + LastSeen: peer.Status.LastSeen, + Os: fmt.Sprintf("%s %s", peer.Meta.OS, osVersion), + KernelVersion: peer.Meta.KernelVersion, + GeonameId: int(peer.Location.GeoNameID), + Version: peer.Meta.WtVersion, + Groups: groupsInfo, + SshEnabled: peer.SSHEnabled, + Hostname: peer.Meta.Hostname, + UserId: peer.UserID, + UiVersion: peer.Meta.UIVersion, + DnsLabel: fqdn(peer, dnsDomain), + ExtraDnsLabels: fqdnList(peer.ExtraDNSLabels, dnsDomain), + LoginExpirationEnabled: peer.LoginExpirationEnabled, + LastLogin: peer.GetLastLogin(), + LoginExpired: peer.Status.LoginExpired, + ApprovalRequired: !approved, + CountryCode: apiv1alpha1.CountryCode(peer.Location.CountryCode), + CityName: apiv1alpha1.CityName(peer.Location.CityName), + SerialNumber: peer.Meta.SystemSerialNumber, + InactivityExpirationEnabled: peer.InactivityExpirationEnabled, + Ephemeral: peer.Ephemeral, + LocalFlags: &apiv1alpha1.PeerLocalFlags{ + BlockInbound: &peer.Meta.Flags.BlockInbound, + BlockLanAccess: &peer.Meta.Flags.BlockLANAccess, + DisableClientRoutes: &peer.Meta.Flags.DisableClientRoutes, + DisableDns: &peer.Meta.Flags.DisableDNS, + DisableFirewall: &peer.Meta.Flags.DisableFirewall, + DisableServerRoutes: &peer.Meta.Flags.DisableServerRoutes, + LazyConnectionEnabled: &peer.Meta.Flags.LazyConnectionEnabled, + RosenpassEnabled: &peer.Meta.Flags.RosenpassEnabled, + RosenpassPermissive: &peer.Meta.Flags.RosenpassPermissive, + ServerSshAllowed: &peer.Meta.Flags.ServerSSHAllowed, + RemoteJobsAllowed: &peer.Meta.Flags.RemoteJobsAllowed, + }, + } + + if !approved { + apiPeer.DisapprovalReason = &reason + } + + return apiPeer +} + +func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []apiv1alpha1.GroupMinimum, dnsDomain string, accessiblePeersCount int) *apiv1alpha1.PeerBatch { + osVersion := peer.Meta.OSVersion + if osVersion == "" { + osVersion = peer.Meta.Core + } + + return &apiv1alpha1.PeerBatch{ + CreatedAt: peer.CreatedAt, + AccessiblePeersCount: accessiblePeersCount, + Peer: apiv1alpha1.Peer{ + PeerMinimum: apiv1alpha1.PeerMinimum{ + Id: peer.ID, + Name: peer.Name, + }, + Ip: peer.IP.String(), + Ipv6: peerIPv6String(peer), + ConnectionIp: peer.Location.ConnectionIP.String(), + Connected: peer.Status.Connected, + LastSeen: peer.Status.LastSeen, + Os: fmt.Sprintf("%s %s", peer.Meta.OS, osVersion), + KernelVersion: peer.Meta.KernelVersion, + GeonameId: int(peer.Location.GeoNameID), + Version: peer.Meta.WtVersion, + Groups: groupsInfo, + SshEnabled: peer.SSHEnabled, + Hostname: peer.Meta.Hostname, + UserId: peer.UserID, + UiVersion: peer.Meta.UIVersion, + DnsLabel: fqdn(peer, dnsDomain), + ExtraDnsLabels: fqdnList(peer.ExtraDNSLabels, dnsDomain), + LoginExpirationEnabled: peer.LoginExpirationEnabled, + LastLogin: peer.GetLastLogin(), + LoginExpired: peer.Status.LoginExpired, + CountryCode: apiv1alpha1.CountryCode(peer.Location.CountryCode), + CityName: apiv1alpha1.CityName(peer.Location.CityName), + SerialNumber: peer.Meta.SystemSerialNumber, + InactivityExpirationEnabled: peer.InactivityExpirationEnabled, + Ephemeral: peer.Ephemeral, + LocalFlags: &apiv1alpha1.PeerLocalFlags{ + BlockInbound: &peer.Meta.Flags.BlockInbound, + BlockLanAccess: &peer.Meta.Flags.BlockLANAccess, + DisableClientRoutes: &peer.Meta.Flags.DisableClientRoutes, + DisableDns: &peer.Meta.Flags.DisableDNS, + DisableFirewall: &peer.Meta.Flags.DisableFirewall, + DisableServerRoutes: &peer.Meta.Flags.DisableServerRoutes, + LazyConnectionEnabled: &peer.Meta.Flags.LazyConnectionEnabled, + RosenpassEnabled: &peer.Meta.Flags.RosenpassEnabled, + RosenpassPermissive: &peer.Meta.Flags.RosenpassPermissive, + ServerSshAllowed: &peer.Meta.Flags.ServerSSHAllowed, + RemoteJobsAllowed: &peer.Meta.Flags.RemoteJobsAllowed, + }, + }, + } +} + +func fqdn(peer *nbpeer.Peer, dnsDomain string) string { + fqdn := peer.FQDN(dnsDomain) + if fqdn == "" { + return peer.DNSLabel + } else { + return fqdn + } +} +func fqdnList(extraLabels []string, dnsDomain string) []string { + fqdnList := make([]string, 0, len(extraLabels)) + for _, label := range extraLabels { + fqdn := fmt.Sprintf("%s.%s", label, dnsDomain) + fqdnList = append(fqdnList, fqdn) + } + return fqdnList +} + +func peerIPv6String(peer *nbpeer.Peer) *string { + if !peer.IPv6.IsValid() { + return nil + } + s := peer.IPv6.String() + return &s +} diff --git a/management/server/api/v1alpha1/peers/peers_handler_test.go b/management/server/api/v1alpha1/peers/peers_handler_test.go new file mode 100644 index 000000000..492d0e12d --- /dev/null +++ b/management/server/api/v1alpha1/peers/peers_handler_test.go @@ -0,0 +1,445 @@ +package peers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "testing" + "time" + + "github.com/gorilla/mux" + "go.uber.org/mock/gomock" + "golang.org/x/exp/maps" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + nbcontext "github.com/netbirdio/netbird/management/server/context" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/apiv1alpha1" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/mock_server" +) + +const ( + testPeerID = "test_peer" + noUpdateChannelTestPeerID = "no-update-channel" + + adminUser = "admin_user" + regularUser = "regular_user" + serviceUser = "service_user" +) + +func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler { + + peersMap := make(map[string]*nbpeer.Peer) + for _, peer := range peers { + peersMap[peer.ID] = peer.Copy() + } + + policy := &types.Policy{ + ID: "policy", + AccountID: "test_id", + Name: "policy", + Enabled: true, + Rules: []*types.PolicyRule{ + { + ID: "rule", + Name: "rule", + Enabled: true, + Action: "accept", + Destinations: []string{"group1"}, + Sources: []string{"group1"}, + Bidirectional: true, + Protocol: "all", + Ports: []string{"80"}, + }, + }, + } + + srvUser := types.NewRegularUser(serviceUser, "", "") + srvUser.IsServiceUser = true + + account := &types.Account{ + Id: "test_id", + Domain: "hotmail.com", + Peers: peersMap, + Users: map[string]*types.User{ + adminUser: types.NewAdminUser(adminUser), + regularUser: types.NewRegularUser(regularUser, "", ""), + serviceUser: srvUser, + }, + Groups: map[string]*types.Group{ + "group1": { + ID: "group1", + AccountID: "test_id", + Name: "group1", + Issued: "api", + Peers: maps.Keys(peersMap), + }, + }, + Settings: &types.Settings{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: time.Hour, + }, + Policies: []*types.Policy{policy}, + Network: &types.Network{ + Identifier: "ciclqisab2ss43jdn8q0", + Net: net.IPNet{ + IP: net.ParseIP("100.67.0.0"), + Mask: net.IPv4Mask(255, 255, 0, 0), + }, + Serial: 51, + }, + } + + ctrl := gomock.NewController(t) + + networkMapController := network_map.NewMockController(ctrl) + networkMapController.EXPECT(). + GetDNSDomain(gomock.Any()). + Return("domain"). + AnyTimes() + + ctrl2 := gomock.NewController(t) + permissionsManager := permissions.NewMockManager(ctrl2) + permissionsManager.EXPECT().ValidateAccountAccess(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(context.Background(), nil).AnyTimes() + permissionsManager.EXPECT(). + ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)). + DoAndReturn(func(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, context.Context, error) { + user, ok := account.Users[userID] + if !ok { + return false, ctx, fmt.Errorf("user not found") + } + return user.HasAdminPower() || user.IsServiceUser, ctx, nil + }). + AnyTimes() + + return &Handler{ + accountManager: &mock_server.MockAccountManager{ + UpdatePeerFunc: func(_ context.Context, accountID, userID string, update *nbpeer.Peer) (*nbpeer.Peer, error) { + var p *nbpeer.Peer + for _, peer := range peers { + if update.ID == peer.ID { + p = peer.Copy() + break + } + } + p.SSHEnabled = update.SSHEnabled + p.LoginExpirationEnabled = update.LoginExpirationEnabled + p.Name = update.Name + return p, nil + }, + UpdatePeerIPFunc: func(_ context.Context, accountID, userID, peerID string, newIP netip.Addr) error { + for _, peer := range peers { + if peer.ID == peerID { + peer.IP = newIP + return nil + } + } + return fmt.Errorf("peer not found") + }, + GetPeerFunc: func(_ context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error) { + var p *nbpeer.Peer + for _, peer := range peers { + if peerID == peer.ID { + p = peer.Copy() + break + } + } + return p, nil + }, + GetUserByIDFunc: func(ctx context.Context, id string) (*types.User, error) { + switch id { + case adminUser: + return account.Users[adminUser], nil + case regularUser: + return account.Users[regularUser], nil + case serviceUser: + return account.Users[serviceUser], nil + default: + return nil, fmt.Errorf("user not found") + } + }, + GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { + return peers, nil + }, + GetPeerGroupsFunc: func(ctx context.Context, accountID, peerID string) ([]*types.Group, error) { + peersID := make([]string, len(peers)) + for _, peer := range peers { + peersID = append(peersID, peer.ID) + } + return []*types.Group{ + { + ID: "group1", + AccountID: accountID, + Name: "group1", + Issued: "api", + Peers: peersID, + }, + }, nil + }, + GetDNSDomainFunc: func(settings *types.Settings) string { + return "netbird.selfhosted" + }, + GetAccountFunc: func(ctx context.Context, accountID string) (*types.Account, error) { + return account, nil + }, + GetAccountByIDFunc: func(ctx context.Context, accountID string, userID string) (*types.Account, error) { + return account, nil + }, + HasConnectedChannelFunc: func(peerID string) bool { + statuses := make(map[string]struct{}) + for _, peer := range peers { + if peer.ID == noUpdateChannelTestPeerID { + break + } + statuses[peer.ID] = struct{}{} + } + _, ok := statuses[peerID] + return ok + }, + GetAccountSettingsFunc: func(ctx context.Context, accountID string, userID string) (*types.Settings, error) { + return account.Settings, nil + }, + }, + networkMapController: networkMapController, + permissionsManager: permissionsManager, + } +} + +// Tests the GetAllPeers endpoint reachable in the route /api/peers +// Use the metadata generated by initTestMetaData() to check for values +func TestGetPeers(t *testing.T) { + + peer := &nbpeer.Peer{ + ID: testPeerID, + Key: "key", + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), + Status: &nbpeer.PeerStatus{Connected: true}, + Name: "PeerName", + LoginExpirationEnabled: false, + Meta: nbpeer.PeerSystemMeta{ + Hostname: "hostname", + GoOS: "GoOS", + Kernel: "kernel", + Core: "core", + Platform: "platform", + OS: "OS", + WtVersion: "development", + SystemSerialNumber: "C02XJ0J0JGH7", + }, + } + + peer1 := peer.Copy() + peer1.ID = noUpdateChannelTestPeerID + + expectedUpdatedPeer := peer.Copy() + expectedUpdatedPeer.LoginExpirationEnabled = true + expectedUpdatedPeer.SSHEnabled = true + expectedUpdatedPeer.Name = "New Name" + + expectedPeer1 := peer1.Copy() + expectedPeer1.Status.Connected = false + + tt := []struct { + name string + expectedStatus int + requestType string + requestPath string + contentType string + requestBody io.Reader + expectedArray bool + expectedPeer *nbpeer.Peer + }{ + { + name: "GetPeersMetaData", + requestType: http.MethodGet, + requestPath: "/peers", + expectedStatus: http.StatusOK, + expectedArray: true, + expectedPeer: peer, + }, + { + name: "GetPeer with update channel", + requestType: http.MethodGet, + requestPath: "/peers/" + testPeerID, + expectedStatus: http.StatusOK, + expectedArray: false, + expectedPeer: peer, + }, + { + name: "PutPeer", + requestType: http.MethodPut, + requestPath: "/peers/" + testPeerID, + contentType: "application/json", + expectedStatus: http.StatusOK, + expectedArray: false, + requestBody: bytes.NewBufferString("{\"login_expiration_enabled\":true,\"name\":\"New Name\",\"ssh_enabled\":true, \"inactivity_expiration_enabled\":true}"), + expectedPeer: expectedUpdatedPeer, + }, + } + + p := initTestMetaData(t, peer, peer1) + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + + recorder := httptest.NewRecorder() + req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: "admin_user", + Domain: "hotmail.com", + AccountId: "test_id", + }) + if tc.contentType != "" { + req.Header.Set("Content-Type", tc.contentType) + } + + v1validator, err := apiv1alpha1.CreateV1ApiValidatingMiddleware() + assert.NoError(t, err) + router := mux.NewRouter() + router.Use(v1validator.Handler) + + router = p.WithEndpointsForRouter(router) + router.ServeHTTP(recorder, req) + + res := recorder.Result() + defer res.Body.Close() + + if status := recorder.Code; status != tc.expectedStatus { + t.Fatalf("handler returned wrong status code: got %v want %v", + status, http.StatusOK) + } + + content, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("I don't know what I expected; %v", err) + } + + var got *apiv1alpha1.Peer + if tc.expectedArray { + respBody := []*apiv1alpha1.Peer{} + err = json.Unmarshal(content, &respBody) + if err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + // hardcode this check for now as we only have two peers in this suite + assert.Equal(t, len(respBody), 2) + + for _, peer := range respBody { + if peer.Id == testPeerID { + got = peer + } + } + + } else { + got = &apiv1alpha1.Peer{} + err = json.Unmarshal(content, got) + if err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + } + + t.Log(got) + + assert.Equal(t, tc.expectedPeer.Name, got.Name) + assert.Equal(t, tc.expectedPeer.Meta.WtVersion, got.Version) + assert.Equal(t, tc.expectedPeer.IP.String(), got.Ip) + assert.Equal(t, "OS core", got.Os) + assert.Equal(t, tc.expectedPeer.LoginExpirationEnabled, got.LoginExpirationEnabled) + assert.Equal(t, tc.expectedPeer.SSHEnabled, got.SshEnabled) + assert.Equal(t, tc.expectedPeer.Status.Connected, got.Connected) + assert.Equal(t, tc.expectedPeer.Meta.SystemSerialNumber, got.SerialNumber) + }) + } +} + +func TestPeersHandlerUpdatePeerIP(t *testing.T) { + testPeer := &nbpeer.Peer{ + ID: testPeerID, + Key: "key", + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), + Status: &nbpeer.PeerStatus{Connected: false, LastSeen: time.Now()}, + Name: "test-host@netbird.io", + LoginExpirationEnabled: false, + UserID: regularUser, + Meta: nbpeer.PeerSystemMeta{ + Hostname: "test-host@netbird.io", + Core: "22.04", + }, + } + + p := initTestMetaData(t, testPeer) + + tt := []struct { + name string + peerID string + requestBody string + callerUserID string + expectedStatus int + expectedIP string + }{ + { + name: "update peer IP successfully", + peerID: testPeerID, + requestBody: `{"name":"test", "ssh_enabled":true, "login_expiration_enabled":true, "inactivity_expiration_enabled":true, "ip": "100.64.0.100"}`, + callerUserID: adminUser, + expectedStatus: http.StatusOK, + expectedIP: "100.64.0.100", + }, + { + name: "update peer IP with invalid IP", + peerID: testPeerID, + requestBody: `{"name":"test", "ssh_enabled":true, "login_expiration_enabled":true, "inactivity_expiration_enabled":true, "ip": "invalid-ip"}`, + callerUserID: adminUser, + expectedStatus: http.StatusUnprocessableEntity, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/peers/%s", tc.peerID), bytes.NewBuffer([]byte(tc.requestBody))) + req.Header.Set("Content-Type", "application/json") + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: tc.callerUserID, + Domain: "hotmail.com", + AccountId: "test_id", + }) + + rr := httptest.NewRecorder() + + v1validator, err := apiv1alpha1.CreateV1ApiValidatingMiddleware() + assert.NoError(t, err) + router := mux.NewRouter() + router.Use(v1validator.Handler) + + router = p.WithEndpointsForRouter(router) + router.ServeHTTP(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + + if tc.expectedStatus == http.StatusOK && tc.expectedIP != "" { + var updatedPeer apiv1alpha1.Peer + err := json.Unmarshal(rr.Body.Bytes(), &updatedPeer) + require.NoError(t, err) + assert.Equal(t, tc.expectedIP, updatedPeer.Ip) + } + }) + } +} diff --git a/management/server/api/v1alpha1/users/users_handler.go b/management/server/api/v1alpha1/users/users_handler.go new file mode 100644 index 000000000..2322e87fd --- /dev/null +++ b/management/server/api/v1alpha1/users/users_handler.go @@ -0,0 +1,275 @@ +package users + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/http/apiv1alpha1" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" + + nbcontext "github.com/netbirdio/netbird/management/server/context" +) + +type Handler struct { + accountManager account.Manager +} + +func NewHandler(accountManager account.Manager) *Handler { + return &Handler{ + accountManager: accountManager, + } +} + +func (h *Handler) WithEndpointsForRouter(router *mux.Router) *mux.Router { + router.HandleFunc("/users", h.getAllUsers).Methods("GET", "OPTIONS") + router.HandleFunc("/users", h.createUser).Methods("POST", "OPTIONS") + router.HandleFunc("/users/{userId}", h.updateUser).Methods("PUT", "OPTIONS") + router.HandleFunc("/users/{userId}", h.deleteUser).Methods("DELETE", "OPTIONS") + return router +} + +// updateUser is a PUT requests to update User data +func (h *Handler) updateUser(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) + return + } + + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + accountID, userID := userAuth.AccountId, userAuth.UserId + vars := mux.Vars(r) + targetUserID := vars["userId"] + if len(targetUserID) == 0 { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid user ID"), w) + return + } + + existingUser, err := h.accountManager.GetUserByID(r.Context(), targetUserID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + req := &apiv1alpha1.UserRequest{} + err = json.NewDecoder(r.Body).Decode(req) + if err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + if req.AutoGroups == nil { + util.WriteErrorResponse("auto_groups field can't be absent", http.StatusBadRequest, w) + return + } + + userRole := types.StrRoleToUserRole(req.Role) + if userRole == types.UserRoleUnknown { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid user role"), w) + return + } + + newUser, err := h.accountManager.SaveUser(r.Context(), accountID, userID, &types.User{ + Id: targetUserID, + Role: userRole, + AutoGroups: req.AutoGroups, + Blocked: req.IsBlocked, + Issued: existingUser.Issued, + IntegrationReference: existingUser.IntegrationReference, + }) + + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, toUserResponse(newUser, userID)) +} + +// deleteUser is a DELETE request to delete a user +func (h *Handler) deleteUser(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) + return + } + + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + accountID, userID := userAuth.AccountId, userAuth.UserId + vars := mux.Vars(r) + targetUserID := vars["userId"] + if len(targetUserID) == 0 { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid user ID"), w) + return + } + + err = h.accountManager.DeleteUser(r.Context(), accountID, userID, targetUserID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} + +// createUser creates a User in the system with a status "invited" (effectively this is a user invite). +func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) + return + } + + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + accountID, userID := userAuth.AccountId, userAuth.UserId + + req := &apiv1alpha1.UserCreateRequest{} + err = json.NewDecoder(r.Body).Decode(req) + if err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + if types.StrRoleToUserRole(req.Role) == types.UserRoleUnknown { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "unknown user role %s", req.Role), w) + return + } + + email := "" + if req.Email != nil { + email = *req.Email + } + + name := "" + if req.Name != nil { + name = *req.Name + } + + newUser, err := h.accountManager.CreateUser(r.Context(), accountID, userID, &types.UserInfo{ + Email: email, + Name: name, + Role: req.Role, + AutoGroups: req.AutoGroups, + IsServiceUser: req.IsServiceUser, + Issued: types.UserIssuedAPI, + }) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, toUserResponse(newUser, userID)) +} + +// getAllUsers returns a list of users of the account this user belongs to. +// It also gathers additional user data (like email and name) from the IDP manager. +func (h *Handler) getAllUsers(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) + return + } + + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + accountID, userID := userAuth.AccountId, userAuth.UserId + data, err := h.accountManager.GetUsersFromAccount(r.Context(), accountID, userID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + serviceUser := r.URL.Query().Get("service_user") + + users := make([]*apiv1alpha1.User, 0) + for _, d := range data { + if d.NonDeletable { + continue + } + if serviceUser == "" { + users = append(users, toUserResponse(d, userID)) + continue + } + + includeServiceUser, err := strconv.ParseBool(serviceUser) + log.WithContext(r.Context()).Tracef("Should include service user: %v", includeServiceUser) + if err != nil { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid service_user query parameter"), w) + return + } + if includeServiceUser == d.IsServiceUser { + users = append(users, toUserResponse(d, userID)) + } + } + + util.WriteJSONObject(r.Context(), w, users) +} + +func toUserResponse(user *types.UserInfo, currenUserID string) *apiv1alpha1.User { + autoGroups := user.AutoGroups + if autoGroups == nil { + autoGroups = []string{} + } + + var userStatus apiv1alpha1.UserStatus + switch user.Status { + case "active": + userStatus = apiv1alpha1.UserStatusActive + case "invited": + userStatus = apiv1alpha1.UserStatusInvited + default: + userStatus = apiv1alpha1.UserStatusBlocked + } + + if user.IsBlocked { + userStatus = apiv1alpha1.UserStatusBlocked + } + + isCurrent := user.ID == currenUserID + + var password *string + if user.Password != "" { + password = &user.Password + } + + var idpID *string + if user.IdPID != "" { + idpID = &user.IdPID + } + + return &apiv1alpha1.User{ + Id: user.ID, + Name: user.Name, + Email: user.Email, + Role: user.Role, + AutoGroups: autoGroups, + Status: userStatus, + IsCurrent: &isCurrent, + IsServiceUser: &user.IsServiceUser, + IsBlocked: user.IsBlocked, + LastLogin: &user.LastLogin, + Issued: &user.Issued, + PendingApproval: user.PendingApproval, + Password: password, + IdpId: idpID, + } +} diff --git a/management/server/api/v1alpha1/users/users_handler_test.go b/management/server/api/v1alpha1/users/users_handler_test.go new file mode 100644 index 000000000..13fc806d5 --- /dev/null +++ b/management/server/api/v1alpha1/users/users_handler_test.go @@ -0,0 +1,452 @@ +package users + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/management/server/mock_server" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/apiv1alpha1" + "github.com/netbirdio/netbird/shared/management/status" +) + +const ( + existingAccountID = "existingAccountID" + notFoundAccountID = "notFoundAccountID" + testDomain = "hotmail.com" + existingUserID = "existingUserID" + notFoundUserID = "notFoundUserID" + serviceUserID = "serviceUserID" + nonDeletableServiceUserID = "nonDeletableServiceUserID" + regularUserID = "regularUserID" +) + +var usersTestAccount = &types.Account{ + Id: existingAccountID, + Domain: testDomain, + Users: map[string]*types.User{ + existingUserID: { + Id: existingUserID, + Role: "admin", + IsServiceUser: false, + AutoGroups: []string{"group_1"}, + Issued: types.UserIssuedAPI, + }, + regularUserID: { + Id: regularUserID, + Role: "user", + IsServiceUser: false, + AutoGroups: []string{"group_1"}, + Issued: types.UserIssuedAPI, + }, + serviceUserID: { + Id: serviceUserID, + Role: "user", + IsServiceUser: true, + AutoGroups: []string{"group_1"}, + Issued: types.UserIssuedAPI, + }, + nonDeletableServiceUserID: { + Id: nonDeletableServiceUserID, + Role: "admin", + IsServiceUser: true, + NonDeletable: true, + Issued: types.UserIssuedIntegration, + }, + }, +} + +func initUsersTestData() *Handler { + return &Handler{ + accountManager: &mock_server.MockAccountManager{ + GetUserByIDFunc: func(ctx context.Context, id string) (*types.User, error) { + return usersTestAccount.Users[id], nil + }, + GetUsersFromAccountFunc: func(_ context.Context, accountID, userID string) (map[string]*types.UserInfo, error) { + usersInfos := make(map[string]*types.UserInfo) + for _, v := range usersTestAccount.Users { + usersInfos[v.Id] = &types.UserInfo{ + ID: v.Id, + Role: string(v.Role), + Name: "", + Email: "", + IsServiceUser: v.IsServiceUser, + NonDeletable: v.NonDeletable, + Issued: v.Issued, + } + } + return usersInfos, nil + }, + CreateUserFunc: func(_ context.Context, accountID, userID string, key *types.UserInfo) (*types.UserInfo, error) { + if userID != existingUserID { + return nil, status.Errorf(status.NotFound, "user with ID %s does not exists", userID) + } + return key, nil + }, + DeleteUserFunc: func(_ context.Context, accountID string, initiatorUserID string, targetUserID string) error { + if targetUserID == notFoundUserID { + return status.Errorf(status.NotFound, "user with ID %s does not exists", targetUserID) + } + if !usersTestAccount.Users[targetUserID].IsServiceUser { + return status.Errorf(status.PermissionDenied, "user with ID %s is not a service user and can not be deleted", targetUserID) + } + return nil + }, + SaveUserFunc: func(_ context.Context, accountID, userID string, update *types.User) (*types.UserInfo, error) { + if update.Id == notFoundUserID { + return nil, status.Errorf(status.NotFound, "user with ID %s does not exists", update.Id) + } + + if userID != existingUserID { + return nil, status.Errorf(status.NotFound, "user with ID %s does not exists", userID) + } + + info, err := update.Copy().ToUserInfo(nil) + if err != nil { + return nil, err + } + return info, nil + }, + }, + } +} + +func TestGetUsers(t *testing.T) { + tt := []struct { + name string + expectedStatus int + requestType string + requestPath string + expectedUserIDs []string + }{ + {name: "getAllUsers", requestType: http.MethodGet, requestPath: "/users", expectedStatus: http.StatusOK, expectedUserIDs: []string{existingUserID, regularUserID, serviceUserID}}, + {name: "GetOnlyServiceUsers", requestType: http.MethodGet, requestPath: "/users?service_user=true", expectedStatus: http.StatusOK, expectedUserIDs: []string{serviceUserID}}, + {name: "GetOnlyRegularUsers", requestType: http.MethodGet, requestPath: "/users?service_user=false", expectedStatus: http.StatusOK, expectedUserIDs: []string{existingUserID, regularUserID}}, + } + + userHandler := initUsersTestData() + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + req := httptest.NewRequest(tc.requestType, tc.requestPath, nil) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: existingUserID, + Domain: testDomain, + AccountId: existingAccountID, + }) + + v1validator, err := apiv1alpha1.CreateV1ApiValidatingMiddleware() + assert.NoError(t, err) + router := mux.NewRouter() + router.Use(v1validator.Handler) + + router = userHandler.WithEndpointsForRouter(router) + router.ServeHTTP(recorder, req) + + res := recorder.Result() + defer res.Body.Close() + + content, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("I don't know what I expected; %v", err) + } + + if status := recorder.Code; status != tc.expectedStatus { + t.Errorf("handler returned wrong status code: got %v want %v, content: %s", + status, tc.expectedStatus, string(content)) + return + } + + respBody := []*types.UserInfo{} + err = json.Unmarshal(content, &respBody) + if err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, len(respBody), len(tc.expectedUserIDs)) + for _, v := range respBody { + assert.Contains(t, tc.expectedUserIDs, v.ID) + assert.Equal(t, v.ID, usersTestAccount.Users[v.ID].Id) + assert.Equal(t, v.Role, string(usersTestAccount.Users[v.ID].Role)) + assert.Equal(t, v.IsServiceUser, usersTestAccount.Users[v.ID].IsServiceUser) + assert.Equal(t, v.Issued, usersTestAccount.Users[v.ID].Issued) + } + }) + } +} + +func TestUpdateUser(t *testing.T) { + tt := []struct { + name string + expectedStatusCode int + requestType string + requestPath string + requestBody io.Reader + expectedUserID string + expectedRole string + expectedStatus string + expectedBlocked bool + expectedIsServiceUser bool + expectedGroups []string + }{ + { + name: "Update_Block_User", + requestType: http.MethodPut, + requestPath: "/users/" + regularUserID, + expectedStatusCode: http.StatusOK, + expectedUserID: regularUserID, + expectedBlocked: true, + expectedRole: "user", + expectedStatus: "blocked", + expectedGroups: []string{"group_1"}, + requestBody: bytes.NewBufferString("{\"role\":\"user\",\"auto_groups\":[\"group_1\"],\"is_service_user\":false, \"is_blocked\": true}"), + }, + { + name: "Update_Change_Role_To_Admin", + requestType: http.MethodPut, + requestPath: "/users/" + regularUserID, + expectedStatusCode: http.StatusOK, + expectedUserID: regularUserID, + expectedBlocked: false, + expectedRole: "admin", + expectedStatus: "blocked", + expectedGroups: []string{"group_1"}, + requestBody: bytes.NewBufferString("{\"role\":\"admin\",\"auto_groups\":[\"group_1\"],\"is_service_user\":false, \"is_blocked\": false}"), + }, + { + name: "Update_Groups", + requestType: http.MethodPut, + requestPath: "/users/" + regularUserID, + expectedStatusCode: http.StatusOK, + expectedUserID: regularUserID, + expectedBlocked: false, + expectedRole: "admin", + expectedStatus: "blocked", + expectedGroups: []string{"group_2", "group_3"}, + requestBody: bytes.NewBufferString("{\"role\":\"admin\",\"auto_groups\":[\"group_3\", \"group_2\"],\"is_service_user\":false, \"is_blocked\": false}"), + }, + { + name: "Should_Fail_Because_AutoGroups_Is_Absent", + requestType: http.MethodPut, + requestPath: "/users/" + regularUserID, + expectedStatusCode: http.StatusUnprocessableEntity, + expectedUserID: regularUserID, + expectedBlocked: false, + expectedRole: "admin", + expectedStatus: "blocked", + expectedGroups: []string{"group_2", "group_3"}, + requestBody: bytes.NewBufferString("{\"role\":\"admin\",\"is_service_user\":false, \"is_blocked\": false}"), + }, + } + + userHandler := initUsersTestData() + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) + req.Header.Set("Content-Type", "application/json") + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: existingUserID, + Domain: testDomain, + AccountId: existingAccountID, + }) + + v1validator, err := apiv1alpha1.CreateV1ApiValidatingMiddleware() + assert.NoError(t, err) + + router := mux.NewRouter() + router.Use(v1validator.Handler) + + router = userHandler.WithEndpointsForRouter(router) + router.ServeHTTP(recorder, req) + + res := recorder.Result() + defer res.Body.Close() + + if status := recorder.Code; status != tc.expectedStatusCode { + t.Fatalf("handler returned wrong status code: got %v want %v", + status, http.StatusOK) + } + + if tc.expectedStatusCode == 200 { + + content, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("I don't know what I expected; %v", err) + } + + respBody := &apiv1alpha1.User{} + err = json.Unmarshal(content, &respBody) + if err != nil { + t.Fatalf("response content is not in correct json format; %v", err) + } + + assert.Equal(t, tc.expectedUserID, respBody.Id) + assert.Equal(t, tc.expectedRole, respBody.Role) + assert.Equal(t, tc.expectedIsServiceUser, *respBody.IsServiceUser) + assert.Equal(t, tc.expectedBlocked, respBody.IsBlocked) + assert.Len(t, respBody.AutoGroups, len(tc.expectedGroups)) + + for _, expectedGroup := range tc.expectedGroups { + exists := false + for _, actualGroup := range respBody.AutoGroups { + if expectedGroup == actualGroup { + exists = true + } + } + assert.True(t, exists, fmt.Sprintf("group %s not found in the response", expectedGroup)) + } + } + }) + } +} + +func TestCreateUser(t *testing.T) { + name := "name" + email := "email" + serviceUserToAdd := apiv1alpha1.UserCreateRequest{ + AutoGroups: []string{}, + Email: nil, + IsServiceUser: true, + Name: &name, + Role: "admin", + } + serviceUserString, err := json.Marshal(serviceUserToAdd) + if err != nil { + t.Fatal(err) + } + + regularUserToAdd := apiv1alpha1.UserCreateRequest{ + AutoGroups: []string{}, + Email: &email, + IsServiceUser: true, + Name: &name, + Role: "admin", + } + regularUserString, err := json.Marshal(regularUserToAdd) + if err != nil { + t.Fatal(err) + } + + tt := []struct { + name string + expectedStatus int + requestType string + requestPath string + requestBody io.Reader + expectedResult []*types.User + }{ + {name: "CreateServiceUser", requestType: http.MethodPost, requestPath: "/users", expectedStatus: http.StatusOK, requestBody: bytes.NewBuffer(serviceUserString)}, + // right now creation is blocked in AC middleware, will be refactored in the future + {name: "CreateRegularUser", requestType: http.MethodPost, requestPath: "/users", expectedStatus: http.StatusOK, requestBody: bytes.NewBuffer(regularUserString)}, + } + + userHandler := initUsersTestData() + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) + rr := httptest.NewRecorder() + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: existingUserID, + Domain: testDomain, + AccountId: existingAccountID, + }) + req.Header.Set("Content-Type", "application/json") + + v1validator, err := apiv1alpha1.CreateV1ApiValidatingMiddleware() + assert.NoError(t, err) + + router := mux.NewRouter() + router.Use(v1validator.Handler) + + router = userHandler.WithEndpointsForRouter(router) + router.ServeHTTP(rr, req) + + res := rr.Result() + defer res.Body.Close() + + if status := rr.Code; status != tc.expectedStatus { + t.Fatalf("handler returned wrong status code: got %v want %v", + status, tc.expectedStatus) + } + }) + } +} + +func TestDeleteUser(t *testing.T) { + tt := []struct { + name string + expectedStatus int + expectedBody bool + requestType string + requestPath string + requestVars map[string]string + requestBody io.Reader + }{ + { + name: "Delete Regular User", + requestType: http.MethodDelete, + requestPath: "/users/" + regularUserID, + requestVars: map[string]string{"userId": regularUserID}, + expectedStatus: http.StatusForbidden, + }, + { + name: "Delete Service User", + requestType: http.MethodDelete, + requestPath: "/users/" + serviceUserID, + requestVars: map[string]string{"userId": serviceUserID}, + expectedStatus: http.StatusOK, + }, + { + name: "Delete Not Existing User", + requestType: http.MethodDelete, + requestPath: "/users/" + notFoundUserID, + requestVars: map[string]string{"userId": notFoundUserID}, + expectedStatus: http.StatusNotFound, + }, + } + + userHandler := initUsersTestData() + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(tc.requestType, tc.requestPath, nil) + req = mux.SetURLVars(req, tc.requestVars) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: existingUserID, + Domain: testDomain, + AccountId: existingAccountID, + }) + rr := httptest.NewRecorder() + + v1validator, err := apiv1alpha1.CreateV1ApiValidatingMiddleware() + assert.NoError(t, err) + + router := mux.NewRouter() + router.Use(v1validator.Handler) + router = userHandler.WithEndpointsForRouter(router) + router.ServeHTTP(rr, req) + + res := rr.Result() + defer res.Body.Close() + + if status := rr.Code; status != tc.expectedStatus { + t.Fatalf("handler returned wrong status code: got %v want %v", + status, tc.expectedStatus) + } + }) + } +} diff --git a/management/server/http/handler.go b/management/server/http/handler.go index 8ebfd6c64..12aaadbb5 100644 --- a/management/server/http/handler.go +++ b/management/server/http/handler.go @@ -7,8 +7,6 @@ import ( "net/netip" "github.com/gorilla/mux" - "github.com/rs/cors" - log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager" @@ -36,7 +34,6 @@ import ( "github.com/netbirdio/netbird/management/server/http/handlers/proxy" - "github.com/netbirdio/netbird/management/server/auth" "github.com/netbirdio/netbird/management/server/geolocation" nbgroups "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/management/server/http/handlers/accounts" @@ -51,18 +48,20 @@ import ( "github.com/netbirdio/netbird/management/server/http/handlers/routes" "github.com/netbirdio/netbird/management/server/http/handlers/setup_keys" "github.com/netbirdio/netbird/management/server/http/handlers/users" - "github.com/netbirdio/netbird/management/server/http/middleware" "github.com/netbirdio/netbird/management/server/http/middleware/bypass" nbinstance "github.com/netbirdio/netbird/management/server/instance" nbnetworks "github.com/netbirdio/netbird/management/server/networks" "github.com/netbirdio/netbird/management/server/networks/resources" "github.com/netbirdio/netbird/management/server/networks/routers" - "github.com/netbirdio/netbird/management/server/telemetry" - "github.com/netbirdio/netbird/shared/ratelimit" ) // NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints. -func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, permissionsManager permissions.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *ratelimit.APIRateLimiter, isValidChildAccount middleware.IsValidChildAccountFunc, agentNetworkManager agentnetwork.Manager, proxyTokenRevocationGuard proxytoken.RevocationGuard) (http.Handler, error) { +func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager account.Manager, networksManager nbnetworks.Manager, + resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, + permissionsManager permissions.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, + networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, + reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, + trustedHTTPProxies []netip.Prefix, agentNetworkManager agentnetwork.Manager, proxyTokenRevocationGuard proxytoken.RevocationGuard) (http.Handler, error) { // Register bypass paths for unauthenticated endpoints if err := bypass.AddBypassPath("/api/instance"); err != nil { @@ -83,28 +82,6 @@ func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager accou return nil, fmt.Errorf("failed to add bypass path: %w", err) } - if rateLimiter == nil { - log.Warn("NewAPIHandler: nil rate limiter, rate limiting disabled") - rateLimiter = ratelimit.NewAPIRateLimiter(nil) - rateLimiter.SetEnabled(false) - } - - authMiddleware := middleware.NewAuthMiddleware( - authManager, - accountManager.GetAccountIDFromUserAuth, - accountManager.SyncUserJWTGroups, - accountManager.GetUserFromUserAuth, - rateLimiter, - appMetrics.GetMeter(), - isValidChildAccount, - ) - - corsMiddleware := cors.AllowAll() - - metricsMiddleware := appMetrics.HTTPMiddleware() - - router.Use(metricsMiddleware.Handler, corsMiddleware.Handler, authMiddleware.Handler) - instanceManager, err := nbinstance.NewManager(ctx, accountManager.GetStore(), idpManager) if err != nil { return nil, fmt.Errorf("failed to create instance manager: %w", err) diff --git a/management/server/http/middleware/middleware_factory.go b/management/server/http/middleware/middleware_factory.go new file mode 100644 index 000000000..3ae617774 --- /dev/null +++ b/management/server/http/middleware/middleware_factory.go @@ -0,0 +1,35 @@ +package middleware + +import ( + "github.com/gorilla/mux" + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/server/auth" + "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/shared/ratelimit" + "github.com/rs/cors" + log "github.com/sirupsen/logrus" +) + +func BuildMiddleware(rateLimiter *ratelimit.APIRateLimiter, authManager auth.Manager, accountManager account.Manager, metrics telemetry.AppMetrics, isValidChildAcctFunc IsValidChildAccountFunc) []mux.MiddlewareFunc { + toret := make([]mux.MiddlewareFunc, 0) + + toret = append(toret, metrics.HTTPMiddleware().Handler) + toret = append(toret, cors.AllowAll().Handler) + + if rateLimiter == nil { + log.Warn("NewAPIHandler: nil rate limiter, rate limiting disabled") + rateLimiter = ratelimit.NewAPIRateLimiter(nil) + rateLimiter.SetEnabled(false) + } + toret = append(toret, NewAuthMiddleware( + authManager, + accountManager.GetAccountIDFromUserAuth, + accountManager.SyncUserJWTGroups, + accountManager.GetUserFromUserAuth, + rateLimiter, + metrics.GetMeter(), + isValidChildAcctFunc, + ).Handler) + + return toret +} diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index 3f2056c32..1b15ba780 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -39,6 +39,7 @@ import ( "github.com/netbirdio/netbird/management/server/geolocation" "github.com/netbirdio/netbird/management/server/groups" http2 "github.com/netbirdio/netbird/management/server/http" + "github.com/netbirdio/netbird/management/server/http/middleware" "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" "github.com/netbirdio/netbird/management/server/networks" "github.com/netbirdio/netbird/management/server/networks/resources" @@ -145,7 +146,10 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager) apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter() - apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil, nil) + apiRouter.Use(middleware.BuildMiddleware(nil, authManagerMock, am, metrics, nil)...) + apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, + groupsManager, geoMock, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, + networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil) if err != nil { t.Fatalf("Failed to create API handler: %v", err) } @@ -284,7 +288,10 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager) apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter() - apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil, nil) + apiRouter.Use(middleware.BuildMiddleware(nil, authManagerMock, am, metrics, nil)...) + apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, + geoMock, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, + networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil) if err != nil { t.Fatalf("Failed to create API handler: %v", err) } diff --git a/shared/management/http/apiv1alpha1/bundle.yaml b/shared/management/http/apiv1alpha1/bundle.yaml new file mode 100644 index 000000000..c8245ab49 --- /dev/null +++ b/shared/management/http/apiv1alpha1/bundle.yaml @@ -0,0 +1,997 @@ +openapi: 3.1.0 +servers: + - url: /api/v1alpha1 + description: Default server +info: + title: NetBird REST API + description: API to manipulate groups, rules, policies and retrieve information about peers and users + version: 1.0.0-alpha1 +tags: + - name: Users + description: Interact with and view information about users. + - name: Tokens + description: Interact with and view information about tokens. + - name: Peers + description: Interact with and view information about peers. + - name: Setup Keys + description: Interact with and view information about setup keys. + - name: Groups + description: Interact with and view information about groups. + - name: Policies + description: Interact with and view information about policies. + - name: Posture Checks + description: Interact with and view information about posture checks. + - name: Routes + description: Interact with and view information about routes. + - name: DNS + description: Interact with and view information about DNS configuration. + - name: DNS Zones + description: Interact with and view information about custom DNS zones. + - name: Events + description: View information about the account and network events. + - name: Accounts + description: View information about the accounts. + - name: Ingress Ports + description: Interact with and view information about the ingress peers and ports. + x-cloud-only: true + - name: Identity Providers + description: Interact with and view information about identity providers. + - name: Services + description: Interact with and view information about reverse proxy services. + - name: Instance + description: Instance setup and status endpoints for initial configuration. + - name: Jobs + description: Interact with and view information about remote jobs. + x-experimental: true + - name: Usage + description: Retrieve current usage statistics for the account. + x-cloud-only: true + - name: Subscription + description: Manage and view information about account subscriptions. + x-cloud-only: true + - name: Plans + description: Retrieve available plans and products. + x-cloud-only: true + - name: Checkout + description: Manage checkout sessions for plan subscriptions. + x-cloud-only: true + - name: AWS Marketplace + description: Manage AWS Marketplace subscriptions. + x-cloud-only: true + - name: Portal + description: Access customer portal for subscription management. + x-cloud-only: true + - name: Invoice + description: Manage and retrieve account invoices. + x-cloud-only: true + - name: MSP + description: MSP portal for Tenant management. + x-cloud-only: true + - name: IDP SCIM Integrations + description: Manage generic SCIM identity provider integrations for user and group sync. + x-cloud-only: true + - name: IDP Google Integrations + description: Manage Google Workspace identity provider integrations for user and group sync. + x-cloud-only: true + - name: IDP Azure Integrations + description: Manage Azure AD identity provider integrations for user and group sync. + x-cloud-only: true + - name: IDP Okta SCIM Integrations + description: Manage Okta SCIM identity provider integrations for user and group sync. + x-cloud-only: true + - name: EDR Intune Integrations + description: Manage Microsoft Intune EDR integrations. + x-cloud-only: true + - name: EDR SentinelOne Integrations + description: Manage SentinelOne EDR integrations. + x-cloud-only: true + - name: EDR Falcon Integrations + description: Manage CrowdStrike Falcon EDR integrations. + x-cloud-only: true + - name: EDR Huntress Integrations + description: Manage Huntress EDR integrations. + x-cloud-only: true + - name: EDR FleetDM Integrations + description: Manage FleetDM EDR integrations. + x-cloud-only: true + - name: EDR Peers + description: Manage EDR compliance bypass for peers. + x-cloud-only: true + - name: Event Streaming Integrations + description: Manage event streaming integrations. + x-cloud-only: true + - name: Notifications + description: Manage notification channels for account event alerts. + x-cloud-only: true +components: + schemas: + CountryCode: + description: 2-letter ISO 3166-1 alpha-2 code that represents the country + type: string + example: "DE" + CityName: + description: Commonly used English name of the city + type: string + example: "Berlin" + Country: + description: Describe country geographical location information + type: object + properties: + country_name: + description: Commonly used English name of the country + type: string + example: "Germany" + country_code: + $ref: '#/components/schemas/CountryCode' + required: + - country_name + - country_code + City: + description: Describe city geographical location information + type: object + properties: + geoname_id: + description: Integer ID of the record in GeoNames database + type: integer + example: 2950158 + city_name: + description: Commonly used English name of the city + type: string + example: "Berlin" + required: + - geoname_id + - city_name + ErrorResponse: + type: object + description: Standard error response + properties: + message: + type: string + description: A human-readable error message. + example: "couldn't parse JSON request" + PeerBatch: + allOf: + - $ref: '#/components/schemas/Peer' + - type: object + properties: + created_at: + description: Peer creation date (UTC) + type: string + format: date-time + example: "2023-05-05T09:00:35.477782Z" + accessible_peers_count: + description: Number of accessible peers + type: integer + example: 5 + required: + - created_at + - accessible_peers_count + PeerMinimum: + type: object + properties: + id: + description: Peer ID + type: string + example: chacbco6lnnbn6cg5s90 + name: + description: Peer's hostname + type: string + example: stage-host-1 + required: + - id + - name + GroupMinimum: + type: object + properties: + id: + description: Group ID + type: string + example: ch8i4ug6lnn4g9hqv7m0 + name: + description: Group Name identifier + type: string + example: devs + peers_count: + description: Count of peers associated to the group + type: integer + example: 2 + resources_count: + description: Count of resources associated to the group + type: integer + example: 5 + issued: + description: How the group was issued (api, integration, jwt) + type: string + enum: + - "api" + - "integration" + - "jwt" + example: api + required: + - id + - name + - peers_count + - resources_count + PeerLocalFlags: + type: object + properties: + rosenpass_enabled: + description: Indicates whether Rosenpass is enabled on this peer + type: boolean + example: true + rosenpass_permissive: + description: Indicates whether Rosenpass is in permissive mode or not + type: boolean + example: false + server_ssh_allowed: + description: Indicates whether SSH access this peer is allowed or not + type: boolean + example: true + remote_jobs_allowed: + description: Indicates whether the peer has opted into management-requested remote jobs (e.g. debug bundles) + type: boolean + example: true + disable_client_routes: + description: Indicates whether client routes are disabled on this peer or not + type: boolean + example: false + disable_server_routes: + description: Indicates whether server routes are disabled on this peer or not + type: boolean + example: false + disable_dns: + description: Indicates whether DNS management is disabled on this peer or not + type: boolean + example: false + disable_firewall: + description: Indicates whether firewall management is disabled on this peer or not + type: boolean + example: false + block_lan_access: + description: Indicates whether LAN access is blocked on this peer when used as a routing peer + type: boolean + example: false + block_inbound: + description: Indicates whether inbound traffic is blocked on this peer + type: boolean + example: false + lazy_connection_enabled: + description: Indicates whether lazy connection is enabled on this peer + type: boolean + example: false + Peer: + allOf: + - $ref: '#/components/schemas/PeerMinimum' + - type: object + properties: + created_at: + description: Peer creation date (UTC) + type: string + format: date-time + example: "2023-05-05T09:00:35.477782Z" + ip: + description: Peer's IP address + type: string + example: 10.64.0.1 + ipv6: + description: Peer's IPv6 overlay address + type: string + format: ipv6 + example: "fd00:4e42:ab12::1" + connection_ip: + description: Peer's public connection IP address + type: string + example: 35.64.0.1 + connected: + description: Peer to Management connection status + type: boolean + example: true + last_seen: + description: Last time peer connected to Netbird's management service + type: string + format: date-time + example: "2023-05-05T10:05:26.420578Z" + os: + description: Peer's operating system and version + type: string + example: Darwin 13.2.1 + kernel_version: + description: Peer's operating system kernel version + type: string + example: 23.2.0 + geoname_id: + description: Unique identifier from the GeoNames database for a specific geographical location. + type: integer + example: 2643743 + version: + description: Peer's daemon or cli version + type: string + example: 0.14.0 + groups: + description: Groups that the peer belongs to + type: array + items: + $ref: '#/components/schemas/GroupMinimum' + ssh_enabled: + description: Indicates whether SSH server is enabled on this peer + type: boolean + example: true + user_id: + description: User ID of the user that enrolled this peer + type: string + example: google-oauth2|277474792786460067937 + hostname: + description: Hostname of the machine + type: string + example: stage-host-1 + ui_version: + description: Peer's desktop UI version + type: string + example: 0.14.0 + dns_label: + description: Peer's DNS label is the parsed peer name for domain resolution. It is used to form an FQDN by appending the account's domain to the peer label. e.g. peer-dns-label.netbird.cloud + type: string + example: stage-host-1.netbird.cloud + login_expiration_enabled: + description: Indicates whether peer login expiration has been enabled or not + type: boolean + example: false + login_expired: + description: Indicates whether peer's login expired or not + type: boolean + example: false + last_login: + description: Last time this peer performed log in (authentication). E.g., user authenticated. + type: string + format: date-time + example: "2023-05-05T09:00:35.477782Z" + inactivity_expiration_enabled: + description: Indicates whether peer inactivity expiration has been enabled or not + type: boolean + example: false + approval_required: + description: (Cloud only) Indicates whether peer needs approval + type: boolean + example: true + disapproval_reason: + description: (Cloud only) Reason why the peer requires approval + type: string + country_code: + $ref: '#/components/schemas/CountryCode' + city_name: + $ref: '#/components/schemas/CityName' + serial_number: + description: System serial number + type: string + example: "C02XJ0J0JGH7" + extra_dns_labels: + description: Extra DNS labels added to the peer + type: array + items: + type: string + example: "stage-host-1" + ephemeral: + description: Indicates whether the peer is ephemeral or not + type: boolean + example: false + local_flags: + $ref: '#/components/schemas/PeerLocalFlags' + required: + - city_name + - connected + - connection_ip + - country_code + - created_at + - dns_label + - geoname_id + - groups + - hostname + - ip + - kernel_version + - last_login + - last_seen + - login_expiration_enabled + - login_expired + - inactivity_expiration_enabled + - os + - ssh_enabled + - user_id + - version + - ui_version + - approval_required + - serial_number + - extra_dns_labels + - ephemeral + PeerRequest: + type: object + properties: + name: + type: string + example: stage-host-1 + ssh_enabled: + type: boolean + example: true + login_expiration_enabled: + type: boolean + example: false + inactivity_expiration_enabled: + type: boolean + example: false + approval_required: + description: (Cloud only) Indicates whether peer needs approval + type: boolean + example: true + ip: + description: Peer's IP address + type: string + format: ipv4 + example: 100.64.0.15 + ipv6: + description: Peer's IPv6 overlay address. Omitted if IPv6 is not enabled for the account. + type: string + format: ipv6 + example: "fd00:4e42:ab12::1" + required: + - name + - ssh_enabled + - login_expiration_enabled + - inactivity_expiration_enabled + User: + type: object + properties: + id: + description: User ID + type: string + example: google-oauth2|277474792786460067937 + email: + description: User's email address + type: string + example: demo@netbird.io + password: + description: User's password. Only present when user is created (create user endpoint is called) and only when IdP supports user creation with password. + type: string + example: super_secure_password + name: + description: User's name from idp provider + type: string + example: Tom Schulz + role: + description: User's NetBird account role + type: string + example: admin + status: + description: User's status + type: string + enum: + - "active" + - "invited" + - "blocked" + example: active + last_login: + description: Last time this user performed a login to the dashboard + type: string + format: date-time + example: "2023-05-05T09:00:35.477782Z" + auto_groups: + description: Group IDs to auto-assign to peers registered by this user + type: array + items: + type: string + example: ch8i4ug6lnn4g9hqv7m0 + is_current: + description: Is true if authenticated user is the same as this user + type: boolean + readOnly: true + example: true + is_service_user: + description: Is true if this user is a service user + type: boolean + readOnly: true + example: false + is_blocked: + description: Is true if this user is blocked. Blocked users can't use the system + type: boolean + example: false + pending_approval: + description: Is true if this user requires approval before being activated. Only applicable for users joining via domain matching when user_approval_required is enabled. + type: boolean + example: false + issued: + description: How user was issued by API or Integration + type: string + example: api + idp_id: + description: Identity provider ID (connector ID) that the user authenticated with. Only populated for users with Dex-encoded user IDs. + type: string + example: okta-abc123 + permissions: + $ref: '#/components/schemas/UserPermissions' + required: + - id + - email + - name + - role + - auto_groups + - status + - is_blocked + - pending_approval + UserCreateRequest: + type: object + properties: + email: + description: User's Email to send invite to + type: string + example: demo@netbird.io + name: + description: User's full name + type: string + example: Tom Schulz + role: + description: User's NetBird account role + type: string + example: admin + auto_groups: + description: Group IDs to auto-assign to peers registered by this user + type: array + items: + type: string + example: ch8i4ug6lnn4g9hqv7m0 + is_service_user: + description: Is true if this user is a service user + type: boolean + example: false + required: + - role + - auto_groups + - is_service_user + UserRequest: + type: object + properties: + role: + description: User's NetBird account role + type: string + example: admin + auto_groups: + description: Group IDs to auto-assign to peers registered by this user + type: array + items: + type: string + example: ch8i4ug6lnn4g9hqv7m0 + is_blocked: + description: If set to true then user is blocked and can't use the system + type: boolean + example: false + required: + - role + - auto_groups + - is_blocked + UserPermissions: + type: object + properties: + is_restricted: + type: boolean + description: Indicates whether this User's Peers view is restricted + modules: + type: object + additionalProperties: + type: object + additionalProperties: + type: boolean + propertyNames: + type: string + description: The operation type + propertyNames: + type: string + description: The module name + example: {"networks": {"read": true, "create": false, "update": false, "delete": false}, "peers": {"read": false, "create": false, "update": false, "delete": false}} + required: + - modules + - is_restricted + responses: + not_found: + description: Resource not found + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' + content: {} + validation_failed_simple: + description: Validation failed + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' + content: {} + bad_request: + description: Bad Request + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' + content: {} + unprocessable: + description: Unprocessable Entity + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' + content: + application/json: + schema: + type: object + properties: + message: + type: string + code: + type: integer + required: + - message + - code + internal_error: + description: Internal Server Error + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' + content: {} + validation_failed: + description: Validation failed + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' + content: {} + forbidden: + description: Forbidden + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' + content: {} + requires_authentication: + description: Requires authentication + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' + content: {} + conflict: + description: Conflict + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + headers: + X-Request-Id: + description: | + Unique identifier assigned to the request by the server and set on every + response. Useful for correlating client requests with server-side logs. + schema: + type: string + example: cot7r4n3l3vh3qj4qveg + example: cot7r4n3l3vh3qj4qveg + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + TokenAuth: + type: apiKey + in: header + name: Authorization + description: >- + Enter the token with the `Token` prefix, e.g. "Token nbp_F3f0d.....". + pathItems: + peers: + get: + summary: List all Peers + description: Returns a list of all peers + tags: + - Peers + parameters: + - name: page + in: query + description: Page number + required: false + schema: + type: integer + minimum: 1 + default: 1 + - name: page_size + in: query + description: Number of peers per page + required: false + schema: + type: integer + minimum: 1 + maximum: 250 + default: 100 + - name: connected + in: query + description: Filter by peer connected status + required: false + schema: + type: boolean + - name: approval_required + in: query + description: Filter by approval_required field + required: false + schema: + type: boolean + - name: os + in: query + description: Peer os + required: false + schema: + type: array + items: + type: string + enum: + - linux + - windows + - mac + - android + - ios + - js + - name: search + in: query + description: filter peers by name, dns_label, ip, os, version, serial_number, owner name, owner email, group names, mac + required: false + schema: + type: string + security: + - BearerAuth: [] + - TokenAuth: [] + responses: + '200': + description: A JSON Array of Peers + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PeerBatch' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '422': + "$ref": "#/components/responses/unprocessable" + '500': + "$ref": "#/components/responses/internal_error" + peer: + get: + summary: Retrieve a Peer + description: Get information about a peer + tags: + - Peers + security: + - BearerAuth: [] + - TokenAuth: [] + parameters: + - in: path + name: peerId + required: true + schema: + type: string + description: The unique identifier of a peer + responses: + '200': + description: A Peer object + content: + application/json: + schema: + $ref: '#/components/schemas/Peer' + '400': + "$ref": '#/components/responses/bad_request' + '401': + "$ref": '#/components/responses/requires_authentication' + '403': + "$ref": '#/components/responses/forbidden' + '500': + "$ref": '#/components/responses/internal_error' + put: + summary: Update a Peer + description: Update information about a peer + tags: + - Peers + security: + - BearerAuth: [] + - TokenAuth: [] + parameters: + - in: path + name: peerId + required: true + schema: + type: string + description: The unique identifier of a peer + requestBody: + description: update a peer + required: true + content: + 'application/json': + schema: + $ref: '#/components/schemas/PeerRequest' + responses: + '200': + description: A Peer object + content: + application/json: + schema: + $ref: '#/components/schemas/Peer' + '400': + "$ref": '#/components/responses/bad_request' + '401': + "$ref": '#/components/responses/requires_authentication' + '403': + "$ref": '#/components/responses/forbidden' + '422': + "$ref": "#/components/responses/unprocessable" + '500': + "$ref": '#/components/responses/internal_error' + delete: + summary: Delete a Peer + description: Delete a peer + tags: + - Peers + security: + - BearerAuth: [] + - TokenAuth: [] + parameters: + - in: path + name: peerId + required: true + schema: + type: string + description: The unique identifier of a peer + responses: + '200': + description: Delete status code + content: {} + '400': + "$ref": '#/components/responses/bad_request' + '401': + "$ref": '#/components/responses/requires_authentication' + '403': + "$ref": '#/components/responses/forbidden' + '500': + "$ref": '#/components/responses/internal_error' + UsersPath: + get: + summary: List all Users + description: Returns a list of all users + tags: + - Users + security: + - BearerAuth: [] + - TokenAuth: [] + parameters: + - in: query + name: service_user + schema: + type: boolean + description: Filters users and returns either regular users or service users + responses: + '200': + description: A JSON array of Users + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '422': + "$ref": "#/components/responses/unprocessable" + '500': + "$ref": "#/components/responses/internal_error" + post: + summary: Create a User + description: Creates a new service user or sends an invite to a regular user + tags: + - Users + security: + - BearerAuth: [] + - TokenAuth: [] + requestBody: + description: User invite information + required: true + content: + 'application/json': + schema: + $ref: '#/components/schemas/UserCreateRequest' + responses: + '200': + description: A User object + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '422': + "$ref": "#/components/responses/unprocessable" + '500': + "$ref": "#/components/responses/internal_error" + UserPath: + put: + summary: Update a User + description: Update information about a User + tags: + - Users + security: + - BearerAuth: [] + - TokenAuth: [] + parameters: + - in: path + name: userId + required: true + schema: + type: string + description: The unique identifier of a user + requestBody: + description: User update + required: true + content: + 'application/json': + schema: + $ref: '#/components/schemas/UserRequest' + responses: + '200': + description: A User object + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '422': + "$ref": "#/components/responses/unprocessable" + '500': + "$ref": "#/components/responses/internal_error" + delete: + summary: Delete a User + description: This method removes a user from accessing the system. For this leaves the IDP user intact unless the `--user-delete-from-idp` is passed to management startup. + tags: + - Users + security: + - BearerAuth: [] + - TokenAuth: [] + parameters: + - in: path + name: userId + required: true + schema: + type: string + description: The unique identifier of a user + responses: + '200': + description: Delete status code + content: {} + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" +paths: + /peers: + $ref: '#/components/pathItems/peers' + /peers/{peerId}: + $ref: '#/components/pathItems/peer' + /users: + $ref: '#/components/pathItems/UsersPath' + /users/{userId}: + $ref: '#/components/pathItems/UserPath' diff --git a/shared/management/http/apiv1alpha1/group/components.yaml b/shared/management/http/apiv1alpha1/group/components.yaml new file mode 100644 index 000000000..6817cd8c0 --- /dev/null +++ b/shared/management/http/apiv1alpha1/group/components.yaml @@ -0,0 +1,31 @@ +components: + schemas: + GroupMinimum: + type: object + properties: + id: + description: Group ID + type: string + example: ch8i4ug6lnn4g9hqv7m0 + name: + description: Group Name identifier + type: string + example: devs + peers_count: + description: Count of peers associated to the group + type: integer + example: 2 + resources_count: + description: Count of resources associated to the group + type: integer + example: 5 + issued: + description: How the group was issued (api, integration, jwt) + type: string + enum: [ "api", "integration", "jwt" ] + example: api + required: + - id + - name + - peers_count + - resources_count \ No newline at end of file diff --git a/shared/management/http/apiv1alpha1/openapi.yaml b/shared/management/http/apiv1alpha1/openapi.yaml new file mode 100644 index 000000000..2abd1709d --- /dev/null +++ b/shared/management/http/apiv1alpha1/openapi.yaml @@ -0,0 +1,250 @@ +openapi: 3.1.0 +servers: + - url: /api/v1alpha1 + description: Default server +info: + title: NetBird REST API + description: API to manipulate groups, rules, policies and retrieve information about peers and users + version: 1.0.0-alpha1 +tags: + - name: Users + description: Interact with and view information about users. + - name: Tokens + description: Interact with and view information about tokens. + - name: Peers + description: Interact with and view information about peers. + - name: Setup Keys + description: Interact with and view information about setup keys. + - name: Groups + description: Interact with and view information about groups. + - name: Policies + description: Interact with and view information about policies. + - name: Posture Checks + description: Interact with and view information about posture checks. + - name: Routes + description: Interact with and view information about routes. + - name: DNS + description: Interact with and view information about DNS configuration. + - name: DNS Zones + description: Interact with and view information about custom DNS zones. + - name: Events + description: View information about the account and network events. + - name: Accounts + description: View information about the accounts. + - name: Ingress Ports + description: Interact with and view information about the ingress peers and ports. + x-cloud-only: true + - name: Identity Providers + description: Interact with and view information about identity providers. + - name: Services + description: Interact with and view information about reverse proxy services. + - name: Instance + description: Instance setup and status endpoints for initial configuration. + - name: Jobs + description: Interact with and view information about remote jobs. + x-experimental: true + + - name: Usage + description: Retrieve current usage statistics for the account. + x-cloud-only: true + - name: Subscription + description: Manage and view information about account subscriptions. + x-cloud-only: true + - name: Plans + description: Retrieve available plans and products. + x-cloud-only: true + - name: Checkout + description: Manage checkout sessions for plan subscriptions. + x-cloud-only: true + - name: AWS Marketplace + description: Manage AWS Marketplace subscriptions. + x-cloud-only: true + - name: Portal + description: Access customer portal for subscription management. + x-cloud-only: true + - name: Invoice + description: Manage and retrieve account invoices. + x-cloud-only: true + - name: MSP + description: MSP portal for Tenant management. + x-cloud-only: true + - name: IDP SCIM Integrations + description: Manage generic SCIM identity provider integrations for user and group sync. + x-cloud-only: true + - name: IDP Google Integrations + description: Manage Google Workspace identity provider integrations for user and group sync. + x-cloud-only: true + - name: IDP Azure Integrations + description: Manage Azure AD identity provider integrations for user and group sync. + x-cloud-only: true + - name: IDP Okta SCIM Integrations + description: Manage Okta SCIM identity provider integrations for user and group sync. + x-cloud-only: true + - name: EDR Intune Integrations + description: Manage Microsoft Intune EDR integrations. + x-cloud-only: true + - name: EDR SentinelOne Integrations + description: Manage SentinelOne EDR integrations. + x-cloud-only: true + - name: EDR Falcon Integrations + description: Manage CrowdStrike Falcon EDR integrations. + x-cloud-only: true + - name: EDR Huntress Integrations + description: Manage Huntress EDR integrations. + x-cloud-only: true + - name: EDR FleetDM Integrations + description: Manage FleetDM EDR integrations. + x-cloud-only: true + - name: EDR Peers + description: Manage EDR compliance bypass for peers. + x-cloud-only: true + - name: Event Streaming Integrations + description: Manage event streaming integrations. + x-cloud-only: true + - name: Notifications + description: Manage notification channels for account event alerts. + x-cloud-only: true + +components: + schemas: + CountryCode: + description: 2-letter ISO 3166-1 alpha-2 code that represents the country + type: string + example: "DE" + CityName: + description: Commonly used English name of the city + type: string + example: "Berlin" + Country: + description: Describe country geographical location information + type: object + properties: + country_name: + description: Commonly used English name of the country + type: string + example: "Germany" + country_code: + $ref: '#/components/schemas/CountryCode' + required: + - country_name + - country_code + City: + description: Describe city geographical location information + type: object + properties: + geoname_id: + description: Integer ID of the record in GeoNames database + type: integer + example: 2950158 + city_name: + description: Commonly used English name of the city + type: string + example: "Berlin" + required: + - geoname_id + - city_name + ErrorResponse: + type: object + description: Standard error response + properties: + message: + type: string + description: A human-readable error message. + example: "couldn't parse JSON request" + responses: + not_found: + description: Resource not found + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' + content: { } + validation_failed_simple: + description: Validation failed + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' + content: { } + bad_request: + description: Bad Request + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' + content: { } + unprocessable: + description: Unprocessable Entity + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' + content: + application/json: + schema: + type: object + properties: + message: + type: string + code: + type: integer + required: + - message + - code + internal_error: + description: Internal Server Error + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' + content: { } + validation_failed: + description: Validation failed + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' + content: { } + forbidden: + description: Forbidden + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' + content: { } + requires_authentication: + description: Requires authentication + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' + content: { } + conflict: + description: Conflict + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + headers: + X-Request-Id: + description: | + Unique identifier assigned to the request by the server and set on every + response. Useful for correlating client requests with server-side logs. + schema: + type: string + example: cot7r4n3l3vh3qj4qveg + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + TokenAuth: + type: apiKey + in: header + name: Authorization + description: >- + Enter the token with the `Token` prefix, e.g. "Token nbp_F3f0d.....". +paths: + /peers: + $ref: './peer/peers.yaml' + /peers/{peerId}: + $ref: './peer/peer.yaml' + /users: + $ref: './user/user_paths.yaml#/UsersPath' + /users/{userId}: + $ref: './user/user_paths.yaml#/UserPath' diff --git a/shared/management/http/apiv1alpha1/peer/components.yaml b/shared/management/http/apiv1alpha1/peer/components.yaml new file mode 100644 index 000000000..e2a7b9240 --- /dev/null +++ b/shared/management/http/apiv1alpha1/peer/components.yaml @@ -0,0 +1,257 @@ +components: + schemas: + PeerRequest: + type: object + properties: + name: + type: string + example: stage-host-1 + ssh_enabled: + type: boolean + example: true + login_expiration_enabled: + type: boolean + example: false + inactivity_expiration_enabled: + type: boolean + example: false + approval_required: + description: (Cloud only) Indicates whether peer needs approval + type: boolean + example: true + ip: + description: Peer's IP address + type: string + format: ipv4 + example: 100.64.0.15 + ipv6: + description: Peer's IPv6 overlay address. Omitted if IPv6 is not enabled for the account. + type: string + format: ipv6 + example: "fd00:4e42:ab12::1" + required: + - name + - ssh_enabled + - login_expiration_enabled + - inactivity_expiration_enabled + PeerMinimum: + type: object + properties: + id: + description: Peer ID + type: string + example: chacbco6lnnbn6cg5s90 + name: + description: Peer's hostname + type: string + example: stage-host-1 + required: + - id + - name + Peer: + allOf: + - $ref: '#/components/schemas/PeerMinimum' + - type: object + properties: + created_at: + description: Peer creation date (UTC) + type: string + format: date-time + example: "2023-05-05T09:00:35.477782Z" + ip: + description: Peer's IP address + type: string + example: 10.64.0.1 + ipv6: + description: Peer's IPv6 overlay address + type: string + format: ipv6 + example: "fd00:4e42:ab12::1" + connection_ip: + description: Peer's public connection IP address + type: string + example: 35.64.0.1 + connected: + description: Peer to Management connection status + type: boolean + example: true + last_seen: + description: Last time peer connected to Netbird's management service + type: string + format: date-time + example: "2023-05-05T10:05:26.420578Z" + os: + description: Peer's operating system and version + type: string + example: Darwin 13.2.1 + kernel_version: + description: Peer's operating system kernel version + type: string + example: 23.2.0 + geoname_id: + description: Unique identifier from the GeoNames database for a specific geographical location. + type: integer + example: 2643743 + version: + description: Peer's daemon or cli version + type: string + example: 0.14.0 + groups: + description: Groups that the peer belongs to + type: array + items: + $ref: '../group/components.yaml#/components/schemas/GroupMinimum' + ssh_enabled: + description: Indicates whether SSH server is enabled on this peer + type: boolean + example: true + user_id: + description: User ID of the user that enrolled this peer + type: string + example: google-oauth2|277474792786460067937 + hostname: + description: Hostname of the machine + type: string + example: stage-host-1 + ui_version: + description: Peer's desktop UI version + type: string + example: 0.14.0 + dns_label: + description: Peer's DNS label is the parsed peer name for domain resolution. It is used to form an FQDN by appending the account's domain to the peer label. e.g. peer-dns-label.netbird.cloud + type: string + example: stage-host-1.netbird.cloud + login_expiration_enabled: + description: Indicates whether peer login expiration has been enabled or not + type: boolean + example: false + login_expired: + description: Indicates whether peer's login expired or not + type: boolean + example: false + last_login: + description: Last time this peer performed log in (authentication). E.g., user authenticated. + type: string + format: date-time + example: "2023-05-05T09:00:35.477782Z" + inactivity_expiration_enabled: + description: Indicates whether peer inactivity expiration has been enabled or not + type: boolean + example: false + approval_required: + description: (Cloud only) Indicates whether peer needs approval + type: boolean + example: true + disapproval_reason: + description: (Cloud only) Reason why the peer requires approval + type: string + country_code: + $ref: '../openapi.yaml#/components/schemas/CountryCode' + city_name: + $ref: '../openapi.yaml#/components/schemas/CityName' + serial_number: + description: System serial number + type: string + example: "C02XJ0J0JGH7" + extra_dns_labels: + description: Extra DNS labels added to the peer + type: array + items: + type: string + example: "stage-host-1" + ephemeral: + description: Indicates whether the peer is ephemeral or not + type: boolean + example: false + local_flags: + $ref: '#/components/schemas/PeerLocalFlags' + required: + - city_name + - connected + - connection_ip + - country_code + - created_at + - dns_label + - geoname_id + - groups + - hostname + - ip + - kernel_version + - last_login + - last_seen + - login_expiration_enabled + - login_expired + - inactivity_expiration_enabled + - os + - ssh_enabled + - user_id + - version + - ui_version + - approval_required + - serial_number + - extra_dns_labels + - ephemeral + PeerLocalFlags: + type: object + properties: + rosenpass_enabled: + description: Indicates whether Rosenpass is enabled on this peer + type: boolean + example: true + rosenpass_permissive: + description: Indicates whether Rosenpass is in permissive mode or not + type: boolean + example: false + server_ssh_allowed: + description: Indicates whether SSH access this peer is allowed or not + type: boolean + example: true + remote_jobs_allowed: + description: Indicates whether the peer has opted into management-requested remote jobs (e.g. debug bundles) + type: boolean + example: true + disable_client_routes: + description: Indicates whether client routes are disabled on this peer or not + type: boolean + example: false + disable_server_routes: + description: Indicates whether server routes are disabled on this peer or not + type: boolean + example: false + disable_dns: + description: Indicates whether DNS management is disabled on this peer or not + type: boolean + example: false + disable_firewall: + description: Indicates whether firewall management is disabled on this peer or not + type: boolean + example: false + block_lan_access: + description: Indicates whether LAN access is blocked on this peer when used as a routing peer + type: boolean + example: false + block_inbound: + description: Indicates whether inbound traffic is blocked on this peer + type: boolean + example: false + lazy_connection_enabled: + description: Indicates whether lazy connection is enabled on this peer + type: boolean + example: false + PeerBatch: + allOf: + - $ref: '#/components/schemas/Peer' + - type: object + properties: + created_at: + description: Peer creation date (UTC) + type: string + format: date-time + example: "2023-05-05T09:00:35.477782Z" + accessible_peers_count: + description: Number of accessible peers + type: integer + example: 5 + required: + - created_at + - accessible_peers_count \ No newline at end of file diff --git a/shared/management/http/apiv1alpha1/peer/peer.yaml b/shared/management/http/apiv1alpha1/peer/peer.yaml new file mode 100644 index 000000000..7c7c1a85d --- /dev/null +++ b/shared/management/http/apiv1alpha1/peer/peer.yaml @@ -0,0 +1,93 @@ +get: + summary: Retrieve a Peer + description: Get information about a peer + tags: [ Peers ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: peerId + required: true + schema: + type: string + description: The unique identifier of a peer + responses: + '200': + description: A Peer object + content: + application/json: + schema: + $ref: './components.yaml#/components/schemas/Peer' + '400': + "$ref": '../openapi.yaml#/components/responses/bad_request' + '401': + "$ref": '../openapi.yaml#/components/responses/requires_authentication' + '403': + "$ref": '../openapi.yaml#/components/responses/forbidden' + '500': + "$ref": '../openapi.yaml#/components/responses/internal_error' +put: + summary: Update a Peer + description: Update information about a peer + tags: [ Peers ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: peerId + required: true + schema: + type: string + description: The unique identifier of a peer + requestBody: + description: update a peer + required: true + content: + 'application/json': + schema: + $ref: './components.yaml#/components/schemas/PeerRequest' + responses: + '200': + description: A Peer object + content: + application/json: + schema: + $ref: './components.yaml#/components/schemas/Peer' + '400': + "$ref": '../openapi.yaml#/components/responses/bad_request' + '401': + "$ref": '../openapi.yaml#/components/responses/requires_authentication' + '403': + "$ref": '../openapi.yaml#/components/responses/forbidden' + '422': + "$ref": "../openapi.yaml#/components/responses/unprocessable" + '500': + "$ref": '../openapi.yaml#/components/responses/internal_error' +delete: + summary: Delete a Peer + description: Delete a peer + tags: [ Peers ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: peerId + required: true + schema: + type: string + description: The unique identifier of a peer + responses: + '200': + description: Delete status code + content: { } + '400': + "$ref": '../openapi.yaml#/components/responses/bad_request' + '401': + "$ref": '../openapi.yaml#/components/responses/requires_authentication' + '403': + "$ref": '../openapi.yaml#/components/responses/forbidden' + '500': + "$ref": '../openapi.yaml#/components/responses/internal_error' diff --git a/shared/management/http/apiv1alpha1/peer/peers.yaml b/shared/management/http/apiv1alpha1/peer/peers.yaml new file mode 100644 index 000000000..c360018fc --- /dev/null +++ b/shared/management/http/apiv1alpha1/peer/peers.yaml @@ -0,0 +1,77 @@ +get: + summary: List all Peers + description: Returns a list of all peers + tags: [ Peers ] + parameters: + - name: page + in: query + description: Page number + required: false + schema: + type: integer + minimum: 1 + default: 1 + - name: page_size + in: query + description: Number of peers per page + required: false + schema: + type: integer + minimum: 1 + maximum: 250 + default: 100 + - name: connected + in: query + description: Filter by peer connected status + required: false + schema: + type: boolean + - name: approval_required + in: query + description: Filter by approval_required field + required: false + schema: + type: boolean + - name: os + in: query + description: Peer os + required: false + schema: + type: array + items: + type: string + enum: + - linux + - windows + - mac + - android + - ios + - js + - name: search + in: query + description: filter peers by name, dns_label, ip, os, version, serial_number, owner name, owner email, group names, mac + required: false + schema: + type: string + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + responses: + '200': + description: A JSON Array of Peers + content: + application/json: + schema: + type: array + items: + $ref: './components.yaml#/components/schemas/PeerBatch' + '400': + "$ref": "../openapi.yaml#/components/responses/bad_request" + '401': + "$ref": "../openapi.yaml#/components/responses/requires_authentication" + '403': + "$ref": "../openapi.yaml#/components/responses/forbidden" + '422': + "$ref": "../openapi.yaml#/components/responses/unprocessable" + '500': + "$ref": "../openapi.yaml#/components/responses/internal_error" diff --git a/shared/management/http/apiv1alpha1/runtime_tooling.go b/shared/management/http/apiv1alpha1/runtime_tooling.go new file mode 100644 index 000000000..6460e2e2f --- /dev/null +++ b/shared/management/http/apiv1alpha1/runtime_tooling.go @@ -0,0 +1,93 @@ +package apiv1alpha1 + +import ( + _ "embed" + "fmt" + "log/slog" + "net/http" + "os" + "strings" + + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" + "github.com/pb33f/libopenapi" + validator "github.com/pb33f/libopenapi-validator" + "github.com/pb33f/libopenapi-validator/config" + "github.com/pb33f/libopenapi-validator/errors" + "github.com/pb33f/libopenapi/datamodel" + log "github.com/sirupsen/logrus" +) + +// TODO (dmitri) this needs to be extracted, as it will grow to 500Kb +// +//go:embed bundle.yaml +var bundle []byte + +func CreateV1ApiValidatingMiddleware() (*V1ValidatorMiddleware, error) { + doc, err := libopenapi.NewDocumentWithConfiguration(bundle, &datamodel.DocumentConfiguration{ + Logger: slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })), + }) + if err != nil { + return nil, err + } + + model, err := doc.BuildV3Model() + if err != nil { + return nil, err + } + + // TODO figure out document validation: rn it's possible to have a spec + // that's not entirely correct -- parts of it fail to parse, but silently + v := validator.NewValidatorFromV3Model(&model.Model, + config.WithoutSecurityValidation(), + config.WithStandardBodyDecoders(), + config.WithRejectUnsupportedBodyContent(), + config.WithRequestDefaults()) + // v.SetDocument(doc) + // v.ValidatePathParams() + // if valid, errs := v.ValidateDocument(); !valid { + // return nil, fmt.Errorf("error validating OpenAPI doc, %s", errs) + // } + + return &V1ValidatorMiddleware{Validator: v}, nil +} + +type V1ValidatorMiddleware struct { + Validator validator.Validator +} + +func (v *V1ValidatorMiddleware) Handler(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + valid, errs := v.Validator.ValidateHttpRequestSync(r) + if !valid { + validationErrs := make([]string, 0, len(errs)) + for _, err := range errs { + validationErrs = append(validationErrs, validationError(err)) + } + + log.WithContext(r.Context()).Debugf("error validating request: %s", strings.Join(validationErrs, ", ")) + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid request: %s", strings.Join(validationErrs, ", ")), w) + + return + } + h.ServeHTTP(w, r) + }) +} + +func validationError(err *errors.ValidationError) string { + if err.SchemaValidationErrors != nil { + errs := make([]string, 0, len(err.SchemaValidationErrors)) + for _, e := range err.SchemaValidationErrors { + errs = append(errs, fmt.Sprintf("field %s: %s", e.FieldPath, e.Reason)) + } + return fmt.Sprintf("%s: %s", err.Message, strings.Join(errs, ", ")) + } else { + if err.SpecLine > 0 && err.SpecCol > 0 { + return fmt.Sprintf("%s, Line: %d, Column: %d", err.Message, err.SpecLine, err.SpecCol) + } else { + return fmt.Sprint(err.Message) + } + } +} diff --git a/shared/management/http/apiv1alpha1/tooling.go b/shared/management/http/apiv1alpha1/tooling.go new file mode 100644 index 000000000..8594348fc --- /dev/null +++ b/shared/management/http/apiv1alpha1/tooling.go @@ -0,0 +1,152 @@ +package apiv1alpha1 + +import ( + "log/slog" + "os" + "path/filepath" + "strings" + "unicode" + + "github.com/pb33f/libopenapi" + "github.com/pb33f/libopenapi/bundler" + "github.com/pb33f/libopenapi/datamodel" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/pb33f/libopenapi/generator/golang" +) + +var ApiPath = filepath.Join("shared", "management", "http", "apiv1alpha1", "openapi.yaml") + +func GenerateV1ApiBindings(openapipath string) ([]byte, *v3.Document, error) { + specFile, err := os.ReadFile(openapipath) + if err != nil { + return nil, nil, err + } + + multiFileDoc, err := libopenapi.NewDocumentWithConfiguration(specFile, &datamodel.DocumentConfiguration{ + AllowFileReferences: true, + BasePath: filepath.Dir(openapipath), + SpecFilePath: openapipath, + ExtractRefsSequentially: true, + Logger: slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelError, + })), + TransformSiblingRefs: true, // enable openapi 3.1 compliance by default + MergeReferencedProperties: true, // enable enhanced resolution by default + PropertyMergeStrategy: datamodel.PreserveLocal, // local properties take precedence + }) + if err != nil { + return nil, nil, err + } + multiFileModel, err := multiFileDoc.BuildV3Model() + if err != nil { + return nil, nil, err + } + + bundle, err := bundler.BundleDocumentComposed(&multiFileModel.Model, &bundler.BundleCompositionConfig{ + StrictValidation: true, + }) + if err != nil { + return nil, nil, err + } + + doc, err := libopenapi.NewDocumentWithConfiguration(bundle, &datamodel.DocumentConfiguration{ + Logger: slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })), + }) + if err != nil { + return nil, nil, err + } + + model, err := doc.BuildV3Model() + if err != nil { + return nil, nil, err + } + + return bundle, &model.Model, nil +} + +func GenerateV1Schema(model *v3.Document) (*golang.GeneratedFile, error) { + // render every schema in components.schemas into one file + gen := golang.NewGenerator( + golang.WithGeneratedComment(true), + golang.WithFormatMapping("date-time", "time.Time", "time"), + golang.WithOptionalFieldsAsPointers(true), + golang.WithEnumConstants(true), + golang.WithNestedTypeNameDelimiter(""), + golang.WithPackageName("apiv1alpha1"), + golang.WithFieldNameResolver(toPublicName)) + + return gen.RenderSchemas(model.Components.Schemas) +} + +// this is to keep existing naming of fields like "id", "url", etc +// libopenapi by default converts them to all-uppercase, like "ID", "URL", etc +func toPublicName(name string) string { + parts := splitIdentifier(name) + if len(parts) == 0 { + return "Value" + } + var b strings.Builder + for _, p := range parts { + rs := []rune(strings.ToLower(p)) + rs[0] = unicode.ToUpper(rs[0]) + b.WriteString(string(rs)) + } + out := b.String() + first := []rune(out)[0] + if unicode.IsDigit(first) { + return "Value" + out + } + return out +} + +func splitIdentifier(name string) []string { + var raw []string + var b strings.Builder + flush := func() { + if b.Len() > 0 { + raw = append(raw, b.String()) + b.Reset() + } + } + for _, r := range name { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + b.WriteRune(r) + default: + flush() + } + } + flush() + var parts []string + for _, part := range raw { + parts = append(parts, splitCamel(part)...) + } + return parts +} + +func splitCamel(value string) []string { + rs := []rune(value) + if len(rs) == 0 { + return nil + } + var parts []string + start := 0 + for i := 1; i < len(rs); i++ { + prev := rs[i-1] + cur := rs[i] + var next rune + if i+1 < len(rs) { + next = rs[i+1] + } + lowerToUpper := unicode.IsLower(prev) && unicode.IsUpper(cur) + acronymToWord := unicode.IsUpper(prev) && unicode.IsUpper(cur) && next != 0 && unicode.IsLower(next) + if lowerToUpper || acronymToWord { + parts = append(parts, string(rs[start:i])) + start = i + } + } + parts = append(parts, string(rs[start:])) + return parts +} diff --git a/shared/management/http/apiv1alpha1/types.gen.go b/shared/management/http/apiv1alpha1/types.gen.go new file mode 100644 index 000000000..3e9ede3e4 --- /dev/null +++ b/shared/management/http/apiv1alpha1/types.gen.go @@ -0,0 +1,414 @@ +// Code generated by libopenapi generator/golang. DO NOT EDIT. + +package apiv1alpha1 + +import ( + "encoding/json" + "time" +) + +// CountryCode 2-letter ISO 3166-1 alpha-2 code that represents the country. +// CountryCode example value is defined in the OpenAPI schema. +type CountryCode string + +// CityName Commonly used English name of the city. +// CityName example value is defined in the OpenAPI schema. +type CityName string + +// Country Describe country geographical location information. +type Country struct { + // CountryName Commonly used English name of the country. + // CountryName example value is defined in the OpenAPI schema. + CountryName string `json:"country_name"` + CountryCode CountryCode `json:"country_code"` +} + +// City Describe city geographical location information. +type City struct { + // GeonameId Integer ID of the record in GeoNames database. + // GeonameId example value is defined in the OpenAPI schema. + GeonameId int `json:"geoname_id"` + // CityName Commonly used English name of the city. + // CityName example value is defined in the OpenAPI schema. + CityName string `json:"city_name"` +} + +// ErrorResponse Standard error response. +type ErrorResponse struct { + // Message A human-readable error message. + // Message example value is defined in the OpenAPI schema. + Message *string `json:"message,omitempty"` +} + +type PeerBatch struct { + Peer + // CreatedAt Peer creation date (UTC). + // CreatedAt example value is defined in the OpenAPI schema. + CreatedAt time.Time `json:"created_at"` + // AccessiblePeersCount Number of accessible peers. + // AccessiblePeersCount example value is defined in the OpenAPI schema. + AccessiblePeersCount int `json:"accessible_peers_count"` +} + +type PeerMinimum struct { + // Id Peer ID. + // Id example value is defined in the OpenAPI schema. + Id string `json:"id"` + // Name Peer's hostname. + // Name example value is defined in the OpenAPI schema. + Name string `json:"name"` +} + +// GroupMinimumIssued How the group was issued (api, integration, jwt). +// GroupMinimumIssued example value is defined in the OpenAPI schema. +type GroupMinimumIssued string + +const ( + GroupMinimumIssuedAPI GroupMinimumIssued = "api" + GroupMinimumIssuedIntegration GroupMinimumIssued = "integration" + GroupMinimumIssuedJWT GroupMinimumIssued = "jwt" +) + +type GroupMinimum struct { + // Id Group ID. + // Id example value is defined in the OpenAPI schema. + Id string `json:"id"` + // Name Group Name identifier. + // Name example value is defined in the OpenAPI schema. + Name string `json:"name"` + // PeersCount Count of peers associated to the group. + // PeersCount example value is defined in the OpenAPI schema. + PeersCount int `json:"peers_count"` + // ResourcesCount Count of resources associated to the group. + // ResourcesCount example value is defined in the OpenAPI schema. + ResourcesCount int `json:"resources_count"` + // Issued How the group was issued (api, integration, jwt). + // Issued example value is defined in the OpenAPI schema. + Issued *GroupMinimumIssued `json:"issued,omitempty"` +} + +type PeerLocalFlags struct { + // RosenpassEnabled Indicates whether Rosenpass is enabled on this peer. + // RosenpassEnabled example value is defined in the OpenAPI schema. + RosenpassEnabled *bool `json:"rosenpass_enabled,omitempty"` + // RosenpassPermissive Indicates whether Rosenpass is in permissive mode or not. + // RosenpassPermissive example value is defined in the OpenAPI schema. + RosenpassPermissive *bool `json:"rosenpass_permissive,omitempty"` + // ServerSshAllowed Indicates whether SSH access this peer is allowed or not. + // ServerSshAllowed example value is defined in the OpenAPI schema. + ServerSshAllowed *bool `json:"server_ssh_allowed,omitempty"` + // RemoteJobsAllowed Indicates whether the peer has opted into management-requested remote jobs (e.g. debug bundles). + // RemoteJobsAllowed example value is defined in the OpenAPI schema. + RemoteJobsAllowed *bool `json:"remote_jobs_allowed,omitempty"` + // DisableClientRoutes Indicates whether client routes are disabled on this peer or not. + // DisableClientRoutes example value is defined in the OpenAPI schema. + DisableClientRoutes *bool `json:"disable_client_routes,omitempty"` + // DisableServerRoutes Indicates whether server routes are disabled on this peer or not. + // DisableServerRoutes example value is defined in the OpenAPI schema. + DisableServerRoutes *bool `json:"disable_server_routes,omitempty"` + // DisableDns Indicates whether DNS management is disabled on this peer or not. + // DisableDns example value is defined in the OpenAPI schema. + DisableDns *bool `json:"disable_dns,omitempty"` + // DisableFirewall Indicates whether firewall management is disabled on this peer or not. + // DisableFirewall example value is defined in the OpenAPI schema. + DisableFirewall *bool `json:"disable_firewall,omitempty"` + // BlockLanAccess Indicates whether LAN access is blocked on this peer when used as a routing peer. + // BlockLanAccess example value is defined in the OpenAPI schema. + BlockLanAccess *bool `json:"block_lan_access,omitempty"` + // BlockInbound Indicates whether inbound traffic is blocked on this peer. + // BlockInbound example value is defined in the OpenAPI schema. + BlockInbound *bool `json:"block_inbound,omitempty"` + // LazyConnectionEnabled Indicates whether lazy connection is enabled on this peer. + // LazyConnectionEnabled example value is defined in the OpenAPI schema. + LazyConnectionEnabled *bool `json:"lazy_connection_enabled,omitempty"` +} + +type Peer struct { + PeerMinimum + // CreatedAt Peer creation date (UTC). + // CreatedAt example value is defined in the OpenAPI schema. + CreatedAt time.Time `json:"created_at"` + // Ip Peer's IP address. + // Ip example value is defined in the OpenAPI schema. + Ip string `json:"ip"` + // Ipv6 Peer's IPv6 overlay address. + // Ipv6 example value is defined in the OpenAPI schema. + Ipv6 *string `json:"ipv6,omitempty"` + // ConnectionIp Peer's public connection IP address. + // ConnectionIp example value is defined in the OpenAPI schema. + ConnectionIp string `json:"connection_ip"` + // Connected Peer to Management connection status. + // Connected example value is defined in the OpenAPI schema. + Connected bool `json:"connected"` + // LastSeen Last time peer connected to Netbird's management service. + // LastSeen example value is defined in the OpenAPI schema. + LastSeen time.Time `json:"last_seen"` + // Os Peer's operating system and version. + // Os example value is defined in the OpenAPI schema. + Os string `json:"os"` + // KernelVersion Peer's operating system kernel version. + // KernelVersion example value is defined in the OpenAPI schema. + KernelVersion string `json:"kernel_version"` + // GeonameId Unique identifier from the GeoNames database for a specific geographical location. + // GeonameId example value is defined in the OpenAPI schema. + GeonameId int `json:"geoname_id"` + // Version Peer's daemon or cli version. + // Version example value is defined in the OpenAPI schema. + Version string `json:"version"` + // Groups Groups that the peer belongs to. + Groups []GroupMinimum `json:"groups"` + // SshEnabled Indicates whether SSH server is enabled on this peer. + // SshEnabled example value is defined in the OpenAPI schema. + SshEnabled bool `json:"ssh_enabled"` + // UserId User ID of the user that enrolled this peer. + // UserId example value is defined in the OpenAPI schema. + UserId string `json:"user_id"` + // Hostname Hostname of the machine. + // Hostname example value is defined in the OpenAPI schema. + Hostname string `json:"hostname"` + // UiVersion Peer's desktop UI version. + // UiVersion example value is defined in the OpenAPI schema. + UiVersion string `json:"ui_version"` + // DnsLabel Peer's DNS label is the parsed peer name for domain resolution. It is used to form an FQDN by appending the account's domain to the peer label. e.g. peer-dns-label.netbird.cloud. + // DnsLabel example value is defined in the OpenAPI schema. + DnsLabel string `json:"dns_label"` + // LoginExpirationEnabled Indicates whether peer login expiration has been enabled or not. + // LoginExpirationEnabled example value is defined in the OpenAPI schema. + LoginExpirationEnabled bool `json:"login_expiration_enabled"` + // LoginExpired Indicates whether peer's login expired or not. + // LoginExpired example value is defined in the OpenAPI schema. + LoginExpired bool `json:"login_expired"` + // LastLogin Last time this peer performed log in (authentication). E.g., user authenticated. + // LastLogin example value is defined in the OpenAPI schema. + LastLogin time.Time `json:"last_login"` + // InactivityExpirationEnabled Indicates whether peer inactivity expiration has been enabled or not. + // InactivityExpirationEnabled example value is defined in the OpenAPI schema. + InactivityExpirationEnabled bool `json:"inactivity_expiration_enabled"` + // ApprovalRequired (Cloud only) Indicates whether peer needs approval. + // ApprovalRequired example value is defined in the OpenAPI schema. + ApprovalRequired bool `json:"approval_required"` + // DisapprovalReason (Cloud only) Reason why the peer requires approval. + DisapprovalReason *string `json:"disapproval_reason,omitempty"` + CountryCode CountryCode `json:"country_code"` + CityName CityName `json:"city_name"` + // SerialNumber System serial number. + // SerialNumber example value is defined in the OpenAPI schema. + SerialNumber string `json:"serial_number"` + // ExtraDnsLabels Extra DNS labels added to the peer. + ExtraDnsLabels []string `json:"extra_dns_labels"` + // Ephemeral Indicates whether the peer is ephemeral or not. + // Ephemeral example value is defined in the OpenAPI schema. + Ephemeral bool `json:"ephemeral"` + LocalFlags *PeerLocalFlags `json:"local_flags,omitempty"` +} + +type PeerRequest struct { + // Name example value is defined in the OpenAPI schema. + Name string `json:"name"` + // SshEnabled example value is defined in the OpenAPI schema. + SshEnabled bool `json:"ssh_enabled"` + // LoginExpirationEnabled example value is defined in the OpenAPI schema. + LoginExpirationEnabled bool `json:"login_expiration_enabled"` + // InactivityExpirationEnabled example value is defined in the OpenAPI schema. + InactivityExpirationEnabled bool `json:"inactivity_expiration_enabled"` + // ApprovalRequired (Cloud only) Indicates whether peer needs approval. + // ApprovalRequired example value is defined in the OpenAPI schema. + ApprovalRequired *bool `json:"approval_required,omitempty"` + // Ip Peer's IP address. + // Ip example value is defined in the OpenAPI schema. + Ip *string `json:"ip,omitempty"` + // Ipv6 Peer's IPv6 overlay address. Omitted if IPv6 is not enabled for the account. + // Ipv6 example value is defined in the OpenAPI schema. + Ipv6 *string `json:"ipv6,omitempty"` +} + +// UserStatus User's status. +// UserStatus example value is defined in the OpenAPI schema. +type UserStatus string + +const ( + UserStatusActive UserStatus = "active" + UserStatusInvited UserStatus = "invited" + UserStatusBlocked UserStatus = "blocked" +) + +type User struct { + // Id User ID. + // Id example value is defined in the OpenAPI schema. + Id string `json:"id"` + // Email User's email address. + // Email example value is defined in the OpenAPI schema. + Email string `json:"email"` + // Password User's password. Only present when user is created (create user endpoint is called) and only when IdP supports user creation with password. + // Password example value is defined in the OpenAPI schema. + Password *string `json:"password,omitempty"` + // Name User's name from idp provider. + // Name example value is defined in the OpenAPI schema. + Name string `json:"name"` + // Role User's NetBird account role. + // Role example value is defined in the OpenAPI schema. + Role string `json:"role"` + // Status User's status. + // Status example value is defined in the OpenAPI schema. + Status UserStatus `json:"status"` + // LastLogin Last time this user performed a login to the dashboard. + // LastLogin example value is defined in the OpenAPI schema. + LastLogin *time.Time `json:"last_login,omitempty"` + // AutoGroups Group IDs to auto-assign to peers registered by this user. + AutoGroups []string `json:"auto_groups"` + // IsCurrent Is true if authenticated user is the same as this user. + // IsCurrent readOnly. + // IsCurrent example value is defined in the OpenAPI schema. + IsCurrent *bool `json:"is_current,omitempty"` + // IsServiceUser Is true if this user is a service user. + // IsServiceUser readOnly. + // IsServiceUser example value is defined in the OpenAPI schema. + IsServiceUser *bool `json:"is_service_user,omitempty"` + // IsBlocked Is true if this user is blocked. Blocked users can't use the system. + // IsBlocked example value is defined in the OpenAPI schema. + IsBlocked bool `json:"is_blocked"` + // PendingApproval Is true if this user requires approval before being activated. Only applicable for users joining via domain matching when user_approval_required is enabled. + // PendingApproval example value is defined in the OpenAPI schema. + PendingApproval bool `json:"pending_approval"` + // Issued How user was issued by API or Integration. + // Issued example value is defined in the OpenAPI schema. + Issued *string `json:"issued,omitempty"` + // IdpId Identity provider ID (connector ID) that the user authenticated with. Only populated for users with Dex-encoded user IDs. + // IdpId example value is defined in the OpenAPI schema. + IdpId *string `json:"idp_id,omitempty"` + Permissions *UserPermissions `json:"permissions,omitempty"` +} + +type UserCreateRequest struct { + // Email User's Email to send invite to. + // Email example value is defined in the OpenAPI schema. + Email *string `json:"email,omitempty"` + // Name User's full name. + // Name example value is defined in the OpenAPI schema. + Name *string `json:"name,omitempty"` + // Role User's NetBird account role. + // Role example value is defined in the OpenAPI schema. + Role string `json:"role"` + // AutoGroups Group IDs to auto-assign to peers registered by this user. + AutoGroups []string `json:"auto_groups"` + // IsServiceUser Is true if this user is a service user. + // IsServiceUser example value is defined in the OpenAPI schema. + IsServiceUser bool `json:"is_service_user"` +} + +type UserRequest struct { + // Role User's NetBird account role. + // Role example value is defined in the OpenAPI schema. + Role string `json:"role"` + // AutoGroups Group IDs to auto-assign to peers registered by this user. + AutoGroups []string `json:"auto_groups"` + // IsBlocked If set to true then user is blocked and can't use the system. + // IsBlocked example value is defined in the OpenAPI schema. + IsBlocked bool `json:"is_blocked"` +} + +type UserPermissionsModulesAdditionalProperty struct { + AdditionalProperties map[string]bool `json:"-"` +} + +func (m *UserPermissionsModulesAdditionalProperty) UnmarshalJSON(data []byte) error { + type Alias UserPermissionsModulesAdditionalProperty + var known Alias + if err := json.Unmarshal(data, &known); err != nil { + return err + } + *m = UserPermissionsModulesAdditionalProperty(known) + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if len(raw) == 0 { + return nil + } + m.AdditionalProperties = make(map[string]bool, len(raw)) + for key, value := range raw { + var decoded bool + if err := json.Unmarshal(value, &decoded); err != nil { + return err + } + m.AdditionalProperties[key] = decoded + } + return nil +} + +func (m UserPermissionsModulesAdditionalProperty) MarshalJSON() ([]byte, error) { + type Alias UserPermissionsModulesAdditionalProperty + encoded, err := json.Marshal(Alias(m)) + if err != nil { + return nil, err + } + var object map[string]json.RawMessage + if err := json.Unmarshal(encoded, &object); err != nil { + return nil, err + } + for key, value := range m.AdditionalProperties { + encodedValue, err := json.Marshal(value) + if err != nil { + return nil, err + } + object[key] = encodedValue + } + return json.Marshal(object) +} + +// UserPermissionsModules example value is defined in the OpenAPI schema. +type UserPermissionsModules struct { + AdditionalProperties map[string]map[string]bool `json:"-"` +} + +func (m *UserPermissionsModules) UnmarshalJSON(data []byte) error { + type Alias UserPermissionsModules + var known Alias + if err := json.Unmarshal(data, &known); err != nil { + return err + } + *m = UserPermissionsModules(known) + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if len(raw) == 0 { + return nil + } + m.AdditionalProperties = make(map[string]map[string]bool, len(raw)) + for key, value := range raw { + var decoded map[string]bool + if err := json.Unmarshal(value, &decoded); err != nil { + return err + } + m.AdditionalProperties[key] = decoded + } + return nil +} + +func (m UserPermissionsModules) MarshalJSON() ([]byte, error) { + type Alias UserPermissionsModules + encoded, err := json.Marshal(Alias(m)) + if err != nil { + return nil, err + } + var object map[string]json.RawMessage + if err := json.Unmarshal(encoded, &object); err != nil { + return nil, err + } + for key, value := range m.AdditionalProperties { + encodedValue, err := json.Marshal(value) + if err != nil { + return nil, err + } + object[key] = encodedValue + } + return json.Marshal(object) +} + +type UserPermissions struct { + // IsRestricted Indicates whether this User's Peers view is restricted. + IsRestricted bool `json:"is_restricted"` + // Modules example value is defined in the OpenAPI schema. + Modules map[string]map[string]bool `json:"modules"` +} diff --git a/shared/management/http/apiv1alpha1/user/user_components.yaml b/shared/management/http/apiv1alpha1/user/user_components.yaml new file mode 100644 index 000000000..d3cac52e8 --- /dev/null +++ b/shared/management/http/apiv1alpha1/user/user_components.yaml @@ -0,0 +1,150 @@ +components: + schemas: + User: + type: object + properties: + id: + description: User ID + type: string + example: google-oauth2|277474792786460067937 + email: + description: User's email address + type: string + example: demo@netbird.io + password: + description: User's password. Only present when user is created (create user endpoint is called) and only when IdP supports user creation with password. + type: string + example: super_secure_password + name: + description: User's name from idp provider + type: string + example: Tom Schulz + role: + description: User's NetBird account role + type: string + example: admin + status: + description: User's status + type: string + enum: [ "active", "invited", "blocked" ] + example: active + last_login: + description: Last time this user performed a login to the dashboard + type: string + format: date-time + example: "2023-05-05T09:00:35.477782Z" + auto_groups: + description: Group IDs to auto-assign to peers registered by this user + type: array + items: + type: string + example: ch8i4ug6lnn4g9hqv7m0 + is_current: + description: Is true if authenticated user is the same as this user + type: boolean + readOnly: true + example: true + is_service_user: + description: Is true if this user is a service user + type: boolean + readOnly: true + example: false + is_blocked: + description: Is true if this user is blocked. Blocked users can't use the system + type: boolean + example: false + pending_approval: + description: Is true if this user requires approval before being activated. Only applicable for users joining via domain matching when user_approval_required is enabled. + type: boolean + example: false + issued: + description: How user was issued by API or Integration + type: string + example: api + idp_id: + description: Identity provider ID (connector ID) that the user authenticated with. Only populated for users with Dex-encoded user IDs. + type: string + example: okta-abc123 + permissions: + $ref: '#/components/schemas/UserPermissions' + required: + - id + - email + - name + - role + - auto_groups + - status + - is_blocked + - pending_approval + UserPermissions: + type: object + properties: + is_restricted: + type: boolean + description: Indicates whether this User's Peers view is restricted + modules: + type: object + additionalProperties: + type: object + additionalProperties: + type: boolean + propertyNames: + type: string + description: The operation type + propertyNames: + type: string + description: The module name + example: { "networks": { "read": true, "create": false, "update": false, "delete": false }, "peers": { "read": false, "create": false, "update": false, "delete": false } } + required: + - modules + - is_restricted + UserRequest: + type: object + properties: + role: + description: User's NetBird account role + type: string + example: admin + auto_groups: + description: Group IDs to auto-assign to peers registered by this user + type: array + items: + type: string + example: ch8i4ug6lnn4g9hqv7m0 + is_blocked: + description: If set to true then user is blocked and can't use the system + type: boolean + example: false + required: + - role + - auto_groups + - is_blocked + UserCreateRequest: + type: object + properties: + email: + description: User's Email to send invite to + type: string + example: demo@netbird.io + name: + description: User's full name + type: string + example: Tom Schulz + role: + description: User's NetBird account role + type: string + example: admin + auto_groups: + description: Group IDs to auto-assign to peers registered by this user + type: array + items: + type: string + example: ch8i4ug6lnn4g9hqv7m0 + is_service_user: + description: Is true if this user is a service user + type: boolean + example: false + required: + - role + - auto_groups + - is_service_user \ No newline at end of file diff --git a/shared/management/http/apiv1alpha1/user/user_paths.yaml b/shared/management/http/apiv1alpha1/user/user_paths.yaml new file mode 100644 index 000000000..be5b836c4 --- /dev/null +++ b/shared/management/http/apiv1alpha1/user/user_paths.yaml @@ -0,0 +1,129 @@ +UsersPath: + get: + summary: List all Users + description: Returns a list of all users + tags: [ Users ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: query + name: service_user + schema: + type: boolean + description: Filters users and returns either regular users or service users + responses: + '200': + description: A JSON array of Users + content: + application/json: + schema: + type: array + items: + $ref: './user_components.yaml#/components/schemas/User' + '400': + "$ref": "../openapi.yaml#/components/responses/bad_request" + '401': + "$ref": "../openapi.yaml#/components/responses/requires_authentication" + '403': + "$ref": "../openapi.yaml#/components/responses/forbidden" + '422': + "$ref": "../openapi.yaml#/components/responses/unprocessable" + '500': + "$ref": "../openapi.yaml#/components/responses/internal_error" + post: + summary: Create a User + description: Creates a new service user or sends an invite to a regular user + tags: [ Users ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + requestBody: + description: User invite information + required: true + content: + 'application/json': + schema: + $ref: './user_components.yaml#/components/schemas/UserCreateRequest' + responses: + '200': + description: A User object + content: + application/json: + schema: + $ref: './user_components.yaml#/components/schemas/User' + '400': + "$ref": "../openapi.yaml#/components/responses/bad_request" + '401': + "$ref": "../openapi.yaml#/components/responses/requires_authentication" + '403': + "$ref": "../openapi.yaml#/components/responses/forbidden" + '422': + "$ref": "../openapi.yaml#/components/responses/unprocessable" + '500': + "$ref": "../openapi.yaml#/components/responses/internal_error" +UserPath: + put: + summary: Update a User + description: Update information about a User + tags: [ Users ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: userId + required: true + schema: + type: string + description: The unique identifier of a user + requestBody: + description: User update + required: true + content: + 'application/json': + schema: + $ref: './user_components.yaml#/components/schemas/UserRequest' + responses: + '200': + description: A User object + content: + application/json: + schema: + $ref: './user_components.yaml#/components/schemas/User' + '400': + "$ref": "../openapi.yaml#/components/responses/bad_request" + '401': + "$ref": "../openapi.yaml#/components/responses/requires_authentication" + '403': + "$ref": "../openapi.yaml#/components/responses/forbidden" + '422': + "$ref": "../openapi.yaml#/components/responses/unprocessable" + '500': + "$ref": "../openapi.yaml#/components/responses/internal_error" + delete: + summary: Delete a User + description: This method removes a user from accessing the system. For this leaves the IDP user intact unless the `--user-delete-from-idp` is passed to management startup. + tags: [ Users ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: path + name: userId + required: true + schema: + type: string + description: The unique identifier of a user + responses: + '200': + description: Delete status code + content: { } + '400': + "$ref": "../openapi.yaml#/components/responses/bad_request" + '401': + "$ref": "../openapi.yaml#/components/responses/requires_authentication" + '403': + "$ref": "../openapi.yaml#/components/responses/forbidden" + '500': + "$ref": "../openapi.yaml#/components/responses/internal_error"