diff --git a/.github/workflows/commands.yaml b/.github/workflows/commands.yaml index 0cef264..b32991d 100644 --- a/.github/workflows/commands.yaml +++ b/.github/workflows/commands.yaml @@ -30,4 +30,4 @@ jobs: make install - name: run command run: | - kuadrantctl install + bin/kuadrantctl install diff --git a/Makefile b/Makefile index d771646..c54d551 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,7 @@ test: fmt vet $(GINKGO) ## install: Build and install kuadrantctl binary ($GOBIN or GOPATH/bin) .PHONY : install install: fmt vet - $(GO) install + GOBIN=$(PROJECT_PATH)/bin $(GO) install .PHONY : fmt fmt: diff --git a/README.md b/README.md index 6da7927..eaf4dca 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ go install github.com/kuadrant/kuadrantctl@latest * [Install Kuadrant](doc/install.md) * [Uninstall Kuadrant](doc/uninstall.md) * [Apply Kuadrant API objects](doc/api-apply.md) +* [Generate Istio virtualservice objects](doc/generate-istio-virtualservice.md) +* [Generate Istio authenticationpolicy objects](doc/generate-istio-authorizationpolicy.md) ## Contributing The [Development guide](doc/development.md) describes how to build the kuadrantctl CLI and how to test your changes before submitting a patch or opening a PR. diff --git a/cmd/generate.go b/cmd/generate.go new file mode 100644 index 0000000..2e89688 --- /dev/null +++ b/cmd/generate.go @@ -0,0 +1,17 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +func generateCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "generate", + Short: "Commands related to kubernetes object generation", + Long: "Commands related to kubernetes object generation", + } + + cmd.AddCommand(generateIstioCommand()) + + return cmd +} diff --git a/cmd/generate_istio.go b/cmd/generate_istio.go new file mode 100644 index 0000000..4279699 --- /dev/null +++ b/cmd/generate_istio.go @@ -0,0 +1,18 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +func generateIstioCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "istio", + Short: "Generate Istio resources", + Long: "Generate Istio resorces", + } + + cmd.AddCommand(generateIstioVirtualServiceCommand()) + cmd.AddCommand(generateIstioAuthorizationPolicyCommand()) + + return cmd +} diff --git a/cmd/generate_istio_authpolicy.go b/cmd/generate_istio_authpolicy.go new file mode 100644 index 0000000..96f5592 --- /dev/null +++ b/cmd/generate_istio_authpolicy.go @@ -0,0 +1,134 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/spf13/cobra" + istiosecurityapi "istio.io/api/security/v1beta1" + istiotypeapi "istio.io/api/type/v1beta1" + istiosecurity "istio.io/client-go/pkg/apis/security/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/kuadrant/kuadrant-controller/pkg/common" + istioutils "github.com/kuadrant/kuadrantctl/pkg/istio" + "github.com/kuadrant/kuadrantctl/pkg/utils" +) + +var ( + generateIstioAPOAS string + generateIstioAPPublicHost string + generateIstioGatewayLabels []string +) + +func generateIstioAuthorizationPolicyCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "authorizationpolicy", + Short: "Generate Istio AuthorizationPolicy", + Long: "Generate Istio AuthorizationPolicy", + RunE: func(cmd *cobra.Command, args []string) error { + return runGenerateIstioAuthorizationPolicyCommand(cmd, args) + }, + } + + // OpenAPI ref + cmd.Flags().StringVar(&generateIstioAPOAS, "oas", "", "/path/to/file.[json|yaml|yml] OR http[s]://domain/resource/path.[json|yaml|yml] OR - (required)") + err := cmd.MarkFlagRequired("oas") + if err != nil { + panic(err) + } + + // public host + cmd.Flags().StringVar(&generateIstioAPPublicHost, "public-host", "", "The address used by a client when attempting to connect to a service (required)") + err = cmd.MarkFlagRequired("public-host") + if err != nil { + panic(err) + } + + // gateway labels + cmd.Flags().StringSliceVar(&generateIstioGatewayLabels, "gateway-label", []string{}, "Gateway label (required)") + err = cmd.MarkFlagRequired("gateway-label") + if err != nil { + panic(err) + } + + return cmd +} + +func runGenerateIstioAuthorizationPolicyCommand(cmd *cobra.Command, args []string) error { + dataRaw, err := utils.ReadExternalResource(generateIstioAPOAS) + if err != nil { + return err + } + + openapiLoader := openapi3.NewLoader() + doc, err := openapiLoader.LoadFromData(dataRaw) + if err != nil { + return err + } + + err = doc.Validate(openapiLoader.Context) + if err != nil { + return fmt.Errorf("OpenAPI validation error: %w", err) + } + + ap, err := generateIstioAuthorizationPolicy(cmd, doc) + if err != nil { + return err + } + + jsonData, err := json.Marshal(ap) + if err != nil { + return err + } + + fmt.Fprintln(cmd.OutOrStdout(), string(jsonData)) + + return nil +} + +func generateIstioAuthorizationPolicy(cmd *cobra.Command, doc *openapi3.T) (*istiosecurity.AuthorizationPolicy, error) { + objectName, err := utils.K8sNameFromOpenAPITitle(doc) + if err != nil { + return nil, err + } + + matchLabels := map[string]string{} + for idx := range generateIstioGatewayLabels { + labels := strings.Split(generateIstioGatewayLabels[idx], "=") + if len(labels) != 2 { + return nil, fmt.Errorf("gateway labels have wrong syntax: %s", generateIstioGatewayLabels[idx]) + } + + matchLabels[labels[0]] = labels[1] + } + + rules := istioutils.AuthorizationPolicyRulesFromOpenAPI(doc, generateIstioAPPublicHost) + + authPolicy := &istiosecurity.AuthorizationPolicy{ + TypeMeta: metav1.TypeMeta{ + Kind: "AuthorizationPolicy", + APIVersion: "security.istio.io/v1beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + // Missing namespace + Name: objectName, + }, + Spec: istiosecurityapi.AuthorizationPolicy{ + Selector: &istiotypeapi.WorkloadSelector{ + MatchLabels: matchLabels, + }, + Rules: rules, + Action: istiosecurityapi.AuthorizationPolicy_CUSTOM, + ActionDetail: &istiosecurityapi.AuthorizationPolicy_Provider{ + Provider: &istiosecurityapi.AuthorizationPolicy_ExtensionProvider{ + Name: common.KuadrantAuthorizationProvider, + }, + }, + }, + } + + return authPolicy, nil +} diff --git a/cmd/generate_istio_virtualservice.go b/cmd/generate_istio_virtualservice.go new file mode 100644 index 0000000..5c4a222 --- /dev/null +++ b/cmd/generate_istio_virtualservice.go @@ -0,0 +1,141 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/spf13/cobra" + istionetworkingapi "istio.io/api/networking/v1beta1" + istionetworking "istio.io/client-go/pkg/apis/networking/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + istioutils "github.com/kuadrant/kuadrantctl/pkg/istio" + "github.com/kuadrant/kuadrantctl/pkg/utils" +) + +var ( + generateIstioVSOAS string + generateIstioVSPublicHost string + generateIstioVSServiceName string + generateIstioVSServiceNamespace string + generateIstioVSServicePort int32 + generateIstioVSGateways []string +) + +func generateIstioVirtualServiceCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "virtualservice", + Short: "Generate Istio VirtualService from OpenAPI 3.x", + Long: "Generate Istio VirtualService from OpenAPI 3.x", + RunE: func(cmd *cobra.Command, args []string) error { + return rungenerateistiovirtualservicecommand(cmd, args) + }, + } + + // OpenAPI ref + cmd.Flags().StringVar(&generateIstioVSOAS, "oas", "", "/path/to/file.[json|yaml|yml] OR http[s]://domain/resource/path.[json|yaml|yml] OR - (required)") + err := cmd.MarkFlagRequired("oas") + if err != nil { + panic(err) + } + + // public host + cmd.Flags().StringVar(&generateIstioVSPublicHost, "public-host", "", "The address used by a client when attempting to connect to a service (required)") + err = cmd.MarkFlagRequired("public-host") + if err != nil { + panic(err) + } + + // service name + cmd.Flags().StringVar(&generateIstioVSServiceName, "service-name", "", "Service name (required)") + err = cmd.MarkFlagRequired("service-name") + if err != nil { + panic(err) + } + + // service namespace + cmd.Flags().StringVarP(&generateIstioVSServiceNamespace, "service-namespace", "", "", "Service namespace (required)") + err = cmd.MarkFlagRequired("service-namespace") + if err != nil { + panic(err) + } + + // service port + cmd.Flags().Int32VarP(&generateIstioVSServicePort, "service-port", "p", 80, "Service port") + + // gateways + cmd.Flags().StringSliceVar(&generateIstioVSGateways, "gateway", []string{}, "Gateways (required)") + err = cmd.MarkFlagRequired("gateway") + if err != nil { + panic(err) + } + + return cmd +} + +func rungenerateistiovirtualservicecommand(cmd *cobra.Command, args []string) error { + dataRaw, err := utils.ReadExternalResource(generateIstioVSOAS) + if err != nil { + return err + } + + openapiLoader := openapi3.NewLoader() + doc, err := openapiLoader.LoadFromData(dataRaw) + if err != nil { + return err + } + + err = doc.Validate(openapiLoader.Context) + if err != nil { + return fmt.Errorf("OpenAPI validation error: %w", err) + } + + vs, err := generateIstioVirtualService(cmd, doc) + if err != nil { + return err + } + + jsonData, err := json.Marshal(vs) + if err != nil { + return err + } + + fmt.Fprintln(cmd.OutOrStdout(), string(jsonData)) + return nil +} + +func generateIstioVirtualService(cmd *cobra.Command, doc *openapi3.T) (*istionetworking.VirtualService, error) { + objectName, err := utils.K8sNameFromOpenAPITitle(doc) + if err != nil { + return nil, err + } + + destination := &istionetworkingapi.Destination{ + Host: fmt.Sprintf("%s.%s.svc", generateIstioVSServiceName, generateIstioVSServiceNamespace), + Port: &istionetworkingapi.PortSelector{Number: uint32(generateIstioVSServicePort)}, + } + + httpRoutes, err := istioutils.HTTPRoutesFromOpenAPI(doc, destination) + if err != nil { + return nil, err + } + + vs := &istionetworking.VirtualService{ + TypeMeta: metav1.TypeMeta{ + Kind: "VirtualService", + APIVersion: "networking.istio.io/v1beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + // Missing namespace + Name: objectName, + }, + Spec: istionetworkingapi.VirtualService{ + Gateways: generateIstioVSGateways, + Hosts: []string{generateIstioVSPublicHost}, + Http: httpRoutes, + }, + } + + return vs, nil +} diff --git a/cmd/root.go b/cmd/root.go index 70988ec..f2e109c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -44,6 +44,7 @@ func GetRootCmd(args []string) *cobra.Command { rootCmd.AddCommand(installCommand()) rootCmd.AddCommand(uninstallCommand()) rootCmd.AddCommand(versionCommand()) + rootCmd.AddCommand(generateCommand()) loggerOpts := zap.Options{Development: verbose} logf.SetLogger(zap.New(zap.UseFlagOptions(&loggerOpts))) diff --git a/doc/generate-istio-authorizationpolicy.md b/doc/generate-istio-authorizationpolicy.md new file mode 100644 index 0000000..8fc90ae --- /dev/null +++ b/doc/generate-istio-authorizationpolicy.md @@ -0,0 +1,30 @@ +## Generate Istio AuthorizationPolicy objects + +The `kuadrantctl generate istio authorizationpolicy` command generates an [Istio AuthorizationPolicy](https://istio.io/latest/docs/reference/config/security/authorization-policy/) +from your [OpenAPI Specification (OAS) 3.x](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.2.md) and kubernetes service information. + +### OpenAPI specification + +OpenAPI document resource can be provided by one of the following channels: +* Filename in the available path. +* URL format (supported schemes are HTTP and HTTPS). The CLI will try to download from the given address. +* Read from stdin standard input stream. + +### Usage : + +```shell +$ kuadrantctl generate istio authorizationpolicy -h +Generate Istio AuthorizationPolicy + +Usage: + kuadrantctl generate istio authorizationpolicy [flags] + +Flags: + --gateway-label strings Gateway label (required) + -h, --help help for authorizationpolicy + --oas string /path/to/file.[json|yaml|yml] OR http[s]://domain/resource/path.[json|yaml|yml] OR - (required) + --public-host string The address used by a client when attempting to connect to a service (required) + +Global Flags: + -v, --verbose verbose output +``` diff --git a/doc/generate-istio-virtualservice.md b/doc/generate-istio-virtualservice.md new file mode 100644 index 0000000..a997ef6 --- /dev/null +++ b/doc/generate-istio-virtualservice.md @@ -0,0 +1,33 @@ +## Generate Istio VirtualService objects + +The `kuadrantctl generate istio virtualservice` command generates an [Istio VirtualService](https://istio.io/latest/docs/reference/config/networking/virtual-service/) +from your [OpenAPI Specification (OAS) 3.x](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.2.md) and kubernetes service information. + +### OpenAPI specification + +OpenAPI document resource can be provided by one of the following channels: +* Filename in the available path. +* URL format (supported schemes are HTTP and HTTPS). The CLI will try to download from the given address. +* Read from stdin standard input stream. + +### Usage : + +```shell +$ kuadrantctl generate istio virtualservice -h +Generate Istio VirtualService from OpenAPI 3.x + +Usage: + kuadrantctl generate istio virtualservice [flags] + +Flags: + --gateway strings Gateways (required) + -h, --help help for virtualservice + --oas string /path/to/file.[json|yaml|yml] OR http[s]://domain/resource/path.[json|yaml|yml] OR - (required) + --public-host string The address used by a client when attempting to connect to a service (required) + --service-name string Service name (required) + --service-namespace string Service namespace (required) + -p, --service-port int32 Service port (default 80) + +Global Flags: + -v, --verbose verbose output +``` diff --git a/examples/oas3/petstore-multiple-sec-requirements.yaml b/examples/oas3/petstore-multiple-sec-requirements.yaml new file mode 100644 index 0000000..7afe0ff --- /dev/null +++ b/examples/oas3/petstore-multiple-sec-requirements.yaml @@ -0,0 +1,39 @@ +--- +openapi: "3.1.0" +info: + title: "Pet Store API" + version: "1.0.0" +servers: + - url: https://toplevel.example.io/v1 +paths: + /cat: + get: # No sec requirements + operationId: "getCat" + responses: + 405: + description: "invalid input" + post: # API key + operationId: "postCat" + security: + - petstore_api_key: [] + responses: + 405: + description: "invalid input" + /dog: + get: # OIDC + operationId: "getDog" + security: + - petstore_oidc: + - read:dogs + responses: + 405: + description: "invalid input" +components: + securitySchemes: + petstore_api_key: + type: apiKey + name: api_key + in: header + petstore_oidc: + type: openIdConnect + openIdConnectUrl: http://example.org/auth/realms/myrealm diff --git a/examples/oas3/petstore.yaml b/examples/oas3/petstore.yaml new file mode 100644 index 0000000..d141f96 --- /dev/null +++ b/examples/oas3/petstore.yaml @@ -0,0 +1,25 @@ +--- +openapi: "3.0.0" +info: + title: "Pet Store API" + version: "1.0.0" +servers: + - url: https://toplevel.example.io/v1 +paths: + /cat: + get: + operationId: "getCat" + responses: + 405: + description: "invalid input" + post: + operationId: "postCat" + responses: + 405: + description: "invalid input" + /dog: + get: + operationId: "getDog" + responses: + 405: + description: "invalid input" diff --git a/go.mod b/go.mod index 57e9e69..9fb872a 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/onsi/ginkgo v1.16.4 github.com/onsi/gomega v1.15.0 github.com/spf13/cobra v1.2.1 + istio.io/api v0.0.0-20211206163441-1a632586cbd4 istio.io/client-go v1.12.1 k8s.io/api v0.22.1 k8s.io/apiextensions-apiserver v0.22.1 diff --git a/pkg/istio/authorizationpolicy.go b/pkg/istio/authorizationpolicy.go new file mode 100644 index 0000000..dfea3e9 --- /dev/null +++ b/pkg/istio/authorizationpolicy.go @@ -0,0 +1,39 @@ +package istio + +import ( + "github.com/getkin/kin-openapi/openapi3" + istiosecurityapi "istio.io/api/security/v1beta1" + + "github.com/kuadrant/kuadrantctl/pkg/utils" +) + +func AuthorizationPolicyRulesFromOpenAPI(oasDoc *openapi3.T, publicDomain string) []*istiosecurityapi.Rule { + rules := []*istiosecurityapi.Rule{} + + for path, pathItem := range oasDoc.Paths { + for opVerb, operation := range pathItem.Operations() { + secReqsP := utils.OpenAPIOperationSecRequirements(oasDoc, operation) + + if secReqsP == nil || len(*secReqsP) == 0 { + continue + } + + // there is at least one sec requirement for this operation, + // add the operation to authorization policy rules + rule := &istiosecurityapi.Rule{ + To: []*istiosecurityapi.Rule_To{ + { + Operation: &istiosecurityapi.Operation{ + Hosts: []string{publicDomain}, + Methods: []string{opVerb}, + Paths: []string{path}, + }, + }, + }, + } + + rules = append(rules, rule) + } + } + return rules +} diff --git a/pkg/istio/http_route.go b/pkg/istio/http_route.go new file mode 100644 index 0000000..3692c75 --- /dev/null +++ b/pkg/istio/http_route.go @@ -0,0 +1,34 @@ +package istio + +import ( + "github.com/getkin/kin-openapi/openapi3" + istioapi "istio.io/api/networking/v1beta1" +) + +func HTTPRoutesFromOpenAPI(oasDoc *openapi3.T, destination *istioapi.Destination) ([]*istioapi.HTTPRoute, error) { + httpRoutes := []*istioapi.HTTPRoute{} + + // Path based routing + for path, pathItem := range oasDoc.Paths { + for opVerb, operation := range pathItem.Operations() { + httpRoute := &istioapi.HTTPRoute{ + // TODO(eastizle): OperationID can be null, fallback to some custom name + Name: operation.OperationID, + Match: []*istioapi.HTTPMatchRequest{ + { + Uri: &istioapi.StringMatch{ + MatchType: &istioapi.StringMatch_Exact{Exact: path}, + }, + Method: &istioapi.StringMatch{ + MatchType: &istioapi.StringMatch_Exact{Exact: opVerb}, + }, + }, + }, + Route: []*istioapi.HTTPRouteDestination{{Destination: destination}}, + } + httpRoutes = append(httpRoutes, httpRoute) + } + } + + return httpRoutes, nil +} diff --git a/pkg/utils/oas3.go b/pkg/utils/oas3.go index e3a8b2c..a9d0fa2 100644 --- a/pkg/utils/oas3.go +++ b/pkg/utils/oas3.go @@ -2,10 +2,34 @@ package utils import ( "fmt" + "regexp" + "strings" "github.com/getkin/kin-openapi/openapi3" + "k8s.io/apimachinery/pkg/util/validation" ) +var ( + // NonAlphanumRegexp not alphanumeric + NonAlphanumRegexp = regexp.MustCompile(`[^0-9A-Za-z]`) +) + +func K8sNameFromOpenAPITitle(obj *openapi3.T) (string, error) { + openapiTitle := obj.Info.Title + openapiTitleToLower := strings.ToLower(openapiTitle) + objName := NonAlphanumRegexp.ReplaceAllString(openapiTitleToLower, "") + + // DNS Subdomain Names + // If the name would be part of some label, validation would be DNS Label Names (validation.IsDNS1123Label) + // https://kubernetes.io/docs/concepts/overview/working-with-objects/names/ + errStrings := validation.IsDNS1123Subdomain(objName) + if len(errStrings) > 0 { + errStr := strings.Join(errStrings, ",") + return "", fmt.Errorf("k8s name from OAS not valid: %s", errStr) + } + return objName, nil +} + func ValidateOAS3(docRaw []byte) error { openapiLoader := openapi3.NewLoader() doc, err := openapiLoader.LoadFromData(docRaw) @@ -20,3 +44,10 @@ func ValidateOAS3(docRaw []byte) error { return nil } + +func OpenAPIOperationSecRequirements(oasDoc *openapi3.T, operation *openapi3.Operation) *openapi3.SecurityRequirements { + if operation.Security == nil { + return &oasDoc.Security + } + return operation.Security +}