diff --git a/examples/project-spawner/project-spawner.sh b/examples/project-spawner/project-spawner.sh new file mode 100755 index 000000000000..80a6788e13a8 --- /dev/null +++ b/examples/project-spawner/project-spawner.sh @@ -0,0 +1,11 @@ +# Generates 500 projects + +set -o errexit +set -o nounset +set -o pipefail + +#!/bin/bash +for i in {1..500} +do + openshift ex new-project projects-${i} +done \ No newline at end of file diff --git a/pkg/authorization/authorizer/authorizer.go b/pkg/authorization/authorizer/authorizer.go index d913e638a08f..730300ac7ff0 100644 --- a/pkg/authorization/authorizer/authorizer.go +++ b/pkg/authorization/authorizer/authorizer.go @@ -351,6 +351,7 @@ func GetBootstrapPolicy(masterNamespace string) *authorizationapi.Policy { Name: authorizationapi.PolicyName, Namespace: masterNamespace, CreationTimestamp: util.Now(), + UID: util.NewUUID(), }, LastModified: util.Now(), Roles: map[string]authorizationapi.Role{ @@ -482,6 +483,7 @@ func GetBootstrapPolicyBinding(masterNamespace string) *authorizationapi.PolicyB Name: masterNamespace, Namespace: masterNamespace, CreationTimestamp: util.Now(), + UID: util.NewUUID(), }, LastModified: util.Now(), PolicyRef: kapi.ObjectReference{Namespace: masterNamespace}, diff --git a/pkg/cmd/server/origin/master.go b/pkg/cmd/server/origin/master.go index 073b44d5cd1e..4c8e8f470308 100644 --- a/pkg/cmd/server/origin/master.go +++ b/pkg/cmd/server/origin/master.go @@ -60,6 +60,7 @@ import ( clientregistry "github.com/openshift/origin/pkg/oauth/registry/client" clientauthorizationregistry "github.com/openshift/origin/pkg/oauth/registry/clientauthorization" oauthetcd "github.com/openshift/origin/pkg/oauth/registry/etcd" + projectauth "github.com/openshift/origin/pkg/project/auth" projectregistry "github.com/openshift/origin/pkg/project/registry/project" routeetcd "github.com/openshift/origin/pkg/route/registry/etcd" routeregistry "github.com/openshift/origin/pkg/route/registry/route" @@ -114,6 +115,8 @@ type MasterConfig struct { AuthorizationAttributeBuilder authorizer.AuthorizationAttributeBuilder MasterAuthorizationNamespace string + ProjectAuthorizationCache *projectauth.AuthorizationCache + // Map requests to contexts RequestContextMapper kapi.RequestContextMapper @@ -188,6 +191,15 @@ func (c *MasterConfig) KubeClient() *kclient.Client { return c.kubeClient } +// PolicyClient returns the policy client object +// It must have the following capabilities: +// list, watch all policyBindings in all namespaces +// list, watch all policies in all namespaces +// create resourceAccessReviews in all namespaces +func (c *MasterConfig) PolicyClient() *osclient.Client { + return c.osClient +} + // DeploymentClient returns the deployment client object func (c *MasterConfig) DeploymentClient() *kclient.Client { return c.kubeClient @@ -288,7 +300,7 @@ func (c *MasterConfig) InstallProtectedAPI(container *restful.Container) []strin "routes": routeregistry.NewREST(routeEtcd), - "projects": projectregistry.NewREST(kclient.Namespaces()), + "projects": projectregistry.NewREST(kclient.Namespaces(), c.ProjectAuthorizationCache), "userIdentityMappings": useridentitymapping.NewREST(userEtcd), "users": userregistry.NewREST(userEtcd), @@ -366,8 +378,6 @@ func initAPIVersionRoute(root *restful.WebService, version string) { func (c *MasterConfig) Run(protected []APIInstaller, unprotected []APIInstaller) { var extra []string - c.ensureComponentAuthorizationRules() - safe := kmaster.NewHandlerContainer(http.NewServeMux()) open := kmaster.NewHandlerContainer(http.NewServeMux()) @@ -437,6 +447,14 @@ func (c *MasterConfig) Run(protected []APIInstaller, unprotected []APIInstaller) // Attempt to verify the server came up for 20 seconds (100 tries * 100ms, 100ms timeout per try) cmdutil.WaitForSuccessfulDial("tcp", c.MasterBindAddr, 100*time.Millisecond, 100*time.Millisecond, 100) + + // Attempt to create the required policy rules now, and then stick in a forever loop to make sure they are always available + c.ensureMasterAuthorizationNamespace() + c.ensureComponentAuthorizationRules() + go util.Forever(func() { + c.ensureMasterAuthorizationNamespace() + c.ensureComponentAuthorizationRules() + }, 10*time.Second) } // getRequestContextMapper returns a mapper from requests to contexts, initializing it if needed @@ -447,6 +465,21 @@ func (c *MasterConfig) getRequestContextMapper() kapi.RequestContextMapper { return c.RequestContextMapper } +// ensureMasterAuthorizationNamespace is called as part of global policy initialization to ensure master namespace exists +func (c *MasterConfig) ensureMasterAuthorizationNamespace() { + + // ensure that master namespace actually exists + namespace, err := c.KubeClient().Namespaces().Get(c.MasterAuthorizationNamespace) + if err != nil { + namespace = &kapi.Namespace{ObjectMeta: kapi.ObjectMeta{Name: c.MasterAuthorizationNamespace}} + kapi.FillObjectMetaSystemFields(api.NewContext(), &namespace.ObjectMeta) + _, err = c.KubeClient().Namespaces().Create(namespace) + if err != nil { + glog.Errorf("Error creating namespace: %v due to %v\n", namespace, err) + } + } +} + // ensureComponentAuthorizationRules initializes the global policies func (c *MasterConfig) ensureComponentAuthorizationRules() { registry := authorizationetcd.New(c.EtcdHelper) @@ -519,6 +552,13 @@ func forbidden(reason string, w http.ResponseWriter, req *http.Request) { fmt.Fprintf(w, "Forbidden: %q %s", req.RequestURI, reason) } +// RunProjectAuthorizationCache starts the project authorization cache +func (c *MasterConfig) RunProjectAuthorizationCache() { + // TODO: look at exposing a configuration option in future to control how often we run this loop + period := 1 * time.Second + c.ProjectAuthorizationCache.Run(period) +} + // RunAssetServer starts the asset server for the OpenShift UI. func (c *MasterConfig) RunAssetServer() { // TODO use version.Get().GitCommit as an etag cache header diff --git a/pkg/cmd/server/start.go b/pkg/cmd/server/start.go index af2fbcc8911d..4c236c15e41c 100644 --- a/pkg/cmd/server/start.go +++ b/pkg/cmd/server/start.go @@ -40,6 +40,7 @@ import ( "github.com/openshift/origin/pkg/authorization/authorizer" authorizationetcd "github.com/openshift/origin/pkg/authorization/registry/etcd" "github.com/openshift/origin/pkg/authorization/rulevalidation" + projectauth "github.com/openshift/origin/pkg/project/auth" "github.com/openshift/origin/pkg/auth/group" "github.com/openshift/origin/pkg/cmd/flagtypes" @@ -501,6 +502,13 @@ func start(cfg *config, args []string) error { osmaster.BuildClients() + osmaster.ProjectAuthorizationCache = projectauth.NewAuthorizationCache( + projectauth.NewReviewer(osmaster.PolicyClient()), + osmaster.KubeClient().Namespaces(), + osmaster.PolicyClient(), + osmaster.PolicyClient(), + osmaster.MasterAuthorizationNamespace) + // Default to a session authenticator (for browsers), and a basicauth authenticator (for clients responding to WWW-Authenticate challenges) defaultAuthRequestHandlers := strings.Join([]string{ string(origin.AuthRequestHandlerSession), @@ -599,6 +607,7 @@ func start(cfg *config, args []string) error { osmaster.RunDeploymentConfigController() osmaster.RunDeploymentConfigChangeController() osmaster.RunDeploymentImageChangeTriggerController() + osmaster.RunProjectAuthorizationCache() existingKubeClient = osmaster.KubeClient() } diff --git a/pkg/project/auth/cache.go b/pkg/project/auth/cache.go new file mode 100644 index 000000000000..562be230082f --- /dev/null +++ b/pkg/project/auth/cache.go @@ -0,0 +1,482 @@ +package auth + +import ( + "fmt" + "time" + + kapi "github.com/GoogleCloudPlatform/kubernetes/pkg/api" + "github.com/GoogleCloudPlatform/kubernetes/pkg/auth/user" + kclient "github.com/GoogleCloudPlatform/kubernetes/pkg/client" + "github.com/GoogleCloudPlatform/kubernetes/pkg/client/cache" + "github.com/GoogleCloudPlatform/kubernetes/pkg/labels" + "github.com/GoogleCloudPlatform/kubernetes/pkg/runtime" + "github.com/GoogleCloudPlatform/kubernetes/pkg/types" + "github.com/GoogleCloudPlatform/kubernetes/pkg/util" + "github.com/GoogleCloudPlatform/kubernetes/pkg/watch" + + authorizationapi "github.com/openshift/origin/pkg/authorization/api" + "github.com/openshift/origin/pkg/client" +) + +// Lister enforces ability to enumerate a resource based on policy +type Lister interface { + // List returns the list of Namespace items that the user can access + List(user user.Info) (*kapi.NamespaceList, error) +} + +// subjectRecord is a cache record for the set of namespaces a subject can access +type subjectRecord struct { + subject string + namespaces util.StringSet +} + +// reviewRequest is the resource we want to review +type reviewRequest struct { + namespace string + // the resource version of the namespace that was observed to make this request + namespaceResourceVersion string + // the map of policy uid to resource version that was observed to make this request + policyUIDToResourceVersion map[types.UID]string + // the map of policy binding uid to resource version that was observed to make this request + policyBindingUIDToResourceVersion map[types.UID]string +} + +// reviewRecord is a cache record for the result of a resource access review +type reviewRecord struct { + *reviewRequest + users []string + groups []string +} + +// reviewRecordKeyFn is a key func for reviewRecord objects +func reviewRecordKeyFn(obj interface{}) (string, error) { + reviewRecord, ok := obj.(*reviewRecord) + if !ok { + return "", fmt.Errorf("expected reviewRecord") + } + return reviewRecord.namespace, nil +} + +// subjectRecordKeyFn is a key func for subjectRecord objects +func subjectRecordKeyFn(obj interface{}) (string, error) { + subjectRecord, ok := obj.(*subjectRecord) + if !ok { + return "", fmt.Errorf("expected subjectRecord") + } + return subjectRecord.subject, nil +} + +// TODO: Eliminate listWatch when this merges upstream: https://github.com/GoogleCloudPlatform/kubernetes/pull/4453 +type listFunc func() (runtime.Object, error) +type watchFunc func(resourceVersion string) (watch.Interface, error) +type listWatch struct { + listFunc listFunc + watchFunc watchFunc +} + +func (lw *listWatch) List() (runtime.Object, error) { + return lw.listFunc() +} + +func (lw *listWatch) Watch(resourceVersion string) (watch.Interface, error) { + return lw.watchFunc(resourceVersion) +} + +// AuthorizationCache maintains a cache on the set of namespaces a user or group can access. +type AuthorizationCache struct { + namespaceStore cache.Store + policyBindingIndexer cache.Indexer + policyIndexer cache.Indexer + reviewRecordStore cache.Store + userSubjectRecordStore cache.Store + groupSubjectRecordStore cache.Store + + masterNamespace string + masterBindingResourceVersion string + masterPolicyResourceVersion string + + reviewer Reviewer + + namespaceInterface kclient.NamespaceInterface + policyBindingsNamespacer client.PolicyBindingsNamespacer + policiesNamespacer client.PoliciesNamespacer + + syncHandler func(request *reviewRequest, userSubjectRecordStore cache.Store, groupSubjectRecordStore cache.Store, reviewRecordStore cache.Store) error +} + +// NewAuthorizationCache creates a new AuthorizationCache +func NewAuthorizationCache(reviewer Reviewer, namespaceInterface kclient.NamespaceInterface, policyBindingsNamespacer client.PolicyBindingsNamespacer, policiesNamespacer client.PoliciesNamespacer, masterNamespace string) *AuthorizationCache { + result := &AuthorizationCache{ + namespaceStore: cache.NewStore(cache.MetaNamespaceKeyFunc), + policyIndexer: cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{"namespace": cache.MetaNamespaceIndexFunc}), + policyBindingIndexer: cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{"namespace": cache.MetaNamespaceIndexFunc}), + reviewRecordStore: cache.NewStore(reviewRecordKeyFn), + userSubjectRecordStore: cache.NewStore(subjectRecordKeyFn), + groupSubjectRecordStore: cache.NewStore(subjectRecordKeyFn), + masterNamespace: masterNamespace, + masterBindingResourceVersion: "", + masterPolicyResourceVersion: "", + namespaceInterface: namespaceInterface, + policyBindingsNamespacer: policyBindingsNamespacer, + policiesNamespacer: policiesNamespacer, + reviewer: reviewer, + } + result.syncHandler = result.syncRequest + return result +} + +// Run begins watching and synchronizing the cache +func (ac *AuthorizationCache) Run(period time.Duration) { + + namespaceReflector := cache.NewReflector( + &listWatch{ + listFunc: func() (runtime.Object, error) { + return ac.namespaceInterface.List(labels.Everything()) + }, + watchFunc: func(resourceVersion string) (watch.Interface, error) { + return ac.namespaceInterface.Watch(labels.Everything(), labels.Everything(), resourceVersion) + }, + }, + &kapi.Namespace{}, + ac.namespaceStore, + ) + namespaceReflector.Run() + + policyBindingReflector := cache.NewReflector( + &listWatch{ + listFunc: func() (runtime.Object, error) { + return ac.policyBindingsNamespacer.PolicyBindings(kapi.NamespaceAll).List(labels.Everything(), labels.Everything()) + }, + watchFunc: func(resourceVersion string) (watch.Interface, error) { + return ac.policyBindingsNamespacer.PolicyBindings(kapi.NamespaceAll).Watch(labels.Everything(), labels.Everything(), resourceVersion) + }, + }, + &authorizationapi.PolicyBinding{}, + ac.policyBindingIndexer, + ) + policyBindingReflector.Run() + + policyReflector := cache.NewReflector( + &listWatch{ + listFunc: func() (runtime.Object, error) { + return ac.policiesNamespacer.Policies(kapi.NamespaceAll).List(labels.Everything(), labels.Everything()) + }, + watchFunc: func(resourceVersion string) (watch.Interface, error) { + return ac.policiesNamespacer.Policies(kapi.NamespaceAll).Watch(labels.Everything(), labels.Everything(), resourceVersion) + }, + }, + &authorizationapi.Policy{}, + ac.policyIndexer, + ) + policyReflector.Run() + + go util.Forever(func() { ac.synchronize() }, period) +} + +// synchronizeNamespaces synchronizes access over each namespace and returns a set of namespace names that were looked at in last sync +func (ac *AuthorizationCache) synchronizeNamespaces(userSubjectRecordStore cache.Store, groupSubjectRecordStore cache.Store, reviewRecordStore cache.Store) *util.StringSet { + namespaceSet := util.NewStringSet() + items := ac.namespaceStore.List() + for i := range items { + namespace := items[i].(*kapi.Namespace) + namespaceSet.Insert(namespace.Name) + reviewRequest := &reviewRequest{ + namespace: namespace.Name, + namespaceResourceVersion: namespace.ResourceVersion, + } + if err := ac.syncHandler(reviewRequest, userSubjectRecordStore, groupSubjectRecordStore, reviewRecordStore); err != nil { + util.HandleError(fmt.Errorf("error synchronizing: %v", err)) + } + } + return &namespaceSet +} + +// synchronizePolicies synchronizes access over each policy +func (ac *AuthorizationCache) synchronizePolicies(userSubjectRecordStore cache.Store, groupSubjectRecordStore cache.Store, reviewRecordStore cache.Store) { + items := ac.policyIndexer.List() + for i := range items { + policy := items[i].(*authorizationapi.Policy) + reviewRequest := &reviewRequest{ + namespace: policy.Namespace, + policyUIDToResourceVersion: map[types.UID]string{policy.UID: policy.ResourceVersion}, + } + if err := ac.syncHandler(reviewRequest, userSubjectRecordStore, groupSubjectRecordStore, reviewRecordStore); err != nil { + util.HandleError(fmt.Errorf("error synchronizing: %v", err)) + } + } +} + +// synchronizePolicyBindings synchronizes access over each policy binding +func (ac *AuthorizationCache) synchronizePolicyBindings(userSubjectRecordStore cache.Store, groupSubjectRecordStore cache.Store, reviewRecordStore cache.Store) { + items := ac.policyBindingIndexer.List() + for i := range items { + binding := items[i].(*authorizationapi.PolicyBinding) + reviewRequest := &reviewRequest{ + namespace: binding.Namespace, + policyBindingUIDToResourceVersion: map[types.UID]string{binding.UID: binding.ResourceVersion}, + } + if err := ac.syncHandler(reviewRequest, userSubjectRecordStore, groupSubjectRecordStore, reviewRecordStore); err != nil { + util.HandleError(fmt.Errorf("error synchronizing: %v", err)) + } + } +} + +// purgeDeletedNamespaces will remove all namespaces enumerated in a reviewRecordStore that are not in the namespace set +func purgeDeletedNamespaces(namespaceSet *util.StringSet, userSubjectRecordStore cache.Store, groupSubjectRecordStore cache.Store, reviewRecordStore cache.Store) { + reviewRecordItems := reviewRecordStore.List() + for i := range reviewRecordItems { + reviewRecord := reviewRecordItems[i].(*reviewRecord) + if !namespaceSet.Has(reviewRecord.namespace) { + deleteSubjectsToNamespace(userSubjectRecordStore, reviewRecord.users, reviewRecord.namespace) + deleteSubjectsToNamespace(groupSubjectRecordStore, reviewRecord.groups, reviewRecord.namespace) + reviewRecordStore.Delete(reviewRecord) + } + } +} + +// invalidateCache returns true if there was a change in the master namespace that holds global policy and policy bindings +func (ac *AuthorizationCache) invalidateCache() bool { + invalidateCache := false + + masterPolicies, err := ac.policyIndexer.Index("namespace", &authorizationapi.Policy{ObjectMeta: kapi.ObjectMeta{Namespace: ac.masterNamespace}}) + if err != nil { + return true + } + for i := range masterPolicies { + policy := masterPolicies[i].(*authorizationapi.Policy) + if policy.ResourceVersion != ac.masterPolicyResourceVersion { + invalidateCache = true + ac.masterPolicyResourceVersion = policy.ResourceVersion + } + } + + masterPolicyBindings, err := ac.policyBindingIndexer.Index("namespace", &authorizationapi.PolicyBinding{ObjectMeta: kapi.ObjectMeta{Namespace: ac.masterNamespace}}) + if err != nil { + return true + } + for i := range masterPolicyBindings { + policyBinding := masterPolicyBindings[i].(*authorizationapi.PolicyBinding) + if policyBinding.ResourceVersion != ac.masterBindingResourceVersion { + invalidateCache = true + ac.masterBindingResourceVersion = policyBinding.ResourceVersion + } + } + return invalidateCache +} + +// synchronize runs a a full synchronization over the cache data. it must be run in a single-writer model, it's not thread-safe by design. +func (ac *AuthorizationCache) synchronize() { + // TODO: upstream cache object should support a high-water mark, if there was no change in any of our caches, then we should be able to return quickly + skip := false + if skip { + return + } + + // by default, we update our current caches and do an incremental change + userSubjectRecordStore := ac.userSubjectRecordStore + groupSubjectRecordStore := ac.groupSubjectRecordStore + reviewRecordStore := ac.reviewRecordStore + + // if there was a global change that forced complete invalidation, we rebuild our cache and do a fast swap at end + invalidateCache := ac.invalidateCache() + if invalidateCache { + userSubjectRecordStore = cache.NewStore(subjectRecordKeyFn) + groupSubjectRecordStore = cache.NewStore(subjectRecordKeyFn) + reviewRecordStore = cache.NewStore(reviewRecordKeyFn) + } + + // iterate over caches and synchronize our three caches + namespaceSet := ac.synchronizeNamespaces(userSubjectRecordStore, groupSubjectRecordStore, reviewRecordStore) + ac.synchronizePolicies(userSubjectRecordStore, groupSubjectRecordStore, reviewRecordStore) + ac.synchronizePolicyBindings(userSubjectRecordStore, groupSubjectRecordStore, reviewRecordStore) + purgeDeletedNamespaces(namespaceSet, userSubjectRecordStore, groupSubjectRecordStore, reviewRecordStore) + + // if we did a full rebuild, now we swap the fully rebuilt cache + if invalidateCache { + ac.userSubjectRecordStore = userSubjectRecordStore + ac.groupSubjectRecordStore = groupSubjectRecordStore + ac.reviewRecordStore = reviewRecordStore + } + +} + +// syncRequest takes a reviewRequest and determines if it should update the caches supplied, it is not thread-safe +func (ac *AuthorizationCache) syncRequest(request *reviewRequest, userSubjectRecordStore cache.Store, groupSubjectRecordStore cache.Store, reviewRecordStore cache.Store) error { + + lastKnownValue, err := lastKnown(reviewRecordStore, request.namespace) + if err != nil { + return err + } + + if skipReview(request, lastKnownValue) { + return nil + } + + namespace := request.namespace + review, err := ac.reviewer.Review(namespace) + if err != nil { + return err + } + + usersToRemove := util.NewStringSet() + groupsToRemove := util.NewStringSet() + if lastKnownValue != nil { + usersToRemove.Insert(lastKnownValue.users...) + usersToRemove.Delete(review.Users()...) + groupsToRemove.Insert(lastKnownValue.groups...) + groupsToRemove.Delete(review.Groups()...) + } + + deleteSubjectsToNamespace(userSubjectRecordStore, usersToRemove.List(), namespace) + deleteSubjectsToNamespace(groupSubjectRecordStore, groupsToRemove.List(), namespace) + addSubjectsToNamespace(userSubjectRecordStore, review.Users(), namespace) + addSubjectsToNamespace(groupSubjectRecordStore, review.Groups(), namespace) + cacheReviewRecord(request, lastKnownValue, review, reviewRecordStore) + return nil +} + +// List returns the set of namespace names the user has access to view +func (ac *AuthorizationCache) List(userInfo user.Info) (*kapi.NamespaceList, error) { + keys := util.StringSet{} + user := userInfo.GetName() + groups := userInfo.GetGroups() + + obj, exists, _ := ac.userSubjectRecordStore.GetByKey(user) + if exists { + subjectRecord := obj.(*subjectRecord) + keys.Insert(subjectRecord.namespaces.List()...) + } + + for _, group := range groups { + obj, exists, _ := ac.groupSubjectRecordStore.GetByKey(group) + if exists { + subjectRecord := obj.(*subjectRecord) + keys.Insert(subjectRecord.namespaces.List()...) + } + } + + namespaceList := &kapi.NamespaceList{} + for key := range keys { + namespace, exists, err := ac.namespaceStore.GetByKey(key) + if err != nil { + return nil, err + } + if exists { + namespaceList.Items = append(namespaceList.Items, *namespace.(*kapi.Namespace)) + } + } + return namespaceList, nil +} + +// skipReview returns true if the request was satisfied by the lastKnown +func skipReview(request *reviewRequest, lastKnownValue *reviewRecord) bool { + + // if your request is nil, you have no reason to make a review + if request == nil { + return true + } + + // if you know nothing from a prior review, you better make a request + if lastKnownValue == nil { + return false + } + // if you are asking about a specific namespace, and you think you knew about a different one, you better check again + if request.namespace != lastKnownValue.namespace { + return false + } + + // if you are making your request relative to a specific resource version, only make it if its different + if len(request.namespaceResourceVersion) > 0 && request.namespaceResourceVersion != lastKnownValue.namespaceResourceVersion { + return false + } + + // if you see a new policy binding, or a newer version, we need to do a review + for k, v := range request.policyBindingUIDToResourceVersion { + oldValue, exists := lastKnownValue.policyBindingUIDToResourceVersion[k] + if !exists || v != oldValue { + return false + } + } + + // if you see a new policy, or a newer version, we need to do a review + for k, v := range request.policyUIDToResourceVersion { + oldValue, exists := lastKnownValue.policyUIDToResourceVersion[k] + if !exists || v != oldValue { + return false + } + } + return true +} + +// deleteSubjectsToNamespace removes the namespace from each subject +// if no other namespaces are active to that subject, it will also delete the subject from the cache entirely +func deleteSubjectsToNamespace(subjectRecordStore cache.Store, subjects []string, namespace string) { + for _, subject := range subjects { + obj, exists, _ := subjectRecordStore.GetByKey(subject) + if exists { + subjectRecord := obj.(*subjectRecord) + delete(subjectRecord.namespaces, namespace) + if len(subjectRecord.namespaces) == 0 { + subjectRecordStore.Delete(subjectRecord) + } + } + } +} + +// addSubjectsToNamespace adds the specified namespace to each subject +func addSubjectsToNamespace(subjectRecordStore cache.Store, subjects []string, namespace string) { + for _, subject := range subjects { + var item *subjectRecord + obj, exists, _ := subjectRecordStore.GetByKey(subject) + if exists { + item = obj.(*subjectRecord) + } else { + item = &subjectRecord{subject: subject, namespaces: util.NewStringSet()} + subjectRecordStore.Add(item) + } + item.namespaces.Insert(namespace) + } +} + +// cacheReviewRecord updates the cache based on the request processed +func cacheReviewRecord(request *reviewRequest, lastKnownValue *reviewRecord, review Review, reviewRecordStore cache.Store) { + reviewRecord := &reviewRecord{ + reviewRequest: &reviewRequest{namespace: request.namespace, policyUIDToResourceVersion: map[types.UID]string{}, policyBindingUIDToResourceVersion: map[types.UID]string{}}, + groups: review.Groups(), + users: review.Users(), + } + // keep what we last believe we knew by default + if lastKnownValue != nil { + reviewRecord.namespaceResourceVersion = lastKnownValue.namespaceResourceVersion + for k, v := range lastKnownValue.policyUIDToResourceVersion { + reviewRecord.policyUIDToResourceVersion[k] = v + } + for k, v := range lastKnownValue.policyBindingUIDToResourceVersion { + reviewRecord.policyBindingUIDToResourceVersion[k] = v + } + } + + // update the review record relative to what drove this request + if len(request.namespaceResourceVersion) > 0 { + reviewRecord.namespaceResourceVersion = request.namespaceResourceVersion + } + for k, v := range request.policyUIDToResourceVersion { + reviewRecord.policyUIDToResourceVersion[k] = v + } + for k, v := range request.policyBindingUIDToResourceVersion { + reviewRecord.policyBindingUIDToResourceVersion[k] = v + } + // update the cache record + reviewRecordStore.Add(reviewRecord) +} + +func lastKnown(reviewRecordStore cache.Store, namespace string) (*reviewRecord, error) { + obj, exists, err := reviewRecordStore.GetByKey(namespace) + if err != nil { + return nil, err + } + if exists { + return obj.(*reviewRecord), nil + } + return nil, nil +} diff --git a/pkg/project/auth/cache_test.go b/pkg/project/auth/cache_test.go new file mode 100644 index 000000000000..8eae53be9a0e --- /dev/null +++ b/pkg/project/auth/cache_test.go @@ -0,0 +1,159 @@ +package auth + +import ( + "fmt" + "strconv" + "testing" + + kapi "github.com/GoogleCloudPlatform/kubernetes/pkg/api" + "github.com/GoogleCloudPlatform/kubernetes/pkg/auth/user" + kclient "github.com/GoogleCloudPlatform/kubernetes/pkg/client" + "github.com/GoogleCloudPlatform/kubernetes/pkg/util" + "github.com/openshift/origin/pkg/client" +) + +// mockReview implements the Review interface for test cases +type mockReview struct { + users []string + groups []string +} + +// Users returns the users that can access a resource +func (r *mockReview) Users() []string { + return r.users +} + +// Groups returns the groups that can access a resource +func (r *mockReview) Groups() []string { + return r.groups +} + +// common test users +var ( + alice = &user.DefaultInfo{ + Name: "Alice", + UID: "alice-uid", + Groups: []string{}, + } + bob = &user.DefaultInfo{ + Name: "Bob", + UID: "bob-uid", + Groups: []string{"employee"}, + } + eve = &user.DefaultInfo{ + Name: "Eve", + UID: "eve-uid", + Groups: []string{"employee"}, + } + frank = &user.DefaultInfo{ + Name: "Frank", + UID: "frank-uid", + Groups: []string{}, + } +) + +// mockReviewer returns the specified values for each supplied resource +type mockReviewer struct { + expectedResults map[string]*mockReview +} + +// Review returns the mapped review from the mock object, or an error if none exists +func (mr *mockReviewer) Review(name string) (Review, error) { + review := mr.expectedResults[name] + if review == nil { + return nil, fmt.Errorf("Item %s does not exist", name) + } + return review, nil +} + +func validateList(t *testing.T, lister Lister, user user.Info, expectedSet util.StringSet) { + namespaceList, err := lister.List(user) + if err != nil { + t.Errorf("Unexpected error %v", err) + } + results := util.StringSet{} + for _, namespace := range namespaceList.Items { + results.Insert(namespace.Name) + } + if results.Len() != expectedSet.Len() || !results.HasAll(expectedSet.List()...) { + t.Errorf("User %v, Expected: %v, Actual: %v", user.GetName(), expectedSet, results) + } +} + +func TestSyncNamespace(t *testing.T) { + namespaceList := kapi.NamespaceList{ + Items: []kapi.Namespace{ + { + ObjectMeta: kapi.ObjectMeta{Name: "foo", ResourceVersion: "1"}, + }, + { + ObjectMeta: kapi.ObjectMeta{Name: "bar", ResourceVersion: "2"}, + }, + { + ObjectMeta: kapi.ObjectMeta{Name: "car", ResourceVersion: "3"}, + }, + }, + } + mockKubeClient := &kclient.Fake{NamespacesList: namespaceList} + mockOriginClient := &client.Fake{} + + reviewer := &mockReviewer{ + expectedResults: map[string]*mockReview{ + "foo": { + users: []string{alice.GetName(), bob.GetName()}, + groups: eve.GetGroups(), + }, + "bar": { + users: []string{frank.GetName(), eve.GetName()}, + groups: []string{"random"}, + }, + "car": { + users: []string{}, + groups: []string{}, + }, + }, + } + + authorizationCache := NewAuthorizationCache(reviewer, mockKubeClient.Namespaces(), mockOriginClient, mockOriginClient, "default") + // we prime the data we need here since we are not running reflectors + for i := range namespaceList.Items { + authorizationCache.namespaceStore.Add(&namespaceList.Items[i]) + } + + // synchronize the cache + authorizationCache.synchronize() + + validateList(t, authorizationCache, alice, util.NewStringSet("foo")) + validateList(t, authorizationCache, bob, util.NewStringSet("foo")) + validateList(t, authorizationCache, eve, util.NewStringSet("foo", "bar")) + validateList(t, authorizationCache, frank, util.NewStringSet("bar")) + + // modify access rules + reviewer.expectedResults["foo"].users = []string{bob.GetName()} + reviewer.expectedResults["foo"].groups = []string{"random"} + reviewer.expectedResults["bar"].users = []string{alice.GetName(), eve.GetName()} + reviewer.expectedResults["bar"].groups = []string{"employee"} + reviewer.expectedResults["car"].users = []string{bob.GetName(), eve.GetName()} + reviewer.expectedResults["car"].groups = []string{"employee"} + + // modify resource version on each namespace to simulate a change had occurred to force cache refresh + for i := range namespaceList.Items { + namespace := namespaceList.Items[i] + oldVersion, err := strconv.Atoi(namespace.ResourceVersion) + if err != nil { + t.Errorf("Bad test setup, resource versions should be numbered, %v", err) + } + newVersion := strconv.Itoa(oldVersion + 1) + namespace.ResourceVersion = newVersion + authorizationCache.namespaceStore.Add(&namespace) + } + + // now refresh the cache (which is resource version aware) + authorizationCache.synchronize() + + // make sure new rights hold + validateList(t, authorizationCache, alice, util.NewStringSet("bar")) + validateList(t, authorizationCache, bob, util.NewStringSet("foo", "bar", "car")) + validateList(t, authorizationCache, eve, util.NewStringSet("bar", "car")) + validateList(t, authorizationCache, frank, util.NewStringSet()) +} diff --git a/pkg/project/auth/doc.go b/pkg/project/auth/doc.go new file mode 100644 index 000000000000..1ae084c460e1 --- /dev/null +++ b/pkg/project/auth/doc.go @@ -0,0 +1,2 @@ +// Package auth provides mechanisms for enforcing authorization to Project resources in OpenShift +package auth diff --git a/pkg/project/auth/reviewer.go b/pkg/project/auth/reviewer.go new file mode 100644 index 000000000000..b4d8d2d144da --- /dev/null +++ b/pkg/project/auth/reviewer.go @@ -0,0 +1,60 @@ +package auth + +import ( + authorizationapi "github.com/openshift/origin/pkg/authorization/api" + "github.com/openshift/origin/pkg/client" +) + +// Review is a list of users and groups that can access a resource +type Review interface { + Users() []string + Groups() []string +} + +type review struct { + response *authorizationapi.ResourceAccessReviewResponse +} + +// Users returns the users that can access a resource +func (r *review) Users() []string { + return r.response.Users +} + +// Groups returns the groups that can access a resource +func (r *review) Groups() []string { + return r.response.Groups +} + +// Reviewer performs access reviews for a project by name +type Reviewer interface { + Review(name string) (Review, error) +} + +// reviewer performs access reviews for a project by name +type reviewer struct { + resourceAccessReviewsNamespacer client.ResourceAccessReviewsNamespacer +} + +// NewReviewer knows how to make access control reviews for a resource by name +func NewReviewer(resourceAccessReviewsNamespacer client.ResourceAccessReviewsNamespacer) Reviewer { + return &reviewer{ + resourceAccessReviewsNamespacer: resourceAccessReviewsNamespacer, + } +} + +// Review performs a resource access review for the given resource by name +func (r *reviewer) Review(name string) (Review, error) { + resourceAccessReview := &authorizationapi.ResourceAccessReview{ + Verb: "get", + Resource: "namespaces", + ResourceName: name, + } + response, err := r.resourceAccessReviewsNamespacer.ResourceAccessReviews(name).Create(resourceAccessReview) + if err != nil { + return nil, err + } + review := &review{ + response: response, + } + return review, nil +} diff --git a/pkg/project/registry/project/rest.go b/pkg/project/registry/project/rest.go index b59b7dfdb1bb..c38e4d726d14 100644 --- a/pkg/project/registry/project/rest.go +++ b/pkg/project/registry/project/rest.go @@ -4,7 +4,7 @@ import ( "fmt" kapi "github.com/GoogleCloudPlatform/kubernetes/pkg/api" - "github.com/GoogleCloudPlatform/kubernetes/pkg/api/errors" + kerrors "github.com/GoogleCloudPlatform/kubernetes/pkg/api/errors" "github.com/GoogleCloudPlatform/kubernetes/pkg/apiserver" kclient "github.com/GoogleCloudPlatform/kubernetes/pkg/client" "github.com/GoogleCloudPlatform/kubernetes/pkg/labels" @@ -12,19 +12,20 @@ import ( "github.com/openshift/origin/pkg/project/api" "github.com/openshift/origin/pkg/project/api/validation" + projectauth "github.com/openshift/origin/pkg/project/auth" ) -// REST implements the RESTStorage interface in terms of an Registry. type REST struct { client kclient.NamespaceInterface + lister projectauth.Lister } // NewREST returns a RESTStorage object that will work against Project resources -func NewREST(client kclient.NamespaceInterface) apiserver.RESTStorage { - return &REST{client: client} +func NewREST(client kclient.NamespaceInterface, lister projectauth.Lister) apiserver.RESTStorage { + return &REST{client: client, lister: lister} } -// New returns a new Project for use with Create and Update. +// New returns a new Project func (s *REST) New() runtime.Object { return &api.Project{} } @@ -66,11 +67,15 @@ func convertNamespaceList(namespaceList *kapi.NamespaceList) *api.ProjectList { // List retrieves a list of Projects that match selector. func (s *REST) List(ctx kapi.Context, selector, fields labels.Selector) (runtime.Object, error) { - namespaces, err := s.client.List(selector) + user, ok := kapi.UserFrom(ctx) + if !ok { + return nil, kerrors.NewForbidden("Project", "", fmt.Errorf("Unable to list projects without a user on the context")) + } + namespaceList, err := s.lister.List(user) if err != nil { return nil, err } - return convertNamespaceList(namespaces), nil + return convertNamespaceList(namespaceList), nil } // Get retrieves a Project by name @@ -90,7 +95,7 @@ func (s *REST) Create(ctx kapi.Context, obj runtime.Object) (runtime.Object, err } kapi.FillObjectMetaSystemFields(ctx, &project.ObjectMeta) if errs := validation.ValidateProject(project); len(errs) > 0 { - return nil, errors.NewInvalid("project", project.Name, errs) + return nil, kerrors.NewInvalid("project", project.Name, errs) } namespace, err := s.client.Create(convertProject(project)) if err != nil { diff --git a/pkg/project/registry/project/rest_test.go b/pkg/project/registry/project/rest_test.go index 7a15576116f7..d45f8aa2b3f3 100644 --- a/pkg/project/registry/project/rest_test.go +++ b/pkg/project/registry/project/rest_test.go @@ -7,11 +7,21 @@ import ( kapi "github.com/GoogleCloudPlatform/kubernetes/pkg/api" "github.com/GoogleCloudPlatform/kubernetes/pkg/api/errors" + "github.com/GoogleCloudPlatform/kubernetes/pkg/auth/user" kclient "github.com/GoogleCloudPlatform/kubernetes/pkg/client" - //"github.com/GoogleCloudPlatform/kubernetes/pkg/labels" + "github.com/GoogleCloudPlatform/kubernetes/pkg/labels" "github.com/openshift/origin/pkg/project/api" ) +// mockLister returns the namespaces in the list +type mockLister struct { + namespaceList *kapi.NamespaceList +} + +func (ml *mockLister) List(user user.Info) (*kapi.NamespaceList, error) { + return ml.namespaceList, nil +} + func TestListProjects(t *testing.T) { namespaceList := kapi.NamespaceList{ Items: []kapi.Namespace{ @@ -25,8 +35,15 @@ func TestListProjects(t *testing.T) { } storage := REST{ client: mockClient.Namespaces(), + lister: &mockLister{&namespaceList}, + } + user := &user.DefaultInfo{ + Name: "test-user", + UID: "test-uid", + Groups: []string{"test-groups"}, } - response, err := storage.List(nil, nil, nil) + ctx := kapi.WithUser(kapi.NewContext(), user) + response, err := storage.List(ctx, labels.Everything(), labels.Everything()) if err != nil { t.Errorf("%#v should be nil.", err) } diff --git a/test/integration/project_test.go b/test/integration/project_test.go index 3dc6bffb2eb9..89099130303c 100644 --- a/test/integration/project_test.go +++ b/test/integration/project_test.go @@ -51,7 +51,7 @@ func TestProjectIsNamespace(t *testing.T) { // create an origin originInterfaces, _ := latest.InterfacesFor(latest.Version) originStorage := map[string]apiserver.RESTStorage{ - "projects": projectregistry.NewREST(kubeClient.Namespaces()), + "projects": projectregistry.NewREST(kubeClient.Namespaces(), nil), } originServer := httptest.NewServer(apiserver.Handle(originStorage, v1beta1.Codec, "/osapi", "v1beta1", originInterfaces.MetadataAccessor, admit.NewAlwaysAdmit(), kapi.NewRequestContextMapper(), latest.RESTMapper)) defer originServer.Close()