Skip to content

Commit 0c88f3f

Browse files
mjudeikisclaude
andcommitted
test(e2e): membership-gated proxy isolation (multi-user, multi-workspace)
Adds an OIDC-suite e2e test for Option A: two users each create an org + workspace; the kcp proxy must let each reach only their own workspace cluster (/clusters/{id}) and refuse the other's with "cluster access denied", reject bare paths (no DefaultCluster default), and open cross access once a workspace-scope Membership is granted. Assertions key off the proxy's own refusal message so they isolate the gate from downstream kcp auth/RBAC. Registered as TestHubProxyMembershipIsolation (OIDC suite — needs two identities). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a7b4aa2 commit 0c88f3f

2 files changed

Lines changed: 196 additions & 0 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/*
2+
Copyright 2026 The Faros Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
// End-to-end coverage for the membership-gated kcp proxy (Option A,
18+
// docs/hub-proxy-workspace-access.md). Two distinct users each own a
19+
// workspace; the hub kcp proxy must let each reach their own workspace cluster
20+
// (/clusters/{id}) and refuse the other's with "cluster access denied", until a
21+
// Membership grants cross access. Requires the OIDC suite for two identities.
22+
23+
package cases
24+
25+
import (
26+
"context"
27+
"encoding/json"
28+
"net/http"
29+
"strings"
30+
"testing"
31+
"time"
32+
33+
"sigs.k8s.io/e2e-framework/pkg/envconf"
34+
"sigs.k8s.io/e2e-framework/pkg/features"
35+
36+
"github.com/faroshq/faros-kedge/test/e2e/framework"
37+
)
38+
39+
// proxyDeniedMessage is the exact message the proxy's membership gate returns
40+
// (pkg/server/proxy/proxy.go clusterAccessDeniedBody). Asserting on it isolates
41+
// "the proxy refused" from any downstream kcp auth/RBAC result, so the test
42+
// doesn't depend on whether kcp trusts Dex in this suite.
43+
const proxyDeniedMessage = "cluster access denied"
44+
45+
// proxyClusterGet issues a raw kcp-proxy request (no tenant headers — the proxy
46+
// authorizes from the path + bearer) for a harmless read in the target cluster.
47+
func proxyClusterGet(ctx context.Context, hubURL, clusterID, bearer string) (int, string) {
48+
url := strings.TrimRight(hubURL, "/") + "/clusters/" + clusterID + "/apis/apis.kcp.io/v1alpha2/apibindings"
49+
code, body, err := framework.DoRESTRequest(ctx, http.MethodGet, url, bearer, nil, nil)
50+
if err != nil {
51+
return 0, err.Error()
52+
}
53+
return code, string(body)
54+
}
55+
56+
// proxyDenied reports whether the response is the proxy's own membership refusal
57+
// (as opposed to a downstream kcp 401/403 with a different body).
58+
func proxyDenied(code int, body string) bool {
59+
return code == http.StatusForbidden && strings.Contains(body, proxyDeniedMessage)
60+
}
61+
62+
// workspaceClusterID polls GET /api/orgs/{org}/workspaces/{ws} until the
63+
// workspace reports its logical-cluster id (set once it reaches Ready).
64+
func workspaceClusterID(ctx context.Context, t *testing.T, hubURL, bearer, org, ws string) string {
65+
t.Helper()
66+
deadline := time.Now().Add(90 * time.Second)
67+
var lastCode int
68+
var lastBody string
69+
for time.Now().Before(deadline) {
70+
code, body, err := framework.DoRESTRequest(ctx, http.MethodGet, workspaceURL(hubURL, org, ws), bearer, orgWSHeaders(org, ws), nil)
71+
lastCode, lastBody = code, string(body)
72+
if err == nil && code == http.StatusOK {
73+
var v struct {
74+
ClusterName string `json:"clusterName"`
75+
}
76+
if json.Unmarshal(body, &v) == nil && v.ClusterName != "" {
77+
return v.ClusterName
78+
}
79+
}
80+
time.Sleep(2 * time.Second)
81+
}
82+
t.Fatalf("workspace %s/%s never reported a clusterName (last code=%d body=%s)", org, ws, lastCode, lastBody)
83+
return ""
84+
}
85+
86+
// waitProxyAllowed polls until the proxy stops refusing access (Membership
87+
// reconciled into the caller's UserMembershipIndex).
88+
func waitProxyAllowed(ctx context.Context, t *testing.T, hubURL, clusterID, bearer, who string) {
89+
t.Helper()
90+
deadline := time.Now().Add(90 * time.Second)
91+
var lastCode int
92+
var lastBody string
93+
for time.Now().Before(deadline) {
94+
code, body := proxyClusterGet(ctx, hubURL, clusterID, bearer)
95+
lastCode, lastBody = code, body
96+
if !proxyDenied(code, body) {
97+
return
98+
}
99+
time.Sleep(2 * time.Second)
100+
}
101+
t.Fatalf("%s never gained proxy access to cluster %s (last code=%d body=%s)", who, clusterID, lastCode, lastBody)
102+
}
103+
104+
// HubProxyMembershipIsolation verifies the membership-gated proxy: each user
105+
// reaches only the workspace clusters their UserMembershipIndex covers, with
106+
// cross-user access refused until granted, and bare paths rejected.
107+
func HubProxyMembershipIsolation() features.Feature {
108+
return features.New("Tenancy/ProxyMembershipIsolation").
109+
Assess("membership_gates_cluster_access", func(ctx context.Context, t *testing.T, _ *envconf.Config) context.Context {
110+
dex := framework.DexEnvFrom(ctx)
111+
if dex == nil || dex.User2Email == "" {
112+
t.Skip("requires OIDC suite with a second Dex user")
113+
}
114+
hubURL := tenancyHubURL(ctx, t)
115+
116+
// ── User A: org + workspace ──────────────────────────────────────
117+
loginA, cancelA := context.WithTimeout(ctx, 90*time.Second)
118+
defer cancelA()
119+
resA, err := framework.HeadlessOIDCLogin(loginA, hubURL, dex.UserEmail, dex.UserPassword)
120+
if err != nil {
121+
t.Fatalf("User A login: %v", err)
122+
}
123+
orgA, err := framework.CreateOrgViaREST(ctx, hubURL, resA.IDToken, uniqueName("e2e-proxy-iso-a"))
124+
if err != nil {
125+
t.Fatalf("orgA: %v", err)
126+
}
127+
t.Cleanup(func() { _, _ = framework.DeleteOrgViaREST(context.Background(), hubURL, resA.IDToken, orgA.UUID) })
128+
wsA, err := framework.CreateWorkspaceViaREST(ctx, hubURL, resA.IDToken, orgA.UUID, "wsA")
129+
if err != nil {
130+
t.Fatalf("wsA: %v", err)
131+
}
132+
cidA := workspaceClusterID(ctx, t, hubURL, resA.IDToken, orgA.UUID, wsA.UUID)
133+
134+
// ── User B: org + workspace ──────────────────────────────────────
135+
loginB, cancelB := context.WithTimeout(ctx, 90*time.Second)
136+
defer cancelB()
137+
resB, err := framework.HeadlessOIDCLogin(loginB, hubURL, dex.User2Email, dex.User2Password)
138+
if err != nil {
139+
t.Fatalf("User B login: %v", err)
140+
}
141+
orgB, err := framework.CreateOrgViaREST(ctx, hubURL, resB.IDToken, uniqueName("e2e-proxy-iso-b"))
142+
if err != nil {
143+
t.Fatalf("orgB: %v", err)
144+
}
145+
t.Cleanup(func() { _, _ = framework.DeleteOrgViaREST(context.Background(), hubURL, resB.IDToken, orgB.UUID) })
146+
wsB, err := framework.CreateWorkspaceViaREST(ctx, hubURL, resB.IDToken, orgB.UUID, "wsB")
147+
if err != nil {
148+
t.Fatalf("wsB: %v", err)
149+
}
150+
cidB := workspaceClusterID(ctx, t, hubURL, resB.IDToken, orgB.UUID, wsB.UUID)
151+
152+
// ── Baseline: each user reaches their own workspace cluster ──────
153+
// (org-scope admin membership covers the org's child workspaces).
154+
waitProxyAllowed(ctx, t, hubURL, cidA, resA.IDToken, "userA→wsA")
155+
waitProxyAllowed(ctx, t, hubURL, cidB, resB.IDToken, "userB→wsB")
156+
157+
// ── Isolation: neither user may reach the other's cluster ────────
158+
if code, body := proxyClusterGet(ctx, hubURL, cidB, resA.IDToken); !proxyDenied(code, body) {
159+
t.Fatalf("userA→userB workspace: expected %q, got code=%d body=%s", proxyDeniedMessage, code, body)
160+
}
161+
if code, body := proxyClusterGet(ctx, hubURL, cidA, resB.IDToken); !proxyDenied(code, body) {
162+
t.Fatalf("userB→userA workspace: expected %q, got code=%d body=%s", proxyDeniedMessage, code, body)
163+
}
164+
t.Log("cross-user cluster access refused in both directions (correct)")
165+
166+
// ── Bare path is rejected (no DefaultCluster default, A-1) ───────
167+
bareURL := strings.TrimRight(hubURL, "/") + "/apis/apis.kcp.io/v1alpha2/apibindings"
168+
if code, body, err := framework.DoRESTRequest(ctx, http.MethodGet, bareURL, resA.IDToken, nil, nil); err != nil || code != http.StatusBadRequest {
169+
t.Fatalf("bare path: expected 400 (no default), got code=%d err=%v body=%s", code, err, body)
170+
}
171+
t.Log("bare path rejected with 400 (no DefaultCluster default)")
172+
173+
// ── Grant: workspace-scope Membership opens cross access ─────────
174+
// The membership endpoint keys off the target's User CR name (it
175+
// does Users().Get(req.User)), which the login surfaces as UserID.
176+
wsMembershipsURL := workspaceURL(hubURL, orgA.UUID, wsA.UUID) + "/memberships"
177+
code, body, err := framework.DoRESTRequest(ctx, http.MethodPost, wsMembershipsURL, resA.IDToken,
178+
orgWSHeaders(orgA.UUID, wsA.UUID),
179+
map[string]string{"user": resB.UserID, "role": "member"})
180+
if err != nil || code != http.StatusCreated {
181+
t.Fatalf("grant User B membership in wsA: code=%d err=%v body=%s", code, err, body)
182+
}
183+
waitProxyAllowed(ctx, t, hubURL, cidA, resB.IDToken, "userB→wsA (after grant)")
184+
t.Log("workspace-scope membership grants User B access to wsA (correct)")
185+
186+
return ctx
187+
}).
188+
Feature()
189+
}

test/e2e/suites/oidc/oidc_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,13 @@ func TestTenancySATokenCrossWorkspace(t *testing.T) {
228228
testenv.Test(t, cases.TenancySATokenCrossWorkspace())
229229
}
230230

231+
// Two-user membership-gated kcp proxy isolation: each user reaches only the
232+
// workspace clusters their UserMembershipIndex covers, cross access is refused
233+
// until granted, and bare paths are rejected. OIDC-only (needs two identities).
234+
func TestHubProxyMembershipIsolation(t *testing.T) {
235+
testenv.Test(t, cases.HubProxyMembershipIsolation())
236+
}
237+
231238
// TestOIDCTokenIssuerMatchesDiscovery verifies that the hub's OIDC issuer URL
232239
// matches what Dex advertises in its discovery document.
233240
func TestOIDCTokenIssuerMatchesDiscovery(t *testing.T) {

0 commit comments

Comments
 (0)