Skip to content

Commit 3843bcf

Browse files
authored
Merge pull request #4082 from mjudeikis/wst.managed.fix
Add aditional validation for WST
2 parents 02b1e95 + bab0657 commit 3843bcf

3 files changed

Lines changed: 667 additions & 5 deletions

File tree

pkg/admission/workspacetype/admission.go

Lines changed: 170 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package workspacetype
1818

1919
import (
2020
"context"
21+
"errors"
2122
"fmt"
2223
"io"
2324

@@ -26,13 +27,32 @@ import (
2627
"k8s.io/apimachinery/pkg/runtime"
2728
"k8s.io/apiserver/pkg/admission"
2829
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
30+
"k8s.io/client-go/tools/cache"
31+
"k8s.io/klog/v2"
2932

33+
kcpkubernetesclientset "github.com/kcp-dev/client-go/kubernetes"
34+
"github.com/kcp-dev/logicalcluster/v3"
35+
apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2"
3036
"github.com/kcp-dev/sdk/apis/core"
3137
tenancyv1alpha1 "github.com/kcp-dev/sdk/apis/tenancy/v1alpha1"
38+
kcpinformers "github.com/kcp-dev/sdk/client/informers/externalversions"
39+
40+
apibindingadmission "github.com/kcp-dev/kcp/pkg/admission/apibinding"
41+
kcpinitializers "github.com/kcp-dev/kcp/pkg/admission/initializers"
42+
"github.com/kcp-dev/kcp/pkg/authorization/delegated"
43+
"github.com/kcp-dev/kcp/pkg/indexers"
3244
)
3345

3446
// Validate WorkspaceTypes creation and updates for
35-
// - "organization" type is only created in root workspace.
47+
// - the "root" WorkspaceType can only be created in the root cluster.
48+
// - .spec.defaultChildWorkspaceType.path, .spec.limitAllowedChildren.types[*].path,
49+
// and .spec.limitAllowedParents.types[*].path must be set when their parent
50+
// fields are present.
51+
// - the user has the "bind" verb on every APIExport listed in
52+
// spec.defaultAPIBindings (newly added entries on update). This prevents
53+
// a privilege escalation where unprivileged users would otherwise cause
54+
// the default-apibinding-controller to create APIBindings on their behalf
55+
// to APIExports they cannot bind directly.
3656

3757
const (
3858
PluginName = "tenancy.kcp.io/WorkspaceType"
@@ -41,18 +61,36 @@ const (
4161
func Register(plugins *admission.Plugins) {
4262
plugins.Register(PluginName,
4363
func(_ io.Reader) (admission.Interface, error) {
44-
return &workspacetype{
45-
Handler: admission.NewHandler(admission.Create, admission.Update),
46-
}, nil
64+
p := &workspacetype{
65+
Handler: admission.NewHandler(admission.Create, admission.Update),
66+
createAuthorizer: delegated.NewDelegatedAuthorizer,
67+
}
68+
p.getAPIExport = func(path logicalcluster.Path, name string) (*apisv1alpha2.APIExport, error) {
69+
return indexers.ByPathAndNameWithFallback[*apisv1alpha2.APIExport](apisv1alpha2.Resource("apiexports"), p.apiExportIndexer, p.cacheAPIExportIndexer, path, name)
70+
}
71+
return p, nil
4772
})
4873
}
4974

5075
type workspacetype struct {
5176
*admission.Handler
77+
78+
getAPIExport func(path logicalcluster.Path, name string) (*apisv1alpha2.APIExport, error)
79+
80+
apiExportIndexer cache.Indexer
81+
cacheAPIExportIndexer cache.Indexer
82+
83+
deepSARClient kcpkubernetesclientset.ClusterInterface
84+
createAuthorizer delegated.DelegatedAuthorizerFactory
5285
}
5386

5487
// Ensure that the required admission interfaces are implemented.
55-
var _ = admission.ValidationInterface(&workspacetype{})
88+
var (
89+
_ = admission.ValidationInterface(&workspacetype{})
90+
_ = admission.InitializationValidator(&workspacetype{})
91+
_ = kcpinitializers.WantsKcpInformers(&workspacetype{})
92+
_ = kcpinitializers.WantsDeepSARClient(&workspacetype{})
93+
)
5694

5795
func (o *workspacetype) Validate(ctx context.Context, a admission.Attributes, _ admission.ObjectInterfaces) (err error) {
5896
clusterName, err := genericapirequest.ClusterNameFrom(ctx)
@@ -97,5 +135,132 @@ func (o *workspacetype) Validate(ctx context.Context, a admission.Attributes, _
97135
}
98136
}
99137

138+
return o.checkDefaultAPIBindingsPermissions(ctx, a, clusterName, wt)
139+
}
140+
141+
// checkDefaultAPIBindingsPermissions ensures the user creating or updating the
142+
// WorkspaceType has the "bind" verb on every APIExport newly added to
143+
// spec.defaultAPIBindings. The default-apibinding-controller later creates
144+
// APIBindings to these exports using its own (system) credentials, so without
145+
// this check unprivileged users could indirectly bind APIExports they have no
146+
// "bind" permission on.
147+
func (o *workspacetype) checkDefaultAPIBindingsPermissions(ctx context.Context, a admission.Attributes, clusterName logicalcluster.Name, wt *tenancyv1alpha1.WorkspaceType) error {
148+
if len(wt.Spec.DefaultAPIBindings) == 0 {
149+
return nil
150+
}
151+
152+
if !o.WaitForReady() {
153+
return admission.NewForbidden(a, errors.New("not yet ready to handle request"))
154+
}
155+
156+
// `grandfathered` holds the entries that should be skipped by the permission
157+
// check below.
158+
//
159+
// * On Create: stays nil. Reads from a nil map return (zero, false), so
160+
// nothing is skipped and *every* binding in wt.Spec.DefaultAPIBindings
161+
// is checked. This is the primary entry point for the permission gate.
162+
//
163+
// * On Update: populated from the old object's bindings. Entries present
164+
// in the old object are "grandfathered" — they were already validated
165+
// when the WorkspaceType was created (or, for objects predating this
166+
// check, predate it entirely). Only newly added bindings are checked,
167+
// so a user without bind permission on an unrelated existing entry can
168+
// still perform legitimate updates.
169+
var grandfathered map[tenancyv1alpha1.APIExportReference]struct{}
170+
if a.GetOperation() == admission.Update {
171+
oldU, ok := a.GetOldObject().(*unstructured.Unstructured)
172+
if !ok {
173+
return fmt.Errorf("unexpected type %T", a.GetOldObject())
174+
}
175+
oldWT := &tenancyv1alpha1.WorkspaceType{}
176+
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(oldU.Object, oldWT); err != nil {
177+
return fmt.Errorf("failed to convert old unstructured to WorkspaceType: %w", err)
178+
}
179+
grandfathered = make(map[tenancyv1alpha1.APIExportReference]struct{}, len(oldWT.Spec.DefaultAPIBindings))
180+
for _, ref := range oldWT.Spec.DefaultAPIBindings {
181+
grandfathered[ref] = struct{}{}
182+
}
183+
}
184+
185+
for _, ref := range wt.Spec.DefaultAPIBindings {
186+
// Skip grandfathered entries on Update; on Create the map is nil so this
187+
// is always false and every entry falls through to the permission check.
188+
if _, ok := grandfathered[ref]; ok {
189+
continue
190+
}
191+
192+
exportPath := ref.Path
193+
exportName := ref.Export
194+
195+
// unified forbidden error that does not leak workspace existence
196+
forbidden := admission.NewForbidden(a, fmt.Errorf("unable to create or update WorkspaceType: no permission to bind to export %s",
197+
logicalcluster.NewPath(exportPath).Join(exportName).String()))
198+
199+
// Resolve the APIExport's cluster. An empty path means the same cluster
200+
// as the WorkspaceType being admitted (matching the reconciler's behavior).
201+
var exportClusterName logicalcluster.Name
202+
switch {
203+
case exportPath == "":
204+
exportClusterName = clusterName
205+
case exportPath == core.RootCluster.String():
206+
exportClusterName = core.RootCluster
207+
default:
208+
path := logicalcluster.NewPath(exportPath)
209+
export, err := o.getAPIExport(path, exportName)
210+
if err != nil {
211+
return forbidden
212+
}
213+
exportClusterName = logicalcluster.From(export)
214+
}
215+
216+
if err := o.checkAPIExportAccess(ctx, a, exportClusterName, exportName); err != nil {
217+
return forbidden
218+
}
219+
}
220+
221+
return nil
222+
}
223+
224+
func (o *workspacetype) checkAPIExportAccess(ctx context.Context, a admission.Attributes, apiExportClusterName logicalcluster.Name, apiExportName string) error {
225+
logger := klog.FromContext(ctx)
226+
authz, err := o.createAuthorizer(apiExportClusterName, o.deepSARClient, delegated.Options{})
227+
if err != nil {
228+
logger.Error(err, "error creating authorizer from delegating authorizer config")
229+
return errors.New("unable to authorize request")
230+
}
231+
return apibindingadmission.CheckAPIExportAccess(ctx, a.GetUserInfo(), apiExportName, authz)
232+
}
233+
234+
func (o *workspacetype) ValidateInitialization() error {
235+
if o.deepSARClient == nil {
236+
return fmt.Errorf(PluginName + " plugin needs a deepSARClient")
237+
}
238+
if o.apiExportIndexer == nil {
239+
return fmt.Errorf(PluginName + " plugin needs an APIExport indexer")
240+
}
241+
if o.cacheAPIExportIndexer == nil {
242+
return fmt.Errorf(PluginName + " plugin needs a cache APIExport indexer")
243+
}
100244
return nil
101245
}
246+
247+
func (o *workspacetype) SetDeepSARClient(client kcpkubernetesclientset.ClusterInterface) {
248+
o.deepSARClient = client
249+
}
250+
251+
func (o *workspacetype) SetKcpInformers(local, global kcpinformers.SharedInformerFactory) {
252+
apiExportsReady := local.Apis().V1alpha2().APIExports().Informer().HasSynced
253+
cacheAPIExportsReady := global.Apis().V1alpha2().APIExports().Informer().HasSynced
254+
o.SetReadyFunc(func() bool {
255+
return apiExportsReady() && cacheAPIExportsReady()
256+
})
257+
o.apiExportIndexer = local.Apis().V1alpha2().APIExports().Informer().GetIndexer()
258+
o.cacheAPIExportIndexer = global.Apis().V1alpha2().APIExports().Informer().GetIndexer()
259+
260+
indexers.AddIfNotPresentOrDie(local.Apis().V1alpha2().APIExports().Informer().GetIndexer(), cache.Indexers{
261+
indexers.ByLogicalClusterPathAndName: indexers.IndexByLogicalClusterPathAndName,
262+
})
263+
indexers.AddIfNotPresentOrDie(global.Apis().V1alpha2().APIExports().Informer().GetIndexer(), cache.Indexers{
264+
indexers.ByLogicalClusterPathAndName: indexers.IndexByLogicalClusterPathAndName,
265+
})
266+
}

0 commit comments

Comments
 (0)