diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 20ed3371a6faa..eb933d0ba549a 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -809,6 +809,13 @@ func (h *Handler) bindDefaultEndpoints() { h.GET(OIDCJWKWURI, h.WithLimiter(h.jwksOIDC)) h.GET("/webapi/thumbprint", h.WithLimiter(h.thumbprint)) + // DiscoveryConfig CRUD + h.GET("/webapi/sites/:site/discoveryconfig", h.WithClusterAuth(h.discoveryconfigList)) + h.POST("/webapi/sites/:site/discoveryconfig", h.WithClusterAuth(h.discoveryconfigCreate)) + h.GET("/webapi/sites/:site/discoveryconfig/:name", h.WithClusterAuth(h.discoveryconfigGet)) + h.PUT("/webapi/sites/:site/discoveryconfig/:name", h.WithClusterAuth(h.discoveryconfigUpdate)) + h.DELETE("/webapi/sites/:site/discoveryconfig/:name", h.WithClusterAuth(h.discoveryconfigDelete)) + // Connection upgrades. h.GET("/webapi/connectionupgrade", h.WithHighLimiter(h.connectionUpgrade)) diff --git a/lib/web/discoveryconfig.go b/lib/web/discoveryconfig.go new file mode 100644 index 0000000000000..0a18e5da342c3 --- /dev/null +++ b/lib/web/discoveryconfig.go @@ -0,0 +1,178 @@ +/* +Copyright 2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package web + +import ( + "net/http" + + "github.com/gravitational/trace" + "github.com/julienschmidt/httprouter" + + "github.com/gravitational/teleport/api/types/discoveryconfig" + "github.com/gravitational/teleport/api/types/header" + "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/httplib" + "github.com/gravitational/teleport/lib/reversetunnelclient" + "github.com/gravitational/teleport/lib/web/ui" +) + +// discoveryconfigCreate creates a DiscoveryConfig +func (h *Handler) discoveryconfigCreate(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) { + var req ui.DiscoveryConfig + if err := httplib.ReadJSON(r, &req); err != nil { + return nil, trace.Wrap(err) + } + + if err := req.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + + dc, err := discoveryconfig.NewDiscoveryConfig( + header.Metadata{ + Name: req.Name, + }, + discoveryconfig.Spec{ + DiscoveryGroup: req.DiscoveryGroup, + AWS: req.AWS, + Azure: req.Azure, + GCP: req.GCP, + Kube: req.Kube, + }, + ) + if err != nil { + return nil, trace.Wrap(err) + } + + clt, err := sctx.GetUserClient(r.Context(), site) + if err != nil { + return nil, trace.Wrap(err) + } + + storedDiscoveryConfig, err := clt.DiscoveryConfigClient().CreateDiscoveryConfig(r.Context(), dc) + if err != nil { + if trace.IsAlreadyExists(err) { + return nil, trace.AlreadyExists("failed to create DiscoveryConfig (%q already exists), please use another name", req.Name) + } + return nil, trace.Wrap(err) + } + + return ui.MakeDiscoveryConfig(storedDiscoveryConfig), nil +} + +// discoveryconfigUpdate updates the DiscoveryConfig based on its name +func (h *Handler) discoveryconfigUpdate(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) { + dcName := p.ByName("name") + if dcName == "" { + return nil, trace.BadParameter("a discoveryconfig name is required") + } + + var req *ui.UpdateDiscoveryConfigRequest + if err := httplib.ReadJSON(r, &req); err != nil { + return nil, trace.Wrap(err) + } + + if err := req.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + + clt, err := sctx.GetUserClient(r.Context(), site) + if err != nil { + return nil, trace.Wrap(err) + } + + dc, err := clt.DiscoveryConfigClient().GetDiscoveryConfig(r.Context(), dcName) + if err != nil { + return nil, trace.Wrap(err) + } + + dc.Spec.DiscoveryGroup = req.DiscoveryGroup + dc.Spec.AWS = req.AWS + dc.Spec.Azure = req.Azure + dc.Spec.GCP = req.GCP + dc.Spec.Kube = req.Kube + + if _, err := clt.DiscoveryConfigClient().UpdateDiscoveryConfig(r.Context(), dc); err != nil { + return nil, trace.Wrap(err) + } + + return ui.MakeDiscoveryConfig(dc), nil +} + +// discoveryconfigDelete removes a DiscoveryConfig based on its name +func (h *Handler) discoveryconfigDelete(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) { + discoveryconfigName := p.ByName("name") + if discoveryconfigName == "" { + return nil, trace.BadParameter("a discoveryconfig name is required") + } + + clt, err := sctx.GetUserClient(r.Context(), site) + if err != nil { + return nil, trace.Wrap(err) + } + + if err := clt.DiscoveryConfigClient().DeleteDiscoveryConfig(r.Context(), discoveryconfigName); err != nil { + return nil, trace.Wrap(err) + } + + return OK(), nil +} + +// discoveryconfigGet returns a DiscoveryConfig based on its name +func (h *Handler) discoveryconfigGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) { + discoveryconfigName := p.ByName("name") + if discoveryconfigName == "" { + return nil, trace.BadParameter("as discoveryconfig name is required") + } + + clt, err := sctx.GetUserClient(r.Context(), site) + if err != nil { + return nil, trace.Wrap(err) + } + + dc, err := clt.DiscoveryConfigClient().GetDiscoveryConfig(r.Context(), discoveryconfigName) + if err != nil { + return nil, trace.Wrap(err) + } + + return ui.MakeDiscoveryConfig(dc), nil +} + +// discoveryconfigList returns a page of DiscoveryConfigs +func (h *Handler) discoveryconfigList(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) { + clt, err := sctx.GetUserClient(r.Context(), site) + if err != nil { + return nil, trace.Wrap(err) + } + + values := r.URL.Query() + limit, err := queryLimitAsInt32(values, "limit", defaults.MaxIterationLimit) + if err != nil { + return nil, trace.Wrap(err) + } + + startKey := values.Get("startKey") + + dcs, nextKey, err := clt.DiscoveryConfigClient().ListDiscoveryConfigs(r.Context(), int(limit), startKey) + if err != nil { + return nil, trace.Wrap(err) + } + + return ui.DiscoveryConfigsListResponse{ + Items: ui.MakeDiscoveryConfigs(dcs), + NextKey: nextKey, + }, nil +} diff --git a/lib/web/discoveryconfig_test.go b/lib/web/discoveryconfig_test.go new file mode 100644 index 0000000000000..f22b8d6f3c725 --- /dev/null +++ b/lib/web/discoveryconfig_test.go @@ -0,0 +1,216 @@ +/* +Copyright 2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package web + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/web/ui" +) + +func TestDiscoveryConfig(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + env := newWebPack(t, 1) + clusterName := env.server.ClusterName() + + username := uuid.NewString() + roleRWDiscoveryConfig, err := types.NewRole( + services.RoleNameForUser(username), types.RoleSpecV6{ + Allow: types.RoleConditions{Rules: []types.Rule{{ + Resources: []string{types.KindDiscoveryConfig}, + Verbs: services.RW(), + }}}, + }) + require.NoError(t, err) + pack := env.proxies[0].authPack(t, username, []types.Role{roleRWDiscoveryConfig}) + + getAllEndpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "discoveryconfig") + t.Run("Get All should return an empty list", func(t *testing.T) { + resp, err := pack.clt.Get(ctx, getAllEndpoint, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code()) + + var listResponse ui.DiscoveryConfigsListResponse + err = json.Unmarshal(resp.Bytes(), &listResponse) + require.NoError(t, err) + require.Empty(t, listResponse.NextKey) + require.Empty(t, listResponse.Items) + }) + + createEndpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "discoveryconfig") + t.Run("Create without a name must fai", func(t *testing.T) { + resp, err := pack.clt.PostJSON(ctx, createEndpoint, ui.DiscoveryConfig{ + DiscoveryGroup: "dg01", + }) + require.ErrorContains(t, err, "missing discovery config name") + require.Equal(t, http.StatusBadRequest, resp.Code()) + }) + + t.Run("Create without a group must fail", func(t *testing.T) { + resp, err := pack.clt.PostJSON(ctx, createEndpoint, ui.DiscoveryConfig{ + Name: "dc01", + }) + require.ErrorContains(t, err, "missing discovery group") + require.Equal(t, http.StatusBadRequest, resp.Code()) + }) + + t.Run("Get One must return not found when it doesn't exist", func(t *testing.T) { + getDC02Endpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "discoveryconfig", "dc02") + resp, err := pack.clt.Get(ctx, getDC02Endpoint, nil) + require.ErrorContains(t, err, "doesn't exist") + require.Equal(t, http.StatusNotFound, resp.Code()) + }) + + t.Run("Create valid", func(t *testing.T) { + resp, err := pack.clt.PostJSON(ctx, createEndpoint, ui.DiscoveryConfig{ + Name: "dc01", + DiscoveryGroup: "dg01", + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code()) + + t.Run("Create fails when name already exists", func(t *testing.T) { + resp, err := pack.clt.PostJSON(ctx, createEndpoint, ui.DiscoveryConfig{ + Name: "dc01", + DiscoveryGroup: "dg01", + }) + require.ErrorContains(t, err, "already exists") + require.Equal(t, http.StatusConflict, resp.Code()) + }) + + getDC01Endpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "discoveryconfig", "dc01") + t.Run("Get one", func(t *testing.T) { + resp, err := pack.clt.Get(ctx, getDC01Endpoint, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code()) + + var discoveryConfigResp ui.DiscoveryConfig + err = json.Unmarshal(resp.Bytes(), &discoveryConfigResp) + require.NoError(t, err) + require.Equal(t, "dg01", discoveryConfigResp.DiscoveryGroup) + require.Equal(t, "dc01", discoveryConfigResp.Name) + }) + + t.Run("Update discovery config", func(t *testing.T) { + updateDC01Endpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "discoveryconfig", "dc01") + resp, err = pack.clt.PutJSON(ctx, updateDC01Endpoint, ui.UpdateDiscoveryConfigRequest{ + DiscoveryGroup: "dgAA", + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code()) + + resp, err = pack.clt.Get(ctx, getDC01Endpoint, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code()) + + var discoveryConfigResp ui.DiscoveryConfig + err = json.Unmarshal(resp.Bytes(), &discoveryConfigResp) + require.NoError(t, err) + require.Equal(t, "dgAA", discoveryConfigResp.DiscoveryGroup) + require.Equal(t, "dc01", discoveryConfigResp.Name) + }) + + t.Run("Delete discovery config", func(t *testing.T) { + deleteDC01Endpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "discoveryconfig", "dc01") + resp, err = pack.clt.Delete(ctx, deleteDC01Endpoint) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code()) + + t.Run("Get All should return an empty list", func(t *testing.T) { + resp, err := pack.clt.Get(ctx, getAllEndpoint, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code()) + + var listResponse ui.DiscoveryConfigsListResponse + err = json.Unmarshal(resp.Bytes(), &listResponse) + require.NoError(t, err) + require.Empty(t, listResponse.NextKey) + require.Empty(t, listResponse.Items) + }) + }) + }) + + t.Run("Update must fail when discovery group is not present", func(t *testing.T) { + updateDC01Endpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "discoveryconfig", "dc01") + resp, err := pack.clt.PutJSON(ctx, updateDC01Endpoint, ui.UpdateDiscoveryConfigRequest{ + DiscoveryGroup: "", + }) + require.ErrorContains(t, err, "missing discovery group") + require.Equal(t, http.StatusBadRequest, resp.Code()) + }) + + t.Run("Update must return not found when it doesn't exist", func(t *testing.T) { + updateDC02Endpoint := pack.clt.Endpoint("webapi", "sites", clusterName, "discoveryconfig", "dc02") + resp, err := pack.clt.PutJSON(ctx, updateDC02Endpoint, ui.UpdateDiscoveryConfigRequest{ + DiscoveryGroup: "dg01", + }) + require.ErrorContains(t, err, "doesn't exist") + require.Equal(t, http.StatusNotFound, resp.Code()) + }) + + t.Run("Create multiple and then list all of them", func(t *testing.T) { + listTestCount := 54 + for i := 0; i < listTestCount; i++ { + resp, err := pack.clt.PostJSON(ctx, createEndpoint, ui.DiscoveryConfig{ + Name: fmt.Sprintf("dc-%d", i), + DiscoveryGroup: "dg01", + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code()) + } + uniqDC := make(map[string]struct{}, listTestCount) + iterationsCount := listTestCount / 5 + startKey := "" + for { + // Add a small limit page to test iteration. + resp, err := pack.clt.Get(ctx, getAllEndpoint, url.Values{ + "limit": []string{"5"}, + "startKey": []string{startKey}, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code()) + + var listResponse ui.DiscoveryConfigsListResponse + err = json.Unmarshal(resp.Bytes(), &listResponse) + require.NoError(t, err) + for _, item := range listResponse.Items { + uniqDC[item.Name] = struct{}{} + } + if listResponse.NextKey == "" { + break + } + iterationsCount-- + require.NotEmpty(t, listResponse.NextKey) + startKey = listResponse.NextKey + } + require.Equal(t, listTestCount, len(uniqDC)) + require.Zero(t, iterationsCount, "invalid number of iterations") + }) +} diff --git a/lib/web/ui/discoveryconfig.go b/lib/web/ui/discoveryconfig.go new file mode 100644 index 0000000000000..4c0312f22dd42 --- /dev/null +++ b/lib/web/ui/discoveryconfig.go @@ -0,0 +1,110 @@ +/* +Copyright 2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ui + +import ( + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/discoveryconfig" +) + +// DiscoveryConfig describes DiscoveryConfig fields +type DiscoveryConfig struct { + // Name is the DiscoveryConfig name. + Name string `json:"name,omitempty"` + // DiscoveryGroup is the Group of the DiscoveryConfig. + DiscoveryGroup string `json:"discoveryGroup,omitempty"` + // AWS is a list of matchers for AWS resources. + AWS []types.AWSMatcher `json:"aws,omitempty"` + // Azure is a list of matchers for Azure resources. + Azure []types.AzureMatcher `json:"azureMatchers,omitempty"` + // GCP is a list of matchers for GCP resources. + GCP []types.GCPMatcher `json:"gcpMatchers,omitempty"` + // Kube is a list of matchers for AWS resources. + Kube []types.KubernetesMatcher `json:"kube,omitempty"` +} + +// CheckAndSetDefaults for the create request. +// Name and SubKind is required. +func (r *DiscoveryConfig) CheckAndSetDefaults() error { + if r.Name == "" { + return trace.BadParameter("missing discovery config name") + } + + if r.DiscoveryGroup == "" { + return trace.BadParameter("missing discovery group") + } + + return nil +} + +// UpdateDiscoveryConfigRequest is a request to update a DiscoveryConfig +type UpdateDiscoveryConfigRequest struct { + // DiscoveryGroup is the Group of the DiscoveryConfig. + DiscoveryGroup string `json:"discoveryGroup,omitempty"` + // AWS is a list of matchers for AWS resources. + AWS []types.AWSMatcher `json:"aws,omitempty"` + // Azure is a list of matchers for Azure resources. + Azure []types.AzureMatcher `json:"azureMatchers,omitempty"` + // GCP is a list of matchers for GCP resources. + GCP []types.GCPMatcher `json:"gcpMatchers,omitempty"` + // Kube is a list of matchers for AWS resources. + Kube []types.KubernetesMatcher `json:"kube,omitempty"` +} + +// CheckAndSetDefaults checks if the provided values are valid. +func (r *UpdateDiscoveryConfigRequest) CheckAndSetDefaults() error { + if r.DiscoveryGroup == "" { + return trace.BadParameter("missing discovery group") + } + + return nil +} + +// DiscoveryConfigsListResponse contains a list of DiscoveryConfigs. +// In case of exceeding the pagination limit (either via query param `limit` or the default 1000) +// a `nextToken` is provided and should be used to obtain the next page (as a query param `startKey`) +type DiscoveryConfigsListResponse struct { + // Items is a list of resources retrieved. + Items []DiscoveryConfig `json:"items"` + // NextKey is the position to resume listing events. + NextKey string `json:"nextKey"` +} + +// MakeDiscoveryConfigs creates a UI list of DiscoveryConfigs. +func MakeDiscoveryConfigs(dcs []*discoveryconfig.DiscoveryConfig) []DiscoveryConfig { + uiList := make([]DiscoveryConfig, 0, len(dcs)) + + for _, dc := range dcs { + uiList = append(uiList, MakeDiscoveryConfig(dc)) + } + + return uiList +} + +// MakeDiscoveryConfig creates a UI DiscoveryConfig representation. +func MakeDiscoveryConfig(dc *discoveryconfig.DiscoveryConfig) DiscoveryConfig { + return DiscoveryConfig{ + Name: dc.GetName(), + DiscoveryGroup: dc.GetDiscoveryGroup(), + AWS: dc.Spec.AWS, + Azure: dc.Spec.Azure, + GCP: dc.Spec.GCP, + Kube: dc.Spec.Kube, + } +}