Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 41 additions & 64 deletions e2e/proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1199,82 +1199,59 @@ var _ = Describe("Proxy", func() {
})
})

When("creating and getting a large configmap through the proxy", func() {
// This test validates both gzip failure paths:
// 1. The workflow engine path (WriteToKube in activity.go): a large CREATE
// response is gzip-encoded by kube; without the fix, WriteToKube would
// forward Accept-Encoding from the client, preventing the REST transport
// from auto-decompressing, and the client receives raw gzip bytes.
// 2. The reverse proxy path (Director in server.go): a large GET response
// is gzip-encoded by kube; without the fix, FilterResp receives gzip
// bytes and fails to decode them as JSON.
BeforeEach(func() {
createConfigMap := proxyrule.Config{Spec: proxyrule.Spec{
Locking: proxyrule.OptimisticLockMode,
Matches: []proxyrule.Match{{
GroupVersion: "v1",
Resource: "configmaps",
Verbs: []string{"create"},
}},
Update: proxyrule.Update{
CreateRelationships: []proxyrule.StringOrTemplate{{
// testresource view = viewer + creator, so this grants
// the creator permission to view the configmap below.
Template: "testresource:{{namespacedName}}#creator@user:{{user.name}}",
When("getting a large configmap through the proxy", func() {
It("succeeds even when the upstream response is gzip-encoded", func(ctx context.Context) {
// Allow configmap gets unconditionally (no SpiceDB check needed).
// The point of this test is to verify the proxy correctly handles
// gzip-encoded responses from the kube API server.
//
// The reverse proxy path (Director + FilterResp in server.go) is tested here.
// The workflow engine path (WriteToKube in activity.go) is tested in the
// TestWriteToKubeDoesNotForwardAcceptEncoding unit test in the distributedtx package.
//
// Without the Accept-Encoding fix in the proxy's Director, FilterResp
// would receive a gzip-compressed body and fail to decode it as JSON.
configMapRule := proxyrule.Config{
Spec: proxyrule.Spec{
Matches: []proxyrule.Match{{
GroupVersion: "v1",
Resource: "configmaps",
Verbs: []string{"get"},
}},
// No Checks: unconditional allow for matched requests.
},
}}

getConfigMap := proxyrule.Config{Spec: proxyrule.Spec{
Matches: []proxyrule.Match{{
GroupVersion: "v1",
Resource: "configmaps",
Verbs: []string{"get"},
}},
Checks: []proxyrule.StringOrTemplate{{
Template: "testresource:{{namespacedName}}#view@user:{{user.name}}",
}},
}}

matcher, err := rules.NewMapMatcher([]proxyrule.Config{
createNamespace(),
createConfigMap,
getConfigMap,
})
}
matcher, err := rules.NewMapMatcher([]proxyrule.Config{configMapRule})
Expect(err).To(Succeed())
*proxySrv.Matcher = matcher
})

It("handles gzip-encoded responses from the workflow engine and reverse proxy", func(ctx context.Context) {
// Create namespace via proxy (goes through workflow engine).
Expect(CreateNamespace(ctx, paulClient, paulNamespace)).To(Succeed())

// 300KB is large enough to trigger kube's gzip encoding (threshold ~128KB).
const dataSize = 300 * 1024
// Create the namespace and configmap directly via adminClient (bypasses
// the proxy entirely). The proxy is only involved in the GET below.
Expect(CreateNamespace(ctx, adminClient, paulNamespace)).To(Succeed())
const dataSize = 300 * 1024 // 300KB — large enough to trigger kube's gzip encoding
cmName := names.SimpleNameGenerator.GenerateName("large-cm-")

// CREATE the large configmap through the proxy.
// This goes through the workflow engine (WriteToKube in activity.go).
// kube gzip-encodes the ~300KB 201 Created response; without the fix
// in WriteToKube, the client receives raw gzip bytes and fails to decode.
_, err := paulClient.CoreV1().ConfigMaps(paulNamespace).Create(ctx, &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: cmName,
Namespace: paulNamespace,
},
Data: map[string]string{
"payload": strings.Repeat("x", dataSize),
},
}, metav1.CreateOptions{})
_, err = adminClient.CoreV1().ConfigMaps(paulNamespace).Create(ctx,
&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: cmName,
Namespace: paulNamespace,
},
Data: map[string]string{
"payload": strings.Repeat("x", dataSize),
},
}, metav1.CreateOptions{})
Expect(err).To(Succeed())

// GET the large configmap through the proxy.
// This goes through the reverse proxy (Director + FilterResp in server.go).
// kube gzip-encodes the ~300KB response; without the fix in Director,
// FilterResp receives gzip bytes and cannot decode them as JSON.
// kube will gzip-encode the ~300KB response because paulClient sends
// Accept-Encoding: gzip. The proxy strips this header in its Director
// so its own transport takes ownership of gzip negotiation and
// auto-decompresses before FilterResp runs — ensuring FilterResp always
// sees plain JSON regardless of response size.
cm, err := paulClient.CoreV1().ConfigMaps(paulNamespace).Get(ctx, cmName, metav1.GetOptions{})
Expect(err).To(Succeed())
Expect(cm.Name).To(Equal(cmName))
// Verify the full payload came back intact (not truncated or corrupted).
Expect(cm.Data["payload"]).To(HaveLen(dataSize))
})
})
Expand Down
44 changes: 44 additions & 0 deletions pkg/authz/distributedtx/activity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,50 @@ func TestCheckKubeResourceError(t *testing.T) {
require.Error(t, err)
}

func TestWriteToKubeDoesNotForwardAcceptEncoding(t *testing.T) {
// Verify that Accept-Encoding is stripped from forwarded headers.
// If Accept-Encoding is forwarded to kube, kube may gzip-encode its response,
// but the REST transport won't auto-decompress (it only decompresses when it
// added Accept-Encoding itself, setting requestedGzip=true internally).
// Forwarding Accept-Encoding would cause res.Raw() to return compressed bytes.
var capturedHeader http.Header
roundtripper := func(req *http.Request) (*http.Response, error) {
capturedHeader = req.Header.Clone()
header := http.Header{}
header.Set("Content-Type", runtime.ContentTypeJSON)
return &http.Response{
Header: header,
StatusCode: http.StatusCreated,
Body: io.NopCloser(bytes.NewReader([]byte(`{"hi":"bye"}`))),
}, nil
}
client := &fake.RESTClient{
Client: fake.CreateHTTPClient(roundtripper),
NegotiatedSerializer: &serializer.CodecFactory{},
}
ah := ActivityHandler{KubeClient: client}

resp, err := ah.WriteToKube(t.Context(), &KubeReqInput{
RequestInfo: &request.RequestInfo{Path: "my_way", Namespace: "ns", Verb: "post"},
RequestURI: "/my_way",
ObjectMeta: &metav1.ObjectMeta{Name: "my_object_meta"},
Body: []byte(`{"hi":"bye"}`),
Header: http.Header{
"Accept-Encoding": []string{"gzip"},
"Content-Type": []string{"application/json"},
},
})

require.NoError(t, err)
require.NotNil(t, capturedHeader)
require.Empty(t, capturedHeader.Get("Accept-Encoding"),
"Accept-Encoding must not be forwarded: if kube gzip-encodes the response, "+
"the REST transport won't decompress it because it didn't set the header itself")
require.Equal(t, "application/json", capturedHeader.Get("Content-Type"),
"non-encoding headers should still be forwarded")
require.JSONEq(t, `{"hi":"bye"}`, string(resp.Body))
}

func TestIdempotencyKey(t *testing.T) {
payload := &v1.WriteRelationshipsRequest{}
key, err := idempotencyKeyForPayload(payload, "test-key")
Expand Down
Loading