Skip to content

Commit 614ecbf

Browse files
authored
Merge pull request #3338 from embik/binding-authorizer
🐛 Add binding authorizer to APIExport virtual workspace
2 parents 2bd1950 + 3b0da75 commit 614ecbf

File tree

5 files changed

+403
-10
lines changed

5 files changed

+403
-10
lines changed
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
Copyright 2025 The KCP 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+
package authorizer
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"slices"
23+
"strings"
24+
25+
"k8s.io/apimachinery/pkg/labels"
26+
"k8s.io/apiserver/pkg/authorization/authorizer"
27+
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
28+
29+
kcpkubernetesclientset "github.com/kcp-dev/client-go/kubernetes"
30+
"github.com/kcp-dev/logicalcluster/v3"
31+
32+
dynamiccontext "github.com/kcp-dev/kcp/pkg/virtual/framework/dynamic/context"
33+
apisv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha1"
34+
apisv1alpha1informers "github.com/kcp-dev/kcp/sdk/client/informers/externalversions/apis/v1alpha1"
35+
)
36+
37+
type boundAPIAuthorizer struct {
38+
getAPIBindingByExport func(clusterName, apiExportName, apiExportCluster string) (*apisv1alpha1.APIBinding, error)
39+
40+
delegate authorizer.Authorizer
41+
}
42+
43+
var readOnlyVerbs = []string{"get", "list", "watch"}
44+
45+
func NewBoundAPIAuthorizer(delegate authorizer.Authorizer, apiBindingInformer apisv1alpha1informers.APIBindingClusterInformer, kubeClusterClient kcpkubernetesclientset.ClusterInterface) authorizer.Authorizer {
46+
apiBindingLister := apiBindingInformer.Lister()
47+
48+
return &boundAPIAuthorizer{
49+
delegate: delegate,
50+
getAPIBindingByExport: func(clusterName, apiExportName, apiExportCluster string) (*apisv1alpha1.APIBinding, error) {
51+
bindings, err := apiBindingLister.Cluster(logicalcluster.Name(clusterName)).List(labels.Everything())
52+
if err != nil {
53+
return nil, err
54+
}
55+
56+
for _, binding := range bindings {
57+
if binding == nil {
58+
continue
59+
}
60+
61+
if binding.Spec.Reference.Export != nil && binding.Spec.Reference.Export.Name == apiExportName && binding.Status.APIExportClusterName == apiExportCluster {
62+
return binding, nil
63+
}
64+
}
65+
66+
return nil, fmt.Errorf("no suitable binding found")
67+
},
68+
}
69+
}
70+
71+
func (a *boundAPIAuthorizer) Authorize(ctx context.Context, attr authorizer.Attributes) (authorizer.Decision, string, error) {
72+
targetCluster, err := genericapirequest.ValidClusterFrom(ctx)
73+
if err != nil {
74+
return authorizer.DecisionNoOpinion, "", fmt.Errorf("error getting valid cluster from context: %w", err)
75+
}
76+
77+
if targetCluster.Wildcard || attr.GetResource() == "" {
78+
// if the target is the wildcard cluster or it's a non-resurce URL request,
79+
// we can skip checking the APIBinding in the target cluster.
80+
return a.delegate.Authorize(ctx, attr)
81+
}
82+
83+
apiDomainKey := dynamiccontext.APIDomainKeyFrom(ctx)
84+
parts := strings.Split(string(apiDomainKey), "/")
85+
if len(parts) < 2 {
86+
return authorizer.DecisionNoOpinion, "", fmt.Errorf("invalid API domain key")
87+
}
88+
apiExportCluster, apiExportName := parts[0], parts[1]
89+
90+
apiBinding, err := a.getAPIBindingByExport(targetCluster.Name.String(), apiExportName, apiExportCluster)
91+
if err != nil {
92+
return authorizer.DecisionDeny, "could not find suitable APIBinding in target logical cluster", nil //nolint:nilerr // this is on purpose, we want to deny, not return a server error
93+
}
94+
95+
// check if request is for a bound resource.
96+
for _, resource := range apiBinding.Status.BoundResources {
97+
if resource.Group == attr.GetAPIGroup() && resource.Resource == attr.GetResource() {
98+
return a.delegate.Authorize(ctx, attr)
99+
}
100+
}
101+
102+
// check if a resource claim for this resource has been accepted.
103+
for _, permissionClaim := range apiBinding.Spec.PermissionClaims {
104+
if permissionClaim.State != apisv1alpha1.ClaimAccepted {
105+
// if the claim is not accepted it cannot be used.
106+
continue
107+
}
108+
109+
if permissionClaim.Group == attr.GetAPIGroup() && permissionClaim.Resource == attr.GetResource() {
110+
return a.delegate.Authorize(ctx, attr)
111+
}
112+
}
113+
114+
// special case: APIBindings are always available from an APIExport VW,
115+
// but the provider should only be allowed to access them read-only to avoid privilege escalation.
116+
if attr.GetAPIGroup() == apisv1alpha1.SchemeGroupVersion.Group && attr.GetResource() == "apibindings" {
117+
if !slices.Contains(readOnlyVerbs, attr.GetVerb()) {
118+
return authorizer.DecisionNoOpinion, "write access to APIBinding is not allowed from virtual workspace", nil
119+
}
120+
121+
return a.delegate.Authorize(ctx, attr)
122+
}
123+
124+
// if we cannot find the API bound to the logical cluster, we deny.
125+
// The APIExport owner has not been invited in.
126+
return authorizer.DecisionDeny, "failed to find suitable reason to allow access in APIBinding", nil
127+
}

