From 750063118e4b212a60b9c80c3912c78c07cbf48d Mon Sep 17 00:00:00 2001 From: Lisa Kim Date: Thu, 2 May 2024 10:22:16 -0700 Subject: [PATCH] [v15] Add yaml parsing and yamilfying access monitoring rule in webapi (#41109) * Add yaml parsing and yamilfying access monitoring rule in webapi * Extract resource without CheckAndSetDefault validator --- lib/web/apiserver.go | 3 + lib/web/resources.go | 21 +++-- lib/web/yaml.go | 114 +++++++++++++++++++++++++ lib/web/yaml_test.go | 195 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 327 insertions(+), 6 deletions(-) create mode 100644 lib/web/yaml.go create mode 100644 lib/web/yaml_test.go diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 5ee2e025c0735..06c6a37e5bd43 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -865,6 +865,9 @@ func (h *Handler) bindDefaultEndpoints() { h.GET("/webapi/apps/:fqdnHint", h.WithAuth(h.getAppFQDN)) h.GET("/webapi/apps/:fqdnHint/:clusterName/:publicAddr", h.WithAuth(h.getAppFQDN)) + h.POST("/webapi/yaml/parse/:kind", h.WithAuth(h.yamlParse)) + h.POST("/webapi/yaml/stringify/:kind", h.WithAuth(h.yamlStringify)) + // Desktop access endpoints. h.GET("/webapi/sites/:site/desktops", h.WithClusterAuth(h.clusterDesktopsGet)) h.GET("/webapi/sites/:site/desktopservices", h.WithClusterAuth(h.clusterDesktopServicesGet)) diff --git a/lib/web/resources.go b/lib/web/resources.go index e71684dc9bac2..955ac4008ccd8 100644 --- a/lib/web/resources.go +++ b/lib/web/resources.go @@ -465,12 +465,9 @@ func checkResourceUpdate(ctx context.Context, payloadResourceName, resourceName // ExtractResourceAndValidate extracts resource information from given string and validates basic fields. func ExtractResourceAndValidate(yaml string) (*services.UnknownResource, error) { - var unknownRes services.UnknownResource - reader := strings.NewReader(yaml) - decoder := kyaml.NewYAMLOrJSONDecoder(reader, 32*1024) - - if err := decoder.Decode(&unknownRes); err != nil { - return nil, trace.BadParameter("not a valid resource declaration") + unknownRes, err := extractResource(yaml) + if err != nil { + return nil, trace.Wrap(err) } if err := unknownRes.Metadata.CheckAndSetDefaults(); err != nil { @@ -480,6 +477,18 @@ func ExtractResourceAndValidate(yaml string) (*services.UnknownResource, error) return &unknownRes, nil } +func extractResource(yaml string) (services.UnknownResource, error) { + var unknownRes services.UnknownResource + reader := strings.NewReader(yaml) + decoder := kyaml.NewYAMLOrJSONDecoder(reader, 32*1024) + + if err := decoder.Decode(&unknownRes); err != nil { + return services.UnknownResource{}, trace.BadParameter("not a valid resource declaration") + } + + return unknownRes, nil +} + func convertListResourcesRequest(r *http.Request, kind string) (*proto.ListResourcesRequest, error) { values := r.URL.Query() diff --git a/lib/web/yaml.go b/lib/web/yaml.go new file mode 100644 index 0000000000000..aae541a92eb01 --- /dev/null +++ b/lib/web/yaml.go @@ -0,0 +1,114 @@ +/* + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package web + +import ( + "net/http" + + yaml "github.com/ghodss/yaml" + "github.com/gravitational/trace" + "github.com/julienschmidt/httprouter" + + accessmonitoringrulesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/accessmonitoringrules/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/httplib" + "github.com/gravitational/teleport/lib/services" +) + +type yamlParseRequest struct { + YAML string `json:"yaml"` +} + +type yamlParseResponse struct { + Resource interface{} `json:"resource"` +} + +type yamlStringifyResponse struct { + YAML string `json:"yaml"` +} + +func (h *Handler) yamlParse(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) { + kind := params.ByName("kind") + if len(kind) == 0 { + return nil, trace.BadParameter("query param %q is required", "kind") + } + + var req yamlParseRequest + if err := httplib.ReadJSON(r, &req); err != nil { + return nil, trace.Wrap(err) + } + + switch kind { + case types.KindAccessMonitoringRule: + resource, err := ConvertYAMLToAccessMonitoringRuleResource(req.YAML) + if err != nil { + return nil, trace.Wrap(err) + } + + return yamlParseResponse{Resource: resource}, nil + + default: + return nil, trace.NotImplemented("parsing YAML for kind %q is not supported", kind) + } +} + +func (h *Handler) yamlStringify(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) { + kind := params.ByName("kind") + if len(kind) == 0 { + return nil, trace.BadParameter("query param %q is required", "kind") + } + + var resource interface{} + + switch kind { + case types.KindAccessMonitoringRule: + var req struct { + Resource *accessmonitoringrulesv1.AccessMonitoringRule `json:"resource"` + } + if err := httplib.ReadJSON(r, &req); err != nil { + return nil, trace.Wrap(err) + } + resource = req.Resource + + default: + return nil, trace.NotImplemented("YAML stringifying for kind %q is not supported", kind) + } + + data, err := yaml.Marshal(resource) + if err != nil { + return nil, trace.Wrap(err) + } + return yamlStringifyResponse{YAML: string(data)}, nil +} + +func ConvertYAMLToAccessMonitoringRuleResource(yaml string) (*accessmonitoringrulesv1.AccessMonitoringRule, error) { + extractedRes, err := extractResource(yaml) + if err != nil { + return nil, trace.Wrap(err) + } + if extractedRes.Kind != types.KindAccessMonitoringRule { + return nil, trace.BadParameter("resource kind %q is invalid", extractedRes.Kind) + } + resource, err := services.UnmarshalAccessMonitoringRule(extractedRes.Raw) + if err != nil { + return nil, trace.Wrap(err) + } + + return resource, nil +} diff --git a/lib/web/yaml_test.go b/lib/web/yaml_test.go new file mode 100644 index 0000000000000..6399ed8082ea3 --- /dev/null +++ b/lib/web/yaml_test.go @@ -0,0 +1,195 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package web + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + accessmonitoringrulesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/accessmonitoringrules/v1" + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + "github.com/gravitational/teleport/api/types" +) + +const validAccessMonitoringRuleYaml = `kind: access_monitoring_rule +metadata: + name: foo +spec: + condition: some-condition + notification: + name: mattermost + recipients: + - apple + subjects: + - access_request +version: v1 +` + +func getAccessMonitoringRuleResource() *accessmonitoringrulesv1.AccessMonitoringRule { + return &accessmonitoringrulesv1.AccessMonitoringRule{ + Kind: types.KindAccessMonitoringRule, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "foo", + }, + Spec: &accessmonitoringrulesv1.AccessMonitoringRuleSpec{ + Subjects: []string{types.KindAccessRequest}, + Condition: "some-condition", + Notification: &accessmonitoringrulesv1.Notification{ + Name: "mattermost", + Recipients: []string{"apple"}, + }, + }, + } +} + +func TestYAMLParse_Valid(t *testing.T) { + t.Parallel() + + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, "test@example.com", nil) + + endpoint := pack.clt.Endpoint("webapi", "yaml", "parse", types.KindAccessMonitoringRule) + re, err := pack.clt.PostJSON(context.Background(), endpoint, yamlParseRequest{ + YAML: validAccessMonitoringRuleYaml, + }) + require.NoError(t, err) + + var endpointResp yamlParseResponse + require.NoError(t, json.Unmarshal(re.Bytes(), &endpointResp)) + + expectedResource := getAccessMonitoringRuleResource() + + // Can't cast a unmarshaled interface{} into the expected type, so + // we are transforming the expected type to the same type as the + // one we got as a response. + b, err := json.Marshal(yamlParseResponse{Resource: expectedResource}) + require.NoError(t, err) + var expectedResp yamlParseResponse + require.NoError(t, json.Unmarshal(b, &expectedResp)) + + require.Equal(t, expectedResp.Resource, endpointResp.Resource) +} + +func TestYAMLParse_Errors(t *testing.T) { + t.Parallel() + + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, "test@example.com", nil) + + testCases := []struct { + desc string + yaml string + kind string + }{ + { + desc: "unsupported kind", + yaml: validAccessMonitoringRuleYaml, + kind: "something-random", + }, + { + desc: "missing kind", + yaml: validAccessMonitoringRuleYaml, + kind: "", + }, + { + desc: "invalid yaml", + yaml: "// 232@#$", + kind: types.KindAccessMonitoringRule, + }, + { + desc: "invalid empty yaml", + yaml: "", + kind: types.KindAccessMonitoringRule, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + endpoint := pack.clt.Endpoint("webapi", "yaml", "parse", tc.kind) + _, err := pack.clt.PostJSON(context.Background(), endpoint, yamlParseRequest{ + YAML: tc.yaml, + }) + + require.Error(t, err) + }) + } +} + +func TestYAMLStringify_Valid(t *testing.T) { + t.Parallel() + + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, "test@example.com", nil) + + req := struct { + Resource *accessmonitoringrulesv1.AccessMonitoringRule `json:"resource"` + }{ + Resource: getAccessMonitoringRuleResource(), + } + + endpoint := pack.clt.Endpoint("webapi", "yaml", "stringify", types.KindAccessMonitoringRule) + re, err := pack.clt.PostJSON(context.Background(), endpoint, req) + require.NoError(t, err) + + var resp yamlStringifyResponse + require.NoError(t, json.Unmarshal(re.Bytes(), &resp)) + require.Equal(t, validAccessMonitoringRuleYaml, resp.YAML) +} + +func TestYAMLStringify_Errors(t *testing.T) { + t.Parallel() + + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, "test@example.com", nil) + + testCases := []struct { + desc string + kind string + }{ + { + desc: "unsupported kind", + kind: "something-random", + }, + { + desc: "missing kind", + kind: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + endpoint := pack.clt.Endpoint("webapi", "yaml", "stringify", tc.kind) + _, err := pack.clt.PostJSON(context.Background(), endpoint, struct { + Resource *accessmonitoringrulesv1.AccessMonitoringRule `json:"resource"` + }{ + Resource: getAccessMonitoringRuleResource(), + }) + + require.Error(t, err) + }) + } +}