Skip to content

Commit 03d85b4

Browse files
mjudeikisclaude
andauthored
feat(proxy): membership-gated cluster access (Option A) (#361)
* feat(proxy): membership-gated cluster access (Option A) Implements docs/hub-proxy-workspace-access.md. The user kcp proxy no longer funnels every request to User.Spec.DefaultCluster; it authorizes the requested /clusters/{id} against the caller's UserMembershipIndex. - clusterAuthorizer (authorizer.go): allows {id} when its (org, ws) is covered by a workspace-scope or org-scope Membership; org-scope is the (org, *) case. A lazily-populated reverse topology cache (clusterID -> (org, ws)), filled by forward-resolving the caller's own memberships via the bootstrapper, keeps the warm path in-memory; membership is read fresh per request so revocation is immediate. Edges ({id}:{edge}) are authorized by their parent. - authorizeKCPPath replaces resolveKCPPath: bare /api|/apis paths are rejected (no DefaultCluster default, A-1); Org-workspace paths keep the O-10 refusal; tenant path-form is refused (address by id); /clusters/{id} is membership-gated. - SA path (A-6): saTokenClaims now also reads the bound-token kubernetes.io.clusterName claim (not just the legacy flat key), matching kcp's WithInClusterServiceAccountRequestRewrite; serveServiceAccount already pins the request to that cluster, so SAs stay single-workspace. kcp RBAC remains authoritative; the gate is membership-based defense-in-depth, failing closed. A-5 (REST clusterName) was already present in workspaceView. Tests cover member/non-member, org-scope, edges, cross-org isolation, bare-path rejection, O-10, and path-form refusal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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> * chore: gofmt/goimports the proxy authorization changes Fix the lint failure: align the membership-gate body constants and drop a stray blank line left by removing the obsolete resolveKCPPath test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 86d36ae commit 03d85b4

6 files changed

Lines changed: 670 additions & 171 deletions

File tree

pkg/server/proxy/authorizer.go

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+
11+
package proxy
12+
13+
import (
14+
"context"
15+
"strings"
16+
"sync"
17+
"time"
18+
19+
tenancyv1alpha1 "github.com/faroshq/faros-kedge/apis/tenancy/v1alpha1"
20+
)
21+
22+
// clusterAuthorizer decides whether a user may address a given workspace
23+
// cluster through the kcp proxy. It implements the membership-gated model from
24+
// docs/hub-proxy-workspace-access.md (Option A):
25+
//
26+
// - A request for /clusters/{id} is allowed when {id} is the cluster of a
27+
// child workspace the caller is a member of — either a workspace-scope
28+
// Membership for that exact workspace, or an org-scope Membership for its
29+
// org (org-scope implies access to every child workspace, per O-15).
30+
// - Edges ({id}:{edge}) are authorized by their parent workspace {id}.
31+
//
32+
// It maintains a lazily-populated reverse topology cache (clusterID → (org,
33+
// ws)) filled by forward-resolving the *caller's own* memberships, so a request
34+
// never resolves another user's workspaces, and the warm path is an in-memory
35+
// lookup. Membership itself is read fresh per request (matching the tenant
36+
// middleware), so revocation takes effect immediately; cluster→owner mappings
37+
// are stable for a workspace's lifetime and safe to keep.
38+
type clusterAuthorizer struct {
39+
members membershipGetter
40+
resolve clusterResolver
41+
children childLister
42+
43+
mu sync.RWMutex
44+
reverse map[string]ownerKey // clusterID → (org, ws), stable
45+
forward map[string]string // "org/ws" → clusterID, stable
46+
childTTL time.Duration
47+
childExp map[string]childCacheEntry // org → child workspace UUIDs (TTL)
48+
now func() time.Time
49+
}
50+
51+
type ownerKey struct {
52+
org string
53+
ws string
54+
}
55+
56+
type childCacheEntry struct {
57+
ws []string
58+
exp time.Time
59+
}
60+
61+
type membershipGetter func(ctx context.Context, userName string) (*tenancyv1alpha1.UserMembershipIndex, error)
62+
type clusterResolver func(ctx context.Context, orgUUID, wsUUID string) (string, error)
63+
type childLister func(ctx context.Context, orgUUID string) ([]string, error)
64+
65+
func newClusterAuthorizer(members membershipGetter, resolve clusterResolver, children childLister) *clusterAuthorizer {
66+
return &clusterAuthorizer{
67+
members: members,
68+
resolve: resolve,
69+
children: children,
70+
reverse: map[string]ownerKey{},
71+
forward: map[string]string{},
72+
childTTL: 30 * time.Second,
73+
childExp: map[string]childCacheEntry{},
74+
now: time.Now,
75+
}
76+
}
77+
78+
// authorize reports whether userName may reach clusterID (a child-workspace
79+
// cluster, or an edge {cluster}:{edge} under one). Failure is closed: any error
80+
// or unknown cluster denies.
81+
func (a *clusterAuthorizer) authorize(ctx context.Context, userName, clusterID string) bool {
82+
base := clusterID
83+
if i := strings.IndexByte(clusterID, ':'); i >= 0 {
84+
base = clusterID[:i] // edge {cluster}:{edge} → authorize the parent cluster
85+
}
86+
if base == "" {
87+
return false
88+
}
89+
90+
idx, err := a.members(ctx, userName)
91+
if err != nil || idx == nil {
92+
return false
93+
}
94+
95+
// Fast path: the cluster's owner is already known.
96+
if owner, ok := a.reverseGet(base); ok {
97+
return membershipCovers(idx, owner)
98+
}
99+
100+
// Slow path: resolve the caller's own reachable workspaces into the cache,
101+
// then re-check. This only ever resolves workspaces in the caller's index.
102+
a.populateForUser(ctx, idx)
103+
if owner, ok := a.reverseGet(base); ok {
104+
return membershipCovers(idx, owner)
105+
}
106+
return false
107+
}
108+
109+
// membershipCovers reports whether the index grants access to (owner.org,
110+
// owner.ws): a workspace-scope entry for that workspace, or an org-scope entry
111+
// (empty WorkspaceUUID) for its org.
112+
func membershipCovers(idx *tenancyv1alpha1.UserMembershipIndex, owner ownerKey) bool {
113+
if idx == nil {
114+
return false
115+
}
116+
for _, e := range idx.Spec.Entries {
117+
if e.OrgUUID != owner.org {
118+
continue
119+
}
120+
if e.WorkspaceUUID == "" || e.WorkspaceUUID == owner.ws {
121+
return true
122+
}
123+
}
124+
return false
125+
}
126+
127+
// populateForUser forward-resolves every workspace the index can reach into the
128+
// topology caches: each workspace-scope entry's workspace, and every child
129+
// workspace of each org-scope entry's org.
130+
func (a *clusterAuthorizer) populateForUser(ctx context.Context, idx *tenancyv1alpha1.UserMembershipIndex) {
131+
for _, e := range idx.Spec.Entries {
132+
if e.WorkspaceUUID != "" {
133+
a.resolveAndStore(ctx, e.OrgUUID, e.WorkspaceUUID)
134+
continue
135+
}
136+
for _, ws := range a.childrenOf(ctx, e.OrgUUID) {
137+
a.resolveAndStore(ctx, e.OrgUUID, ws)
138+
}
139+
}
140+
}
141+
142+
// resolveAndStore resolves (org, ws) → clusterID (cached, stable) and records
143+
// the reverse mapping.
144+
func (a *clusterAuthorizer) resolveAndStore(ctx context.Context, org, ws string) {
145+
key := org + "/" + ws
146+
a.mu.RLock()
147+
cid, ok := a.forward[key]
148+
a.mu.RUnlock()
149+
if !ok {
150+
var err error
151+
cid, err = a.resolve(ctx, org, ws)
152+
if err != nil || cid == "" {
153+
return
154+
}
155+
}
156+
a.mu.Lock()
157+
a.forward[key] = cid
158+
a.reverse[cid] = ownerKey{org: org, ws: ws}
159+
a.mu.Unlock()
160+
}
161+
162+
// childrenOf returns an org's child workspace UUIDs, cached with a short TTL so
163+
// a newly created child becomes reachable for org-scope members promptly.
164+
func (a *clusterAuthorizer) childrenOf(ctx context.Context, org string) []string {
165+
a.mu.RLock()
166+
entry, ok := a.childExp[org]
167+
a.mu.RUnlock()
168+
if ok && a.now().Before(entry.exp) {
169+
return entry.ws
170+
}
171+
ws, err := a.children(ctx, org)
172+
if err != nil {
173+
if ok {
174+
return entry.ws // serve stale on error rather than dropping access
175+
}
176+
return nil
177+
}
178+
a.mu.Lock()
179+
a.childExp[org] = childCacheEntry{ws: ws, exp: a.now().Add(a.childTTL)}
180+
a.mu.Unlock()
181+
return ws
182+
}
183+
184+
func (a *clusterAuthorizer) reverseGet(clusterID string) (ownerKey, bool) {
185+
a.mu.RLock()
186+
defer a.mu.RUnlock()
187+
owner, ok := a.reverse[clusterID]
188+
return owner, ok
189+
}
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
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+
11+
package proxy
12+
13+
import (
14+
"context"
15+
"fmt"
16+
"net/http"
17+
"testing"
18+
19+
"k8s.io/klog/v2"
20+
21+
tenancyv1alpha1 "github.com/faroshq/faros-kedge/apis/tenancy/v1alpha1"
22+
)
23+
24+
// fakeAuthorizer builds a clusterAuthorizer over in-memory fixtures. entries is
25+
// the caller's UserMembershipIndex; resolve maps "org/ws" → clusterID; children
26+
// maps org → child workspace UUIDs.
27+
func fakeAuthorizer(entries []tenancyv1alpha1.MembershipIndexEntry, resolve map[string]string, children map[string][]string) *clusterAuthorizer {
28+
members := func(_ context.Context, _ string) (*tenancyv1alpha1.UserMembershipIndex, error) {
29+
return &tenancyv1alpha1.UserMembershipIndex{
30+
Spec: tenancyv1alpha1.UserMembershipIndexSpec{Entries: entries},
31+
}, nil
32+
}
33+
res := func(_ context.Context, org, ws string) (string, error) {
34+
if cid, ok := resolve[org+"/"+ws]; ok {
35+
return cid, nil
36+
}
37+
return "", fmt.Errorf("no cluster for %s/%s", org, ws)
38+
}
39+
ch := func(_ context.Context, org string) ([]string, error) {
40+
return children[org], nil
41+
}
42+
return newClusterAuthorizer(members, res, ch)
43+
}
44+
45+
func wsEntry(org, ws string) tenancyv1alpha1.MembershipIndexEntry {
46+
return tenancyv1alpha1.MembershipIndexEntry{OrgUUID: org, WorkspaceUUID: ws, Role: "admin"}
47+
}
48+
49+
func orgEntry(org string) tenancyv1alpha1.MembershipIndexEntry {
50+
return tenancyv1alpha1.MembershipIndexEntry{OrgUUID: org, Role: "admin"}
51+
}
52+
53+
func TestClusterAuthorizer(t *testing.T) {
54+
tests := []struct {
55+
name string
56+
entries []tenancyv1alpha1.MembershipIndexEntry
57+
resolve map[string]string
58+
children map[string][]string
59+
clusterID string
60+
want bool
61+
}{
62+
{
63+
name: "workspace-scope member is allowed",
64+
entries: []tenancyv1alpha1.MembershipIndexEntry{wsEntry("o1", "w1")},
65+
resolve: map[string]string{"o1/w1": "cidA"},
66+
clusterID: "cidA",
67+
want: true,
68+
},
69+
{
70+
name: "non-member is denied",
71+
entries: []tenancyv1alpha1.MembershipIndexEntry{wsEntry("o1", "w1")},
72+
resolve: map[string]string{"o1/w1": "cidA"},
73+
clusterID: "cidB",
74+
want: false,
75+
},
76+
{
77+
name: "org-scope member reaches every child workspace",
78+
entries: []tenancyv1alpha1.MembershipIndexEntry{orgEntry("o1")},
79+
children: map[string][]string{"o1": {"w1", "w2"}},
80+
resolve: map[string]string{"o1/w1": "cidA", "o1/w2": "cidB"},
81+
clusterID: "cidB",
82+
want: true,
83+
},
84+
{
85+
name: "org-scope member denied a cluster outside the org",
86+
entries: []tenancyv1alpha1.MembershipIndexEntry{orgEntry("o1")},
87+
children: map[string][]string{"o1": {"w1"}},
88+
resolve: map[string]string{"o1/w1": "cidA"},
89+
clusterID: "cidZ",
90+
want: false,
91+
},
92+
{
93+
name: "edge under a member workspace is allowed",
94+
entries: []tenancyv1alpha1.MembershipIndexEntry{wsEntry("o1", "w1")},
95+
resolve: map[string]string{"o1/w1": "cidA"},
96+
clusterID: "cidA:edge1",
97+
want: true,
98+
},
99+
{
100+
name: "edge under a non-member workspace is denied",
101+
entries: []tenancyv1alpha1.MembershipIndexEntry{wsEntry("o1", "w1")},
102+
resolve: map[string]string{"o1/w1": "cidA"},
103+
clusterID: "cidB:edge1",
104+
want: false,
105+
},
106+
{
107+
name: "cross-org isolation: member of o1 cannot reach o2's cluster",
108+
entries: []tenancyv1alpha1.MembershipIndexEntry{wsEntry("o1", "w1")},
109+
resolve: map[string]string{"o1/w1": "cidA", "o2/w9": "cidOther"},
110+
clusterID: "cidOther",
111+
want: false,
112+
},
113+
{
114+
name: "empty cluster id is denied",
115+
entries: []tenancyv1alpha1.MembershipIndexEntry{wsEntry("o1", "w1")},
116+
resolve: map[string]string{"o1/w1": "cidA"},
117+
clusterID: "",
118+
want: false,
119+
},
120+
}
121+
122+
for _, tc := range tests {
123+
t.Run(tc.name, func(t *testing.T) {
124+
a := fakeAuthorizer(tc.entries, tc.resolve, tc.children)
125+
if got := a.authorize(context.Background(), "user", tc.clusterID); got != tc.want {
126+
t.Errorf("authorize(%q) = %v, want %v", tc.clusterID, got, tc.want)
127+
}
128+
})
129+
}
130+
}
131+
132+
func TestAuthorizeKCPPath(t *testing.T) {
133+
p := &KCPProxy{
134+
logger: klog.Background(),
135+
authorizer: fakeAuthorizer(
136+
[]tenancyv1alpha1.MembershipIndexEntry{wsEntry("o1", "w1")},
137+
map[string]string{"o1/w1": "cidA"},
138+
nil,
139+
),
140+
}
141+
142+
tests := []struct {
143+
name string
144+
urlPath string
145+
wantStatus int
146+
wantPath string
147+
}{
148+
{
149+
name: "bare /apis path is rejected (no default)",
150+
urlPath: "/apis/v1/pods",
151+
wantStatus: http.StatusBadRequest,
152+
},
153+
{
154+
name: "bare /api path is rejected (no default)",
155+
urlPath: "/api/v1/namespaces",
156+
wantStatus: http.StatusBadRequest,
157+
},
158+
{
159+
name: "org workspace path is refused (O-10)",
160+
urlPath: "/clusters/root:kedge:tenants:org1/api/v1/pods",
161+
wantStatus: http.StatusForbidden,
162+
},
163+
{
164+
name: "tenant path-form is refused (address by id)",
165+
urlPath: "/clusters/root:kedge:tenants:org1:ws1/api/v1/pods",
166+
wantStatus: http.StatusForbidden,
167+
},
168+
{
169+
name: "member cluster id passes through unchanged",
170+
urlPath: "/clusters/cidA/apis/v1/pods",
171+
wantStatus: 0,
172+
wantPath: "/clusters/cidA/apis/v1/pods",
173+
},
174+
{
175+
name: "member edge passes through unchanged",
176+
urlPath: "/clusters/cidA:edge1/apis/v1/pods",
177+
wantStatus: 0,
178+
wantPath: "/clusters/cidA:edge1/apis/v1/pods",
179+
},
180+
{
181+
name: "non-member cluster id is denied",
182+
urlPath: "/clusters/cidB/apis/v1/pods",
183+
wantStatus: http.StatusForbidden,
184+
},
185+
}
186+
187+
for _, tc := range tests {
188+
t.Run(tc.name, func(t *testing.T) {
189+
gotPath, gotStatus, gotBody := p.authorizeKCPPath(context.Background(), "user", tc.urlPath)
190+
if gotStatus != tc.wantStatus {
191+
t.Fatalf("status = %d (body %q), want %d", gotStatus, gotBody, tc.wantStatus)
192+
}
193+
if tc.wantStatus == 0 && gotPath != tc.wantPath {
194+
t.Errorf("path = %q, want %q", gotPath, tc.wantPath)
195+
}
196+
if tc.wantStatus != 0 && gotPath != "" {
197+
t.Errorf("expected empty path on denial, got %q", gotPath)
198+
}
199+
})
200+
}
201+
}

0 commit comments

Comments
 (0)