@@ -178,31 +178,17 @@ func (p *KCPProxy) serveOIDC(w http.ResponseWriter, r *http.Request, token strin
178178 return
179179 }
180180
181- // TODO(#65): bare paths (/api/..., /apis/...) are not handled here — kcpPath stays
182- // empty and the request is forwarded to the kcp root URL instead of the user's
183- // workspace. Add the same else-branch as serveStaticToken:
184- // kcpPath = "/clusters/" + user.Spec.DefaultCluster + r.URL.Path
185- // https://github.com/faroshq/kedge/issues/65
186-
187- // Determine kcp path based on incoming request format.
188- var kcpPath string
189- if strings .HasPrefix (r .URL .Path , "/clusters/" ) {
190- // kcp-syntax: /clusters/{logicalClusterName}/api/...
191- rest := strings .TrimPrefix (r .URL .Path , "/clusters/" )
192- slashIdx := strings .Index (rest , "/" )
193- clusterID := rest
194- if slashIdx >= 0 {
195- clusterID = rest [:slashIdx ]
196- }
197- // Allow exact match or mount access ({clusterName}:{mountName}).
198- if clusterID != user .Spec .DefaultCluster && ! strings .HasPrefix (clusterID , user .Spec .DefaultCluster + ":" ) {
199- p .logger .Info ("cluster access denied" , "user" , user .Name , "requested" , clusterID , "allowed" , user .Spec .DefaultCluster )
200- w .Header ().Set ("Content-Type" , "application/json" )
201- w .WriteHeader (http .StatusForbidden )
202- _ , _ = fmt .Fprint (w , `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"cluster access denied","reason":"Forbidden","code":403}` )
203- return
181+ kcpPath , errStatus , errBody := resolveKCPPath (r .URL .Path , user .Spec .DefaultCluster )
182+ if errStatus != 0 {
183+ if strings .HasPrefix (r .URL .Path , "/clusters/" ) {
184+ p .logger .Info ("cluster access denied" , "user" , user .Name , "path" , r .URL .Path , "allowed" , user .Spec .DefaultCluster )
185+ } else {
186+ p .logger .Error (nil , "user has no default cluster" , "user" , user .Name )
204187 }
205- kcpPath = r .URL .Path // already in /clusters/{id}/... format
188+ w .Header ().Set ("Content-Type" , "application/json" )
189+ w .WriteHeader (errStatus )
190+ _ , _ = fmt .Fprint (w , errBody )
191+ return
206192 }
207193
208194 target := * p .kcpTarget
@@ -253,36 +239,17 @@ func (p *KCPProxy) serveStaticToken(w http.ResponseWriter, r *http.Request, toke
253239 return
254240 }
255241
256- // Determine kcp path based on incoming request format.
257- var kcpPath string
258- if strings .HasPrefix (r .URL .Path , "/clusters/" ) {
259- // kcp-syntax: /clusters/{logicalClusterName}/api/...
260- rest := strings .TrimPrefix (r .URL .Path , "/clusters/" )
261- slashIdx := strings .Index (rest , "/" )
262- clusterID := rest
263- if slashIdx >= 0 {
264- clusterID = rest [:slashIdx ]
265- }
266- // Allow exact match or mount access ({clusterName}:{mountName}).
267- if clusterID != user .Spec .DefaultCluster && ! strings .HasPrefix (clusterID , user .Spec .DefaultCluster + ":" ) {
268- p .logger .Info ("cluster access denied" , "user" , user .Name , "requested" , clusterID , "allowed" , user .Spec .DefaultCluster )
269- w .Header ().Set ("Content-Type" , "application/json" )
270- w .WriteHeader (http .StatusForbidden )
271- _ , _ = fmt .Fprint (w , `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"cluster access denied","reason":"Forbidden","code":403}` )
272- return
273- }
274- kcpPath = r .URL .Path // already in /clusters/{id}/... format
275- } else {
276- // Bare path: construct workspace path from user's default cluster.
277- if user .Spec .DefaultCluster != "" {
278- kcpPath = "/clusters/" + user .Spec .DefaultCluster + r .URL .Path
242+ kcpPath , errStatus , errBody := resolveKCPPath (r .URL .Path , user .Spec .DefaultCluster )
243+ if errStatus != 0 {
244+ if strings .HasPrefix (r .URL .Path , "/clusters/" ) {
245+ p .logger .Info ("cluster access denied" , "user" , user .Name , "path" , r .URL .Path , "allowed" , user .Spec .DefaultCluster )
279246 } else {
280247 p .logger .Error (nil , "user has no default cluster" , "user" , user .Name )
281- w .Header ().Set ("Content-Type" , "application/json" )
282- w .WriteHeader (http .StatusForbidden )
283- _ , _ = fmt .Fprint (w , `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"user workspace not configured","reason":"Forbidden","code":403}` )
284- return
285248 }
249+ w .Header ().Set ("Content-Type" , "application/json" )
250+ w .WriteHeader (errStatus )
251+ _ , _ = fmt .Fprint (w , errBody )
252+ return
286253 }
287254
288255 target := * p .kcpTarget
@@ -524,6 +491,39 @@ func writeUnauthorized(w http.ResponseWriter) {
524491 _ , _ = fmt .Fprint (w , `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}` )
525492}
526493
494+ // resolveKCPPath computes the target kcp path for the given request URL path.
495+ //
496+ // Two formats are accepted:
497+ // - /clusters/{logicalClusterName}/... — validated against defaultCluster;
498+ // returns the original path unchanged on success.
499+ // - /api/... or /apis/... (bare paths) — prepended with
500+ // /clusters/{defaultCluster}; returns 403 if defaultCluster is empty.
501+ //
502+ // Returns (kcpPath, 0, "") on success, or ("", httpStatus, jsonBody) on error.
503+ // The caller is responsible for logging context and writing the HTTP response.
504+ func resolveKCPPath (urlPath , defaultCluster string ) (string , int , string ) {
505+ if strings .HasPrefix (urlPath , "/clusters/" ) {
506+ // kcp-syntax: validate that the requested cluster matches the user's workspace.
507+ rest := strings .TrimPrefix (urlPath , "/clusters/" )
508+ slashIdx := strings .Index (rest , "/" )
509+ clusterID := rest
510+ if slashIdx >= 0 {
511+ clusterID = rest [:slashIdx ]
512+ }
513+ // Allow exact match or mount access ({clusterName}:{mountName}).
514+ if clusterID != defaultCluster && ! strings .HasPrefix (clusterID , defaultCluster + ":" ) {
515+ return "" , http .StatusForbidden , `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"cluster access denied","reason":"Forbidden","code":403}`
516+ }
517+ return urlPath , 0 , ""
518+ }
519+
520+ // Bare path: scope to the user's default cluster.
521+ if defaultCluster != "" {
522+ return "/clusters/" + defaultCluster + urlPath , 0 , ""
523+ }
524+ return "" , http .StatusForbidden , `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"user has no default cluster","reason":"Forbidden","code":403}`
525+ }
526+
527527// resolveUser looks up the User CRD by OIDC issuer+sub hash and returns the full User object.
528528func (p * KCPProxy ) resolveUser (ctx context.Context , issuer , sub string ) (* tenancyv1alpha1.User , error ) {
529529 hash := sha256 .Sum256 ([]byte (issuer + "/" + sub ))
0 commit comments