diff --git a/mockgcp/common/httpmux/errors.go b/mockgcp/common/httpmux/errors.go new file mode 100644 index 0000000000..61fbd1ac0d --- /dev/null +++ b/mockgcp/common/httpmux/errors.go @@ -0,0 +1,93 @@ +// Copyright 2024 Google LLC +// +// 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 httpmux + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "k8s.io/klog/v2" +) + +type wrappedStatus struct { + Error *wrappedError `json:"error,omitempty"` +} + +type wrappedError struct { + Code int `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Status string `json:"status,omitempty"` + Errors []errorDetails `json:"errors,omitempty"` +} + +type errorDetails struct { + Domain string `json:"domain,omitempty"` + Message string `json:"message,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// customErrorHandler wraps errors in an error blockk +func customErrorHandler(ctx context.Context, mux *runtime.ServeMux, marshaler runtime.Marshaler, w http.ResponseWriter, r *http.Request, err error) { + s := status.Convert(err) + // pb := s.Proto() + + w.Header().Del("Trailer") + w.Header().Del("Transfer-Encoding") + + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + + httpStatusCode := runtime.HTTPStatusFromCode(s.Code()) + wrapped := &wrappedStatus{ + Error: &wrappedError{ + Code: httpStatusCode, + Message: s.Message(), + }, + } + + switch s.Code() { + case codes.PermissionDenied: + wrapped.Error.Status = "PERMISSION_DENIED" + case codes.AlreadyExists: + wrapped.Error.Status = "ALREADY_EXISTS" + case codes.NotFound: + wrapped.Error.Status = "NOT_FOUND" + wrapped.Error.Errors = append(wrapped.Error.Errors, errorDetails{ + Domain: "global", + Message: wrapped.Error.Message, + Reason: "notFound", + }) + } + + buf, merr := json.Marshal(wrapped) + if merr != nil { + klog.Warningf("Failed to marshal error message %q: %v", s, merr) + runtime.DefaultHTTPErrorHandler(ctx, mux, marshaler, w, r, err) + return + } + + if err := addGCPHeaders(ctx, w, nil); err != nil { + klog.Warningf("unexpected error from header filter: %v", err) + } + + w.WriteHeader(httpStatusCode) + if _, err := w.Write(buf); err != nil { + klog.Warningf("Failed to write response: %v", err) + } + +} diff --git a/mockgcp/common/httpmux/mux.go b/mockgcp/common/httpmux/mux.go new file mode 100644 index 0000000000..d6c0a0c0d0 --- /dev/null +++ b/mockgcp/common/httpmux/mux.go @@ -0,0 +1,79 @@ +// Copyright 2024 Google LLC +// +// 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 httpmux + +import ( + "context" + "net/http" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "google.golang.org/grpc" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "k8s.io/klog/v2" +) + +// NewServeMux constructs an http server with our error handling etc +func NewServeMux(ctx context.Context, conn *grpc.ClientConn, handlers ...func(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error) (*runtime.ServeMux, error) { + marshaler := &runtime.HTTPBodyMarshaler{ + Marshaler: &runtime.JSONPb{ + MarshalOptions: protojson.MarshalOptions{ + EmitUnpopulated: false, + }, + UnmarshalOptions: protojson.UnmarshalOptions{ + DiscardUnknown: true, + }, + }, + } + + outgoingHeaderMatcher := func(key string) (string, bool) { + switch key { + case "content-type": + return "", false + default: + klog.Warningf("unknown grpc metadata header %q", key) + return "", false + } + } + + mux := runtime.NewServeMux( + runtime.WithErrorHandler(customErrorHandler), + runtime.WithMarshalerOption(runtime.MIMEWildcard, marshaler), + runtime.WithOutgoingHeaderMatcher(outgoingHeaderMatcher), + runtime.WithForwardResponseOption(addGCPHeaders), + ) + + for _, handler := range handlers { + if err := handler(ctx, mux, conn); err != nil { + return nil, err + } + } + + return mux, nil +} + +func addGCPHeaders(ctx context.Context, w http.ResponseWriter, resp proto.Message) error { + if w.Header().Get("Content-Type") == "application/json" { + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + } + w.Header().Set("Cache-Control", "private") + w.Header().Set("Server", "ESF") + w.Header()["Vary"] = []string{"Origin", "X-Origin", "Referer"} + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "SAMEORIGIN") + w.Header().Set("X-Xss-Protection", "0") + + return nil +} diff --git a/mockgcp/mock_http_roundtrip.go b/mockgcp/mock_http_roundtrip.go index c096e74525..2bd0c770a3 100644 --- a/mockgcp/mock_http_roundtrip.go +++ b/mockgcp/mock_http_roundtrip.go @@ -265,6 +265,10 @@ func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) response := &http.Response{} response.Body = ioutil.NopCloser(&body) response.Header = w.header + if w.statusCode == 0 { + w.statusCode = 200 + } + response.Status = fmt.Sprintf("%d %s", w.statusCode, http.StatusText(w.statusCode)) response.StatusCode = w.statusCode return response, nil } diff --git a/mockgcp/mockiam/service.go b/mockgcp/mockiam/service.go index 456a1d2f46..47f23c44fa 100644 --- a/mockgcp/mockiam/service.go +++ b/mockgcp/mockiam/service.go @@ -22,10 +22,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/common" + "github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/common/httpmux" "github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/common/projects" pb "github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/generated/mockgcp/iam/admin/v1" "github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/pkg/storage" - "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" ) // MockService represents a mocked IAM service. @@ -63,10 +63,5 @@ func (s *MockService) Register(grpcServer *grpc.Server) { } func (s *MockService) NewHTTPMux(ctx context.Context, conn *grpc.ClientConn) (http.Handler, error) { - mux := runtime.NewServeMux() - if err := pb.RegisterIAMHandler(ctx, mux, conn); err != nil { - return nil, err - } - - return mux, nil + return httpmux.NewServeMux(ctx, conn, pb.RegisterIAMHandler) } diff --git a/mockgcp/mockiam/serviceaccounts.go b/mockgcp/mockiam/serviceaccounts.go index fee6002881..262f64fd56 100644 --- a/mockgcp/mockiam/serviceaccounts.go +++ b/mockgcp/mockiam/serviceaccounts.go @@ -16,6 +16,7 @@ package mockiam import ( "context" + "crypto/md5" "regexp" "strconv" "time" @@ -25,6 +26,7 @@ import ( "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/emptypb" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/klog/v2" "github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/common/projects" pb "github.com/GoogleCloudPlatform/k8s-config-connector/mockgcp/generated/mockgcp/iam/admin/v1" @@ -59,7 +61,7 @@ func (s *ServerV1) GetServiceAccount(ctx context.Context, req *pb.GetServiceAcco } if found == nil { - return nil, status.Errorf(codes.NotFound, "serviceaccount %q not found", req.Name) + return nil, status.Errorf(codes.NotFound, "Service account %q not found", req.Name) } return found, nil @@ -69,7 +71,7 @@ func (s *ServerV1) GetServiceAccount(ctx context.Context, req *pb.GetServiceAcco fqn := name.String() if err := s.storage.Get(ctx, fqn, sa); err != nil { if apierrors.IsNotFound(err) { - return nil, status.Errorf(codes.NotFound, "serviceaccount %q not found", req.Name) + return nil, status.Errorf(codes.NotFound, "Service account %q not found", req.Name) } return nil, status.Errorf(codes.Internal, "error reading serviceaccount: %v", err) } @@ -119,6 +121,9 @@ func (s *ServerV1) CreateServiceAccount(ctx context.Context, req *pb.CreateServi sa.UniqueId = strconv.FormatInt(uniqueID, 10) sa.Email = name.Email sa.DisplayName = displayName + sa.Oauth2ClientId = sa.UniqueId + + sa.Etag = computeEtag(sa) fqn := name.String() if err := s.storage.Create(ctx, fqn, sa); err != nil { @@ -179,3 +184,13 @@ func (s *ServerV1) PatchServiceAccount(ctx context.Context, req *pb.PatchService } return sa, nil } + +func computeEtag(obj proto.Message) []byte { + // TODO: Do we risk exposing internal fields? Doesn't matter on a mock, I guess + b, err := proto.Marshal(obj) + if err != nil { + klog.Fatalf("failed to marshal proto object: %v", err) + } + hash := md5.Sum(b) + return hash[:] +} diff --git a/pkg/test/resourcefixture/testdata/basic/iam/v1beta1/iamserviceaccount/_http.log b/pkg/test/resourcefixture/testdata/basic/iam/v1beta1/iamserviceaccount/_http.log new file mode 100644 index 0000000000..034addc267 --- /dev/null +++ b/pkg/test/resourcefixture/testdata/basic/iam/v1beta1/iamserviceaccount/_http.log @@ -0,0 +1,276 @@ +POST https://iam.googleapis.com/v1/projects/${projectId}/serviceAccounts?alt=json&prettyPrint=false +Content-Type: application/json +User-Agent: google-api-go-client/0.5 Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev +X-Goog-Api-Client: gl-go/1.21.4 gdcl/0.139.0 + +{ + "accountId": "gsa-${uniqueId}", + "serviceAccount": { + "displayName": "ExampleGSA" + } +} + +200 OK +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "displayName": "ExampleGSA", + "email": "gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com", + "etag": "abcdef0123A=", + "name": "projects/${projectId}/serviceAccounts/gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com", + "oauth2ClientId": "888888888888888888888", + "projectId": "${projectId}", + "uniqueId": "111111111111111111111" +} + +--- + +GET https://iam.googleapis.com/v1/projects/${projectId}/serviceAccounts/gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com?alt=json&prettyPrint=false +User-Agent: google-api-go-client/0.5 Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev +X-Goog-Api-Client: gl-go/1.21.4 gdcl/0.139.0 + +200 OK +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "displayName": "ExampleGSA", + "email": "gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com", + "etag": "abcdef0123A=", + "name": "projects/${projectId}/serviceAccounts/gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com", + "oauth2ClientId": "888888888888888888888", + "projectId": "${projectId}", + "uniqueId": "111111111111111111111" +} + +--- + +GET https://iam.googleapis.com/v1/projects/${projectId}/serviceAccounts/gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com?alt=json&prettyPrint=false +User-Agent: google-api-go-client/0.5 Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev +X-Goog-Api-Client: gl-go/1.21.4 gdcl/0.139.0 + +200 OK +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "displayName": "ExampleGSA", + "email": "gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com", + "etag": "abcdef0123A=", + "name": "projects/${projectId}/serviceAccounts/gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com", + "oauth2ClientId": "888888888888888888888", + "projectId": "${projectId}", + "uniqueId": "111111111111111111111" +} + +--- + +GET https://iam.googleapis.com/v1/projects/${projectId}/serviceAccounts/gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com?alt=json&prettyPrint=false +User-Agent: google-api-go-client/0.5 Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev +X-Goog-Api-Client: gl-go/1.21.4 gdcl/0.139.0 + +200 OK +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "displayName": "ExampleGSA", + "email": "gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com", + "etag": "abcdef0123A=", + "name": "projects/${projectId}/serviceAccounts/gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com", + "oauth2ClientId": "888888888888888888888", + "projectId": "${projectId}", + "uniqueId": "111111111111111111111" +} + +--- + +GET https://iam.googleapis.com/v1/projects/${projectId}/serviceAccounts/gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com?alt=json&prettyPrint=false +User-Agent: google-api-go-client/0.5 Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev +X-Goog-Api-Client: gl-go/1.21.4 gdcl/0.139.0 + +200 OK +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "displayName": "ExampleGSA", + "email": "gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com", + "etag": "abcdef0123A=", + "name": "projects/${projectId}/serviceAccounts/gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com", + "oauth2ClientId": "888888888888888888888", + "projectId": "${projectId}", + "uniqueId": "111111111111111111111" +} + +--- + +GET https://iam.googleapis.com/v1/projects/${projectId}/serviceAccounts/gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com?alt=json&prettyPrint=false +User-Agent: google-api-go-client/0.5 Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev +X-Goog-Api-Client: gl-go/1.21.4 gdcl/0.139.0 + +200 OK +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "displayName": "ExampleGSA", + "email": "gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com", + "etag": "abcdef0123A=", + "name": "projects/${projectId}/serviceAccounts/gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com", + "oauth2ClientId": "888888888888888888888", + "projectId": "${projectId}", + "uniqueId": "111111111111111111111" +} + +--- + +GET https://iam.googleapis.com/v1/projects/${projectId}/serviceAccounts/gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com?alt=json&prettyPrint=false +User-Agent: google-api-go-client/0.5 Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev +X-Goog-Api-Client: gl-go/1.21.4 gdcl/0.139.0 + +200 OK +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "displayName": "ExampleGSA", + "email": "gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com", + "etag": "abcdef0123A=", + "name": "projects/${projectId}/serviceAccounts/gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com", + "oauth2ClientId": "888888888888888888888", + "projectId": "${projectId}", + "uniqueId": "111111111111111111111" +} + +--- + +PATCH https://iam.googleapis.com/v1/projects/${projectId}/serviceAccounts/gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com?alt=json&prettyPrint=false +Content-Type: application/json +User-Agent: google-api-go-client/0.5 Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev +X-Goog-Api-Client: gl-go/1.21.4 gdcl/0.139.0 + +{ + "serviceAccount": { + "displayName": "ExampleGSA2", + "etag": "abcdef0123A=" + }, + "updateMask": "display_name" +} + +200 OK +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "displayName": "ExampleGSA2", + "email": "gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com", + "etag": "abcdef0123A=", + "name": "projects/${projectId}/serviceAccounts/gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com", + "oauth2ClientId": "888888888888888888888", + "projectId": "${projectId}", + "uniqueId": "111111111111111111111" +} + +--- + +GET https://iam.googleapis.com/v1/projects/${projectId}/serviceAccounts/gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com?alt=json&prettyPrint=false +User-Agent: google-api-go-client/0.5 Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev +X-Goog-Api-Client: gl-go/1.21.4 gdcl/0.139.0 + +200 OK +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{ + "displayName": "ExampleGSA2", + "email": "gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com", + "etag": "abcdef0123A=", + "name": "projects/${projectId}/serviceAccounts/gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com", + "oauth2ClientId": "888888888888888888888", + "projectId": "${projectId}", + "uniqueId": "111111111111111111111" +} + +--- + +DELETE https://iam.googleapis.com/v1/projects/${projectId}/serviceAccounts/gsa-${uniqueId}@${projectId}.iam.gserviceaccount.com?alt=json&prettyPrint=false +User-Agent: google-api-go-client/0.5 Terraform/ (+https://www.terraform.io) Terraform-Plugin-SDK/2.10.1 terraform-provider-google-beta/dev +X-Goog-Api-Client: gl-go/1.21.4 gdcl/0.139.0 + +200 OK +Cache-Control: private +Content-Type: application/json; charset=UTF-8 +Server: ESF +Vary: Origin +Vary: X-Origin +Vary: Referer +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Xss-Protection: 0 + +{} \ No newline at end of file