diff --git a/pkg/authorization/api/types.go b/pkg/authorization/api/types.go index 456c097da3b6..e65b4081e13c 100644 --- a/pkg/authorization/api/types.go +++ b/pkg/authorization/api/types.go @@ -69,78 +69,80 @@ var ( // about who the rule applies to or which namespace the rule applies to. type PolicyRule struct { // Verbs is a list of Verbs that apply to ALL the ResourceKinds and AttributeRestrictions contained in this rule. VerbAll represents all kinds. - Verbs []string `json:"verbs"` + Verbs []string // AttributeRestrictions will vary depending on what the Authorizer/AuthorizationAttributeBuilder pair supports. // If the Authorizer does not recognize how to handle the AttributeRestrictions, the Authorizer should report an error. - AttributeRestrictions kruntime.EmbeddedObject `json:"attributeRestrictions"` + AttributeRestrictions kruntime.EmbeddedObject // Resources is a list of resources this rule applies to. ResourceAll represents all resources. - Resources []string `json:"resources"` + Resources []string + // ResourceNames is an optional white list of names that the rule applies to. An empty set means that everything is allowed. + ResourceNames kutil.StringSet } // Role is a logical grouping of PolicyRules that can be referenced as a unit by RoleBindings. type Role struct { - kapi.TypeMeta `json:",inline"` - kapi.ObjectMeta `json:"metadata,omitempty"` + kapi.TypeMeta + kapi.ObjectMeta // Rules holds all the PolicyRules for this Role - Rules []PolicyRule `json:"rules"` + Rules []PolicyRule } // RoleBinding references a Role, but not contain it. It adds who and namespace information. // It can reference any Role in the same namespace or in the global namespace. type RoleBinding struct { - kapi.TypeMeta `json:",inline"` - kapi.ObjectMeta `json:"metadata,omitempty"` + kapi.TypeMeta + kapi.ObjectMeta // UserNames holds all the usernames directly bound to the role - UserNames []string `json:"userNames"` + UserNames []string // GroupNames holds all the groups directly bound to the role - GroupNames []string `json:"groupNames"` + GroupNames []string // Since Policy is a singleton, this is sufficient knowledge to locate a role // RoleRefs can only reference the current namespace and the global namespace // If the RoleRef cannot be resolved, the Authorizer must return an error. - RoleRef kapi.ObjectReference `json:"roleRef"` + RoleRef kapi.ObjectReference } // Policy is a object that holds all the Roles for a particular namespace. There is at most // one Policy document per namespace. type Policy struct { - kapi.TypeMeta `json:",inline"` - kapi.ObjectMeta `json:"metadata,omitempty" ` + kapi.TypeMeta + kapi.ObjectMeta // LastModified is the last time that any part of the Policy was created, updated, or deleted - LastModified kutil.Time `json:"lastModified"` + LastModified kutil.Time // Roles holds all the Roles held by this Policy, mapped by Role.Name - Roles map[string]Role `json:"roles"` + Roles map[string]Role } // PolicyBinding is a object that holds all the RoleBindings for a particular namespace. There is // one PolicyBinding document per referenced Policy namespace type PolicyBinding struct { - kapi.TypeMeta `json:",inline"` - kapi.ObjectMeta `json:"metadata,omitempty"` + kapi.TypeMeta + kapi.ObjectMeta // LastModified is the last time that any part of the PolicyBinding was created, updated, or deleted - LastModified kutil.Time `json:"lastModified"` + LastModified kutil.Time // PolicyRef is a reference to the Policy that contains all the Roles that this PolicyBinding's RoleBindings may reference - PolicyRef kapi.ObjectReference `json:"policyRef"` + PolicyRef kapi.ObjectReference // RoleBindings holds all the RoleBindings held by this PolicyBinding, mapped by RoleBinding.Name - RoleBindings map[string]RoleBinding `json:"roleBindings"` + RoleBindings map[string]RoleBinding } // PolicyList is a collection of Policies type PolicyList struct { - kapi.TypeMeta `json:",inline"` - kapi.ListMeta `json:"metadata,omitempty"` - Items []Policy `json:"items"` + kapi.TypeMeta + kapi.ListMeta + Items []Policy } // PolicyBindingList is a collection of PolicyBindings type PolicyBindingList struct { - kapi.TypeMeta `json:",inline"` - kapi.ListMeta `json:"metadata,omitempty"` - Items []PolicyBinding `json:"items"` + kapi.TypeMeta + kapi.ListMeta + Items []PolicyBinding } diff --git a/pkg/authorization/api/v1beta1/conversion.go b/pkg/authorization/api/v1beta1/conversion.go index 807e1372d7db..7faf5640948d 100644 --- a/pkg/authorization/api/v1beta1/conversion.go +++ b/pkg/authorization/api/v1beta1/conversion.go @@ -22,30 +22,41 @@ import ( "github.com/GoogleCloudPlatform/kubernetes/pkg/conversion" "github.com/GoogleCloudPlatform/kubernetes/pkg/api" + "github.com/GoogleCloudPlatform/kubernetes/pkg/util" newer "github.com/openshift/origin/pkg/authorization/api" ) func init() { err := api.Scheme.AddConversionFuncs( func(in *PolicyRule, out *newer.PolicyRule, s conversion.Scope) error { - if err := s.DefaultConvert(in, out, conversion.IgnoreMissingFields); err != nil { + if err := s.Convert(&in.AttributeRestrictions, &out.AttributeRestrictions, 0); err != nil { return err } - if out.Resources == nil { - out.Resources = make([]string, 0) - } - if in.ResourceKinds != nil { - out.Resources = append(out.Resources, in.ResourceKinds...) - } + out.Verbs = []string{} + out.Verbs = append(out.Verbs, in.Verbs...) + + out.Resources = []string{} + out.Resources = append(out.Resources, in.Resources...) + out.Resources = append(out.Resources, in.ResourceKinds...) + + out.ResourceNames = util.NewStringSet(in.ResourceNames...) return nil }, func(in *newer.PolicyRule, out *PolicyRule, s conversion.Scope) error { - if err := s.DefaultConvert(in, out, conversion.IgnoreMissingFields); err != nil { + if err := s.Convert(&in.AttributeRestrictions, &out.AttributeRestrictions, 0); err != nil { return err } + out.Verbs = []string{} + out.Verbs = append(out.Verbs, in.Verbs...) + + out.Resources = []string{} + out.Resources = append(out.Resources, in.Resources...) + + out.ResourceNames = in.ResourceNames.List() + return nil }, func(in *Policy, out *newer.Policy, s conversion.Scope) error { diff --git a/pkg/authorization/api/v1beta1/types.go b/pkg/authorization/api/v1beta1/types.go index bbf2fa59b196..a2fb4e0c5137 100644 --- a/pkg/authorization/api/v1beta1/types.go +++ b/pkg/authorization/api/v1beta1/types.go @@ -26,6 +26,8 @@ type PolicyRule struct { ResourceKinds []string `json:"resourceKinds,omitempty"` // Resources is a list of resources this rule applies to. ResourceAll represents all resources. Resources []string `json:"resources"` + // ResourceNames is an optional white list of names that the rule applies to. An empty set means that everything is allowed. + ResourceNames []string `json:"resourceNames,omitempty"` } // Role is a logical grouping of PolicyRules that can be referenced as a unit by RoleBindings. diff --git a/pkg/authorization/authorizer/authorizer.go b/pkg/authorization/authorizer/authorizer.go index 226aad695d77..7d2929d84a1f 100644 --- a/pkg/authorization/authorizer/authorizer.go +++ b/pkg/authorization/authorizer/authorizer.go @@ -7,6 +7,7 @@ import ( "strings" kapi "github.com/GoogleCloudPlatform/kubernetes/pkg/api" + "github.com/GoogleCloudPlatform/kubernetes/pkg/api/meta" "github.com/GoogleCloudPlatform/kubernetes/pkg/auth/user" klabels "github.com/GoogleCloudPlatform/kubernetes/pkg/labels" "github.com/GoogleCloudPlatform/kubernetes/pkg/util" @@ -30,6 +31,7 @@ type AuthorizationAttributes interface { GetVerb() string GetResource() string GetNamespace() string + GetResourceName() string // GetRequestAttributes is of type interface{} because different verbs and different Authorizer/AuthorizationAttributeBuilder pairs may have different contract requirements GetRequestAttributes() interface{} } @@ -49,15 +51,17 @@ type openshiftAuthorizationAttributes struct { verb string resource string namespace string + resourceName string requestAttributes interface{} } type openshiftAuthorizationAttributeBuilder struct { requestsToUsers *authcontext.RequestContextMap + infoResolver *APIRequestInfoResolver } -func NewAuthorizationAttributeBuilder(requestsToUsers *authcontext.RequestContextMap) AuthorizationAttributeBuilder { - return &openshiftAuthorizationAttributeBuilder{requestsToUsers} +func NewAuthorizationAttributeBuilder(requestsToUsers *authcontext.RequestContextMap, infoResolver *APIRequestInfoResolver) AuthorizationAttributeBuilder { + return &openshiftAuthorizationAttributeBuilder{requestsToUsers, infoResolver} } func doesApplyToUser(ruleUsers, ruleGroups []string, user user.Info) bool { @@ -227,11 +231,13 @@ func (a *openshiftAuthorizer) authorizeWithNamespaceRules(namespace string, pass } func (a openshiftAuthorizationAttributes) ruleMatches(rule authorizationapi.PolicyRule) (bool, error) { - resourceNames := resolveResources(rule) + allowedResourceTypes := resolveResources(rule) if a.verbMatches(util.NewStringSet(rule.Verbs...)) { - if a.resourceMatches(resourceNames) { - return true, nil + if a.resourceMatches(allowedResourceTypes) { + if a.nameMatches(rule.ResourceNames) { + return true, nil + } } } @@ -255,8 +261,8 @@ func resolveResources(rule authorizationapi.PolicyRule) util.StringSet { continue } - if resourceNames, exists := authorizationapi.GroupsToResources[currResource]; exists { - toVisit = append(toVisit, resourceNames...) + if resourceTypes, exists := authorizationapi.GroupsToResources[currResource]; exists { + toVisit = append(toVisit, resourceTypes...) } } @@ -267,8 +273,20 @@ func (a openshiftAuthorizationAttributes) verbMatches(verbs util.StringSet) bool return verbs.Has(authorizationapi.VerbAll) || verbs.Has(strings.ToLower(a.GetVerb())) } -func (a openshiftAuthorizationAttributes) resourceMatches(resourceNames util.StringSet) bool { - return resourceNames.Has(authorizationapi.ResourceAll) || resourceNames.Has(strings.ToLower(a.GetResource())) +func (a openshiftAuthorizationAttributes) resourceMatches(allowedResourceTypes util.StringSet) bool { + return allowedResourceTypes.Has(authorizationapi.ResourceAll) || allowedResourceTypes.Has(strings.ToLower(a.GetResource())) +} + +// nameMatches checks to see if the resourceName of the action is in a the specified whitelist. An empty whitelist indicates that any name is allowed. +// An empty string in the whitelist should only match the action's resourceName if the resourceName itself is empty string. This behavior allows for the +// combination of a whitelist for gets in the same rule as a list that won't have a resourceName. I don't recommend writing such a rule, but we do +// handle it like you'd expect: white list is respected for gets while not preventing the list you explicitly asked for. +func (a openshiftAuthorizationAttributes) nameMatches(allowedResourceNames util.StringSet) bool { + if len(allowedResourceNames) == 0 { + return true + } + + return allowedResourceNames.Has(a.GetResourceName()) } func (a openshiftAuthorizationAttributes) GetUserInfo() user.Info { @@ -283,6 +301,10 @@ func (a openshiftAuthorizationAttributes) GetResource() string { return a.resource } +func (a openshiftAuthorizationAttributes) GetResourceName() string { + return a.resourceName +} + func (a openshiftAuthorizationAttributes) GetNamespace() string { return a.namespace } @@ -292,7 +314,7 @@ func (a openshiftAuthorizationAttributes) GetRequestAttributes() interface{} { } func (a *openshiftAuthorizationAttributeBuilder) GetAttributes(req *http.Request) (AuthorizationAttributes, error) { - verb, resource, namespace, _, err := VerbAndKindAndNamespace(req) + requestInfo, err := a.infoResolver.GetAPIRequestInfo(req) if err != nil { return nil, err } @@ -308,92 +330,154 @@ func (a *openshiftAuthorizationAttributeBuilder) GetAttributes(req *http.Request return openshiftAuthorizationAttributes{ user: userInfo, - verb: verb, - resource: resource, - namespace: namespace, + verb: requestInfo.Verb, + resource: requestInfo.Resource, + namespace: requestInfo.Namespace, + resourceName: requestInfo.Name, requestAttributes: nil, }, nil } -// TODO waiting on kube rebase -// this section is copied from kube. Need to modify kube to make this pluggable +// TODO waiting on kube rebase to kill this + +// APIRequestInfo holds information parsed from the http.Request +type APIRequestInfo struct { + // Verb is the kube verb associated with the request, not the http verb. This includes things like list and watch. + Verb string + APIVersion string + Namespace string + // Resource is the name of the resource being requested. This is not the kind. For example: pods + Resource string + // Kind is the type of object being manipulated. For example: Pod + Kind string + // Name is empty for some verbs, but if the request directly indicates a name (not in body content) then this field is filled in. + Name string + // Parts are the path parts for the request relative to /{resource}/{name} + Parts []string +} + +type APIRequestInfoResolver struct { + ApiPrefixes util.StringSet + RestMapper meta.RESTMapper +} + var specialVerbs = map[string]bool{ "proxy": true, "redirect": true, "watch": true, } -var ErrNoStandardParts = errors.New("the provided URL does not match the standard API form") +// GetAPIRequestInfo returns the information from the http request. If error is not nil, APIRequestInfo holds the information as best it is known before the failure +// Valid Inputs: +// Storage paths +// /ns/{namespace}/{resource} +// /ns/{namespace}/{resource}/{resourceName} +// /{resource} +// /{resource}/{resourceName} +// /{resource}/{resourceName}?namespace={namespace} +// /{resource}?namespace={namespace} +// +// Special verbs: +// /proxy/{resource}/{resourceName} +// /proxy/ns/{namespace}/{resource}/{resourceName} +// /redirect/ns/{namespace}/{resource}/{resourceName} +// /redirect/{resource}/{resourceName} +// /watch/{resource} +// /watch/ns/{namespace}/{resource} +// +// Fully qualified paths for above: +// /api/{version}/* +// /api/{version}/* +func (r *APIRequestInfoResolver) GetAPIRequestInfo(req *http.Request) (APIRequestInfo, error) { + requestInfo := APIRequestInfo{} + + currentParts := splitPath(req.URL.Path) + if len(currentParts) < 1 { + return requestInfo, fmt.Errorf("Unable to determine kind and namespace from an empty URL path") + } + + for _, currPrefix := range r.ApiPrefixes.List() { + // handle input of form /api/{version}/* by adjusting special paths + if currentParts[0] == currPrefix { + if len(currentParts) > 1 { + requestInfo.APIVersion = currentParts[1] + } -// VerbAndKindAndNamespace returns verb, kind, namespace, remaining parts, error -func VerbAndKindAndNamespace(req *http.Request) (string, string, string, []string, error) { - parts := splitPath(req.URL.Path) - if len(parts) == 0 { - return "", "", "", nil, ErrNoStandardParts + if len(currentParts) > 2 { + currentParts = currentParts[2:] + } else { + return requestInfo, fmt.Errorf("Unable to determine kind and namespace from url, %v", req.URL) + } + } } - verb := "" - switch req.Method { - case "POST": - verb = "create" - case "GET": - verb = "get" - case "PUT": - verb = "update" - case "DELETE": - verb = "delete" - } + // handle input of form /{specialVerb}/* + if _, ok := specialVerbs[currentParts[0]]; ok { + requestInfo.Verb = currentParts[0] - if parts[0] == "osapi" { - if len(parts) > 2 { - parts = parts[2:] + if len(currentParts) > 1 { + currentParts = currentParts[1:] } else { - return "", "", "", nil, ErrNoStandardParts + return requestInfo, fmt.Errorf("Unable to determine kind and namespace from url, %v", req.URL) } + } else { + switch req.Method { + case "POST": + requestInfo.Verb = "create" + case "GET": + requestInfo.Verb = "get" + case "PUT": + requestInfo.Verb = "update" + case "DELETE": + requestInfo.Verb = "delete" + } + } - // TODO tweak upstream to eliminate this copy kubernetes/pkg/apiserver/handlers.go - // handle input of form /api/{version}/* by adjusting special paths - if parts[0] == "api" { - if len(parts) > 2 { - parts = parts[2:] - } else { - return "", "", "", parts, ErrNoStandardParts + // URL forms: /ns/{namespace}/{resource}/*, where parts are adjusted to be relative to kind + if currentParts[0] == "ns" { + if len(currentParts) < 3 { + return requestInfo, fmt.Errorf("ResourceTypeAndNamespace expects a path of form /ns/{namespace}/*") + } + requestInfo.Resource = currentParts[2] + requestInfo.Namespace = currentParts[1] + currentParts = currentParts[2:] + + } else { + // URL forms: /{resource}/* + // URL forms: POST /{resource} is a legacy API convention to create in "default" namespace + // URL forms: /{resource}/{resourceName} use the "default" namespace if omitted from query param + // URL forms: /{resource} assume cross-namespace operation if omitted from query param + requestInfo.Resource = currentParts[0] + requestInfo.Namespace = req.URL.Query().Get("namespace") + if len(requestInfo.Namespace) == 0 { + if len(currentParts) > 1 || req.Method == "POST" { + requestInfo.Namespace = kapi.NamespaceDefault + } else { + requestInfo.Namespace = kapi.NamespaceAll + } } } - // handle input of form /{specialVerb}/* - if _, ok := specialVerbs[parts[0]]; ok { - verb = parts[0] - if len(parts) > 1 { - parts = parts[1:] - } else { - return "", "", "", parts, ErrNoStandardParts - } + // parsing successful, so we now know the proper value for .Parts + requestInfo.Parts = currentParts + + // if there's another part remaining after the kind, then that's the resource name + if len(requestInfo.Parts) >= 2 { + requestInfo.Name = requestInfo.Parts[1] } - // URL forms: /ns/{namespace}/{kind}/*, where parts are adjusted to be relative to kind - if parts[0] == "ns" { - if len(parts) < 3 { - return "", "", "", parts, fmt.Errorf("ResourceTypeAndNamespace expects a path of form /ns/{namespace}/*") - } - return verb, parts[1], parts[2], parts[2:], ErrNoStandardParts - } - - // URL forms: /{kind}/* - // URL forms: POST /{kind} is a legacy API convention to create in "default" namespace - // URL forms: /{kind}/{resourceName} use the "default" namespace if omitted from query param - // URL forms: /{kind} assume cross-namespace operation if omitted from query param - kind := parts[0] - namespace := req.URL.Query().Get("namespace") - if len(namespace) == 0 { - if len(parts) > 1 || req.Method == "POST" { - namespace = kapi.NamespaceDefault - } else { - namespace = kapi.NamespaceAll - } + // if there's no name on the request and we thought it was a get before, then the actual verb is a list + if len(requestInfo.Name) == 0 && requestInfo.Verb == "get" { + requestInfo.Verb = "list" + } + + // if we have a resource, we have a good shot at being able to determine kind + if len(requestInfo.Resource) > 0 { + _, requestInfo.Kind, _ = r.RestMapper.VersionAndKindForResource(requestInfo.Resource) } - return verb, kind, namespace, parts, nil + + return requestInfo, nil } // splitPath returns the segments for a URL path. @@ -471,6 +555,16 @@ func GetBootstrapPolicy(masterNamespace string) *authorizationapi.Policy { }, }, }, + "basic-user": { + ObjectMeta: kapi.ObjectMeta{ + Name: "view-self", + Namespace: masterNamespace, + }, + Rules: []authorizationapi.PolicyRule{ + {Verbs: []string{"get"}, Resources: []string{"users"}, ResourceNames: util.NewStringSet("~")}, + {Verbs: []string{"list"}, Resources: []string{"projects"}}, + }, + }, "system:deployer": { ObjectMeta: kapi.ObjectMeta{ Name: "system:deployer", @@ -554,6 +648,17 @@ func GetBootstrapPolicyBinding(masterNamespace string) *authorizationapi.PolicyB }, UserNames: []string{"system:admin"}, }, + "basic-user-binding": { + ObjectMeta: kapi.ObjectMeta{ + Name: "basic-user-binding", + Namespace: masterNamespace, + }, + RoleRef: kapi.ObjectReference{ + Name: "basic-user", + Namespace: masterNamespace, + }, + GroupNames: []string{"system:authenticated"}, + }, "insecure-cluster-admin-binding": { ObjectMeta: kapi.ObjectMeta{ Name: "insecure-cluster-admin-binding", diff --git a/pkg/authorization/authorizer/authorizer_test.go b/pkg/authorization/authorizer/authorizer_test.go index ef1483de9bf4..571df9a492d4 100644 --- a/pkg/authorization/authorizer/authorizer_test.go +++ b/pkg/authorization/authorizer/authorizer_test.go @@ -29,6 +29,42 @@ type authorizeTest struct { expectedError string } +func TestResourceNameDeny(t *testing.T) { + test := &authorizeTest{ + attributes: &openshiftAuthorizationAttributes{ + user: &user.DefaultInfo{ + Name: "just-a-user", + }, + verb: "get", + resource: "users", + resourceName: "just-a-user", + namespace: testMasterNamespace, + }, + expectedAllowed: false, + expectedReason: "denied by default", + } + test.globalPolicy, test.globalPolicyBinding = newDefaultGlobalPolicy() + test.test(t) +} + +func TestResourceNameAllow(t *testing.T) { + test := &authorizeTest{ + attributes: &openshiftAuthorizationAttributes{ + user: &user.DefaultInfo{ + Name: "just-a-user", + }, + verb: "get", + resource: "users", + resourceName: "~", + namespace: testMasterNamespace, + }, + expectedAllowed: true, + expectedReason: "allowed by rule in master", + } + test.globalPolicy, test.globalPolicyBinding = newDefaultGlobalPolicy() + test.test(t) +} + func TestAdminEditingGlobalDeploymentConfig(t *testing.T) { test := &authorizeTest{ attributes: &openshiftAuthorizationAttributes{ @@ -305,9 +341,19 @@ func newDefaultGlobalPolicy() ([]authorizationapi.Policy, []authorizationapi.Pol Name: "cluster-admin", Namespace: testMasterNamespace, }, - // until we get components authenticating, mssing users will be given all rights. Yay, security! UserNames: []string{"ClusterAdmin"}, }, + "user-only": { + ObjectMeta: kapi.ObjectMeta{ + Name: "user-only", + Namespace: testMasterNamespace, + }, + RoleRef: kapi.ObjectReference{ + Name: "basic-user", + Namespace: testMasterNamespace, + }, + UserNames: []string{"just-a-user"}, + }, }, }, ) diff --git a/pkg/cmd/server/origin/master.go b/pkg/cmd/server/origin/master.go index 9c9fb78023dc..008de2c304f1 100644 --- a/pkg/cmd/server/origin/master.go +++ b/pkg/cmd/server/origin/master.go @@ -488,18 +488,12 @@ func (c *MasterConfig) ensureComponentAuthorizationRules() { // TODO Have MasterConfig take a fully formed Authorizer func (c *MasterConfig) authorizationFilter(handler http.Handler) http.Handler { authorizationEtcd := authorizationetcd.New(c.EtcdHelper) - authorizationAttributeBuilder := authorizer.NewAuthorizationAttributeBuilder(c.getRequestsToUsers()) + + authorizationAttributeBuilder := authorizer.NewAuthorizationAttributeBuilder(c.getRequestsToUsers(), &authorizer.APIRequestInfoResolver{util.NewStringSet("api", "osapi"), latest.RESTMapper}) authz := authorizer.NewAuthorizer(c.MasterAuthorizationNamespace, authorizationEtcd, authorizationEtcd) return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { attributes, err := authorizationAttributeBuilder.GetAttributes(req) - // TODO: this significantly relaxes the authorization guarantees - however the unprotected resources need - // to be clearly split out upstream in a way that we can detect. - if err == authorizer.ErrNoStandardParts { - glog.V(4).Infof("Allowing %q because it is not a recognized form", req.RequestURI) - handler.ServeHTTP(w, req) - return - } if err != nil { // fail forbidden(err.Error(), w, req)