|
| 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 | +} |
0 commit comments