@@ -18,6 +18,7 @@ package workspacetype
1818
1919import (
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
3757const (
3858 PluginName = "tenancy.kcp.io/WorkspaceType"
@@ -41,18 +61,36 @@ const (
4161func 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
5075type 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
5795func (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