Allow webhook authorizer to use SubjectAccessReviewInterface

This commit is contained in:
Jordan Liggitt
2016-10-04 22:39:53 -04:00
parent e19e78916c
commit 6b3dde745f
2 changed files with 150 additions and 76 deletions

View File

@@ -19,15 +19,15 @@ package webhook
import ( import (
"encoding/json" "encoding/json"
"fmt"
"time" "time"
"github.com/golang/glog" "github.com/golang/glog"
"k8s.io/kubernetes/pkg/api/unversioned" "k8s.io/kubernetes/pkg/api/unversioned"
"k8s.io/kubernetes/pkg/apis/authorization"
"k8s.io/kubernetes/pkg/apis/authorization/v1beta1" "k8s.io/kubernetes/pkg/apis/authorization/v1beta1"
"k8s.io/kubernetes/pkg/auth/authorizer" "k8s.io/kubernetes/pkg/auth/authorizer"
"k8s.io/kubernetes/pkg/client/restclient" authorizationclient "k8s.io/kubernetes/pkg/client/clientset_generated/internalclientset/typed/authorization/unversioned"
"k8s.io/kubernetes/pkg/util/cache" "k8s.io/kubernetes/pkg/util/cache"
"k8s.io/kubernetes/plugin/pkg/webhook" "k8s.io/kubernetes/plugin/pkg/webhook"
@@ -44,10 +44,16 @@ const retryBackoff = 500 * time.Millisecond
var _ authorizer.Authorizer = (*WebhookAuthorizer)(nil) var _ authorizer.Authorizer = (*WebhookAuthorizer)(nil)
type WebhookAuthorizer struct { type WebhookAuthorizer struct {
*webhook.GenericWebhook subjectAccessReview authorizationclient.SubjectAccessReviewInterface
responseCache *cache.LRUExpireCache responseCache *cache.LRUExpireCache
authorizedTTL time.Duration authorizedTTL time.Duration
unauthorizedTTL time.Duration unauthorizedTTL time.Duration
initialBackoff time.Duration
}
// NewFromInterface creates a WebhookAuthorizer using the given subjectAccessReview client
func NewFromInterface(subjectAccessReview authorizationclient.SubjectAccessReviewInterface, authorizedTTL, unauthorizedTTL time.Duration) (*WebhookAuthorizer, error) {
return newWithBackoff(subjectAccessReview, authorizedTTL, unauthorizedTTL, retryBackoff)
} }
// New creates a new WebhookAuthorizer from the provided kubeconfig file. // New creates a new WebhookAuthorizer from the provided kubeconfig file.
@@ -71,16 +77,22 @@ type WebhookAuthorizer struct {
// For additional HTTP configuration, refer to the kubeconfig documentation // For additional HTTP configuration, refer to the kubeconfig documentation
// http://kubernetes.io/v1.1/docs/user-guide/kubeconfig-file.html. // http://kubernetes.io/v1.1/docs/user-guide/kubeconfig-file.html.
func New(kubeConfigFile string, authorizedTTL, unauthorizedTTL time.Duration) (*WebhookAuthorizer, error) { func New(kubeConfigFile string, authorizedTTL, unauthorizedTTL time.Duration) (*WebhookAuthorizer, error) {
return newWithBackoff(kubeConfigFile, authorizedTTL, unauthorizedTTL, retryBackoff) subjectAccessReview, err := subjectAccessReviewInterfaceFromKubeconfig(kubeConfigFile)
}
// newWithBackoff allows tests to skip the sleep.
func newWithBackoff(kubeConfigFile string, authorizedTTL, unauthorizedTTL, initialBackoff time.Duration) (*WebhookAuthorizer, error) {
gw, err := webhook.NewGenericWebhook(kubeConfigFile, groupVersions, initialBackoff)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &WebhookAuthorizer{gw, cache.NewLRUExpireCache(1024), authorizedTTL, unauthorizedTTL}, nil return newWithBackoff(subjectAccessReview, authorizedTTL, unauthorizedTTL, retryBackoff)
}
// newWithBackoff allows tests to skip the sleep.
func newWithBackoff(subjectAccessReview authorizationclient.SubjectAccessReviewInterface, authorizedTTL, unauthorizedTTL, initialBackoff time.Duration) (*WebhookAuthorizer, error) {
return &WebhookAuthorizer{
subjectAccessReview: subjectAccessReview,
responseCache: cache.NewLRUExpireCache(1024),
authorizedTTL: authorizedTTL,
unauthorizedTTL: unauthorizedTTL,
initialBackoff: initialBackoff,
}, nil
} }
// Authorize makes a REST request to the remote service describing the attempted action as a JSON // Authorize makes a REST request to the remote service describing the attempted action as a JSON
@@ -128,9 +140,9 @@ func newWithBackoff(kubeConfigFile string, authorizedTTL, unauthorizedTTL, initi
// } // }
// //
func (w *WebhookAuthorizer) Authorize(attr authorizer.Attributes) (authorized bool, reason string, err error) { func (w *WebhookAuthorizer) Authorize(attr authorizer.Attributes) (authorized bool, reason string, err error) {
r := &v1beta1.SubjectAccessReview{} r := &authorization.SubjectAccessReview{}
if user := attr.GetUser(); user != nil { if user := attr.GetUser(); user != nil {
r.Spec = v1beta1.SubjectAccessReviewSpec{ r.Spec = authorization.SubjectAccessReviewSpec{
User: user.GetName(), User: user.GetName(),
Groups: user.GetGroups(), Groups: user.GetGroups(),
Extra: convertToSARExtra(user.GetExtra()), Extra: convertToSARExtra(user.GetExtra()),
@@ -138,7 +150,7 @@ func (w *WebhookAuthorizer) Authorize(attr authorizer.Attributes) (authorized bo
} }
if attr.IsResourceRequest() { if attr.IsResourceRequest() {
r.Spec.ResourceAttributes = &v1beta1.ResourceAttributes{ r.Spec.ResourceAttributes = &authorization.ResourceAttributes{
Namespace: attr.GetNamespace(), Namespace: attr.GetNamespace(),
Verb: attr.GetVerb(), Verb: attr.GetVerb(),
Group: attr.GetAPIGroup(), Group: attr.GetAPIGroup(),
@@ -148,7 +160,7 @@ func (w *WebhookAuthorizer) Authorize(attr authorizer.Attributes) (authorized bo
Name: attr.GetName(), Name: attr.GetName(),
} }
} else { } else {
r.Spec.NonResourceAttributes = &v1beta1.NonResourceAttributes{ r.Spec.NonResourceAttributes = &authorization.NonResourceAttributes{
Path: attr.GetPath(), Path: attr.GetPath(),
Verb: attr.GetVerb(), Verb: attr.GetVerb(),
} }
@@ -158,26 +170,22 @@ func (w *WebhookAuthorizer) Authorize(attr authorizer.Attributes) (authorized bo
return false, "", err return false, "", err
} }
if entry, ok := w.responseCache.Get(string(key)); ok { if entry, ok := w.responseCache.Get(string(key)); ok {
r.Status = entry.(v1beta1.SubjectAccessReviewStatus) r.Status = entry.(authorization.SubjectAccessReviewStatus)
} else { } else {
result := w.WithExponentialBackoff(func() restclient.Result { var (
return w.RestClient.Post().Body(r).Do() result *authorization.SubjectAccessReview
err error
)
webhook.WithExponentialBackoff(w.initialBackoff, func() error {
result, err = w.subjectAccessReview.Create(r)
return err
}) })
if err := result.Error(); err != nil { if err != nil {
// An error here indicates bad configuration or an outage. Log for debugging. // An error here indicates bad configuration or an outage. Log for debugging.
glog.Errorf("Failed to make webhook authorizer request: %v", err) glog.Errorf("Failed to make webhook authorizer request: %v", err)
return false, "", err return false, "", err
} }
var statusCode int r.Status = result.Status
result.StatusCode(&statusCode)
switch {
case statusCode < 200,
statusCode >= 300:
return false, "", fmt.Errorf("Error contacting webhook: %d", statusCode)
}
if err := result.Into(r); err != nil {
return false, "", err
}
if r.Status.Allowed { if r.Status.Allowed {
w.responseCache.Add(string(key), r.Status, w.authorizedTTL) w.responseCache.Add(string(key), r.Status, w.authorizedTTL)
} else { } else {
@@ -187,14 +195,35 @@ func (w *WebhookAuthorizer) Authorize(attr authorizer.Attributes) (authorized bo
return r.Status.Allowed, r.Status.Reason, nil return r.Status.Allowed, r.Status.Reason, nil
} }
func convertToSARExtra(extra map[string][]string) map[string]v1beta1.ExtraValue { func convertToSARExtra(extra map[string][]string) map[string]authorization.ExtraValue {
if extra == nil { if extra == nil {
return nil return nil
} }
ret := map[string]v1beta1.ExtraValue{} ret := map[string]authorization.ExtraValue{}
for k, v := range extra { for k, v := range extra {
ret[k] = v1beta1.ExtraValue(v) ret[k] = authorization.ExtraValue(v)
} }
return ret return ret
} }
// subjectAccessReviewInterfaceFromKubeconfig builds a client from the specified kubeconfig file,
// and returns a SubjectAccessReviewInterface that uses that client. Note that the client submits SubjectAccessReview
// requests to the exact path specified in the kubeconfig file, so arbitrary non-API servers can be targeted.
func subjectAccessReviewInterfaceFromKubeconfig(kubeConfigFile string) (authorizationclient.SubjectAccessReviewInterface, error) {
gw, err := webhook.NewGenericWebhook(kubeConfigFile, groupVersions, 0)
if err != nil {
return nil, err
}
return &subjectAccessReviewClient{gw}, nil
}
type subjectAccessReviewClient struct {
w *webhook.GenericWebhook
}
func (t *subjectAccessReviewClient) Create(subjectAccessReview *authorization.SubjectAccessReview) (*authorization.SubjectAccessReview, error) {
result := &authorization.SubjectAccessReview{}
err := t.w.RestClient.Post().Body(subjectAccessReview).Do().Into(result)
return result, err
}

View File

@@ -24,6 +24,7 @@ import (
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
@@ -183,7 +184,11 @@ current-context: default
return fmt.Errorf("failed to execute test template: %v", err) return fmt.Errorf("failed to execute test template: %v", err)
} }
// Create a new authorizer // Create a new authorizer
_, err = newWithBackoff(p, 0, 0, 0) sarClient, err := subjectAccessReviewInterfaceFromKubeconfig(p)
if err != nil {
return fmt.Errorf("error building sar client: %v", err)
}
_, err = newWithBackoff(sarClient, 0, 0, 0)
return err return err
}() }()
if err != nil && !tt.wantErr { if err != nil && !tt.wantErr {
@@ -203,6 +208,7 @@ type Service interface {
// NewTestServer wraps a Service as an httptest.Server. // NewTestServer wraps a Service as an httptest.Server.
func NewTestServer(s Service, cert, key, caCert []byte) (*httptest.Server, error) { func NewTestServer(s Service, cert, key, caCert []byte) (*httptest.Server, error) {
const webhookPath = "/testserver"
var tlsConfig *tls.Config var tlsConfig *tls.Config
if cert != nil { if cert != nil {
cert, err := tls.X509KeyPair(cert, key) cert, err := tls.X509KeyPair(cert, key)
@@ -223,26 +229,44 @@ func NewTestServer(s Service, cert, key, caCert []byte) (*httptest.Server, error
} }
serveHTTP := func(w http.ResponseWriter, r *http.Request) { serveHTTP := func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, fmt.Sprintf("unexpected method: %v", r.Method), http.StatusMethodNotAllowed)
return
}
if r.URL.Path != webhookPath {
http.Error(w, fmt.Sprintf("unexpected path: %v", r.URL.Path), http.StatusNotFound)
return
}
var review v1beta1.SubjectAccessReview var review v1beta1.SubjectAccessReview
if err := json.NewDecoder(r.Body).Decode(&review); err != nil { bodyData, _ := ioutil.ReadAll(r.Body)
if err := json.Unmarshal(bodyData, &review); err != nil {
http.Error(w, fmt.Sprintf("failed to decode body: %v", err), http.StatusBadRequest) http.Error(w, fmt.Sprintf("failed to decode body: %v", err), http.StatusBadRequest)
return return
} }
// ensure we received the serialized review as expected
if review.APIVersion != "authorization.k8s.io/v1beta1" {
http.Error(w, fmt.Sprintf("wrong api version: %s", string(bodyData)), http.StatusBadRequest)
return
}
// once we have a successful request, always call the review to record that we were called
s.Review(&review)
if s.HTTPStatusCode() < 200 || s.HTTPStatusCode() >= 300 { if s.HTTPStatusCode() < 200 || s.HTTPStatusCode() >= 300 {
http.Error(w, "HTTP Error", s.HTTPStatusCode()) http.Error(w, "HTTP Error", s.HTTPStatusCode())
return return
} }
s.Review(&review)
type status struct { type status struct {
Allowed bool `json:"allowed"` Allowed bool `json:"allowed"`
Reason string `json:"reason"` Reason string `json:"reason"`
EvaluationError string `json:"evaluationError"`
} }
resp := struct { resp := struct {
APIVersion string `json:"apiVersion"` APIVersion string `json:"apiVersion"`
Status status `json:"status"` Status status `json:"status"`
}{ }{
APIVersion: v1beta1.SchemeGroupVersion.String(), APIVersion: v1beta1.SchemeGroupVersion.String(),
Status: status{review.Status.Allowed, review.Status.Reason}, Status: status{review.Status.Allowed, review.Status.Reason, review.Status.EvaluationError},
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp) json.NewEncoder(w).Encode(resp)
@@ -251,6 +275,12 @@ func NewTestServer(s Service, cert, key, caCert []byte) (*httptest.Server, error
server := httptest.NewUnstartedServer(http.HandlerFunc(serveHTTP)) server := httptest.NewUnstartedServer(http.HandlerFunc(serveHTTP))
server.TLS = tlsConfig server.TLS = tlsConfig
server.StartTLS() server.StartTLS()
// Adjust the path to point to our custom path
serverURL, _ := url.Parse(server.URL)
serverURL.Path = webhookPath
server.URL = serverURL.String()
return server, nil return server, nil
} }
@@ -258,9 +288,11 @@ func NewTestServer(s Service, cert, key, caCert []byte) (*httptest.Server, error
type mockService struct { type mockService struct {
allow bool allow bool
statusCode int statusCode int
called int
} }
func (m *mockService) Review(r *v1beta1.SubjectAccessReview) { func (m *mockService) Review(r *v1beta1.SubjectAccessReview) {
m.called++
r.Status.Allowed = m.allow r.Status.Allowed = m.allow
} }
func (m *mockService) Allow() { m.allow = true } func (m *mockService) Allow() { m.allow = true }
@@ -291,7 +323,11 @@ func newAuthorizer(callbackURL string, clientCert, clientKey, ca []byte, cacheTi
if err := json.NewEncoder(tempfile).Encode(config); err != nil { if err := json.NewEncoder(tempfile).Encode(config); err != nil {
return nil, err return nil, err
} }
return newWithBackoff(p, cacheTime, cacheTime, 0) sarClient, err := subjectAccessReviewInterfaceFromKubeconfig(p)
if err != nil {
return nil, fmt.Errorf("error building sar client: %v", err)
}
return newWithBackoff(sarClient, cacheTime, cacheTime, 0)
} }
func TestTLSConfig(t *testing.T) { func TestTLSConfig(t *testing.T) {
@@ -506,29 +542,36 @@ func TestWebhook(t *testing.T) {
} }
type webhookCacheTestCase struct { type webhookCacheTestCase struct {
statusCode int attr authorizer.AttributesRecord
allow bool
statusCode int
expectedErr bool expectedErr bool
expectedAuthorized bool expectedAuthorized bool
expectedCached bool expectedCalls int
} }
func testWebhookCacheCases(t *testing.T, serv *mockService, wh *WebhookAuthorizer, attr authorizer.AttributesRecord, tests []webhookCacheTestCase) { func testWebhookCacheCases(t *testing.T, serv *mockService, wh *WebhookAuthorizer, tests []webhookCacheTestCase) {
for _, test := range tests { for i, test := range tests {
serv.called = 0
serv.allow = test.allow
serv.statusCode = test.statusCode serv.statusCode = test.statusCode
authorized, _, err := wh.Authorize(attr) authorized, _, err := wh.Authorize(test.attr)
if test.expectedErr && err == nil { if test.expectedErr && err == nil {
t.Errorf("Expected error") t.Errorf("%d: Expected error", i)
continue
} else if !test.expectedErr && err != nil { } else if !test.expectedErr && err != nil {
t.Fatal(err) t.Errorf("%d: unexpected error: %v", i, err)
continue
} }
if test.expectedAuthorized && !authorized {
if test.expectedCached { if test.expectedAuthorized != authorized {
t.Errorf("Webhook should have successful response cached, but authorizer reported unauthorized.") t.Errorf("%d: expected authorized=%v, got %v", i, test.expectedAuthorized, authorized)
} else { }
t.Errorf("Webhook returned HTTP %d, but authorizer reported unauthorized.", test.statusCode)
} if test.expectedCalls != serv.called {
} else if !test.expectedAuthorized && authorized { t.Errorf("%d: expected %d calls, got %d", i, test.expectedCalls, serv.called)
t.Errorf("Webhook returned HTTP %d, but authorizer reported success.", test.statusCode)
} }
} }
} }
@@ -549,27 +592,29 @@ func TestWebhookCache(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
aliceAttr := authorizer.AttributesRecord{User: &user.DefaultInfo{Name: "alice"}}
bobAttr := authorizer.AttributesRecord{User: &user.DefaultInfo{Name: "bob"}}
tests := []webhookCacheTestCase{ tests := []webhookCacheTestCase{
{statusCode: 500, expectedErr: true, expectedAuthorized: false, expectedCached: false}, // server error and 429's retry
{statusCode: 404, expectedErr: true, expectedAuthorized: false, expectedCached: false}, {attr: aliceAttr, allow: false, statusCode: 500, expectedErr: true, expectedAuthorized: false, expectedCalls: 5},
{statusCode: 403, expectedErr: true, expectedAuthorized: false, expectedCached: false}, {attr: aliceAttr, allow: false, statusCode: 429, expectedErr: true, expectedAuthorized: false, expectedCalls: 5},
{statusCode: 401, expectedErr: true, expectedAuthorized: false, expectedCached: false}, // regular errors return errors but do not retry
{statusCode: 200, expectedErr: false, expectedAuthorized: true, expectedCached: false}, {attr: aliceAttr, allow: false, statusCode: 404, expectedErr: true, expectedAuthorized: false, expectedCalls: 1},
{statusCode: 500, expectedErr: false, expectedAuthorized: true, expectedCached: true}, {attr: aliceAttr, allow: false, statusCode: 403, expectedErr: true, expectedAuthorized: false, expectedCalls: 1},
{attr: aliceAttr, allow: false, statusCode: 401, expectedErr: true, expectedAuthorized: false, expectedCalls: 1},
// successful responses are cached
{attr: aliceAttr, allow: true, statusCode: 200, expectedErr: false, expectedAuthorized: true, expectedCalls: 1},
// later requests within the cache window don't hit the backend
{attr: aliceAttr, allow: false, statusCode: 500, expectedErr: false, expectedAuthorized: true, expectedCalls: 0},
// a request with different attributes doesn't hit the cache
{attr: bobAttr, allow: false, statusCode: 500, expectedErr: true, expectedAuthorized: false, expectedCalls: 5},
// successful response for other attributes is cached
{attr: bobAttr, allow: true, statusCode: 200, expectedErr: false, expectedAuthorized: true, expectedCalls: 1},
// later requests within the cache window don't hit the backend
{attr: bobAttr, allow: false, statusCode: 500, expectedErr: false, expectedAuthorized: true, expectedCalls: 0},
} }
attr := authorizer.AttributesRecord{User: &user.DefaultInfo{Name: "alice"}} testWebhookCacheCases(t, serv, wh, tests)
serv.allow = true
testWebhookCacheCases(t, serv, wh, attr, tests)
// For a different request, webhook should be called again.
tests = []webhookCacheTestCase{
{statusCode: 500, expectedErr: true, expectedAuthorized: false, expectedCached: false},
{statusCode: 200, expectedErr: false, expectedAuthorized: true, expectedCached: false},
{statusCode: 500, expectedErr: false, expectedAuthorized: true, expectedCached: true},
}
attr = authorizer.AttributesRecord{User: &user.DefaultInfo{Name: "bob"}}
testWebhookCacheCases(t, serv, wh, attr, tests)
} }