add field and label selectors to authorization attributes

Co-authored-by: Jordan Liggitt <liggitt@google.com>

Kubernetes-commit: 92e3445e9d7a587ddb56b3ff4b1445244fbf9abd
This commit is contained in:
David Eads 2024-05-23 15:12:26 -04:00 committed by Kubernetes Publisher
parent 6dd5496a01
commit f26d4ed894
11 changed files with 1093 additions and 90 deletions

View File

@ -22,6 +22,8 @@ import (
"sort"
"strings"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
)
@ -58,6 +60,8 @@ var _ authorizer.Attributes = (interface {
GetAPIVersion() string
IsResourceRequest() bool
GetPath() string
GetFieldSelector() (fields.Requirements, error)
GetLabelSelector() (labels.Requirements, error)
})(nil)
// The user info accessors known to cache key construction. If this fails to compile, the cache
@ -72,7 +76,13 @@ var _ user.Info = (interface {
// Authorize returns an authorization decision by delegating to another Authorizer. If an equivalent
// check has already been performed, a cached result is returned. Not safe for concurrent use.
func (ca *cachingAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
serializableAttributes := authorizer.AttributesRecord{
type SerializableAttributes struct {
authorizer.AttributesRecord
LabelSelector string
}
serializableAttributes := SerializableAttributes{
AttributesRecord: authorizer.AttributesRecord{
Verb: a.GetVerb(),
Namespace: a.GetNamespace(),
APIGroup: a.GetAPIGroup(),
@ -82,6 +92,15 @@ func (ca *cachingAuthorizer) Authorize(ctx context.Context, a authorizer.Attribu
Name: a.GetName(),
ResourceRequest: a.IsResourceRequest(),
Path: a.GetPath(),
},
}
// in the error case, we won't honor this field selector, so the cache doesn't need it.
if fieldSelector, err := a.GetFieldSelector(); len(fieldSelector) > 0 {
serializableAttributes.FieldSelectorRequirements, serializableAttributes.FieldSelectorParsingErr = fieldSelector, err
}
if labelSelector, _ := a.GetLabelSelector(); len(labelSelector) > 0 {
// the labels requirements have private elements so those don't help us serialize to a unique key
serializableAttributes.LabelSelector = labelSelector.String()
}
if u := a.GetUser(); u != nil {

View File

@ -18,14 +18,31 @@ package validating
import (
"context"
"errors"
"fmt"
"testing"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
genericfeatures "k8s.io/apiserver/pkg/features"
utilfeature "k8s.io/apiserver/pkg/util/feature"
featuregatetesting "k8s.io/component-base/featuregate/testing"
)
func mustParseLabelSelector(str string) labels.Requirements {
ret, err := labels.Parse(str)
if err != nil {
panic(err)
}
retRequirements, _ /*selectable*/ := ret.Requirements()
return retRequirements
}
func TestCachingAuthorizer(t *testing.T) {
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, genericfeatures.AuthorizeWithSelectors, true)
type result struct {
decision authorizer.Decision
reason string
@ -216,6 +233,261 @@ func TestCachingAuthorizer(t *testing.T) {
},
},
},
{
name: "honor good field selector",
calls: []invocation{
{
attributes: authorizer.AttributesRecord{
Name: "test name",
FieldSelectorRequirements: fields.ParseSelectorOrDie("foo=bar").Requirements(),
},
expected: result{
decision: authorizer.DecisionAllow,
reason: "test reason",
error: fmt.Errorf("test error"),
},
},
{
attributes: authorizer.AttributesRecord{
Name: "test name",
},
expected: result{
decision: authorizer.DecisionAllow,
reason: "test reason 2",
error: fmt.Errorf("test error 2"),
},
},
{
// now this should be cached
attributes: authorizer.AttributesRecord{
Name: "test name",
FieldSelectorRequirements: fields.ParseSelectorOrDie("foo=bar").Requirements(),
},
expected: result{
decision: authorizer.DecisionAllow,
reason: "test reason",
error: fmt.Errorf("test error"),
},
},
},
backend: []result{
{
decision: authorizer.DecisionAllow,
reason: "test reason",
error: fmt.Errorf("test error"),
},
{
decision: authorizer.DecisionAllow,
reason: "test reason 2",
error: fmt.Errorf("test error 2"),
},
},
},
{
name: "ignore malformed field selector first",
calls: []invocation{
{
attributes: authorizer.AttributesRecord{
Name: "test name",
FieldSelectorParsingErr: errors.New("malformed"),
},
expected: result{
decision: authorizer.DecisionAllow,
reason: "test reason",
error: fmt.Errorf("test error"),
},
},
{
// notice that this does not have the malformed field selector.
// it should use the cached result
attributes: authorizer.AttributesRecord{
Name: "test name",
},
expected: result{
decision: authorizer.DecisionAllow,
reason: "test reason",
error: fmt.Errorf("test error"),
},
},
},
backend: []result{
{
decision: authorizer.DecisionAllow,
reason: "test reason",
error: fmt.Errorf("test error"),
},
},
},
{
name: "ignore malformed field selector second",
calls: []invocation{
{
attributes: authorizer.AttributesRecord{
Name: "test name",
},
expected: result{
decision: authorizer.DecisionAllow,
reason: "test reason",
error: fmt.Errorf("test error"),
},
},
{
// this should use the broader cached value because the selector will be ignored
attributes: authorizer.AttributesRecord{
Name: "test name",
FieldSelectorParsingErr: errors.New("malformed"),
},
expected: result{
decision: authorizer.DecisionAllow,
reason: "test reason",
error: fmt.Errorf("test error"),
},
},
},
backend: []result{
{
decision: authorizer.DecisionAllow,
reason: "test reason",
error: fmt.Errorf("test error"),
},
},
},
{
name: "honor good label selector",
calls: []invocation{
{
attributes: authorizer.AttributesRecord{
Name: "test name",
LabelSelectorRequirements: mustParseLabelSelector("foo=bar"),
},
expected: result{
decision: authorizer.DecisionAllow,
reason: "test reason",
error: fmt.Errorf("test error"),
},
},
{
attributes: authorizer.AttributesRecord{
Name: "test name",
},
expected: result{
decision: authorizer.DecisionAllow,
reason: "test reason 2",
error: fmt.Errorf("test error 2"),
},
},
{
// now this should be cached
attributes: authorizer.AttributesRecord{
Name: "test name",
LabelSelectorRequirements: mustParseLabelSelector("foo=bar"),
},
expected: result{
decision: authorizer.DecisionAllow,
reason: "test reason",
error: fmt.Errorf("test error"),
},
},
{
attributes: authorizer.AttributesRecord{
Name: "test name",
LabelSelectorRequirements: mustParseLabelSelector("diff=zero"),
},
expected: result{
decision: authorizer.DecisionAllow,
reason: "test reason 3",
error: fmt.Errorf("test error 3"),
},
},
},
backend: []result{
{
decision: authorizer.DecisionAllow,
reason: "test reason",
error: fmt.Errorf("test error"),
},
{
decision: authorizer.DecisionAllow,
reason: "test reason 2",
error: fmt.Errorf("test error 2"),
},
{
decision: authorizer.DecisionAllow,
reason: "test reason 3",
error: fmt.Errorf("test error 3"),
},
},
},
{
name: "ignore malformed label selector first",
calls: []invocation{
{
attributes: authorizer.AttributesRecord{
Name: "test name",
LabelSelectorParsingErr: errors.New("malformed mess"),
},
expected: result{
decision: authorizer.DecisionAllow,
reason: "test reason",
error: fmt.Errorf("test error"),
},
},
{
// notice that this does not have the malformed field selector.
// it should use the cached result
attributes: authorizer.AttributesRecord{
Name: "test name",
},
expected: result{
decision: authorizer.DecisionAllow,
reason: "test reason",
error: fmt.Errorf("test error"),
},
},
},
backend: []result{
{
decision: authorizer.DecisionAllow,
reason: "test reason",
error: fmt.Errorf("test error"),
},
},
},
{
name: "ignore malformed label selector second",
calls: []invocation{
{
attributes: authorizer.AttributesRecord{
Name: "test name",
},
expected: result{
decision: authorizer.DecisionAllow,
reason: "test reason",
error: fmt.Errorf("test error"),
},
},
{
// this should use the broader cached value because the selector will be ignored
attributes: authorizer.AttributesRecord{
Name: "test name",
LabelSelectorParsingErr: errors.New("malformed mess"),
},
expected: result{
decision: authorizer.DecisionAllow,
reason: "test reason",
error: fmt.Errorf("test error"),
},
},
},
backend: []result{
{
decision: authorizer.DecisionAllow,
reason: "test reason",
error: fmt.Errorf("test error"),
},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
var misses int

View File

@ -20,6 +20,8 @@ import (
"context"
"net/http"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apiserver/pkg/authentication/user"
)
@ -62,6 +64,16 @@ type Attributes interface {
// GetPath returns the path of the request
GetPath() string
// ParseFieldSelector is lazy, thread-safe, and stores the parsed result and error.
// It returns an error if the field selector cannot be parsed.
// The returned requirements must be treated as readonly and not modified.
GetFieldSelector() (fields.Requirements, error)
// ParseLabelSelector is lazy, thread-safe, and stores the parsed result and error.
// It returns an error if the label selector cannot be parsed.
// The returned requirements must be treated as readonly and not modified.
GetLabelSelector() (labels.Requirements, error)
}
// Authorizer makes an authorization decision based on information gained by making
@ -100,6 +112,11 @@ type AttributesRecord struct {
Name string
ResourceRequest bool
Path string
FieldSelectorRequirements fields.Requirements
FieldSelectorParsingErr error
LabelSelectorRequirements labels.Requirements
LabelSelectorParsingErr error
}
func (a AttributesRecord) GetUser() user.Info {
@ -146,6 +163,14 @@ func (a AttributesRecord) GetPath() string {
return a.Path
}
func (a AttributesRecord) GetFieldSelector() (fields.Requirements, error) {
return a.FieldSelectorRequirements, a.FieldSelectorParsingErr
}
func (a AttributesRecord) GetLabelSelector() (labels.Requirements, error) {
return a.LabelSelectorRequirements, a.LabelSelectorParsingErr
}
type Decision int
const (

View File

@ -22,6 +22,11 @@ import (
"net/http"
"time"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels"
genericfeatures "k8s.io/apiserver/pkg/features"
utilfeature "k8s.io/apiserver/pkg/util/feature"
"k8s.io/klog/v2"
"k8s.io/apimachinery/pkg/runtime"
@ -117,5 +122,31 @@ func GetAuthorizerAttributes(ctx context.Context) (authorizer.Attributes, error)
attribs.Namespace = requestInfo.Namespace
attribs.Name = requestInfo.Name
if utilfeature.DefaultFeatureGate.Enabled(genericfeatures.AuthorizeWithSelectors) {
// parsing here makes it easy to keep the AttributesRecord type value-only and avoids any mutex copies when
// doing shallow copies in other steps.
if len(requestInfo.FieldSelector) > 0 {
fieldSelector, err := fields.ParseSelector(requestInfo.FieldSelector)
if err != nil {
attribs.FieldSelectorRequirements, attribs.FieldSelectorParsingErr = nil, err
} else {
if requirements := fieldSelector.Requirements(); len(requirements) > 0 {
attribs.FieldSelectorRequirements, attribs.FieldSelectorParsingErr = fieldSelector.Requirements(), nil
}
}
}
if len(requestInfo.LabelSelector) > 0 {
labelSelector, err := labels.Parse(requestInfo.LabelSelector)
if err != nil {
attribs.LabelSelectorRequirements, attribs.LabelSelectorParsingErr = nil, err
} else {
if requirements, _ /*selectable*/ := labelSelector.Requirements(); len(requirements) > 0 {
attribs.LabelSelectorRequirements, attribs.LabelSelectorParsingErr = requirements, nil
}
}
}
}
return &attribs, nil
}

View File

@ -19,6 +19,12 @@ package filters
import (
"context"
"errors"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/selection"
genericfeatures "k8s.io/apiserver/pkg/features"
utilfeature "k8s.io/apiserver/pkg/util/feature"
featuregatetesting "k8s.io/component-base/featuregate/testing"
"net/http"
"net/http/httptest"
"reflect"
@ -34,10 +40,16 @@ import (
)
func TestGetAuthorizerAttributes(t *testing.T) {
basicLabelRequirement, err := labels.NewRequirement("foo", selection.DoubleEquals, []string{"bar"})
if err != nil {
t.Fatal(err)
}
testcases := map[string]struct {
Verb string
Path string
ExpectedAttributes *authorizer.AttributesRecord
EnableAuthorizationSelector bool
}{
"non-resource root": {
Verb: "POST",
@ -102,9 +114,104 @@ func TestGetAuthorizerAttributes(t *testing.T) {
Resource: "jobs",
},
},
"disabled, ignore good field selector": {
Verb: "GET",
Path: "/apis/batch/v1/namespaces/myns/jobs?fieldSelector%=foo%3Dbar",
ExpectedAttributes: &authorizer.AttributesRecord{
Verb: "list",
Path: "/apis/batch/v1/namespaces/myns/jobs",
ResourceRequest: true,
APIGroup: batch.GroupName,
APIVersion: "v1",
Namespace: "myns",
Resource: "jobs",
},
},
"enabled, good field selector": {
Verb: "GET",
Path: "/apis/batch/v1/namespaces/myns/jobs?fieldSelector=foo%3D%3Dbar",
ExpectedAttributes: &authorizer.AttributesRecord{
Verb: "list",
Path: "/apis/batch/v1/namespaces/myns/jobs",
ResourceRequest: true,
APIGroup: batch.GroupName,
APIVersion: "v1",
Namespace: "myns",
Resource: "jobs",
FieldSelectorRequirements: fields.Requirements{
fields.OneTermEqualSelector("foo", "bar").Requirements()[0],
},
},
EnableAuthorizationSelector: true,
},
"enabled, bad field selector": {
Verb: "GET",
Path: "/apis/batch/v1/namespaces/myns/jobs?fieldSelector=%2Abar",
ExpectedAttributes: &authorizer.AttributesRecord{
Verb: "list",
Path: "/apis/batch/v1/namespaces/myns/jobs",
ResourceRequest: true,
APIGroup: batch.GroupName,
APIVersion: "v1",
Namespace: "myns",
Resource: "jobs",
FieldSelectorParsingErr: errors.New("invalid selector: '*bar'; can't understand '*bar'"),
},
EnableAuthorizationSelector: true,
},
"disabled, ignore good label selector": {
Verb: "GET",
Path: "/apis/batch/v1/namespaces/myns/jobs?labelSelector%=foo%3Dbar",
ExpectedAttributes: &authorizer.AttributesRecord{
Verb: "list",
Path: "/apis/batch/v1/namespaces/myns/jobs",
ResourceRequest: true,
APIGroup: batch.GroupName,
APIVersion: "v1",
Namespace: "myns",
Resource: "jobs",
},
},
"enabled, good label selector": {
Verb: "GET",
Path: "/apis/batch/v1/namespaces/myns/jobs?labelSelector=foo%3D%3Dbar",
ExpectedAttributes: &authorizer.AttributesRecord{
Verb: "list",
Path: "/apis/batch/v1/namespaces/myns/jobs",
ResourceRequest: true,
APIGroup: batch.GroupName,
APIVersion: "v1",
Namespace: "myns",
Resource: "jobs",
LabelSelectorRequirements: labels.Requirements{
*basicLabelRequirement,
},
},
EnableAuthorizationSelector: true,
},
"enabled, bad label selector": {
Verb: "GET",
Path: "/apis/batch/v1/namespaces/myns/jobs?labelSelector=%2Abar",
ExpectedAttributes: &authorizer.AttributesRecord{
Verb: "list",
Path: "/apis/batch/v1/namespaces/myns/jobs",
ResourceRequest: true,
APIGroup: batch.GroupName,
APIVersion: "v1",
Namespace: "myns",
Resource: "jobs",
LabelSelectorParsingErr: errors.New("unable to parse requirement: <nil>: Invalid value: \"*bar\": name part must consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyName', or 'my.name', or '123-abc', regex used for validation is '([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]')"),
},
EnableAuthorizationSelector: true,
},
}
for k, tc := range testcases {
t.Run(k, func(t *testing.T) {
if tc.EnableAuthorizationSelector {
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, genericfeatures.AuthorizeWithSelectors, true)
}
req, _ := http.NewRequest(tc.Verb, tc.Path, nil)
req.RemoteAddr = "127.0.0.1"
@ -122,6 +229,7 @@ func TestGetAuthorizerAttributes(t *testing.T) {
} else if !reflect.DeepEqual(attribs, tc.ExpectedAttributes) {
t.Errorf("%s: expected\n\t%#v\ngot\n\t%#v", k, tc.ExpectedAttributes, attribs)
}
})
}
}

View File

@ -27,6 +27,8 @@ import (
metainternalversionscheme "k8s.io/apimachinery/pkg/apis/meta/internalversion/scheme"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/sets"
genericfeatures "k8s.io/apiserver/pkg/features"
utilfeature "k8s.io/apiserver/pkg/util/feature"
"k8s.io/klog/v2"
)
@ -62,6 +64,13 @@ type RequestInfo struct {
Name string
// Parts are the path parts for the request, always starting with /{resource}/{name}
Parts []string
// FieldSelector contains the unparsed field selector from a request. It is only present if the apiserver
// honors field selectors for the verb this request is associated with.
FieldSelector string
// LabelSelector contains the unparsed field selector from a request. It is only present if the apiserver
// honors field selectors for the verb this request is associated with.
LabelSelector string
}
// specialVerbs contains just strings which are used in REST paths for special actions that don't fall under the normal
@ -77,6 +86,9 @@ var specialVerbsNoSubresources = sets.NewString("proxy")
// this list allows the parser to distinguish between a namespace subresource, and a namespaced resource
var namespaceSubresources = sets.NewString("status", "finalize")
// verbsWithSelectors is the list of verbs which support fieldSelector and labelSelector parameters
var verbsWithSelectors = sets.NewString("list", "watch", "deletecollection")
// NamespaceSubResourcesForTest exports namespaceSubresources for testing in pkg/controlplane/master_test.go, so we never drift
var NamespaceSubResourcesForTest = sets.NewString(namespaceSubresources.List()...)
@ -151,6 +163,7 @@ func (r *RequestInfoFactory) NewRequestInfo(req *http.Request) (*RequestInfo, er
currentParts = currentParts[1:]
// handle input of form /{specialVerb}/*
verbViaPathPrefix := false
if specialVerbs.Has(currentParts[0]) {
if len(currentParts) < 2 {
return &requestInfo, fmt.Errorf("unable to determine kind and namespace from url, %v", req.URL)
@ -158,6 +171,7 @@ func (r *RequestInfoFactory) NewRequestInfo(req *http.Request) (*RequestInfo, er
requestInfo.Verb = currentParts[0]
currentParts = currentParts[1:]
verbViaPathPrefix = true
} else {
switch req.Method {
@ -238,11 +252,28 @@ func (r *RequestInfoFactory) NewRequestInfo(req *http.Request) (*RequestInfo, er
}
}
}
// if there's no name on the request and we thought it was a delete before, then the actual verb is deletecollection
if len(requestInfo.Name) == 0 && requestInfo.Verb == "delete" {
requestInfo.Verb = "deletecollection"
}
if utilfeature.DefaultFeatureGate.Enabled(genericfeatures.AuthorizeWithSelectors) {
// Don't support selector authorization on requests that used the deprecated verb-via-path mechanism, since they don't support selectors consistently.
// There are multi-object and single-object watch endpoints, and only the multi-object one supports selectors.
if !verbViaPathPrefix && verbsWithSelectors.Has(requestInfo.Verb) {
// interestingly these are parsed above, but the current structure there means that if one (or anything) in the
// listOptions fails to decode, the field and label selectors are lost.
// therefore, do the straight query param read here.
if vals := req.URL.Query()["fieldSelector"]; len(vals) > 0 {
requestInfo.FieldSelector = vals[0]
}
if vals := req.URL.Query()["labelSelector"]; len(vals) > 0 {
requestInfo.LabelSelector = vals[0]
}
}
}
return &requestInfo, nil
}

View File

@ -25,6 +25,9 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/sets"
genericfeatures "k8s.io/apiserver/pkg/features"
utilfeature "k8s.io/apiserver/pkg/util/feature"
featuregatetesting "k8s.io/component-base/featuregate/testing"
)
func TestGetAPIRequestInfo(t *testing.T) {
@ -190,64 +193,129 @@ func newTestRequestInfoResolver() *RequestInfoFactory {
}
}
func TestFieldSelectorParsing(t *testing.T) {
func TestSelectorParsing(t *testing.T) {
tests := []struct {
name string
method string
url string
expectedName string
expectedErr error
expectedVerb string
expectedFieldSelector string
expectedLabelSelector string
}{
{
name: "no selector",
method: "GET",
url: "/apis/group/version/resource",
expectedVerb: "list",
expectedFieldSelector: "",
},
{
name: "metadata.name selector",
method: "GET",
url: "/apis/group/version/resource?fieldSelector=metadata.name=name1",
expectedName: "name1",
expectedVerb: "list",
expectedFieldSelector: "metadata.name=name1",
},
{
name: "metadata.name selector with watch",
method: "GET",
url: "/apis/group/version/resource?watch=true&fieldSelector=metadata.name=name1",
expectedName: "name1",
expectedVerb: "watch",
expectedFieldSelector: "metadata.name=name1",
},
{
name: "random selector",
url: "/apis/group/version/resource?fieldSelector=foo=bar",
method: "GET",
url: "/apis/group/version/resource?fieldSelector=foo=bar&labelSelector=baz=qux",
expectedName: "",
expectedVerb: "list",
expectedFieldSelector: "foo=bar",
expectedLabelSelector: "baz=qux",
},
{
name: "invalid selector with metadata.name",
method: "GET",
url: "/apis/group/version/resource?fieldSelector=metadata.name=name1,foo",
expectedName: "",
expectedErr: fmt.Errorf("invalid selector"),
expectedVerb: "list",
expectedFieldSelector: "metadata.name=name1,foo",
},
{
name: "invalid selector with metadata.name with watch",
method: "GET",
url: "/apis/group/version/resource?fieldSelector=metadata.name=name1,foo&watch=true",
expectedName: "",
expectedErr: fmt.Errorf("invalid selector"),
expectedVerb: "watch",
expectedFieldSelector: "metadata.name=name1,foo",
},
{
name: "invalid selector with metadata.name with watch false",
method: "GET",
url: "/apis/group/version/resource?fieldSelector=metadata.name=name1,foo&watch=false",
expectedName: "",
expectedErr: fmt.Errorf("invalid selector"),
expectedVerb: "list",
expectedFieldSelector: "metadata.name=name1,foo",
},
{
name: "selector on deletecollection is honored",
method: "DELETE",
url: "/apis/group/version/resource?fieldSelector=foo=bar&labelSelector=baz=qux",
expectedName: "",
expectedVerb: "deletecollection",
expectedFieldSelector: "foo=bar",
expectedLabelSelector: "baz=qux",
},
{
name: "selector on repeated param matches parsed param",
method: "GET",
url: "/apis/group/version/resource?fieldSelector=metadata.name=foo&fieldSelector=metadata.name=bar&labelSelector=foo=bar&labelSelector=foo=baz",
expectedName: "foo",
expectedVerb: "list",
expectedFieldSelector: "metadata.name=foo",
expectedLabelSelector: "foo=bar",
},
{
name: "selector on other verb is ignored",
method: "GET",
url: "/apis/group/version/resource/name?fieldSelector=foo=bar&labelSelector=foo=bar",
expectedName: "name",
expectedVerb: "get",
expectedFieldSelector: "",
expectedLabelSelector: "",
},
{
name: "selector on deprecated root type watch is not parsed",
method: "GET",
url: "/apis/group/version/watch/resource?fieldSelector=metadata.name=foo&labelSelector=foo=bar",
expectedName: "",
expectedVerb: "watch",
expectedFieldSelector: "",
expectedLabelSelector: "",
},
{
name: "selector on deprecated root item watch is not parsed",
method: "GET",
url: "/apis/group/version/watch/resource/name?fieldSelector=metadata.name=foo&labelSelector=foo=bar",
expectedName: "name",
expectedVerb: "watch",
expectedFieldSelector: "",
expectedLabelSelector: "",
},
}
resolver := newTestRequestInfoResolver()
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, genericfeatures.AuthorizeWithSelectors, true)
for _, tc := range tests {
req, _ := http.NewRequest("GET", tc.url, nil)
req, _ := http.NewRequest(tc.method, tc.url, nil)
apiRequestInfo, err := resolver.NewRequestInfo(req)
if err != nil {
@ -261,5 +329,11 @@ func TestFieldSelectorParsing(t *testing.T) {
if e, a := tc.expectedVerb, apiRequestInfo.Verb; e != a {
t.Errorf("%s: expected verb %v, actual %v", tc.name, e, a)
}
if e, a := tc.expectedFieldSelector, apiRequestInfo.FieldSelector; e != a {
t.Errorf("%s: expected fieldSelector %v, actual %v", tc.name, e, a)
}
if e, a := tc.expectedLabelSelector, apiRequestInfo.LabelSelector; e != a {
t.Errorf("%s: expected labelSelector %v, actual %v", tc.name, e, a)
}
}
}

View File

@ -95,6 +95,13 @@ const (
// Enables serving watch requests in separate goroutines.
APIServingWithRoutine featuregate.Feature = "APIServingWithRoutine"
// owner: @deads2k
// kep: https://kep.k8s.io/4601
// alpha: v1.31
//
// Allows authorization to use field and label selectors.
AuthorizeWithSelectors featuregate.Feature = "AuthorizeWithSelectors"
// owner: @cici37 @jpbetz
// kep: http://kep.k8s.io/3488
// alpha: v1.26
@ -358,6 +365,8 @@ var defaultKubernetesFeatureGates = map[featuregate.Feature]featuregate.FeatureS
APIServingWithRoutine: {Default: true, PreRelease: featuregate.Beta},
AuthorizeWithSelectors: {Default: false, PreRelease: featuregate.Alpha},
ValidatingAdmissionPolicy: {Default: true, PreRelease: featuregate.GA, LockToDefault: true}, // remove in 1.32
CustomResourceValidationExpressions: {Default: true, PreRelease: featuregate.GA, LockToDefault: true}, // remove in 1.31

View File

@ -88,6 +88,8 @@ func v1beta1ResourceAttributesToV1ResourceAttributes(in *authorizationv1beta1.Re
Resource: in.Resource,
Subresource: in.Subresource,
Name: in.Name,
FieldSelector: in.FieldSelector,
LabelSelector: in.LabelSelector,
}
}

View File

@ -32,6 +32,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/selection"
"k8s.io/apimachinery/pkg/util/cache"
utilnet "k8s.io/apimachinery/pkg/util/net"
"k8s.io/apimachinery/pkg/util/wait"
@ -40,7 +41,7 @@ import (
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
authorizationcel "k8s.io/apiserver/pkg/authorization/cel"
"k8s.io/apiserver/pkg/features"
genericfeatures "k8s.io/apiserver/pkg/features"
utilfeature "k8s.io/apiserver/pkg/util/feature"
"k8s.io/apiserver/pkg/util/webhook"
"k8s.io/apiserver/plugin/pkg/authorizer/webhook/metrics"
@ -196,15 +197,7 @@ func (w *WebhookAuthorizer) Authorize(ctx context.Context, attr authorizer.Attri
}
if attr.IsResourceRequest() {
r.Spec.ResourceAttributes = &authorizationv1.ResourceAttributes{
Namespace: attr.GetNamespace(),
Verb: attr.GetVerb(),
Group: attr.GetAPIGroup(),
Version: attr.GetAPIVersion(),
Resource: attr.GetResource(),
Subresource: attr.GetSubresource(),
Name: attr.GetName(),
}
r.Spec.ResourceAttributes = resourceAttributesFrom(attr)
} else {
r.Spec.NonResourceAttributes = &authorizationv1.NonResourceAttributes{
Path: attr.GetPath(),
@ -212,7 +205,7 @@ func (w *WebhookAuthorizer) Authorize(ctx context.Context, attr authorizer.Attri
}
}
// skipping match when feature is not enabled
if utilfeature.DefaultFeatureGate.Enabled(features.StructuredAuthorizationConfiguration) {
if utilfeature.DefaultFeatureGate.Enabled(genericfeatures.StructuredAuthorizationConfiguration) {
// Process Match Conditions before calling the webhook
matches, err := w.match(ctx, r)
// If at least one matchCondition evaluates to an error (but none are FALSE):
@ -305,6 +298,109 @@ func (w *WebhookAuthorizer) Authorize(ctx context.Context, attr authorizer.Attri
}
func resourceAttributesFrom(attr authorizer.Attributes) *authorizationv1.ResourceAttributes {
ret := &authorizationv1.ResourceAttributes{
Namespace: attr.GetNamespace(),
Verb: attr.GetVerb(),
Group: attr.GetAPIGroup(),
Version: attr.GetAPIVersion(),
Resource: attr.GetResource(),
Subresource: attr.GetSubresource(),
Name: attr.GetName(),
}
if utilfeature.DefaultFeatureGate.Enabled(genericfeatures.AuthorizeWithSelectors) {
// If we are able to get any requirements while parsing selectors, use them, even if there's an error.
// This is because selectors only narrow, so if a subset of selector requirements are available, the request can be allowed.
if selectorRequirements, _ := fieldSelectorToAuthorizationAPI(attr); len(selectorRequirements) > 0 {
ret.FieldSelector = &authorizationv1.FieldSelectorAttributes{
Requirements: selectorRequirements,
}
}
if selectorRequirements, _ := labelSelectorToAuthorizationAPI(attr); len(selectorRequirements) > 0 {
ret.LabelSelector = &authorizationv1.LabelSelectorAttributes{
Requirements: selectorRequirements,
}
}
}
return ret
}
func fieldSelectorToAuthorizationAPI(attr authorizer.Attributes) ([]metav1.FieldSelectorRequirement, error) {
requirements, getFieldSelectorErr := attr.GetFieldSelector()
if len(requirements) == 0 {
return nil, getFieldSelectorErr
}
retRequirements := []metav1.FieldSelectorRequirement{}
for _, requirement := range requirements {
retRequirement := metav1.FieldSelectorRequirement{}
switch {
case requirement.Operator == selection.Equals || requirement.Operator == selection.DoubleEquals || requirement.Operator == selection.In:
retRequirement.Operator = metav1.FieldSelectorOpIn
retRequirement.Key = requirement.Field
retRequirement.Values = []string{requirement.Value}
case requirement.Operator == selection.NotEquals || requirement.Operator == selection.NotIn:
retRequirement.Operator = metav1.FieldSelectorOpNotIn
retRequirement.Key = requirement.Field
retRequirement.Values = []string{requirement.Value}
default:
// ignore this particular requirement. since requirements are AND'd, it is safe to ignore unknown requirements
// for authorization since the resulting check will only be as broad or broader than the intended.
continue
}
retRequirements = append(retRequirements, retRequirement)
}
if len(retRequirements) == 0 {
// this means that all requirements were dropped (likely due to unknown operators), so we are checking the broader
// unrestricted action.
return nil, getFieldSelectorErr
}
return retRequirements, getFieldSelectorErr
}
func labelSelectorToAuthorizationAPI(attr authorizer.Attributes) ([]metav1.LabelSelectorRequirement, error) {
requirements, getLabelSelectorErr := attr.GetLabelSelector()
if len(requirements) == 0 {
return nil, getLabelSelectorErr
}
retRequirements := []metav1.LabelSelectorRequirement{}
for _, requirement := range requirements {
retRequirement := metav1.LabelSelectorRequirement{
Key: requirement.Key(),
}
if values := requirement.ValuesUnsorted(); len(values) > 0 {
retRequirement.Values = values
}
switch requirement.Operator() {
case selection.Equals, selection.DoubleEquals, selection.In:
retRequirement.Operator = metav1.LabelSelectorOpIn
case selection.NotEquals, selection.NotIn:
retRequirement.Operator = metav1.LabelSelectorOpNotIn
case selection.Exists:
retRequirement.Operator = metav1.LabelSelectorOpExists
case selection.DoesNotExist:
retRequirement.Operator = metav1.LabelSelectorOpDoesNotExist
default:
// ignore this particular requirement. since requirements are AND'd, it is safe to ignore unknown requirements
// for authorization since the resulting check will only be as broad or broader than the intended.
continue
}
retRequirements = append(retRequirements, retRequirement)
}
if len(retRequirements) == 0 {
// this means that all requirements were dropped (likely due to unknown operators), so we are checking the broader
// unrestricted action.
return nil, getLabelSelectorErr
}
return retRequirements, getLabelSelectorErr
}
// TODO: need to finish the method to get the rules when using webhook mode
func (w *WebhookAuthorizer) RulesFor(user user.Info, namespace string) ([]authorizer.ResourceRuleInfo, []authorizer.NonResourceRuleInfo, bool, error) {
var (
@ -482,6 +578,8 @@ func v1ResourceAttributesToV1beta1ResourceAttributes(in *authorizationv1.Resourc
Resource: in.Resource,
Subresource: in.Subresource,
Name: in.Name,
FieldSelector: in.FieldSelector,
LabelSelector: in.LabelSelector,
}
}

View File

@ -0,0 +1,334 @@
/*
Copyright 2024 The Kubernetes Authors.
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 webhook
import (
"errors"
"reflect"
"testing"
authorizationv1 "k8s.io/api/authorization/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/selection"
"k8s.io/apiserver/pkg/authorization/authorizer"
genericfeatures "k8s.io/apiserver/pkg/features"
utilfeature "k8s.io/apiserver/pkg/util/feature"
featuregatetesting "k8s.io/component-base/featuregate/testing"
)
func mustLabelRequirement(selector string) labels.Requirements {
ret, err := labels.Parse(selector)
if err != nil {
panic(err)
}
requirements, _ := ret.Requirements()
return requirements
}
func Test_resourceAttributesFrom(t *testing.T) {
type args struct {
attr authorizer.Attributes
}
tests := []struct {
name string
args args
want *authorizationv1.ResourceAttributes
enableAuthorizationSelector bool
}{
{
name: "field selector: don't parse when disabled",
args: args{
attr: authorizer.AttributesRecord{
FieldSelectorRequirements: fields.Requirements{
fields.OneTermEqualSelector("foo", "bar").Requirements()[0],
},
FieldSelectorParsingErr: nil,
},
},
want: &authorizationv1.ResourceAttributes{},
},
{
name: "label selector: don't parse when disabled",
args: args{
attr: authorizer.AttributesRecord{
LabelSelectorRequirements: mustLabelRequirement("foo in (bar,baz)"),
LabelSelectorParsingErr: nil,
},
},
want: &authorizationv1.ResourceAttributes{},
},
{
name: "field selector: ignore error",
args: args{
attr: authorizer.AttributesRecord{
FieldSelectorRequirements: fields.Requirements{
fields.OneTermEqualSelector("foo", "bar").Requirements()[0],
},
FieldSelectorParsingErr: errors.New("failed"),
},
},
want: &authorizationv1.ResourceAttributes{
FieldSelector: &authorizationv1.FieldSelectorAttributes{
Requirements: []metav1.FieldSelectorRequirement{{Key: "foo", Operator: "In", Values: []string{"bar"}}},
},
},
enableAuthorizationSelector: true,
},
{
name: "label selector: ignore error",
args: args{
attr: authorizer.AttributesRecord{
LabelSelectorRequirements: mustLabelRequirement("foo in (bar,baz)"),
LabelSelectorParsingErr: errors.New("failed"),
},
},
want: &authorizationv1.ResourceAttributes{
LabelSelector: &authorizationv1.LabelSelectorAttributes{
Requirements: []metav1.LabelSelectorRequirement{{Key: "foo", Operator: "In", Values: []string{"bar", "baz"}}},
},
},
enableAuthorizationSelector: true,
},
{
name: "field selector: equals, double equals, in",
args: args{
attr: authorizer.AttributesRecord{
FieldSelectorRequirements: fields.Requirements{
{Operator: selection.Equals, Field: "foo", Value: "bar"},
{Operator: selection.DoubleEquals, Field: "one", Value: "two"},
{Operator: selection.In, Field: "apple", Value: "banana"},
},
FieldSelectorParsingErr: nil,
},
},
want: &authorizationv1.ResourceAttributes{
FieldSelector: &authorizationv1.FieldSelectorAttributes{
Requirements: []metav1.FieldSelectorRequirement{
{
Key: "foo",
Operator: "In",
Values: []string{"bar"},
},
{
Key: "one",
Operator: "In",
Values: []string{"two"},
},
{
Key: "apple",
Operator: "In",
Values: []string{"banana"},
},
},
},
},
enableAuthorizationSelector: true,
},
{
name: "field selector: not equals, not in",
args: args{
attr: authorizer.AttributesRecord{
FieldSelectorRequirements: fields.Requirements{
{Operator: selection.NotEquals, Field: "foo", Value: "bar"},
{Operator: selection.NotIn, Field: "apple", Value: "banana"},
},
FieldSelectorParsingErr: nil,
},
},
want: &authorizationv1.ResourceAttributes{
FieldSelector: &authorizationv1.FieldSelectorAttributes{
Requirements: []metav1.FieldSelectorRequirement{
{
Key: "foo",
Operator: "NotIn",
Values: []string{"bar"},
},
{
Key: "apple",
Operator: "NotIn",
Values: []string{"banana"},
},
},
},
},
enableAuthorizationSelector: true,
},
{
name: "field selector: unknown operator skipped",
args: args{
attr: authorizer.AttributesRecord{
FieldSelectorRequirements: fields.Requirements{
{Operator: selection.NotEquals, Field: "foo", Value: "bar"},
{Operator: selection.Operator("bad"), Field: "apple", Value: "banana"},
},
FieldSelectorParsingErr: nil,
},
},
want: &authorizationv1.ResourceAttributes{
FieldSelector: &authorizationv1.FieldSelectorAttributes{
Requirements: []metav1.FieldSelectorRequirement{
{
Key: "foo",
Operator: "NotIn",
Values: []string{"bar"},
},
},
},
},
enableAuthorizationSelector: true,
},
{
name: "field selector: no requirements has no fieldselector",
args: args{
attr: authorizer.AttributesRecord{
FieldSelectorRequirements: fields.Requirements{
{Operator: selection.Operator("bad"), Field: "apple", Value: "banana"},
},
FieldSelectorParsingErr: nil,
},
},
want: &authorizationv1.ResourceAttributes{},
enableAuthorizationSelector: true,
},
{
name: "label selector: in, equals, double equals",
args: args{
attr: authorizer.AttributesRecord{
LabelSelectorRequirements: mustLabelRequirement("foo in (bar,baz), one=two, apple==banana"),
LabelSelectorParsingErr: nil,
},
},
want: &authorizationv1.ResourceAttributes{
LabelSelector: &authorizationv1.LabelSelectorAttributes{
Requirements: []metav1.LabelSelectorRequirement{
{
Key: "apple",
Operator: "In",
Values: []string{"banana"},
},
{
Key: "foo",
Operator: "In",
Values: []string{"bar", "baz"},
},
{
Key: "one",
Operator: "In",
Values: []string{"two"},
},
},
},
},
enableAuthorizationSelector: true,
},
{
name: "label selector: not in, not equals",
args: args{
attr: authorizer.AttributesRecord{
LabelSelectorRequirements: mustLabelRequirement("foo notin (bar,baz), one!=two"),
LabelSelectorParsingErr: nil,
},
},
want: &authorizationv1.ResourceAttributes{
LabelSelector: &authorizationv1.LabelSelectorAttributes{
Requirements: []metav1.LabelSelectorRequirement{
{
Key: "foo",
Operator: "NotIn",
Values: []string{"bar", "baz"},
},
{
Key: "one",
Operator: "NotIn",
Values: []string{"two"},
},
},
},
},
enableAuthorizationSelector: true,
},
{
name: "label selector: exists, not exists",
args: args{
attr: authorizer.AttributesRecord{
LabelSelectorRequirements: mustLabelRequirement("foo, !one"),
LabelSelectorParsingErr: nil,
},
},
want: &authorizationv1.ResourceAttributes{
LabelSelector: &authorizationv1.LabelSelectorAttributes{
Requirements: []metav1.LabelSelectorRequirement{
{
Key: "foo",
Operator: "Exists",
},
{
Key: "one",
Operator: "DoesNotExist",
},
},
},
},
enableAuthorizationSelector: true,
},
{
name: "label selector: unknown operator skipped",
args: args{
attr: authorizer.AttributesRecord{
LabelSelectorRequirements: mustLabelRequirement("foo != bar, apple > 1"),
LabelSelectorParsingErr: nil,
},
},
want: &authorizationv1.ResourceAttributes{
LabelSelector: &authorizationv1.LabelSelectorAttributes{
Requirements: []metav1.LabelSelectorRequirement{
{
Key: "foo",
Operator: "NotIn",
Values: []string{"bar"},
},
},
},
},
enableAuthorizationSelector: true,
},
{
name: "label selector: no requirements has no labelselector",
args: args{
attr: authorizer.AttributesRecord{
LabelSelectorRequirements: mustLabelRequirement("apple > 1"),
LabelSelectorParsingErr: nil,
},
},
want: &authorizationv1.ResourceAttributes{},
enableAuthorizationSelector: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.enableAuthorizationSelector {
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, genericfeatures.AuthorizeWithSelectors, true)
}
if got := resourceAttributesFrom(tt.args.attr); !reflect.DeepEqual(got, tt.want) {
t.Errorf("resourceAttributesFrom() = %v, want %v", got, tt.want)
}
})
}
}