diff --git a/docs/admin/authorization.md b/docs/admin/authorization.md index 942f93a03d4..b82322d001d 100644 --- a/docs/admin/authorization.md +++ b/docs/admin/authorization.md @@ -57,16 +57,18 @@ The following implementations are available, and are selected by flag: ### Request Attributes -A request has 5 attributes that can be considered for authorization: +A request has the following attributes that can be considered for authorization: - user (the user-string which a user was authenticated as). - group (the list of group names the authenticated user is a member of). - - whether the request is readonly (GETs are readonly). - - what resource is being accessed. - - applies only to the API endpoints, such as - `/api/v1/namespaces/default/pods`. For miscellaneous endpoints, like `/version`, the - resource is the empty string. - - the namespace of the object being access, or the empty string if the - endpoint does not support namespaced objects. + - whether the request is for an API resource. + - the request path. + - allows authorizing access to miscellaneous endpoints like `/api` or `/healthz` (see [kubectl](#kubectl)). + - the request verb. + - API verbs like `get`, `list`, `create`, `update`, and `watch` are used for API requests + - HTTP verbs like `get`, `post`, and `put` are used for non-API requests + - what resource is being accessed (for API requests only) + - the namespace of the object being accessed (for namespaced API requests only) + - the API group being accessed (for API requests only) We anticipate adding more attributes to allow finer grained access control and to assist in policy management. @@ -79,18 +81,29 @@ The file format is [one JSON object per line](http://jsonlines.org/). There sho one map per line. Each line is a "policy object". A policy object is a map with the following properties: - - `user`, type string; the user-string from `--token-auth-file`. If you specify `user`, it must match the username of the authenticated user. - - `group`, type string; if you specify `group`, it must match one of the groups of the authenticated user. - - `readonly`, type boolean, when true, means that the policy only applies to GET - operations. - - `resource`, type string; a resource from an URL, such as `pods`. - - `namespace`, type string; a namespace string. + - Versioning properties: + - `apiVersion`, type string; valid values are "abac.authorization.kubernetes.io/v1beta1". Allows versioning and conversion of the policy format. + - `kind`, type string: valid values are "Policy". Allows versioning and conversion of the policy format. + + - `spec` property set to a map with the following properties: + - Subject-matching properties: + - `user`, type string; the user-string from `--token-auth-file`. If you specify `user`, it must match the username of the authenticated user. `*` matches all requests. + - `group`, type string; if you specify `group`, it must match one of the groups of the authenticated user. `*` matches all requests. + + - `readonly`, type boolean, when true, means that the policy only applies to get, list, and watch operations. + + - Resource-matching properties: + - `apiGroup`, type string; an API group, such as `extensions`. `*` matches all API groups. + - `namespace`, type string; a namespace string. `*` matches all resource requests. + - `resource`, type string; a resource, such as `pods`. `*` matches all resource requests. + + - Non-resource-matching properties: + - `nonResourcePath`, type string; matches the non-resource request paths (like `/version` and `/apis`). `*` matches all non-resource requests. `/foo/*` matches `/foo/` and all of its subpaths. An unset property is the same as a property set to the zero value for its type (e.g. empty string, 0, false). However, unset should be preferred for readability. -In the future, policies may be expressed in a JSON format, and managed via a REST -interface. +In the future, policies may be expressed in a JSON format, and managed via a REST interface. ### Authorization Algorithm @@ -99,21 +112,35 @@ A request has attributes which correspond to the properties of a policy object. When a request is received, the attributes are determined. Unknown attributes are set to the zero value of its type (e.g. empty string, 0, false). -An unset property will match any value of the corresponding -attribute. An unset attribute will match any value of the corresponding property. +A property set to "*" will match any value of the corresponding attribute. The tuple of attributes is checked for a match against every policy in the policy file. If at least one line matches the request attributes, then the request is authorized (but may fail later validation). -To permit any user to do something, write a policy with the user property unset. -To permit an action Policy with an unset namespace applies regardless of namespace. +To permit any user to do something, write a policy with the user property set to "*". +To permit a user to do anything, write a policy with the apiGroup, namespace, resource, and nonResourcePath properties set to "*". + +### Kubectl + +Kubectl uses the `/api` and `/apis` endpoints of api-server to negotiate client/server versions. To validate objects sent to the API by create/update operations, kubectl queries certain swagger resources. For API version `v1` those would be `/swaggerapi/api/v1` & `/swaggerapi/experimental/v1`. + +When using ABAC authorization, those special resources have to be explicitly exposed via the `nonResourcePath` property in a policy (see [examples](#examples) below): + +* `/api`, `/api/*`, `/apis`, and `/apis/*` for API version negotiation. +* `/version` for retrieving the server version via `kubectl version`. +* `/swaggerapi/*` for create/update operations. + +To inspect the HTTP calls involved in a specific kubectl operation you can turn up the verbosity: + + kubectl --v=8 version ### Examples - 1. Alice can do anything: `{"user":"alice"}` - 2. Kubelet can read any pods: `{"user":"kubelet", "resource": "pods", "readonly": true}` - 3. Kubelet can read and write events: `{"user":"kubelet", "resource": "events"}` - 4. Bob can just read pods in namespace "projectCaribou": `{"user":"bob", "resource": "pods", "readonly": true, "namespace": "projectCaribou"}` + 1. Alice can do anything to all resources: `{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user": "alice", "namespace": "*", "resource": "*", "apiGroup": "*"}}` + 2. Kubelet can read any pods: `{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user": "kubelet", "namespace": "*", "resource": "pods", "readonly": true}}` + 3. Kubelet can read and write events: `{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user": "kubelet", "namespace": "*", "resource": "events"}}` + 4. Bob can just read pods in namespace "projectCaribou": `{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user": "bob", "namespace": "projectCaribou", "resource": "pods", "readonly": true}}` + 5. Anyone can make read-only requests to all non-API paths: `{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "spec": {"user": "*", "readonly": true, "nonResourcePath": "*"}}` [Complete file example](http://releases.k8s.io/HEAD/pkg/auth/authorizer/abac/example_policy_file.jsonl) @@ -134,7 +161,7 @@ system:serviceaccount::default For example, if you wanted to grant the default service account in the kube-system full privilege to the API, you would add this line to your policy file: ```json -{"user":"system:serviceaccount:kube-system:default"} +{"apiVersion":"abac.authorization.kubernetes.io/v1beta1","kind":"Policy","user":"system:serviceaccount:kube-system:default","namespace":"*","resource":"*","apiGroup":"*"} ``` The apiserver will need to be restarted to pickup the new policy lines. diff --git a/pkg/apis/abac/latest/latest.go b/pkg/apis/abac/latest/latest.go new file mode 100644 index 00000000000..bcbef485680 --- /dev/null +++ b/pkg/apis/abac/latest/latest.go @@ -0,0 +1,24 @@ +/* +Copyright 2014 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package latest + +import ( + "k8s.io/kubernetes/pkg/apis/abac/v1beta1" +) + +// Codec is the default codec for serializing input that should use the latest supported version. +var Codec = v1beta1.Codec diff --git a/pkg/apis/abac/register.go b/pkg/apis/abac/register.go new file mode 100644 index 00000000000..0ae7b85c04d --- /dev/null +++ b/pkg/apis/abac/register.go @@ -0,0 +1,37 @@ +/* +Copyright 2015 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "k8s.io/kubernetes/pkg/api/unversioned" + "k8s.io/kubernetes/pkg/runtime" +) + +// Group is the API group for abac +const Group = "abac.authorization.kubernetes.io" + +// Scheme is the default instance of runtime.Scheme to which types in the abac API group are registered. +var Scheme = runtime.NewScheme() + +func init() { + Scheme.AddInternalGroupVersion(unversioned.GroupVersion{Group: Group, Version: ""}) + Scheme.AddKnownTypes(unversioned.GroupVersion{Group: Group, Version: ""}, + &Policy{}, + ) +} + +func (*Policy) IsAnAPIObject() {} diff --git a/pkg/apis/abac/types.go b/pkg/apis/abac/types.go new file mode 100644 index 00000000000..1b694c0481b --- /dev/null +++ b/pkg/apis/abac/types.go @@ -0,0 +1,70 @@ +/* +Copyright 2015 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import "k8s.io/kubernetes/pkg/api/unversioned" + +// Policy contains a single ABAC policy rule +type Policy struct { + unversioned.TypeMeta + + // Spec describes the policy rule + Spec PolicySpec +} + +// PolicySpec contains the attributes for a policy rule +type PolicySpec struct { + + // User is the username this rule applies to. + // Either user or group is required to match the request. + // "*" matches all users. + User string + + // Group is the group this rule applies to. + // Either user or group is required to match the request. + // "*" matches all groups. + Group string + + // Readonly matches readonly requests when true, and all requests when false + Readonly bool + + // APIGroup is the name of an API group. APIGroup, Resource, and Namespace are required to match resource requests. + // "*" matches all API groups + APIGroup string + + // Resource is the name of a resource. APIGroup, Resource, and Namespace are required to match resource requests. + // "*" matches all resources + Resource string + + // Namespace is the name of a namespace. APIGroup, Resource, and Namespace are required to match resource requests. + // "*" matches all namespaces (including unnamespaced requests) + Namespace string + + // NonResourcePath matches non-resource request paths. + // "*" matches all paths + // "/foo/*" matches all subpaths of foo + NonResourcePath string + + // TODO: "expires" string in RFC3339 format. + + // TODO: want a way to allow some users to restart containers of a pod but + // not delete or modify it. + + // TODO: want a way to allow a controller to create a pod based only on a + // certain podTemplates. + +} diff --git a/pkg/apis/abac/v0/conversion.go b/pkg/apis/abac/v0/conversion.go new file mode 100644 index 00000000000..56b7a31b0c4 --- /dev/null +++ b/pkg/apis/abac/v0/conversion.go @@ -0,0 +1,58 @@ +/* +Copyright 2015 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v0 + +import ( + "k8s.io/kubernetes/pkg/apis/abac" + "k8s.io/kubernetes/pkg/conversion" +) + +func init() { + api.Scheme.AddConversionFuncs( + func(in *Policy, out *api.Policy, s conversion.Scope) error { + // Begin by copying all fields + out.Spec.User = in.User + out.Spec.Group = in.Group + out.Spec.Namespace = in.Namespace + out.Spec.Resource = in.Resource + out.Spec.Readonly = in.Readonly + + // In v0, unspecified user and group matches all subjects + if len(in.User) == 0 && len(in.Group) == 0 { + out.Spec.User = "*" + } + + // In v0, leaving namespace empty matches all namespaces + if len(in.Namespace) == 0 { + out.Spec.Namespace = "*" + } + // In v0, leaving resource empty matches all resources + if len(in.Resource) == 0 { + out.Spec.Resource = "*" + } + // Any rule in v0 should match all API groups + out.Spec.APIGroup = "*" + + // In v0, leaving namespace and resource blank allows non-resource paths + if len(in.Namespace) == 0 && len(in.Resource) == 0 { + out.Spec.NonResourcePath = "*" + } + + return nil + }, + ) +} diff --git a/pkg/apis/abac/v0/conversion_test.go b/pkg/apis/abac/v0/conversion_test.go new file mode 100644 index 00000000000..256b4e25100 --- /dev/null +++ b/pkg/apis/abac/v0/conversion_test.go @@ -0,0 +1,77 @@ +/* +Copyright 2015 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v0_test + +import ( + "reflect" + "testing" + + "k8s.io/kubernetes/pkg/apis/abac" + "k8s.io/kubernetes/pkg/apis/abac/v0" +) + +func TestConversion(t *testing.T) { + testcases := map[string]struct { + old *v0.Policy + expected *api.Policy + }{ + // a completely empty policy rule allows everything to all users + "empty": { + old: &v0.Policy{}, + expected: &api.Policy{Spec: api.PolicySpec{User: "*", Readonly: false, NonResourcePath: "*", Namespace: "*", Resource: "*", APIGroup: "*"}}, + }, + + // specifying a user is preserved + "user": { + old: &v0.Policy{User: "bob"}, + expected: &api.Policy{Spec: api.PolicySpec{User: "bob", Readonly: false, NonResourcePath: "*", Namespace: "*", Resource: "*", APIGroup: "*"}}, + }, + + // specifying a group is preserved (and no longer matches all users) + "group": { + old: &v0.Policy{Group: "mygroup"}, + expected: &api.Policy{Spec: api.PolicySpec{Group: "mygroup", Readonly: false, NonResourcePath: "*", Namespace: "*", Resource: "*", APIGroup: "*"}}, + }, + + // specifying a namespace removes the * match on non-resource path + "namespace": { + old: &v0.Policy{Namespace: "myns"}, + expected: &api.Policy{Spec: api.PolicySpec{User: "*", Readonly: false, NonResourcePath: "", Namespace: "myns", Resource: "*", APIGroup: "*"}}, + }, + + // specifying a resource removes the * match on non-resource path + "resource": { + old: &v0.Policy{Resource: "myresource"}, + expected: &api.Policy{Spec: api.PolicySpec{User: "*", Readonly: false, NonResourcePath: "", Namespace: "*", Resource: "myresource", APIGroup: "*"}}, + }, + + // specifying a namespace+resource removes the * match on non-resource path + "namespace+resource": { + old: &v0.Policy{Namespace: "myns", Resource: "myresource"}, + expected: &api.Policy{Spec: api.PolicySpec{User: "*", Readonly: false, NonResourcePath: "", Namespace: "myns", Resource: "myresource", APIGroup: "*"}}, + }, + } + for k, tc := range testcases { + internal := &api.Policy{} + if err := api.Scheme.Convert(tc.old, internal); err != nil { + t.Errorf("%s: unexpected error: %v", k, err) + } + if !reflect.DeepEqual(internal, tc.expected) { + t.Errorf("%s: expected\n\t%#v, got \n\t%#v", k, tc.expected, internal) + } + } +} diff --git a/pkg/apis/abac/v0/register.go b/pkg/apis/abac/v0/register.go new file mode 100644 index 00000000000..1651e5570c6 --- /dev/null +++ b/pkg/apis/abac/v0/register.go @@ -0,0 +1,37 @@ +/* +Copyright 2015 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v0 + +import ( + "k8s.io/kubernetes/pkg/api/unversioned" + "k8s.io/kubernetes/pkg/apis/abac" + "k8s.io/kubernetes/pkg/runtime" +) + +// GroupVersion is the API group and version for abac v0 +var GroupVersion = unversioned.GroupVersion{Group: api.Group, Version: "v0"} + +// Codec encodes internal objects to the v0 version for the abac group +var Codec = runtime.CodecFor(api.Scheme, GroupVersion.String()) + +func init() { + api.Scheme.AddKnownTypes(GroupVersion, + &Policy{}, + ) +} + +func (*Policy) IsAnAPIObject() {} diff --git a/pkg/apis/abac/v0/types.go b/pkg/apis/abac/v0/types.go new file mode 100644 index 00000000000..58bb569f40b --- /dev/null +++ b/pkg/apis/abac/v0/types.go @@ -0,0 +1,45 @@ +/* +Copyright 2015 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v0 + +import "k8s.io/kubernetes/pkg/api/unversioned" + +// Policy contains a single ABAC policy rule +type Policy struct { + unversioned.TypeMeta `json:",inline"` + + // User is the username this rule applies to. + // Either user or group is required to match the request. + // "*" matches all users. + User string `json:"user,omitempty"` + + // Group is the group this rule applies to. + // Either user or group is required to match the request. + // "*" matches all groups. + Group string `json:"group,omitempty"` + + // Readonly matches readonly requests when true, and all requests when false + Readonly bool `json:"readonly,omitempty"` + + // Resource is the name of a resource + // "*" matches all resources + Resource string `json:"resource,omitempty"` + + // Namespace is the name of a namespace + // "*" matches all namespaces (including unnamespaced requests) + Namespace string `json:"namespace,omitempty"` +} diff --git a/pkg/apis/abac/v1beta1/register.go b/pkg/apis/abac/v1beta1/register.go new file mode 100644 index 00000000000..b5bf2b6f9fd --- /dev/null +++ b/pkg/apis/abac/v1beta1/register.go @@ -0,0 +1,37 @@ +/* +Copyright 2015 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta1 + +import ( + "k8s.io/kubernetes/pkg/api/unversioned" + "k8s.io/kubernetes/pkg/apis/abac" + "k8s.io/kubernetes/pkg/runtime" +) + +// GroupVersion is the API group and version for abac v1beta1 +var GroupVersion = unversioned.GroupVersion{Group: api.Group, Version: "v1beta1"} + +// Codec encodes internal objects to the v1beta1 version for the abac group +var Codec = runtime.CodecFor(api.Scheme, GroupVersion.String()) + +func init() { + api.Scheme.AddKnownTypes(GroupVersion, + &Policy{}, + ) +} + +func (*Policy) IsAnAPIObject() {} diff --git a/pkg/apis/abac/v1beta1/types.go b/pkg/apis/abac/v1beta1/types.go new file mode 100644 index 00000000000..7ce61ac4ac5 --- /dev/null +++ b/pkg/apis/abac/v1beta1/types.go @@ -0,0 +1,60 @@ +/* +Copyright 2015 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta1 + +import "k8s.io/kubernetes/pkg/api/unversioned" + +// Policy contains a single ABAC policy rule +type Policy struct { + unversioned.TypeMeta `json:",inline"` + + // Spec describes the policy rule + Spec PolicySpec `json:"spec"` +} + +// PolicySpec contains the attributes for a policy rule +type PolicySpec struct { + // User is the username this rule applies to. + // Either user or group is required to match the request. + // "*" matches all users. + User string `json:"user,omitempty"` + + // Group is the group this rule applies to. + // Either user or group is required to match the request. + // "*" matches all groups. + Group string `json:"group,omitempty"` + + // Readonly matches readonly requests when true, and all requests when false + Readonly bool `json:"readonly,omitempty"` + + // APIGroup is the name of an API group. APIGroup, Resource, and Namespace are required to match resource requests. + // "*" matches all API groups + APIGroup string `json:"apiGroup,omitempty"` + + // Resource is the name of a resource. APIGroup, Resource, and Namespace are required to match resource requests. + // "*" matches all resources + Resource string `json:"resource,omitempty"` + + // Namespace is the name of a namespace. APIGroup, Resource, and Namespace are required to match resource requests. + // "*" matches all namespaces (including unnamespaced requests) + Namespace string `json:"namespace,omitempty"` + + // NonResourcePath matches non-resource request paths. + // "*" matches all paths + // "/foo/*" matches all subpaths of foo + NonResourcePath string `json:"nonResourcePath,omitempty"` +} diff --git a/pkg/apiserver/handlers.go b/pkg/apiserver/handlers.go index 92d49ca9709..83862485c09 100644 --- a/pkg/apiserver/handlers.go +++ b/pkg/apiserver/handlers.go @@ -362,19 +362,24 @@ func (r *requestAttributeGetter) GetAttribs(req *http.Request) authorizer.Attrib } } - apiRequestInfo, _ := r.requestInfoResolver.GetRequestInfo(req) + requestInfo, _ := r.requestInfoResolver.GetRequestInfo(req) - attribs.APIGroup = apiRequestInfo.APIGroup - attribs.Verb = apiRequestInfo.Verb + // Start with common attributes that apply to resource and non-resource requests + attribs.ResourceRequest = requestInfo.IsResourceRequest + attribs.Path = requestInfo.Path + attribs.Verb = requestInfo.Verb + + // If the request was for a resource in an API group, include that info + attribs.APIGroup = requestInfo.APIGroup // If a path follows the conventions of the REST object store, then // we can extract the resource. Otherwise, not. - attribs.Resource = apiRequestInfo.Resource + attribs.Resource = requestInfo.Resource // If the request specifies a namespace, then the namespace is filled in. // Assumes there is no empty string namespace. Unspecified results // in empty (does not understand defaulting rules.) - attribs.Namespace = apiRequestInfo.Namespace + attribs.Namespace = requestInfo.Namespace return &attribs } diff --git a/pkg/apiserver/handlers_test.go b/pkg/apiserver/handlers_test.go index 49f1ebeca9a..df979250918 100644 --- a/pkg/apiserver/handlers_test.go +++ b/pkg/apiserver/handlers_test.go @@ -30,6 +30,8 @@ import ( "k8s.io/kubernetes/pkg/api" "k8s.io/kubernetes/pkg/api/errors" "k8s.io/kubernetes/pkg/api/testapi" + "k8s.io/kubernetes/pkg/auth/authorizer" + "k8s.io/kubernetes/pkg/util/sets" ) type fakeRL bool @@ -218,6 +220,83 @@ func TestTimeout(t *testing.T) { } } +func TestGetAttribs(t *testing.T) { + r := &requestAttributeGetter{api.NewRequestContextMapper(), &RequestInfoResolver{sets.NewString("api", "apis"), sets.NewString("api")}} + + testcases := map[string]struct { + Verb string + Path string + ExpectedAttributes *authorizer.AttributesRecord + }{ + "non-resource root": { + Verb: "POST", + Path: "/", + ExpectedAttributes: &authorizer.AttributesRecord{ + Verb: "post", + Path: "/", + }, + }, + "non-resource api prefix": { + Verb: "GET", + Path: "/api/", + ExpectedAttributes: &authorizer.AttributesRecord{ + Verb: "get", + Path: "/api/", + }, + }, + "non-resource group api prefix": { + Verb: "GET", + Path: "/apis/extensions/", + ExpectedAttributes: &authorizer.AttributesRecord{ + Verb: "get", + Path: "/apis/extensions/", + }, + }, + + "resource": { + Verb: "POST", + Path: "/api/v1/nodes/mynode", + ExpectedAttributes: &authorizer.AttributesRecord{ + Verb: "create", + Path: "/api/v1/nodes/mynode", + ResourceRequest: true, + Resource: "nodes", + }, + }, + "namespaced resource": { + Verb: "PUT", + Path: "/api/v1/namespaces/myns/pods/mypod", + ExpectedAttributes: &authorizer.AttributesRecord{ + Verb: "update", + Path: "/api/v1/namespaces/myns/pods/mypod", + ResourceRequest: true, + Namespace: "myns", + Resource: "pods", + }, + }, + "API group resource": { + Verb: "GET", + Path: "/apis/extensions/v1beta1/namespaces/myns/jobs", + ExpectedAttributes: &authorizer.AttributesRecord{ + Verb: "list", + Path: "/apis/extensions/v1beta1/namespaces/myns/jobs", + ResourceRequest: true, + APIGroup: "extensions", + Namespace: "myns", + Resource: "jobs", + }, + }, + } + + for k, tc := range testcases { + req, _ := http.NewRequest(tc.Verb, tc.Path, nil) + attribs := r.GetAttribs(req) + if !reflect.DeepEqual(attribs, tc.ExpectedAttributes) { + t.Errorf("%s: expected\n\t%#v\ngot\n\t%#v", k, tc.ExpectedAttributes, attribs) + } + } +} + func TestGetAPIRequestInfo(t *testing.T) { successCases := []struct { method string diff --git a/pkg/auth/authorizer/abac/abac.go b/pkg/auth/authorizer/abac/abac.go index 4c4b9e6bd2c..77e90a95810 100644 --- a/pkg/auth/authorizer/abac/abac.go +++ b/pkg/auth/authorizer/abac/abac.go @@ -21,50 +21,35 @@ package abac import ( "bufio" - "encoding/json" "errors" + "fmt" "os" + "strings" + "github.com/golang/glog" + + "k8s.io/kubernetes/pkg/apis/abac" + "k8s.io/kubernetes/pkg/apis/abac/latest" + "k8s.io/kubernetes/pkg/apis/abac/v0" + _ "k8s.io/kubernetes/pkg/apis/abac/v1beta1" "k8s.io/kubernetes/pkg/auth/authorizer" ) -// TODO: make this into a real API object. Note that when that happens, it -// will get MetaData. However, the Kind and Namespace in the struct below -// will be separate from the Kind and Namespace in the Metadata. Obviously, -// meta.Kind will be something like policy, and policy.Kind has to be allowed -// to be different. Less obviously, namespace needs to be different as well. -// This will allow wildcard matching strings to be used in the future for the -// body.Namespace, if we want to add that feature, without affecting the -// meta.Namespace. -type policy struct { - User string `json:"user,omitempty"` - Group string `json:"group,omitempty"` - // TODO: add support for robot accounts as well as human user accounts. - // TODO: decide how to namespace user names when multiple authentication - // providers are in use. Either add "Realm", or assume "user@example.com" - // format. - - // TODO: Make the "cluster" Kinds be one API group (nodes, bindings, - // events, endpoints). The "user" Kinds are another (pods, services, - // replicationControllers, operations) Make a "plugin", e.g. build - // controller, be another group. That way when we add a new object to a - // the API, we don't have to add lots of policy? - - // TODO: make this a proper REST object with its own registry. - Readonly bool `json:"readonly,omitempty"` - Resource string `json:"resource,omitempty"` - Namespace string `json:"namespace,omitempty"` - - // TODO: "expires" string in RFC3339 format. - - // TODO: want a way to allow some users to restart containers of a pod but - // not delete or modify it. - - // TODO: want a way to allow a controller to create a pod based only on a - // certain podTemplates. +type policyLoadError struct { + path string + line int + data []byte + err error } -type policyList []policy +func (p policyLoadError) Error() string { + if p.line >= 0 { + return fmt.Sprintf("error reading policy file %s, line %d: %s: %v", p.path, p.line, string(p.data), p.err) + } + return fmt.Sprintf("error reading policy file %s: %v", p.path, p.err) +} + +type policyList []*api.Policy // TODO: Have policies be created via an API call and stored in REST storage. func NewFromFile(path string) (policyList, error) { @@ -79,29 +64,151 @@ func NewFromFile(path string) (policyList, error) { scanner := bufio.NewScanner(file) pl := make(policyList, 0) + i := 0 + unversionedLines := 0 for scanner.Scan() { - var p policy + i++ + p := &api.Policy{} b := scanner.Bytes() - // TODO: skip comment lines. - err = json.Unmarshal(b, &p) - if err != nil { - // TODO: line number in errors. - return nil, err + + // skip comment lines and blank lines + trimmed := strings.TrimSpace(string(b)) + if len(trimmed) == 0 || strings.HasPrefix(trimmed, "#") { + continue } + + version, kind, err := api.Scheme.DataVersionAndKind(b) + if err != nil { + return nil, policyLoadError{path, i, b, err} + } + + if version == "" && kind == "" { + unversionedLines++ + // Migrate unversioned policy object + oldPolicy := &v0.Policy{} + if err := latest.Codec.DecodeInto(b, oldPolicy); err != nil { + return nil, policyLoadError{path, i, b, err} + } + if err := api.Scheme.Convert(oldPolicy, p); err != nil { + return nil, policyLoadError{path, i, b, err} + } + } else { + decodedObj, err := latest.Codec.Decode(b) + if err != nil { + return nil, policyLoadError{path, i, b, err} + } + decodedPolicy, ok := decodedObj.(*api.Policy) + if !ok { + return nil, policyLoadError{path, i, b, fmt.Errorf("unrecognized object: %#v", decodedObj)} + } + p = decodedPolicy + } + pl = append(pl, p) } + if unversionedLines > 0 { + glog.Warningf(`Policy file %s contained unversioned rules. See docs/admin/authorization.md#abac-mode for ABAC file format details.`, path) + } + if err := scanner.Err(); err != nil { - return nil, err + return nil, policyLoadError{path, -1, nil, err} } return pl, nil } -func (p policy) matches(a authorizer.Attributes) bool { - if p.subjectMatches(a) { - if p.Readonly == false || (p.Readonly == a.IsReadOnly()) { - if p.Resource == "" || (p.Resource == a.GetResource()) { - if p.Namespace == "" || (p.Namespace == a.GetNamespace()) { +func matches(p api.Policy, a authorizer.Attributes) bool { + if subjectMatches(p, a) { + if verbMatches(p, a) { + // Resource and non-resource requests are mutually exclusive, at most one will match a policy + if resourceMatches(p, a) { + return true + } + if nonResourceMatches(p, a) { + return true + } + } + } + return false +} + +// subjectMatches returns true if specified user and group properties in the policy match the attributes +func subjectMatches(p api.Policy, a authorizer.Attributes) bool { + matched := false + + // If the policy specified a user, ensure it matches + if len(p.Spec.User) > 0 { + if p.Spec.User == "*" { + matched = true + } else { + matched = p.Spec.User == a.GetUserName() + if !matched { + return false + } + } + } + + // If the policy specified a group, ensure it matches + if len(p.Spec.Group) > 0 { + if p.Spec.Group == "*" { + matched = true + } else { + matched = false + for _, group := range a.GetGroups() { + if p.Spec.Group == group { + matched = true + } + } + if !matched { + return false + } + } + } + + return matched +} + +func verbMatches(p api.Policy, a authorizer.Attributes) bool { + // TODO: match on verb + + // All policies allow read only requests + if a.IsReadOnly() { + return true + } + + // Allow if policy is not readonly + if !p.Spec.Readonly { + return true + } + + return false +} + +func nonResourceMatches(p api.Policy, a authorizer.Attributes) bool { + // A non-resource policy cannot match a resource request + if !a.IsResourceRequest() { + // Allow wildcard match + if p.Spec.NonResourcePath == "*" { + return true + } + // Allow exact match + if p.Spec.NonResourcePath == a.GetPath() { + return true + } + // Allow a trailing * subpath match + if strings.HasSuffix(p.Spec.NonResourcePath, "*") && strings.HasPrefix(a.GetPath(), strings.TrimRight(p.Spec.NonResourcePath, "*")) { + return true + } + } + return false +} + +func resourceMatches(p api.Policy, a authorizer.Attributes) bool { + // A resource policy cannot match a non-resource request + if a.IsResourceRequest() { + if p.Spec.Namespace == "*" || p.Spec.Namespace == a.GetNamespace() { + if p.Spec.Resource == "*" || p.Spec.Resource == a.GetResource() { + if p.Spec.APIGroup == "*" || p.Spec.APIGroup == a.GetAPIGroup() { return true } } @@ -110,31 +217,10 @@ func (p policy) matches(a authorizer.Attributes) bool { return false } -func (p policy) subjectMatches(a authorizer.Attributes) bool { - if p.User != "" { - // Require user match - if p.User != a.GetUserName() { - return false - } - } - - if p.Group != "" { - // Require group match - for _, group := range a.GetGroups() { - if p.Group == group { - return true - } - } - return false - } - - return true -} - // Authorizer implements authorizer.Authorize func (pl policyList) Authorize(a authorizer.Attributes) error { for _, p := range pl { - if p.matches(a) { + if matches(*p, a) { return nil } } diff --git a/pkg/auth/authorizer/abac/abac_test.go b/pkg/auth/authorizer/abac/abac_test.go index ef42ade6ca9..5fa1154fd3f 100644 --- a/pkg/auth/authorizer/abac/abac_test.go +++ b/pkg/auth/authorizer/abac/abac_test.go @@ -21,8 +21,12 @@ import ( "os" "testing" + "k8s.io/kubernetes/pkg/apis/abac" + "k8s.io/kubernetes/pkg/apis/abac/v0" + "k8s.io/kubernetes/pkg/apis/abac/v1beta1" "k8s.io/kubernetes/pkg/auth/authorizer" "k8s.io/kubernetes/pkg/auth/user" + "k8s.io/kubernetes/pkg/runtime" ) func TestEmptyFile(t *testing.T) { @@ -56,7 +60,7 @@ func TestExampleFile(t *testing.T) { } } -func TestNotAuthorized(t *testing.T) { +func TestAuthorizeV0(t *testing.T) { a, err := newWithContents(t, `{ "readonly": true, "resource": "events" } {"user":"scheduler", "readonly": true, "resource": "pods" } {"user":"scheduler", "resource": "bindings" } @@ -78,6 +82,102 @@ func TestNotAuthorized(t *testing.T) { Verb string Resource string NS string + APIGroup string + Path string + ExpectAllow bool + }{ + // Scheduler can read pods + {User: uScheduler, Verb: "list", Resource: "pods", NS: "ns1", ExpectAllow: true}, + {User: uScheduler, Verb: "list", Resource: "pods", NS: "", ExpectAllow: true}, + // Scheduler cannot write pods + {User: uScheduler, Verb: "create", Resource: "pods", NS: "ns1", ExpectAllow: false}, + {User: uScheduler, Verb: "create", Resource: "pods", NS: "", ExpectAllow: false}, + // Scheduler can write bindings + {User: uScheduler, Verb: "get", Resource: "bindings", NS: "ns1", ExpectAllow: true}, + {User: uScheduler, Verb: "get", Resource: "bindings", NS: "", ExpectAllow: true}, + + // Alice can read and write anything in the right namespace. + {User: uAlice, Verb: "get", Resource: "pods", NS: "projectCaribou", ExpectAllow: true}, + {User: uAlice, Verb: "get", Resource: "widgets", NS: "projectCaribou", ExpectAllow: true}, + {User: uAlice, Verb: "get", Resource: "", NS: "projectCaribou", ExpectAllow: true}, + {User: uAlice, Verb: "update", Resource: "pods", NS: "projectCaribou", ExpectAllow: true}, + {User: uAlice, Verb: "update", Resource: "widgets", NS: "projectCaribou", ExpectAllow: true}, + {User: uAlice, Verb: "update", Resource: "", NS: "projectCaribou", ExpectAllow: true}, + {User: uAlice, Verb: "update", Resource: "foo", NS: "projectCaribou", APIGroup: "bar", ExpectAllow: true}, + // .. but not the wrong namespace. + {User: uAlice, Verb: "get", Resource: "pods", NS: "ns1", ExpectAllow: false}, + {User: uAlice, Verb: "get", Resource: "widgets", NS: "ns1", ExpectAllow: false}, + {User: uAlice, Verb: "get", Resource: "", NS: "ns1", ExpectAllow: false}, + + // Chuck can read events, since anyone can. + {User: uChuck, Verb: "get", Resource: "events", NS: "ns1", ExpectAllow: true}, + {User: uChuck, Verb: "get", Resource: "events", NS: "", ExpectAllow: true}, + // Chuck can't do other things. + {User: uChuck, Verb: "update", Resource: "events", NS: "ns1", ExpectAllow: false}, + {User: uChuck, Verb: "get", Resource: "pods", NS: "ns1", ExpectAllow: false}, + {User: uChuck, Verb: "get", Resource: "floop", NS: "ns1", ExpectAllow: false}, + // Chunk can't access things with no kind or namespace + {User: uChuck, Verb: "get", Path: "/", Resource: "", NS: "", ExpectAllow: false}, + } + for i, tc := range testCases { + attr := authorizer.AttributesRecord{ + User: &tc.User, + Verb: tc.Verb, + Resource: tc.Resource, + Namespace: tc.NS, + APIGroup: tc.APIGroup, + Path: tc.Path, + + ResourceRequest: len(tc.NS) > 0 || len(tc.Resource) > 0, + } + err := a.Authorize(attr) + actualAllow := bool(err == nil) + if tc.ExpectAllow != actualAllow { + t.Logf("tc: %v -> attr %v", tc, attr) + t.Errorf("%d: Expected allowed=%v but actually allowed=%v\n\t%v", + i, tc.ExpectAllow, actualAllow, tc) + } + } +} + +func TestAuthorizeV1beta1(t *testing.T) { + a, err := newWithContents(t, + ` + # Comment line, after a blank line + {"apiVersion":"abac.authorization.kubernetes.io/v1beta1","kind":"Policy","spec":{"user":"*", "readonly": true, "nonResourcePath": "/api"}} + {"apiVersion":"abac.authorization.kubernetes.io/v1beta1","kind":"Policy","spec":{"user":"*", "nonResourcePath": "/custom"}} + {"apiVersion":"abac.authorization.kubernetes.io/v1beta1","kind":"Policy","spec":{"user":"*", "nonResourcePath": "/root/*"}} + {"apiVersion":"abac.authorization.kubernetes.io/v1beta1","kind":"Policy","spec":{"user":"noresource", "nonResourcePath": "*"}} + {"apiVersion":"abac.authorization.kubernetes.io/v1beta1","kind":"Policy","spec":{"user":"*", "readonly": true, "resource": "events", "namespace": "*"}} + {"apiVersion":"abac.authorization.kubernetes.io/v1beta1","kind":"Policy","spec":{"user":"scheduler", "readonly": true, "resource": "pods", "namespace": "*"}} + {"apiVersion":"abac.authorization.kubernetes.io/v1beta1","kind":"Policy","spec":{"user":"scheduler", "resource": "bindings", "namespace": "*"}} + {"apiVersion":"abac.authorization.kubernetes.io/v1beta1","kind":"Policy","spec":{"user":"kubelet", "readonly": true, "resource": "bindings", "namespace": "*"}} + {"apiVersion":"abac.authorization.kubernetes.io/v1beta1","kind":"Policy","spec":{"user":"kubelet", "resource": "events", "namespace": "*"}} + {"apiVersion":"abac.authorization.kubernetes.io/v1beta1","kind":"Policy","spec":{"user":"alice", "resource": "*", "namespace": "projectCaribou"}} + {"apiVersion":"abac.authorization.kubernetes.io/v1beta1","kind":"Policy","spec":{"user":"bob", "readonly": true, "resource": "*", "namespace": "projectCaribou"}} + {"apiVersion":"abac.authorization.kubernetes.io/v1beta1","kind":"Policy","spec":{"user":"debbie", "resource": "pods", "namespace": "projectCaribou"}} + {"apiVersion":"abac.authorization.kubernetes.io/v1beta1","kind":"Policy","spec":{"user":"apigroupuser", "resource": "*", "namespace": "projectAnyGroup", "apiGroup": "*"}} + {"apiVersion":"abac.authorization.kubernetes.io/v1beta1","kind":"Policy","spec":{"user":"apigroupuser", "resource": "*", "namespace": "projectEmptyGroup", "apiGroup": "" }} + {"apiVersion":"abac.authorization.kubernetes.io/v1beta1","kind":"Policy","spec":{"user":"apigroupuser", "resource": "*", "namespace": "projectXGroup", "apiGroup": "x"}}`) + + if err != nil { + t.Fatalf("unable to read policy file: %v", err) + } + + uScheduler := user.DefaultInfo{Name: "scheduler", UID: "uid1"} + uAlice := user.DefaultInfo{Name: "alice", UID: "uid3"} + uChuck := user.DefaultInfo{Name: "chuck", UID: "uid5"} + uDebbie := user.DefaultInfo{Name: "debbie", UID: "uid6"} + uNoResource := user.DefaultInfo{Name: "noresource", UID: "uid7"} + uAPIGroup := user.DefaultInfo{Name: "apigroupuser", UID: "uid8"} + + testCases := []struct { + User user.DefaultInfo + Verb string + Resource string + APIGroup string + NS string + Path string ExpectAllow bool }{ // Scheduler can read pods @@ -102,6 +202,9 @@ func TestNotAuthorized(t *testing.T) { {User: uAlice, Verb: "get", Resource: "widgets", NS: "ns1", ExpectAllow: false}, {User: uAlice, Verb: "get", Resource: "", NS: "ns1", ExpectAllow: false}, + // Debbie can write to pods in the right namespace + {User: uDebbie, Verb: "update", Resource: "pods", NS: "projectCaribou", ExpectAllow: true}, + // Chuck can read events, since anyone can. {User: uChuck, Verb: "get", Resource: "events", NS: "ns1", ExpectAllow: true}, {User: uChuck, Verb: "get", Resource: "events", NS: "", ExpectAllow: true}, @@ -109,24 +212,49 @@ func TestNotAuthorized(t *testing.T) { {User: uChuck, Verb: "update", Resource: "events", NS: "ns1", ExpectAllow: false}, {User: uChuck, Verb: "get", Resource: "pods", NS: "ns1", ExpectAllow: false}, {User: uChuck, Verb: "get", Resource: "floop", NS: "ns1", ExpectAllow: false}, - // Chunk can't access things with no kind or namespace - // TODO: find a way to give someone access to miscellaneous endpoints, such as - // /healthz, /version, etc. - {User: uChuck, Verb: "get", Resource: "", NS: "", ExpectAllow: false}, + // Chuck can't access things with no resource or namespace + {User: uChuck, Verb: "get", Path: "/", Resource: "", NS: "", ExpectAllow: false}, + // but can access /api + {User: uChuck, Verb: "get", Path: "/api", Resource: "", NS: "", ExpectAllow: true}, + // though he cannot write to it + {User: uChuck, Verb: "create", Path: "/api", Resource: "", NS: "", ExpectAllow: false}, + // while he can write to /custom + {User: uChuck, Verb: "update", Path: "/custom", Resource: "", NS: "", ExpectAllow: true}, + // he cannot get "/root" + {User: uChuck, Verb: "get", Path: "/root", Resource: "", NS: "", ExpectAllow: false}, + // but can get any subpath + {User: uChuck, Verb: "get", Path: "/root/", Resource: "", NS: "", ExpectAllow: true}, + {User: uChuck, Verb: "get", Path: "/root/test/1/2/3", Resource: "", NS: "", ExpectAllow: true}, + + // the user "noresource" can get any non-resource request + {User: uNoResource, Verb: "get", Path: "", Resource: "", NS: "", ExpectAllow: true}, + {User: uNoResource, Verb: "get", Path: "/", Resource: "", NS: "", ExpectAllow: true}, + {User: uNoResource, Verb: "get", Path: "/foo/bar/baz", Resource: "", NS: "", ExpectAllow: true}, + // but cannot get any request where IsResourceRequest() == true + {User: uNoResource, Verb: "get", Path: "/", Resource: "", NS: "bar", ExpectAllow: false}, + {User: uNoResource, Verb: "get", Path: "/foo/bar/baz", Resource: "foo", NS: "bar", ExpectAllow: false}, + + // Test APIGroup matching + {User: uAPIGroup, Verb: "get", APIGroup: "x", Resource: "foo", NS: "projectAnyGroup", ExpectAllow: true}, + {User: uAPIGroup, Verb: "get", APIGroup: "x", Resource: "foo", NS: "projectEmptyGroup", ExpectAllow: false}, + {User: uAPIGroup, Verb: "get", APIGroup: "x", Resource: "foo", NS: "projectXGroup", ExpectAllow: true}, } for i, tc := range testCases { attr := authorizer.AttributesRecord{ - User: &tc.User, - Verb: tc.Verb, - Resource: tc.Resource, - Namespace: tc.NS, + User: &tc.User, + Verb: tc.Verb, + Resource: tc.Resource, + APIGroup: tc.APIGroup, + Namespace: tc.NS, + ResourceRequest: len(tc.NS) > 0 || len(tc.Resource) > 0, + Path: tc.Path, } - t.Logf("tc: %v -> attr %v", tc, attr) + // t.Logf("tc %2v: %v -> attr %v", i, tc, attr) err := a.Authorize(attr) actualAllow := bool(err == nil) if tc.ExpectAllow != actualAllow { - t.Errorf("%d: Expected allowed=%v but actually allowed=%v\n\t%v", - i, tc.ExpectAllow, actualAllow, tc) + t.Errorf("%d: Expected allowed=%v but actually allowed=%v, for case %+v & %+v", + i, tc.ExpectAllow, actualAllow, tc, attr) } } } @@ -134,116 +262,316 @@ func TestNotAuthorized(t *testing.T) { func TestSubjectMatches(t *testing.T) { testCases := map[string]struct { User user.DefaultInfo - PolicyUser string - PolicyGroup string + Policy runtime.Object ExpectMatch bool }{ - "empty policy matches unauthed user": { - User: user.DefaultInfo{}, - PolicyUser: "", - PolicyGroup: "", + "v0 empty policy matches unauthed user": { + User: user.DefaultInfo{}, + Policy: &v0.Policy{ + User: "", + Group: "", + }, ExpectMatch: true, }, - "empty policy matches authed user": { - User: user.DefaultInfo{Name: "Foo"}, - PolicyUser: "", - PolicyGroup: "", + "v0 empty policy matches authed user": { + User: user.DefaultInfo{Name: "Foo"}, + Policy: &v0.Policy{ + User: "", + Group: "", + }, ExpectMatch: true, }, - "empty policy matches authed user with groups": { - User: user.DefaultInfo{Name: "Foo", Groups: []string{"a", "b"}}, - PolicyUser: "", - PolicyGroup: "", + "v0 empty policy matches authed user with groups": { + User: user.DefaultInfo{Name: "Foo", Groups: []string{"a", "b"}}, + Policy: &v0.Policy{ + User: "", + Group: "", + }, ExpectMatch: true, }, - "user policy does not match unauthed user": { - User: user.DefaultInfo{}, - PolicyUser: "Foo", - PolicyGroup: "", + "v0 user policy does not match unauthed user": { + User: user.DefaultInfo{}, + Policy: &v0.Policy{ + User: "Foo", + Group: "", + }, ExpectMatch: false, }, - "user policy does not match different user": { - User: user.DefaultInfo{Name: "Bar"}, - PolicyUser: "Foo", - PolicyGroup: "", + "v0 user policy does not match different user": { + User: user.DefaultInfo{Name: "Bar"}, + Policy: &v0.Policy{ + User: "Foo", + Group: "", + }, ExpectMatch: false, }, - "user policy is case-sensitive": { - User: user.DefaultInfo{Name: "foo"}, - PolicyUser: "Foo", - PolicyGroup: "", + "v0 user policy is case-sensitive": { + User: user.DefaultInfo{Name: "foo"}, + Policy: &v0.Policy{ + User: "Foo", + Group: "", + }, ExpectMatch: false, }, - "user policy does not match substring": { - User: user.DefaultInfo{Name: "FooBar"}, - PolicyUser: "Foo", - PolicyGroup: "", + "v0 user policy does not match substring": { + User: user.DefaultInfo{Name: "FooBar"}, + Policy: &v0.Policy{ + User: "Foo", + Group: "", + }, ExpectMatch: false, }, - "user policy matches username": { - User: user.DefaultInfo{Name: "Foo"}, - PolicyUser: "Foo", - PolicyGroup: "", + "v0 user policy matches username": { + User: user.DefaultInfo{Name: "Foo"}, + Policy: &v0.Policy{ + User: "Foo", + Group: "", + }, ExpectMatch: true, }, - "group policy does not match unauthed user": { - User: user.DefaultInfo{}, - PolicyUser: "", - PolicyGroup: "Foo", + "v0 group policy does not match unauthed user": { + User: user.DefaultInfo{}, + Policy: &v0.Policy{ + User: "", + Group: "Foo", + }, ExpectMatch: false, }, - "group policy does not match user in different group": { - User: user.DefaultInfo{Name: "FooBar", Groups: []string{"B"}}, - PolicyUser: "", - PolicyGroup: "A", + "v0 group policy does not match user in different group": { + User: user.DefaultInfo{Name: "FooBar", Groups: []string{"B"}}, + Policy: &v0.Policy{ + User: "", + Group: "A", + }, ExpectMatch: false, }, - "group policy is case-sensitive": { - User: user.DefaultInfo{Name: "Foo", Groups: []string{"A", "B", "C"}}, - PolicyUser: "", - PolicyGroup: "b", + "v0 group policy is case-sensitive": { + User: user.DefaultInfo{Name: "Foo", Groups: []string{"A", "B", "C"}}, + Policy: &v0.Policy{ + User: "", + Group: "b", + }, ExpectMatch: false, }, - "group policy does not match substring": { - User: user.DefaultInfo{Name: "Foo", Groups: []string{"A", "BBB", "C"}}, - PolicyUser: "", - PolicyGroup: "B", + "v0 group policy does not match substring": { + User: user.DefaultInfo{Name: "Foo", Groups: []string{"A", "BBB", "C"}}, + Policy: &v0.Policy{ + User: "", + Group: "B", + }, ExpectMatch: false, }, - "group policy matches user in group": { - User: user.DefaultInfo{Name: "Foo", Groups: []string{"A", "B", "C"}}, - PolicyUser: "", - PolicyGroup: "B", + "v0 group policy matches user in group": { + User: user.DefaultInfo{Name: "Foo", Groups: []string{"A", "B", "C"}}, + Policy: &v0.Policy{ + User: "", + Group: "B", + }, ExpectMatch: true, }, - "user and group policy requires user match": { - User: user.DefaultInfo{Name: "Bar", Groups: []string{"A", "B", "C"}}, - PolicyUser: "Foo", - PolicyGroup: "B", + "v0 user and group policy requires user match": { + User: user.DefaultInfo{Name: "Bar", Groups: []string{"A", "B", "C"}}, + Policy: &v0.Policy{ + User: "Foo", + Group: "B", + }, ExpectMatch: false, }, - "user and group policy requires group match": { - User: user.DefaultInfo{Name: "Foo", Groups: []string{"A", "B", "C"}}, - PolicyUser: "Foo", - PolicyGroup: "D", + "v0 user and group policy requires group match": { + User: user.DefaultInfo{Name: "Foo", Groups: []string{"A", "B", "C"}}, + Policy: &v0.Policy{ + User: "Foo", + Group: "D", + }, ExpectMatch: false, }, - "user and group policy matches": { - User: user.DefaultInfo{Name: "Foo", Groups: []string{"A", "B", "C"}}, - PolicyUser: "Foo", - PolicyGroup: "B", + "v0 user and group policy matches": { + User: user.DefaultInfo{Name: "Foo", Groups: []string{"A", "B", "C"}}, + Policy: &v0.Policy{ + User: "Foo", + Group: "B", + }, + ExpectMatch: true, + }, + + "v1 empty policy does not match unauthed user": { + User: user.DefaultInfo{}, + Policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "", + Group: "", + }, + }, + ExpectMatch: false, + }, + "v1 empty policy does not match authed user": { + User: user.DefaultInfo{Name: "Foo"}, + Policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "", + Group: "", + }, + }, + ExpectMatch: false, + }, + "v1 empty policy does not match authed user with groups": { + User: user.DefaultInfo{Name: "Foo", Groups: []string{"a", "b"}}, + Policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "", + Group: "", + }, + }, + ExpectMatch: false, + }, + + "v1 user policy does not match unauthed user": { + User: user.DefaultInfo{}, + Policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "Foo", + Group: "", + }, + }, + ExpectMatch: false, + }, + "v1 user policy does not match different user": { + User: user.DefaultInfo{Name: "Bar"}, + Policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "Foo", + Group: "", + }, + }, + ExpectMatch: false, + }, + "v1 user policy is case-sensitive": { + User: user.DefaultInfo{Name: "foo"}, + Policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "Foo", + Group: "", + }, + }, + ExpectMatch: false, + }, + "v1 user policy does not match substring": { + User: user.DefaultInfo{Name: "FooBar"}, + Policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "Foo", + Group: "", + }, + }, + ExpectMatch: false, + }, + "v1 user policy matches username": { + User: user.DefaultInfo{Name: "Foo"}, + Policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "Foo", + Group: "", + }, + }, + ExpectMatch: true, + }, + + "v1 group policy does not match unauthed user": { + User: user.DefaultInfo{}, + Policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "", + Group: "Foo", + }, + }, + ExpectMatch: false, + }, + "v1 group policy does not match user in different group": { + User: user.DefaultInfo{Name: "FooBar", Groups: []string{"B"}}, + Policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "", + Group: "A", + }, + }, + ExpectMatch: false, + }, + "v1 group policy is case-sensitive": { + User: user.DefaultInfo{Name: "Foo", Groups: []string{"A", "B", "C"}}, + Policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "", + Group: "b", + }, + }, + ExpectMatch: false, + }, + "v1 group policy does not match substring": { + User: user.DefaultInfo{Name: "Foo", Groups: []string{"A", "BBB", "C"}}, + Policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "", + Group: "B", + }, + }, + ExpectMatch: false, + }, + "v1 group policy matches user in group": { + User: user.DefaultInfo{Name: "Foo", Groups: []string{"A", "B", "C"}}, + Policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "", + Group: "B", + }, + }, + ExpectMatch: true, + }, + + "v1 user and group policy requires user match": { + User: user.DefaultInfo{Name: "Bar", Groups: []string{"A", "B", "C"}}, + Policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "Foo", + Group: "B", + }, + }, + ExpectMatch: false, + }, + "v1 user and group policy requires group match": { + User: user.DefaultInfo{Name: "Foo", Groups: []string{"A", "B", "C"}}, + Policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "Foo", + Group: "D", + }, + }, + ExpectMatch: false, + }, + "v1 user and group policy matches": { + User: user.DefaultInfo{Name: "Foo", Groups: []string{"A", "B", "C"}}, + Policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "Foo", + Group: "B", + }, + }, ExpectMatch: true, }, } for k, tc := range testCases { + policy := &api.Policy{} + if err := api.Scheme.Convert(tc.Policy, policy); err != nil { + t.Errorf("%s: error converting: %v", k, err) + continue + } attr := authorizer.AttributesRecord{ User: &tc.User, } - actualMatch := policy{User: tc.PolicyUser, Group: tc.PolicyGroup}.subjectMatches(attr) + actualMatch := subjectMatches(*policy, attr) if tc.ExpectMatch != actualMatch { t.Errorf("%v: Expected actorMatches=%v but actually got=%v", k, tc.ExpectMatch, actualMatch) @@ -269,27 +597,30 @@ func newWithContents(t *testing.T, contents string) (authorizer.Authorizer, erro func TestPolicy(t *testing.T) { tests := []struct { - policy policy + policy runtime.Object attr authorizer.Attributes matches bool name string }{ + // v0 { - policy: policy{}, + policy: &v0.Policy{}, attr: authorizer.AttributesRecord{}, matches: true, - name: "null", + name: "v0 null", }, + + // v0 mismatches { - policy: policy{ + policy: &v0.Policy{ Readonly: true, }, attr: authorizer.AttributesRecord{}, matches: false, - name: "read-only mismatch", + name: "v0 read-only mismatch", }, { - policy: policy{ + policy: &v0.Policy{ User: "foo", }, attr: authorizer.AttributesRecord{ @@ -298,20 +629,21 @@ func TestPolicy(t *testing.T) { }, }, matches: false, - name: "user name mis-match", + name: "v0 user name mis-match", }, { - policy: policy{ + policy: &v0.Policy{ Resource: "foo", }, attr: authorizer.AttributesRecord{ - Resource: "bar", + Resource: "bar", + ResourceRequest: true, }, matches: false, - name: "resource mis-match", + name: "v0 resource mis-match", }, { - policy: policy{ + policy: &v0.Policy{ User: "foo", Resource: "foo", Namespace: "foo", @@ -320,27 +652,314 @@ func TestPolicy(t *testing.T) { User: &user.DefaultInfo{ Name: "foo", }, - Resource: "foo", - Namespace: "foo", + Resource: "foo", + Namespace: "foo", + ResourceRequest: true, }, matches: true, - name: "namespace mis-match", + name: "v0 namespace mis-match", + }, + + // v0 matches + { + policy: &v0.Policy{}, + attr: authorizer.AttributesRecord{ResourceRequest: true}, + matches: true, + name: "v0 null resource", }, { - policy: policy{ - Namespace: "foo", + policy: &v0.Policy{ + Readonly: true, }, attr: authorizer.AttributesRecord{ - Namespace: "bar", + Verb: "get", + }, + matches: true, + name: "v0 read-only match", + }, + { + policy: &v0.Policy{ + User: "foo", + }, + attr: authorizer.AttributesRecord{ + User: &user.DefaultInfo{ + Name: "foo", + }, + }, + matches: true, + name: "v0 user name match", + }, + { + policy: &v0.Policy{ + Resource: "foo", + }, + attr: authorizer.AttributesRecord{ + Resource: "foo", + ResourceRequest: true, + }, + matches: true, + name: "v0 resource match", + }, + + // v1 mismatches + { + policy: &v1beta1.Policy{}, + attr: authorizer.AttributesRecord{ + ResourceRequest: true, }, matches: false, - name: "resource mis-match", + name: "v1 null", + }, + { + policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "foo", + }, + }, + attr: authorizer.AttributesRecord{ + User: &user.DefaultInfo{ + Name: "bar", + }, + ResourceRequest: true, + }, + matches: false, + name: "v1 user name mis-match", + }, + { + policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "*", + Readonly: true, + }, + }, + attr: authorizer.AttributesRecord{ + ResourceRequest: true, + }, + matches: false, + name: "v1 read-only mismatch", + }, + { + policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "*", + Resource: "foo", + }, + }, + attr: authorizer.AttributesRecord{ + Resource: "bar", + ResourceRequest: true, + }, + matches: false, + name: "v1 resource mis-match", + }, + { + policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "foo", + Namespace: "barr", + Resource: "baz", + }, + }, + attr: authorizer.AttributesRecord{ + User: &user.DefaultInfo{ + Name: "foo", + }, + Namespace: "bar", + Resource: "baz", + ResourceRequest: true, + }, + matches: false, + name: "v1 namespace mis-match", + }, + { + policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "*", + NonResourcePath: "/api", + }, + }, + attr: authorizer.AttributesRecord{ + Path: "/api2", + ResourceRequest: false, + }, + matches: false, + name: "v1 non-resource mis-match", + }, + { + policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "*", + NonResourcePath: "/api/*", + }, + }, + attr: authorizer.AttributesRecord{ + Path: "/api2/foo", + ResourceRequest: false, + }, + matches: false, + name: "v1 non-resource wildcard subpath mis-match", + }, + + // v1 matches + { + policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "foo", + }, + }, + attr: authorizer.AttributesRecord{ + User: &user.DefaultInfo{ + Name: "foo", + }, + ResourceRequest: true, + }, + matches: true, + name: "v1 user match", + }, + { + policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "*", + }, + }, + attr: authorizer.AttributesRecord{ + ResourceRequest: true, + }, + matches: true, + name: "v1 user wildcard match", + }, + { + policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + Group: "bar", + }, + }, + attr: authorizer.AttributesRecord{ + User: &user.DefaultInfo{ + Name: "foo", + Groups: []string{"bar"}, + }, + ResourceRequest: true, + }, + matches: true, + name: "v1 group match", + }, + { + policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + Group: "*", + }, + }, + attr: authorizer.AttributesRecord{ + User: &user.DefaultInfo{ + Name: "foo", + Groups: []string{"bar"}, + }, + ResourceRequest: true, + }, + matches: true, + name: "v1 group wildcard match", + }, + { + policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "*", + Readonly: true, + }, + }, + attr: authorizer.AttributesRecord{ + Verb: "get", + ResourceRequest: true, + }, + matches: true, + name: "v1 read-only match", + }, + { + policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "*", + Resource: "foo", + }, + }, + attr: authorizer.AttributesRecord{ + Resource: "foo", + ResourceRequest: true, + }, + matches: true, + name: "v1 resource match", + }, + { + policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "foo", + Namespace: "bar", + Resource: "baz", + }, + }, + attr: authorizer.AttributesRecord{ + User: &user.DefaultInfo{ + Name: "foo", + }, + Namespace: "bar", + Resource: "baz", + ResourceRequest: true, + }, + matches: true, + name: "v1 namespace match", + }, + { + policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "*", + NonResourcePath: "/api", + }, + }, + attr: authorizer.AttributesRecord{ + Path: "/api", + ResourceRequest: false, + }, + matches: true, + name: "v1 non-resource match", + }, + { + policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "*", + NonResourcePath: "*", + }, + }, + attr: authorizer.AttributesRecord{ + Path: "/api", + ResourceRequest: false, + }, + matches: true, + name: "v1 non-resource wildcard match", + }, + { + policy: &v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + User: "*", + NonResourcePath: "/api/*", + }, + }, + attr: authorizer.AttributesRecord{ + Path: "/api/foo", + ResourceRequest: false, + }, + matches: true, + name: "v1 non-resource wildcard subpath match", }, } for _, test := range tests { - matches := test.policy.matches(test.attr) + policy := &api.Policy{} + if err := api.Scheme.Convert(test.policy, policy); err != nil { + t.Errorf("%s: error converting: %v", test.name, err) + continue + } + matches := matches(*policy, test.attr) if test.matches != matches { - t.Errorf("unexpected value for %s, expected: %t, saw: %t", test.name, test.matches, matches) + t.Errorf("%s: expected: %t, saw: %t", test.name, test.matches, matches) + continue } } } diff --git a/pkg/auth/authorizer/abac/example_policy_file.jsonl b/pkg/auth/authorizer/abac/example_policy_file.jsonl index 554d97e5270..fd28f70a3cf 100644 --- a/pkg/auth/authorizer/abac/example_policy_file.jsonl +++ b/pkg/auth/authorizer/abac/example_policy_file.jsonl @@ -1,9 +1,10 @@ -{"user":"admin"} -{"user":"scheduler", "readonly": true, "resource": "pods"} -{"user":"scheduler", "resource": "bindings"} -{"user":"kubelet", "readonly": true, "resource": "pods"} -{"user":"kubelet", "readonly": true, "resource": "services"} -{"user":"kubelet", "readonly": true, "resource": "endpoints"} -{"user":"kubelet", "resource": "events"} -{"user":"alice", "namespace": "projectCaribou"} -{"user":"bob", "readonly": true, "namespace": "projectCaribou"} +{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "user":"*", "nonResourcePath": "*", "readonly": true} +{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "user":"admin", "namespace": "*", "resource": "*", "apiGroup": "*" } +{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "user":"scheduler", "namespace": "*", "resource": "pods", "readonly": true } +{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "user":"scheduler", "namespace": "*", "resource": "bindings" } +{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "user":"kubelet", "namespace": "*", "resource": "pods", "readonly": true } +{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "user":"kubelet", "namespace": "*", "resource": "services", "readonly": true } +{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "user":"kubelet", "namespace": "*", "resource": "endpoints", "readonly": true } +{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "user":"kubelet", "namespace": "*", "resource": "events" } +{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "user":"alice", "namespace": "projectCaribou", "resource": "*", "apiGroup": "*" } +{"apiVersion": "abac.authorization.kubernetes.io/v1beta1", "kind": "Policy", "user":"bob", "namespace": "projectCaribou", "resource": "*", "apiGroup": "*", "readonly": true } \ No newline at end of file diff --git a/pkg/auth/authorizer/interfaces.go b/pkg/auth/authorizer/interfaces.go index eadda02bf28..b5e2ab5c077 100644 --- a/pkg/auth/authorizer/interfaces.go +++ b/pkg/auth/authorizer/interfaces.go @@ -50,6 +50,13 @@ type Attributes interface { // The group of the resource, if a request is for a REST object. GetAPIGroup() string + + // IsResourceRequest returns true for requests to API resources, like /api/v1/nodes, + // and false for non-resource endpoints like /api, /healthz, and /swaggerapi + IsResourceRequest() bool + + // GetPath returns the path of the request + GetPath() string } // Authorizer makes an authorization decision based on information gained by making @@ -72,11 +79,13 @@ type RequestAttributesGetter interface { // AttributesRecord implements Attributes interface. type AttributesRecord struct { - User user.Info - Verb string - Namespace string - APIGroup string - Resource string + User user.Info + Verb string + Namespace string + APIGroup string + Resource string + ResourceRequest bool + Path string } func (a AttributesRecord) GetUserName() string { @@ -106,3 +115,11 @@ func (a AttributesRecord) GetResource() string { func (a AttributesRecord) GetAPIGroup() string { return a.APIGroup } + +func (a AttributesRecord) IsResourceRequest() bool { + return a.ResourceRequest +} + +func (a AttributesRecord) GetPath() string { + return a.Path +}