pkg/virtual/apiexport/builder/build.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ func BuildVirtualWorkspace(
7070
cfg *rest.Config,
7171
kubeClusterClient, deepSARClient kcpkubernetesclientset.ClusterInterface,
7272
kcpClusterClient kcpclientset.ClusterInterface,
73-
cachedKcpInformers kcpinformers.SharedInformerFactory,
73+
cachedKcpInformers, kcpInformers kcpinformers.SharedInformerFactory,
7474
) ([]rootapiserver.NamedVirtualWorkspace, error) {
7575
if !strings.HasSuffix(rootPathPrefix, "/") {
7676
rootPathPrefix += "/"
@@ -203,6 +203,7 @@ func BuildVirtualWorkspace(
203203
for name, informer := range map[string]cache.SharedIndexInformer{
204204
"apiresourceschemas": cachedKcpInformers.Apis().V1alpha1().APIResourceSchemas().Informer(),
205205
"apiexports": cachedKcpInformers.Apis().V1alpha1().APIExports().Informer(),
206+
"apibindings": kcpInformers.Apis().V1alpha1().APIBindings().Informer(),
206207
} {
207208
if !cache.WaitForNamedCacheSync(name, hookContext.Done(), informer.HasSynced) {
208209
klog.Background().Error(nil, "informer not synced")
@@ -218,7 +219,7 @@ func BuildVirtualWorkspace(
218219

219220
return apiReconciler, nil
220221
},
221-
Authorizer: newAuthorizer(kubeClusterClient, deepSARClient, cachedKcpInformers),
222+
Authorizer: newAuthorizer(kubeClusterClient, deepSARClient, cachedKcpInformers, kcpInformers),
222223
}
223224

224225
return []rootapiserver.NamedVirtualWorkspace{
@@ -291,14 +292,17 @@ func digestUrl(urlPath, rootPathPrefix string) (
291292
return cluster, dynamiccontext.APIDomainKey(key), strings.TrimSuffix(urlPath, realPath), true
292293
}
293294

294-
func newAuthorizer(kubeClusterClient, deepSARClient kcpkubernetesclientset.ClusterInterface, cachedKcpInformers kcpinformers.SharedInformerFactory) authorizer.Authorizer {
295+
func newAuthorizer(kubeClusterClient, deepSARClient kcpkubernetesclientset.ClusterInterface, cachedKcpInformers, kcpInformers kcpinformers.SharedInformerFactory) authorizer.Authorizer {
295296
maximalPermissionAuth := virtualapiexportauth.NewMaximalPermissionAuthorizer(deepSARClient, cachedKcpInformers.Apis().V1alpha1().APIExports())
296297
maximalPermissionAuth = authorization.NewDecorator("virtual.apiexport.maxpermissionpolicy.authorization.kcp.io", maximalPermissionAuth).AddAuditLogging().AddAnonymization().AddReasonAnnotation()
297298

298299
apiExportsContentAuth := virtualapiexportauth.NewAPIExportsContentAuthorizer(maximalPermissionAuth, kubeClusterClient)
299300
apiExportsContentAuth = authorization.NewDecorator("virtual.apiexport.content.authorization.kcp.io", apiExportsContentAuth).AddAuditLogging().AddAnonymization()
300301

301-
return apiExportsContentAuth
302+
boundApiAuth := virtualapiexportauth.NewBoundAPIAuthorizer(apiExportsContentAuth, kcpInformers.Apis().V1alpha1().APIBindings(), kubeClusterClient)
303+
boundApiAuth = authorization.NewDecorator("virtual.apiexport.boundapi.authorization.kcp.io", boundApiAuth).AddAuditLogging().AddAnonymization()
304+
305+
return boundApiAuth
302306
}
303307

304308
// apiDefinitionWithCancel calls the cancelFn on tear-down.

pkg/virtual/apiexport/options/options.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ func (o *APIExport) Validate(flagPrefix string) []error {
5656
func (o *APIExport) NewVirtualWorkspaces(
5757
rootPathPrefix string,
5858
config *rest.Config,
59-
cachedKcpInformers kcpinformers.SharedInformerFactory,
59+
cachedKcpInformers, wildcardKcpInformers kcpinformers.SharedInformerFactory,
6060
) (workspaces []rootapiserver.NamedVirtualWorkspace, err error) {
6161
config = rest.AddUserAgent(rest.CopyConfig(config), "apiexport-virtual-workspace")
6262
kcpClusterClient, err := kcpclientset.NewForConfig(config)
@@ -72,5 +72,5 @@ func (o *APIExport) NewVirtualWorkspaces(
7272
return nil, err
7373
}
7474

75-
return builder.BuildVirtualWorkspace(path.Join(rootPathPrefix, builder.VirtualWorkspaceName), config, kubeClusterClient, deepSARClient, kcpClusterClient, cachedKcpInformers)
75+
return builder.BuildVirtualWorkspace(path.Join(rootPathPrefix, builder.VirtualWorkspaceName), config, kubeClusterClient, deepSARClient, kcpClusterClient, cachedKcpInformers, wildcardKcpInformers)
7676
}

pkg/virtual/options/options.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func (o *Options) NewVirtualWorkspaces(
6565
wildcardKubeInformers kcpkubernetesinformers.SharedInformerFactory,
6666
wildcardKcpInformers, cachedKcpInformers kcpinformers.SharedInformerFactory,
6767
) ([]rootapiserver.NamedVirtualWorkspace, error) {
68-
apiexports, err := o.APIExport.NewVirtualWorkspaces(rootPathPrefix, config, cachedKcpInformers)
68+
apiexports, err := o.APIExport.NewVirtualWorkspaces(rootPathPrefix, config, cachedKcpInformers, wildcardKcpInformers)
6969
if err != nil {
7070
return nil, err
7171
}

0 commit comments

Comments
 (0)