kubernetes/test/integration/auth/auth_test.go

1554 lines
51 KiB
Go

/*
Copyright 2014 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 auth
// This file tests authentication and (soon) authorization of HTTP requests to an API server object.
// It does not use the client in pkg/client/... because authentication and authorization needs
// to work for any client of the HTTP interface.
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
authenticationv1beta1 "k8s.io/api/authentication/v1beta1"
certificatesv1 "k8s.io/api/certificates/v1"
rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
utilnet "k8s.io/apimachinery/pkg/util/net"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/group"
"k8s.io/apiserver/pkg/authentication/request/bearertoken"
"k8s.io/apiserver/pkg/authentication/serviceaccount"
"k8s.io/apiserver/pkg/authentication/token/cache"
"k8s.io/apiserver/pkg/authorization/authorizer"
unionauthz "k8s.io/apiserver/pkg/authorization/union"
webhookutil "k8s.io/apiserver/pkg/util/webhook"
"k8s.io/apiserver/plugin/pkg/authenticator/token/webhook"
clientset "k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
v1 "k8s.io/client-go/tools/clientcmd/api/v1"
resttransport "k8s.io/client-go/transport"
"k8s.io/kubernetes/cmd/kube-apiserver/app/options"
kubeapiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing"
"k8s.io/kubernetes/pkg/apis/autoscaling"
api "k8s.io/kubernetes/pkg/apis/core"
"k8s.io/kubernetes/pkg/apis/extensions"
"k8s.io/kubernetes/pkg/controlplane"
"k8s.io/kubernetes/test/integration"
"k8s.io/kubernetes/test/integration/authutil"
"k8s.io/kubernetes/test/integration/framework"
)
const (
AliceToken string = "abc123" // username: alice. Present in token file.
BobToken string = "xyz987" // username: bob. Present in token file.
UnknownToken string = "qwerty" // Not present in token file.
)
func getTestWebhookTokenAuth(serverURL string, customDial utilnet.DialFunc) (authenticator.Request, error) {
kubecfgFile, err := os.CreateTemp("", "webhook-kubecfg")
if err != nil {
return nil, err
}
defer os.Remove(kubecfgFile.Name())
config := v1.Config{
Clusters: []v1.NamedCluster{
{
Cluster: v1.Cluster{Server: serverURL},
},
},
}
if err := json.NewEncoder(kubecfgFile).Encode(config); err != nil {
return nil, err
}
retryBackoff := wait.Backoff{
Duration: 500 * time.Millisecond,
Factor: 1.5,
Jitter: 0.2,
Steps: 5,
}
clientConfig, err := webhookutil.LoadKubeconfig(kubecfgFile.Name(), customDial)
if err != nil {
return nil, err
}
webhookTokenAuth, err := webhook.New(clientConfig, "v1beta1", nil, retryBackoff)
if err != nil {
return nil, err
}
return bearertoken.New(cache.New(webhookTokenAuth, false, 2*time.Minute, 2*time.Minute)), nil
}
func getTestWebhookTokenAuthCustomDialer(serverURL string) (authenticator.Request, error) {
customDial := http.DefaultTransport.(*http.Transport).DialContext
return getTestWebhookTokenAuth(serverURL, customDial)
}
func path(resource, namespace, name string) string {
return pathWithPrefix("", resource, namespace, name)
}
func pathWithPrefix(prefix, resource, namespace, name string) string {
path := "/api/v1"
if prefix != "" {
path = path + "/" + prefix
}
if namespace != "" {
path = path + "/namespaces/" + namespace
}
// Resource names are lower case.
resource = strings.ToLower(resource)
if resource != "" {
path = path + "/" + resource
}
if name != "" {
path = path + "/" + name
}
return path
}
func pathWithSubResource(resource, namespace, name, subresource string) string {
path := pathWithPrefix("", resource, namespace, name)
if subresource != "" {
path = path + "/" + subresource
}
return path
}
func timeoutPath(resource, namespace, name string) string {
return addTimeoutFlag(path(resource, namespace, name))
}
// Bodies for requests used in subsequent tests.
var aPod = `
{
"kind": "Pod",
"apiVersion": "v1",
"metadata": {
"name": "a",
"creationTimestamp": null%s
},
"spec": {
"containers": [
{
"name": "foo",
"image": "bar/foo"
}
]
}
}
`
var aRC = `
{
"kind": "ReplicationController",
"apiVersion": "v1",
"metadata": {
"name": "a",
"labels": {
"name": "a"
}%s
},
"spec": {
"replicas": 2,
"selector": {
"name": "a"
},
"template": {
"metadata": {
"labels": {
"name": "a"
}
},
"spec": {
"containers": [
{
"name": "foo",
"image": "bar/foo"
}
]
}
}
}
}
`
var aService = `
{
"kind": "Service",
"apiVersion": "v1",
"metadata": {
"name": "a",
"labels": {
"name": "a"
}%s
},
"spec": {
"ports": [
{
"protocol": "TCP",
"port": 8000
}
],
"selector": {
"name": "a"
},
"clusterIP": "10.0.0.100"
}
}
`
var aNode = `
{
"kind": "Node",
"apiVersion": "v1",
"metadata": {
"name": "a"%s
},
"spec": {
"externalID": "external"
}
}
`
func aEvent(namespace string) string {
return `
{
"kind": "Event",
"apiVersion": "v1",
"metadata": {
"name": "a"%s
},
"involvedObject": {
"kind": "Pod",
"namespace": "` + namespace + `",
"name": "a",
"apiVersion": "v1"
}
}
`
}
var aBinding = `
{
"kind": "Binding",
"apiVersion": "v1",
"metadata": {
"name": "a"%s
},
"target": {
"name": "10.10.10.10"
}
}
`
var emptyEndpoints = `
{
"kind": "Endpoints",
"apiVersion": "v1",
"metadata": {
"name": "a"%s
}
}
`
var aEndpoints = `
{
"kind": "Endpoints",
"apiVersion": "v1",
"metadata": {
"name": "a"%s
},
"subsets": [
{
"addresses": [
{
"ip": "10.10.1.1"
}
],
"ports": [
{
"port": 1909,
"protocol": "TCP"
}
]
}
]
}
`
var deleteNow = `
{
"kind": "DeleteOptions",
"apiVersion": "v1",
"gracePeriodSeconds": 0%s
}
`
// To ensure that a POST completes before a dependent GET, set a timeout.
func addTimeoutFlag(URLString string) string {
u, _ := url.Parse(URLString)
values := u.Query()
values.Set("timeout", "60s")
u.RawQuery = values.Encode()
return u.String()
}
type testRequest struct {
verb string
URL string
body string
statusCodes map[int]bool // allowed status codes.
}
func getTestRequests(namespace string) []testRequest {
requests := []testRequest{
// Normal methods on pods
{"GET", path("pods", "", ""), "", integration.Code200},
{"GET", path("pods", namespace, ""), "", integration.Code200},
{"POST", timeoutPath("pods", namespace, ""), aPod, integration.Code201},
{"PUT", timeoutPath("pods", namespace, "a"), aPod, integration.Code200},
{"GET", path("pods", namespace, "a"), "", integration.Code200},
// GET and POST for /exec should return Bad Request (400) since the pod has not been assigned a node yet.
{"GET", path("pods", namespace, "a") + "/exec", "", integration.Code400},
{"POST", path("pods", namespace, "a") + "/exec", "", integration.Code400},
// PUT for /exec should return Method Not Allowed (405).
{"PUT", path("pods", namespace, "a") + "/exec", "", integration.Code405},
// GET and POST for /portforward should return Bad Request (400) since the pod has not been assigned a node yet.
{"GET", path("pods", namespace, "a") + "/portforward", "", integration.Code400},
{"POST", path("pods", namespace, "a") + "/portforward", "", integration.Code400},
// PUT for /portforward should return Method Not Allowed (405).
{"PUT", path("pods", namespace, "a") + "/portforward", "", integration.Code405},
{"PATCH", path("pods", namespace, "a"), "{%v}", integration.Code200},
{"DELETE", timeoutPath("pods", namespace, "a"), deleteNow, integration.Code200},
// Non-standard methods (not expected to work,
// but expected to pass/fail authorization prior to
// failing validation.
{"OPTIONS", path("pods", namespace, ""), "", integration.Code405},
{"OPTIONS", path("pods", namespace, "a"), "", integration.Code405},
{"HEAD", path("pods", namespace, ""), "", integration.Code405},
{"HEAD", path("pods", namespace, "a"), "", integration.Code405},
{"TRACE", path("pods", namespace, ""), "", integration.Code405},
{"TRACE", path("pods", namespace, "a"), "", integration.Code405},
{"NOSUCHVERB", path("pods", namespace, ""), "", integration.Code405},
// Normal methods on services
{"GET", path("services", "", ""), "", integration.Code200},
{"GET", path("services", namespace, ""), "", integration.Code200},
{"POST", timeoutPath("services", namespace, ""), aService, integration.Code201},
// Create an endpoint for the service (this is done automatically by endpoint controller
// whenever a service is created, but this test does not run that controller)
{"POST", timeoutPath("endpoints", namespace, ""), emptyEndpoints, integration.Code201},
// Should return service unavailable when endpoint.subset is empty.
{"GET", pathWithSubResource("services", namespace, "a", "proxy") + "/", "", integration.Code503},
{"PUT", timeoutPath("services", namespace, "a"), aService, integration.Code200},
{"GET", path("services", namespace, "a"), "", integration.Code200},
{"DELETE", timeoutPath("endpoints", namespace, "a"), "", integration.Code200},
{"DELETE", timeoutPath("services", namespace, "a"), "", integration.Code200},
// Normal methods on replicationControllers
{"GET", path("replicationControllers", "", ""), "", integration.Code200},
{"GET", path("replicationControllers", namespace, ""), "", integration.Code200},
{"POST", timeoutPath("replicationControllers", namespace, ""), aRC, integration.Code201},
{"PUT", timeoutPath("replicationControllers", namespace, "a"), aRC, integration.Code200},
{"GET", path("replicationControllers", namespace, "a"), "", integration.Code200},
{"DELETE", timeoutPath("replicationControllers", namespace, "a"), "", integration.Code200},
// Normal methods on endpoints
{"GET", path("endpoints", "", ""), "", integration.Code200},
{"GET", path("endpoints", namespace, ""), "", integration.Code200},
{"POST", timeoutPath("endpoints", namespace, ""), aEndpoints, integration.Code201},
{"PUT", timeoutPath("endpoints", namespace, "a"), aEndpoints, integration.Code200},
{"GET", path("endpoints", namespace, "a"), "", integration.Code200},
{"DELETE", timeoutPath("endpoints", namespace, "a"), "", integration.Code200},
// Normal methods on nodes
{"GET", path("nodes", "", ""), "", integration.Code200},
{"POST", timeoutPath("nodes", "", ""), aNode, integration.Code201},
{"PUT", timeoutPath("nodes", "", "a"), aNode, integration.Code200},
{"GET", path("nodes", "", "a"), "", integration.Code200},
{"DELETE", timeoutPath("nodes", "", "a"), "", integration.Code200},
// Normal methods on events
{"GET", path("events", "", ""), "", integration.Code200},
{"GET", path("events", namespace, ""), "", integration.Code200},
{"POST", timeoutPath("events", namespace, ""), aEvent(namespace), integration.Code201},
{"PUT", timeoutPath("events", namespace, "a"), aEvent(namespace), integration.Code200},
{"GET", path("events", namespace, "a"), "", integration.Code200},
{"DELETE", timeoutPath("events", namespace, "a"), "", integration.Code200},
// Normal methods on bindings
{"GET", path("bindings", namespace, ""), "", integration.Code405},
{"POST", timeoutPath("pods", namespace, ""), aPod, integration.Code201}, // Need a pod to bind or you get a 404
{"POST", timeoutPath("bindings", namespace, ""), aBinding, integration.Code201},
{"PUT", timeoutPath("bindings", namespace, "a"), aBinding, integration.Code404},
{"GET", path("bindings", namespace, "a"), "", integration.Code404}, // No bindings instances
{"DELETE", timeoutPath("bindings", namespace, "a"), "", integration.Code404},
// Non-existent object type.
{"GET", path("foo", "", ""), "", integration.Code404},
{"POST", path("foo", namespace, ""), `{"foo": "foo"}`, integration.Code404},
{"PUT", path("foo", namespace, "a"), `{"foo": "foo"}`, integration.Code404},
{"GET", path("foo", namespace, "a"), "", integration.Code404},
{"DELETE", timeoutPath("foo", namespace, ""), "", integration.Code404},
// Special verbs on nodes
{"GET", pathWithSubResource("nodes", namespace, "a", "proxy"), "", integration.Code404},
{"GET", pathWithPrefix("redirect", "nodes", namespace, "a"), "", integration.Code404},
// TODO: test .../watch/..., which doesn't end before the test timeout.
// TODO: figure out how to create a node so that it can successfully proxy/redirect.
// Non-object endpoints
{"GET", "/", "", integration.Code200},
{"GET", "/api", "", integration.Code200},
{"GET", "/healthz", "", integration.Code200},
{"GET", "/version", "", integration.Code200},
{"GET", "/invalidURL", "", integration.Code404},
}
return requests
}
// The TestAuthMode* tests a large number of URLs and checks that they
// are FORBIDDEN or not, depending on the mode. They do not attempt to do
// detailed verification of behaviour beyond authorization. They are not
// fuzz tests.
//
// TODO(etune): write a fuzz test of the REST API.
func TestAuthModeAlwaysAllow(t *testing.T) {
kubeClient, kubeConfig, tearDownFn := framework.StartTestServer(t, framework.TestServerSetup{
ModifyServerRunOptions: func(opts *options.ServerRunOptions) {
// Disable ServiceAccount admission plugin as we don't have serviceaccount controller running.
opts.Admission.GenericAdmission.DisablePlugins = []string{"ServiceAccount"}
opts.Authorization.Modes = []string{"AlwaysAllow"}
},
})
defer tearDownFn()
ns := framework.CreateNamespaceOrDie(kubeClient, "auth-always-allow", t)
defer framework.DeleteNamespaceOrDie(kubeClient, ns, t)
transport, err := rest.TransportFor(kubeConfig)
if err != nil {
t.Fatal(err)
}
previousResourceVersion := make(map[string]float64)
for _, r := range getTestRequests(ns.Name) {
var bodyStr string
if r.body != "" {
sub := ""
if r.verb == "PUT" {
// For update operations, insert previous resource version
if resVersion := previousResourceVersion[getPreviousResourceVersionKey(r.URL, "")]; resVersion != 0 {
sub += fmt.Sprintf(",\r\n\"resourceVersion\": \"%v\"", resVersion)
}
sub += fmt.Sprintf(",\r\n\"namespace\": %q", ns.Name)
}
bodyStr = fmt.Sprintf(r.body, sub)
}
r.body = bodyStr
bodyBytes := bytes.NewReader([]byte(bodyStr))
req, err := http.NewRequest(r.verb, kubeConfig.Host+r.URL, bodyBytes)
if err != nil {
t.Logf("case %v", r)
t.Fatalf("unexpected error: %v", err)
}
if r.verb == "PATCH" {
req.Header.Set("Content-Type", "application/merge-patch+json")
}
func() {
resp, err := transport.RoundTrip(req)
if err != nil {
t.Logf("case %v", r)
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if _, ok := r.statusCodes[resp.StatusCode]; !ok {
t.Logf("case %v", r)
t.Errorf("Expected status one of %v, but got %v", r.statusCodes, resp.StatusCode)
t.Errorf("Body: %v", string(b))
} else {
if r.verb == "POST" {
// For successful create operations, extract resourceVersion
id, currentResourceVersion, err := parseResourceVersion(b)
if err == nil {
key := getPreviousResourceVersionKey(r.URL, id)
previousResourceVersion[key] = currentResourceVersion
} else {
t.Logf("error in trying to extract resource version: %s", err)
}
}
}
}()
}
}
func parseResourceVersion(response []byte) (string, float64, error) {
var resultBodyMap map[string]interface{}
err := json.Unmarshal(response, &resultBodyMap)
if err != nil {
return "", 0, fmt.Errorf("unexpected error unmarshaling resultBody: %v", err)
}
metadata, ok := resultBodyMap["metadata"].(map[string]interface{})
if !ok {
return "", 0, fmt.Errorf("unexpected error, metadata not found in JSON response: %v", string(response))
}
id, ok := metadata["name"].(string)
if !ok {
return "", 0, fmt.Errorf("unexpected error, id not found in JSON response: %v", string(response))
}
resourceVersionString, ok := metadata["resourceVersion"].(string)
if !ok {
return "", 0, fmt.Errorf("unexpected error, resourceVersion not found in JSON response: %v", string(response))
}
resourceVersion, err := strconv.ParseFloat(resourceVersionString, 64)
if err != nil {
return "", 0, fmt.Errorf("unexpected error, could not parse resourceVersion as float64, err: %s. JSON response: %v", err, string(response))
}
return id, resourceVersion, nil
}
func getPreviousResourceVersionKey(url, id string) string {
baseURL := strings.Split(url, "?")[0]
key := baseURL
if id != "" {
key = fmt.Sprintf("%s/%v", baseURL, id)
}
return key
}
func TestAuthModeAlwaysDeny(t *testing.T) {
kubeClient, kubeConfig, tearDownFn := framework.StartTestServer(t, framework.TestServerSetup{
ModifyServerRunOptions: func(opts *options.ServerRunOptions) {
// Disable ServiceAccount admission plugin as we don't have serviceaccount controller running.
opts.Admission.GenericAdmission.DisablePlugins = []string{"ServiceAccount"}
opts.Authorization.Modes = []string{"AlwaysDeny"}
opts.Authentication.TokenFile.TokenFile = "testdata/tokens.csv"
},
})
defer tearDownFn()
ns := framework.CreateNamespaceOrDie(kubeClient, "auth-always-deny", t)
defer framework.DeleteNamespaceOrDie(kubeClient, ns, t)
transport, err := rest.TransportFor(kubeConfig)
if err != nil {
t.Fatal(err)
}
transport = resttransport.NewBearerAuthRoundTripper(AliceToken, transport)
for _, r := range getTestRequests(ns.Name) {
bodyBytes := bytes.NewReader([]byte(r.body))
req, err := http.NewRequest(r.verb, kubeConfig.Host+r.URL, bodyBytes)
if err != nil {
t.Logf("case %v", r)
t.Fatalf("unexpected error: %v", err)
}
func() {
resp, err := transport.RoundTrip(req)
if err != nil {
t.Logf("case %v", r)
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Logf("case %v", r)
t.Errorf("Expected status Forbidden but got status %v", resp.Status)
}
}()
}
}
// TestAliceNotForbiddenOrUnauthorized tests a user who is known to
// the authentication system and authorized to do any actions.
func TestAliceNotForbiddenOrUnauthorized(t *testing.T) {
kubeClient, kubeConfig, tearDownFn := framework.StartTestServer(t, framework.TestServerSetup{
ModifyServerRunOptions: func(opts *options.ServerRunOptions) {
// Disable ServiceAccount admission plugin as we don't have serviceaccount controller running.
opts.Admission.GenericAdmission.DisablePlugins = []string{"ServiceAccount"}
opts.Authentication.TokenFile.TokenFile = "testdata/tokens.csv"
opts.Authorization.Modes = []string{"ABAC"}
opts.Authorization.PolicyFile = "testdata/allowalice.jsonl"
},
})
defer tearDownFn()
ns := framework.CreateNamespaceOrDie(kubeClient, "auth-alice-not-forbidden", t)
defer framework.DeleteNamespaceOrDie(kubeClient, ns, t)
previousResourceVersion := make(map[string]float64)
transport, err := rest.TransportFor(kubeConfig)
if err != nil {
t.Fatal(err)
}
for _, r := range getTestRequests(ns.Name) {
token := AliceToken
var bodyStr string
if r.body != "" {
sub := ""
if r.verb == "PUT" {
// For update operations, insert previous resource version
if resVersion := previousResourceVersion[getPreviousResourceVersionKey(r.URL, "")]; resVersion != 0 {
sub += fmt.Sprintf(",\r\n\"resourceVersion\": \"%v\"", resVersion)
}
sub += fmt.Sprintf(",\r\n\"namespace\": %q", ns.Name)
}
bodyStr = fmt.Sprintf(r.body, sub)
}
r.body = bodyStr
bodyBytes := bytes.NewReader([]byte(bodyStr))
req, err := http.NewRequest(r.verb, kubeConfig.Host+r.URL, bodyBytes)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
if r.verb == "PATCH" {
req.Header.Set("Content-Type", "application/merge-patch+json")
}
func() {
resp, err := transport.RoundTrip(req)
if err != nil {
t.Logf("case %v", r)
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if _, ok := r.statusCodes[resp.StatusCode]; !ok {
t.Logf("case %v", r)
t.Errorf("Expected status one of %v, but got %v", r.statusCodes, resp.StatusCode)
t.Errorf("Body: %v", string(b))
} else {
if r.verb == "POST" {
// For successful create operations, extract resourceVersion
id, currentResourceVersion, err := parseResourceVersion(b)
if err == nil {
key := getPreviousResourceVersionKey(r.URL, id)
previousResourceVersion[key] = currentResourceVersion
}
}
}
}()
}
}
// TestBobIsForbidden tests that a user who is known to
// the authentication system but not authorized to do any actions
// should receive "Forbidden".
func TestBobIsForbidden(t *testing.T) {
kubeClient, kubeConfig, tearDownFn := framework.StartTestServer(t, framework.TestServerSetup{
ModifyServerRunOptions: func(opts *options.ServerRunOptions) {
// Disable ServiceAccount admission plugin as we don't have serviceaccount controller running.
opts.Admission.GenericAdmission.DisablePlugins = []string{"ServiceAccount"}
opts.Authentication.TokenFile.TokenFile = "testdata/tokens.csv"
opts.Authorization.Modes = []string{"ABAC"}
opts.Authorization.PolicyFile = "testdata/allowalice.jsonl"
},
})
defer tearDownFn()
ns := framework.CreateNamespaceOrDie(kubeClient, "auth-bob-forbidden", t)
defer framework.DeleteNamespaceOrDie(kubeClient, ns, t)
transport, err := rest.TransportFor(kubeConfig)
if err != nil {
t.Fatal(err)
}
for _, r := range getTestRequests(ns.Name) {
token := BobToken
bodyBytes := bytes.NewReader([]byte(r.body))
req, err := http.NewRequest(r.verb, kubeConfig.Host+r.URL, bodyBytes)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
func() {
resp, err := transport.RoundTrip(req)
if err != nil {
t.Logf("case %v", r)
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
// Expect all of bob's actions to return Forbidden
if resp.StatusCode != http.StatusForbidden {
t.Logf("case %v", r)
t.Errorf("Expected not status Forbidden, but got %s", resp.Status)
}
}()
}
}
// TestUnknownUserIsUnauthorized tests that a user who is unknown
// to the authentication system get status code "Unauthorized".
// An authorization module is installed in this scenario for integration
// test purposes, but requests aren't expected to reach it.
func TestUnknownUserIsUnauthorized(t *testing.T) {
kubeClient, kubeConfig, tearDownFn := framework.StartTestServer(t, framework.TestServerSetup{
ModifyServerRunOptions: func(opts *options.ServerRunOptions) {
// Disable ServiceAccount admission plugin as we don't have serviceaccount controller running.
opts.Admission.GenericAdmission.DisablePlugins = []string{"ServiceAccount"}
opts.Authentication.TokenFile.TokenFile = "testdata/tokens.csv"
opts.Authorization.Modes = []string{"ABAC"}
opts.Authorization.PolicyFile = "testdata/allowalice.jsonl"
},
})
defer tearDownFn()
ns := framework.CreateNamespaceOrDie(kubeClient, "auth-unknown-unauthorized", t)
defer framework.DeleteNamespaceOrDie(kubeClient, ns, t)
transport, err := rest.TransportFor(kubeConfig)
if err != nil {
t.Fatal(err)
}
for _, r := range getTestRequests(ns.Name) {
token := UnknownToken
bodyBytes := bytes.NewReader([]byte(r.body))
req, err := http.NewRequest(r.verb, kubeConfig.Host+r.URL, bodyBytes)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
func() {
resp, err := transport.RoundTrip(req)
if err != nil {
t.Logf("case %v", r)
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
// Expect all of unauthenticated user's request to be "Unauthorized"
if resp.StatusCode != http.StatusUnauthorized {
t.Logf("case %v", r)
t.Errorf("Expected status %v, but got %v", http.StatusUnauthorized, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
t.Errorf("Body: %v", string(b))
}
}()
}
}
type impersonateAuthorizer struct{}
// alice can't act as anyone and bob can't do anything but act-as someone
func (impersonateAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
// alice can impersonate service accounts and do other actions
if a.GetUser() != nil && a.GetUser().GetName() == "alice" && a.GetVerb() == "impersonate" && a.GetResource() == "serviceaccounts" {
return authorizer.DecisionAllow, "", nil
}
if a.GetUser() != nil && a.GetUser().GetName() == "alice" && a.GetVerb() != "impersonate" {
return authorizer.DecisionAllow, "", nil
}
// bob can impersonate anyone, but that's it
if a.GetUser() != nil && a.GetUser().GetName() == "bob" && a.GetVerb() == "impersonate" {
return authorizer.DecisionAllow, "", nil
}
if a.GetUser() != nil && a.GetUser().GetName() == "bob" && a.GetVerb() != "impersonate" {
return authorizer.DecisionDeny, "", nil
}
// service accounts can do everything
if a.GetUser() != nil && strings.HasPrefix(a.GetUser().GetName(), serviceaccount.ServiceAccountUsernamePrefix) {
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionNoOpinion, "I can't allow that. Go ask alice.", nil
}
func TestImpersonateIsForbidden(t *testing.T) {
kubeClient, kubeConfig, tearDownFn := framework.StartTestServer(t, framework.TestServerSetup{
ModifyServerRunOptions: func(opts *options.ServerRunOptions) {
// Disable ServiceAccount admission plugin as we don't have serviceaccount controller running.
opts.Admission.GenericAdmission.DisablePlugins = []string{"ServiceAccount"}
opts.Authentication.TokenFile.TokenFile = "testdata/tokens.csv"
},
ModifyServerConfig: func(config *controlplane.Config) {
// Prepend an impersonation authorizer with specific opinions about alice and bob
config.GenericConfig.Authorization.Authorizer = unionauthz.New(impersonateAuthorizer{}, config.GenericConfig.Authorization.Authorizer)
},
})
defer tearDownFn()
ns := framework.CreateNamespaceOrDie(kubeClient, "auth-impersonate-forbidden", t)
defer framework.DeleteNamespaceOrDie(kubeClient, ns, t)
transport, err := rest.TransportFor(kubeConfig)
if err != nil {
t.Fatal(err)
}
// bob can't perform actions himself
for _, r := range getTestRequests(ns.Name) {
token := BobToken
bodyBytes := bytes.NewReader([]byte(r.body))
req, err := http.NewRequest(r.verb, kubeConfig.Host+r.URL, bodyBytes)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
func() {
resp, err := transport.RoundTrip(req)
if err != nil {
t.Logf("case %v", r)
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
// Expect all of bob's actions to return Forbidden
if resp.StatusCode != http.StatusForbidden {
t.Logf("case %v", r)
t.Errorf("Expected status Forbidden, but got %s", resp.Status)
}
}()
}
// bob can impersonate alice to do other things
for _, r := range getTestRequests(ns.Name) {
token := BobToken
bodyBytes := bytes.NewReader([]byte(r.body))
req, err := http.NewRequest(r.verb, kubeConfig.Host+r.URL, bodyBytes)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
req.Header.Set("Impersonate-User", "alice")
func() {
resp, err := transport.RoundTrip(req)
if err != nil {
t.Logf("case %v", r)
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
// Expect all the requests to be allowed, don't care what they actually do
if resp.StatusCode == http.StatusForbidden {
t.Logf("case %v", r)
t.Errorf("Expected status not %v, but got %v", http.StatusForbidden, resp.StatusCode)
}
}()
}
// alice can't impersonate bob
for _, r := range getTestRequests(ns.Name) {
token := AliceToken
bodyBytes := bytes.NewReader([]byte(r.body))
req, err := http.NewRequest(r.verb, kubeConfig.Host+r.URL, bodyBytes)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
req.Header.Set("Impersonate-User", "bob")
func() {
resp, err := transport.RoundTrip(req)
if err != nil {
t.Logf("case %v", r)
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
// Expect all of bob's actions to return Forbidden
if resp.StatusCode != http.StatusForbidden {
t.Logf("case %v", r)
t.Errorf("Expected not status Forbidden, but got %s", resp.Status)
}
}()
}
// bob can impersonate a service account
for _, r := range getTestRequests(ns.Name) {
token := BobToken
bodyBytes := bytes.NewReader([]byte(r.body))
req, err := http.NewRequest(r.verb, kubeConfig.Host+r.URL, bodyBytes)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
req.Header.Set("Impersonate-User", serviceaccount.MakeUsername("default", "default"))
func() {
resp, err := transport.RoundTrip(req)
if err != nil {
t.Logf("case %v", r)
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
// Expect all the requests to be allowed, don't care what they actually do
if resp.StatusCode == http.StatusForbidden {
t.Logf("case %v", r)
t.Errorf("Expected status not %v, but got %v", http.StatusForbidden, resp.StatusCode)
}
}()
}
}
func TestImpersonateWithUID(t *testing.T) {
server := kubeapiservertesting.StartTestServerOrDie(
t,
nil,
[]string{
"--authorization-mode=RBAC",
"--anonymous-auth",
},
framework.SharedEtcd(),
)
t.Cleanup(server.TearDownFn)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
t.Run("impersonation with uid header", func(t *testing.T) {
adminClient := clientset.NewForConfigOrDie(server.ClientConfig)
authutil.GrantUserAuthorization(t, ctx, adminClient, "alice",
rbacv1.PolicyRule{
Verbs: []string{"create"},
APIGroups: []string{"certificates.k8s.io"},
Resources: []string{"certificatesigningrequests"},
},
)
req := csrPEM(t)
clientConfig := rest.CopyConfig(server.ClientConfig)
clientConfig.Impersonate = rest.ImpersonationConfig{
UserName: "alice",
UID: "1234",
}
client := clientset.NewForConfigOrDie(clientConfig)
createdCsr, err := client.CertificatesV1().CertificateSigningRequests().Create(
ctx,
&certificatesv1.CertificateSigningRequest{
Spec: certificatesv1.CertificateSigningRequestSpec{
SignerName: "kubernetes.io/kube-apiserver-client",
Request: req,
Usages: []certificatesv1.KeyUsage{"client auth"},
},
ObjectMeta: metav1.ObjectMeta{
Name: "impersonated-csr",
},
},
metav1.CreateOptions{},
)
if err != nil {
t.Fatalf("Unexpected error creating Certificate Signing Request: %v", err)
}
// require that all the original fields and the impersonated user's info
// is in the returned spec.
expectedCsrSpec := certificatesv1.CertificateSigningRequestSpec{
Groups: []string{"system:authenticated"},
SignerName: "kubernetes.io/kube-apiserver-client",
Request: req,
Usages: []certificatesv1.KeyUsage{"client auth"},
Username: "alice",
UID: "1234",
}
actualCsrSpec := createdCsr.Spec
if diff := cmp.Diff(expectedCsrSpec, actualCsrSpec); diff != "" {
t.Fatalf("CSR spec was different than expected, -got, +want:\n %s", diff)
}
})
t.Run("impersonation with only UID fails", func(t *testing.T) {
clientConfig := rest.CopyConfig(server.ClientConfig)
clientConfig.Impersonate = rest.ImpersonationConfig{
UID: "1234",
}
client := clientset.NewForConfigOrDie(clientConfig)
_, err := client.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
if !errors.IsInternalError(err) {
t.Fatalf("expected internal error, got %T %v", err, err)
}
if diff := cmp.Diff(
`an error on the server ("Internal Server Error: \"/api/v1/nodes\": `+
`requested [{UID 1234 authentication.k8s.io/v1 }] without impersonating a user") `+
`has prevented the request from succeeding (get nodes)`,
err.Error(),
); diff != "" {
t.Fatalf("internal error different than expected, -got, +want:\n %s", diff)
}
})
t.Run("impersonating UID without authorization fails", func(t *testing.T) {
adminClient := clientset.NewForConfigOrDie(server.ClientConfig)
authutil.GrantUserAuthorization(t, ctx, adminClient, "system:anonymous",
rbacv1.PolicyRule{
Verbs: []string{"impersonate"},
APIGroups: []string{""},
Resources: []string{"users"},
ResourceNames: []string{"some-user-anonymous-can-impersonate"},
},
)
clientConfig := rest.AnonymousClientConfig(server.ClientConfig)
clientConfig.Impersonate = rest.ImpersonationConfig{
UserName: "some-user-anonymous-can-impersonate",
UID: "1234",
}
client := clientset.NewForConfigOrDie(clientConfig)
_, err := client.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
if !errors.IsForbidden(err) {
t.Fatalf("expected forbidden error, got %T %v", err, err)
}
if diff := cmp.Diff(
`uids.authentication.k8s.io "1234" is forbidden: `+
`User "system:anonymous" cannot impersonate resource "uids" in API group "authentication.k8s.io" at the cluster scope`,
err.Error(),
); diff != "" {
t.Fatalf("forbidden error different than expected, -got, +want:\n %s", diff)
}
})
}
func csrPEM(t *testing.T) []byte {
t.Helper()
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("Unexpected error generating ed25519 key: %v", err)
}
csrDER, err := x509.CreateCertificateRequest(
rand.Reader,
&x509.CertificateRequest{
Subject: pkix.Name{
Organization: []string{},
},
},
privateKey)
if err != nil {
t.Fatalf("Unexpected error creating x509 certificate request: %v", err)
}
csrPemBlock := &pem.Block{
Type: "CERTIFICATE REQUEST",
Bytes: csrDER,
}
req := pem.EncodeToMemory(csrPemBlock)
if req == nil {
t.Fatalf("Failed to encode PEM to memory.")
}
return req
}
func newABACFileWithContents(t *testing.T, contents string) string {
dir := t.TempDir()
file := filepath.Join(dir, "auth_test")
if err := os.WriteFile(file, []byte(contents), 0700); err != nil {
t.Fatalf("unexpected error writing policyfile: %v", err)
}
return file
}
type trackingAuthorizer struct {
requestAttributes []authorizer.Attributes
}
func (a *trackingAuthorizer) Authorize(ctx context.Context, attributes authorizer.Attributes) (authorizer.Decision, string, error) {
a.requestAttributes = append(a.requestAttributes, attributes)
return authorizer.DecisionAllow, "", nil
}
// TestAuthorizationAttributeDetermination tests that authorization attributes are built correctly
func TestAuthorizationAttributeDetermination(t *testing.T) {
trackingAuthorizer := &trackingAuthorizer{}
kubeClient, kubeConfig, tearDownFn := framework.StartTestServer(t, framework.TestServerSetup{
ModifyServerRunOptions: func(opts *options.ServerRunOptions) {
// Disable ServiceAccount admission plugin as we don't have serviceaccount controller running.
opts.Admission.GenericAdmission.DisablePlugins = []string{"ServiceAccount"}
opts.Authentication.TokenFile.TokenFile = "testdata/tokens.csv"
},
ModifyServerConfig: func(config *controlplane.Config) {
config.GenericConfig.Authorization.Authorizer = unionauthz.New(config.GenericConfig.Authorization.Authorizer, trackingAuthorizer)
},
})
defer tearDownFn()
ns := framework.CreateNamespaceOrDie(kubeClient, "auth-attribute-determination", t)
defer framework.DeleteNamespaceOrDie(kubeClient, ns, t)
transport, err := rest.TransportFor(kubeConfig)
if err != nil {
t.Fatal(err)
}
requests := map[string]struct {
verb string
URL string
expectedAttributes authorizer.Attributes
}{
"prefix/version/resource": {"GET", "/api/v1/pods", authorizer.AttributesRecord{APIGroup: api.GroupName, Resource: "pods"}},
"prefix/group/version/resource": {"GET", "/apis/extensions/v1/pods", authorizer.AttributesRecord{APIGroup: extensions.GroupName, Resource: "pods"}},
"prefix/group/version/resource2": {"GET", "/apis/autoscaling/v1/horizontalpodautoscalers", authorizer.AttributesRecord{APIGroup: autoscaling.GroupName, Resource: "horizontalpodautoscalers"}},
}
currentAuthorizationAttributesIndex := 0
for testName, r := range requests {
token := BobToken
req, err := http.NewRequest(r.verb, kubeConfig.Host+r.URL, nil)
if err != nil {
t.Logf("case %v", testName)
t.Fatalf("unexpected error: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
func() {
resp, err := transport.RoundTrip(req)
if err != nil {
t.Logf("case %v", r)
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
found := false
for i := currentAuthorizationAttributesIndex; i < len(trackingAuthorizer.requestAttributes); i++ {
if trackingAuthorizer.requestAttributes[i].GetAPIGroup() == r.expectedAttributes.GetAPIGroup() &&
trackingAuthorizer.requestAttributes[i].GetResource() == r.expectedAttributes.GetResource() {
found = true
break
}
t.Logf("%#v did not match %#v", r.expectedAttributes, trackingAuthorizer.requestAttributes[i].(*authorizer.AttributesRecord))
}
if !found {
t.Errorf("did not find %#v in %#v", r.expectedAttributes, trackingAuthorizer.requestAttributes[currentAuthorizationAttributesIndex:])
}
currentAuthorizationAttributesIndex = len(trackingAuthorizer.requestAttributes)
}()
}
}
// TestNamespaceAuthorization tests that authorization can be controlled
// by namespace.
func TestNamespaceAuthorization(t *testing.T) {
kubeClient, kubeConfig, tearDownFn := framework.StartTestServer(t, framework.TestServerSetup{
ModifyServerRunOptions: func(opts *options.ServerRunOptions) {
// Disable ServiceAccount admission plugin as we don't have serviceaccount controller running.
opts.Admission.GenericAdmission.DisablePlugins = []string{"ServiceAccount"}
opts.Authentication.TokenFile.TokenFile = "testdata/tokens.csv"
opts.Authorization.PolicyFile = newABACFileWithContents(t, `{"namespace": "auth-namespace"}`)
opts.Authorization.Modes = []string{"ABAC"}
},
})
defer tearDownFn()
ns := framework.CreateNamespaceOrDie(kubeClient, "auth-namespace", t)
defer framework.DeleteNamespaceOrDie(kubeClient, ns, t)
previousResourceVersion := make(map[string]float64)
transport, err := rest.TransportFor(kubeConfig)
if err != nil {
t.Fatal(err)
}
requests := []struct {
verb string
URL string
namespace string
body string
statusCodes map[int]bool // allowed status codes.
}{
{"POST", timeoutPath("pods", ns.Name, ""), "foo", aPod, integration.Code201},
{"GET", path("pods", ns.Name, ""), "foo", "", integration.Code200},
{"GET", path("pods", ns.Name, "a"), "foo", "", integration.Code200},
{"DELETE", timeoutPath("pods", ns.Name, "a"), "foo", "", integration.Code200},
{"POST", timeoutPath("pods", "foo", ""), "bar", aPod, integration.Code403},
{"GET", path("pods", "foo", ""), "bar", "", integration.Code403},
{"GET", path("pods", "foo", "a"), "bar", "", integration.Code403},
{"DELETE", timeoutPath("pods", "foo", "a"), "bar", "", integration.Code403},
{"POST", timeoutPath("pods", metav1.NamespaceDefault, ""), "", aPod, integration.Code403},
{"GET", path("pods", "", ""), "", "", integration.Code403},
{"GET", path("pods", metav1.NamespaceDefault, "a"), "", "", integration.Code403},
{"DELETE", timeoutPath("pods", metav1.NamespaceDefault, "a"), "", "", integration.Code403},
}
for _, r := range requests {
token := BobToken
var bodyStr string
if r.body != "" {
sub := ""
if r.verb == "PUT" && r.body != "" {
// For update operations, insert previous resource version
if resVersion := previousResourceVersion[getPreviousResourceVersionKey(r.URL, "")]; resVersion != 0 {
sub += fmt.Sprintf(",\r\n\"resourceVersion\": \"%v\"", resVersion)
}
namespace := r.namespace
// FIXME: Is that correct?
if len(namespace) == 0 {
namespace = "default"
}
sub += fmt.Sprintf(",\r\n\"namespace\": %q", namespace)
}
bodyStr = fmt.Sprintf(r.body, sub)
}
r.body = bodyStr
bodyBytes := bytes.NewReader([]byte(bodyStr))
req, err := http.NewRequest(r.verb, kubeConfig.Host+r.URL, bodyBytes)
if err != nil {
t.Logf("case %v", r)
t.Fatalf("unexpected error: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
func() {
resp, err := transport.RoundTrip(req)
if err != nil {
t.Logf("case %v", r)
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if _, ok := r.statusCodes[resp.StatusCode]; !ok {
t.Logf("case %v", r)
t.Errorf("Expected status one of %v, but got %v", r.statusCodes, resp.StatusCode)
t.Errorf("Body: %v", string(b))
} else {
if r.verb == "POST" {
// For successful create operations, extract resourceVersion
id, currentResourceVersion, err := parseResourceVersion(b)
if err == nil {
key := getPreviousResourceVersionKey(r.URL, id)
previousResourceVersion[key] = currentResourceVersion
}
}
}
}()
}
}
// TestKindAuthorization tests that authorization can be controlled
// by namespace.
func TestKindAuthorization(t *testing.T) {
kubeClient, kubeConfig, tearDownFn := framework.StartTestServer(t, framework.TestServerSetup{
ModifyServerRunOptions: func(opts *options.ServerRunOptions) {
// Disable ServiceAccount admission plugin as we don't have serviceaccount controller running.
opts.Admission.GenericAdmission.DisablePlugins = []string{"ServiceAccount"}
opts.Authentication.TokenFile.TokenFile = "testdata/tokens.csv"
opts.Authorization.PolicyFile = newABACFileWithContents(t, `{"resource": "services"}`)
opts.Authorization.Modes = []string{"ABAC"}
},
})
defer tearDownFn()
ns := framework.CreateNamespaceOrDie(kubeClient, "auth-kind", t)
defer framework.DeleteNamespaceOrDie(kubeClient, ns, t)
previousResourceVersion := make(map[string]float64)
transport, err := rest.TransportFor(kubeConfig)
if err != nil {
t.Fatal(err)
}
requests := []testRequest{
{"POST", timeoutPath("services", ns.Name, ""), aService, integration.Code201},
{"GET", path("services", ns.Name, ""), "", integration.Code200},
{"GET", path("services", ns.Name, "a"), "", integration.Code200},
{"DELETE", timeoutPath("services", ns.Name, "a"), "", integration.Code200},
{"POST", timeoutPath("pods", ns.Name, ""), aPod, integration.Code403},
{"GET", path("pods", "", ""), "", integration.Code403},
{"GET", path("pods", ns.Name, "a"), "", integration.Code403},
{"DELETE", timeoutPath("pods", ns.Name, "a"), "", integration.Code403},
}
for _, r := range requests {
token := BobToken
var bodyStr string
if r.body != "" {
bodyStr = fmt.Sprintf(r.body, "")
if r.verb == "PUT" && r.body != "" {
// For update operations, insert previous resource version
if resVersion := previousResourceVersion[getPreviousResourceVersionKey(r.URL, "")]; resVersion != 0 {
resourceVersionJSON := fmt.Sprintf(",\r\n\"resourceVersion\": \"%v\"", resVersion)
bodyStr = fmt.Sprintf(r.body, resourceVersionJSON)
}
}
}
r.body = bodyStr
bodyBytes := bytes.NewReader([]byte(bodyStr))
req, err := http.NewRequest(r.verb, kubeConfig.Host+r.URL, bodyBytes)
if err != nil {
t.Logf("case %v", r)
t.Fatalf("unexpected error: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
func() {
resp, err := transport.RoundTrip(req)
if err != nil {
t.Logf("case %v", r)
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if _, ok := r.statusCodes[resp.StatusCode]; !ok {
t.Logf("case %v", r)
t.Errorf("Expected status one of %v, but got %v", r.statusCodes, resp.StatusCode)
t.Errorf("Body: %v", string(b))
} else {
if r.verb == "POST" {
// For successful create operations, extract resourceVersion
id, currentResourceVersion, err := parseResourceVersion(b)
if err == nil {
key := getPreviousResourceVersionKey(r.URL, id)
previousResourceVersion[key] = currentResourceVersion
}
}
}
}()
}
}
// TestReadOnlyAuthorization tests that authorization can be controlled
// by namespace.
func TestReadOnlyAuthorization(t *testing.T) {
kubeClient, kubeConfig, tearDownFn := framework.StartTestServer(t, framework.TestServerSetup{
ModifyServerRunOptions: func(opts *options.ServerRunOptions) {
// Disable ServiceAccount admission plugin as we don't have serviceaccount controller running.
opts.Admission.GenericAdmission.DisablePlugins = []string{"ServiceAccount"}
opts.Authentication.TokenFile.TokenFile = "testdata/tokens.csv"
opts.Authorization.PolicyFile = newABACFileWithContents(t, `{"readonly": true}`)
opts.Authorization.Modes = []string{"ABAC"}
},
})
defer tearDownFn()
ns := framework.CreateNamespaceOrDie(kubeClient, "auth-read-only", t)
defer framework.DeleteNamespaceOrDie(kubeClient, ns, t)
transport, err := rest.TransportFor(kubeConfig)
if err != nil {
t.Fatal(err)
}
requests := []testRequest{
{"POST", path("pods", ns.Name, ""), aPod, integration.Code403},
{"GET", path("pods", ns.Name, ""), "", integration.Code200},
{"GET", path("pods", metav1.NamespaceDefault, "a"), "", integration.Code404},
}
for _, r := range requests {
token := BobToken
bodyBytes := bytes.NewReader([]byte(r.body))
req, err := http.NewRequest(r.verb, kubeConfig.Host+r.URL, bodyBytes)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
func() {
resp, err := transport.RoundTrip(req)
if err != nil {
t.Logf("case %v", r)
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
if _, ok := r.statusCodes[resp.StatusCode]; !ok {
t.Logf("case %v", r)
t.Errorf("Expected status one of %v, but got %v", r.statusCodes, resp.StatusCode)
b, _ := io.ReadAll(resp.Body)
t.Errorf("Body: %v", string(b))
}
}()
}
}
// TestWebhookTokenAuthenticator tests that a control plane can use the webhook token
// authenticator to call out to a remote web server for authentication
// decisions.
func TestWebhookTokenAuthenticator(t *testing.T) {
testWebhookTokenAuthenticator(false, t)
}
// TestWebhookTokenAuthenticatorCustomDial is the same as TestWebhookTokenAuthenticator, but uses a
// custom dialer
func TestWebhookTokenAuthenticatorCustomDial(t *testing.T) {
testWebhookTokenAuthenticator(true, t)
}
func testWebhookTokenAuthenticator(customDialer bool, t *testing.T) {
authServer := newTestWebhookTokenAuthServer()
defer authServer.Close()
var authenticator authenticator.Request
var err error
if customDialer == false {
authenticator, err = getTestWebhookTokenAuth(authServer.URL, nil)
} else {
authenticator, err = getTestWebhookTokenAuthCustomDialer(authServer.URL)
}
if err != nil {
t.Fatalf("error starting webhook token authenticator server: %v", err)
}
kubeClient, kubeConfig, tearDownFn := framework.StartTestServer(t, framework.TestServerSetup{
ModifyServerRunOptions: func(opts *options.ServerRunOptions) {
// Disable ServiceAccount admission plugin as we don't have serviceaccount controller running.
opts.Admission.GenericAdmission.DisablePlugins = []string{"ServiceAccount"}
opts.Authorization.Modes = []string{"ABAC"}
opts.Authorization.PolicyFile = "testdata/allowalice.jsonl"
},
ModifyServerConfig: func(config *controlplane.Config) {
config.GenericConfig.Authentication.Authenticator = group.NewAuthenticatedGroupAdder(authenticator)
// Disable checking API audiences that is set by testserver by default.
config.GenericConfig.Authentication.APIAudiences = nil
},
})
defer tearDownFn()
ns := framework.CreateNamespaceOrDie(kubeClient, "auth-webhook-token", t)
defer framework.DeleteNamespaceOrDie(kubeClient, ns, t)
transport, err := rest.TransportFor(kubeConfig)
if err != nil {
t.Fatal(err)
}
for _, r := range getTestRequests(ns.Name) {
// Expect Bob's requests to all fail.
token := BobToken
bodyBytes := bytes.NewReader([]byte(r.body))
req, err := http.NewRequest(r.verb, kubeConfig.Host+r.URL, bodyBytes)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
func() {
resp, err := transport.RoundTrip(req)
if err != nil {
t.Logf("case %v", r)
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
// Expect all of Bob's actions to return Forbidden
if resp.StatusCode != http.StatusForbidden {
t.Logf("case %v", r)
t.Errorf("Expected http.Forbidden, but got %s", resp.Status)
}
}()
// Expect Alice's requests to succeed.
token = AliceToken
bodyBytes = bytes.NewReader([]byte(r.body))
req, err = http.NewRequest(r.verb, kubeConfig.Host+r.URL, bodyBytes)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
func() {
resp, err := transport.RoundTrip(req)
if err != nil {
t.Logf("case %v", r)
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
// Expect all of Alice's actions to at least get past authn/authz.
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
t.Logf("case %v", r)
t.Errorf("Expected something other than Unauthorized/Forbidden, but got %s", resp.Status)
}
}()
}
}
// newTestWebhookTokenAuthServer creates an http token authentication server
// that knows about both Alice and Bob.
func newTestWebhookTokenAuthServer() *httptest.Server {
serveHTTP := func(w http.ResponseWriter, r *http.Request) {
var review authenticationv1beta1.TokenReview
if err := json.NewDecoder(r.Body).Decode(&review); err != nil {
http.Error(w, fmt.Sprintf("failed to decode body: %v", err), http.StatusBadRequest)
return
}
type userInfo struct {
Username string `json:"username"`
UID string `json:"uid"`
Groups []string `json:"groups"`
}
type status struct {
Authenticated bool `json:"authenticated"`
User userInfo `json:"user"`
}
var username, uid string
authenticated := false
if review.Spec.Token == AliceToken {
authenticated, username, uid = true, "alice", "1"
} else if review.Spec.Token == BobToken {
authenticated, username, uid = true, "bob", "2"
}
resp := struct {
APIVersion string `json:"apiVersion"`
Status status `json:"status"`
}{
APIVersion: authenticationv1beta1.SchemeGroupVersion.String(),
Status: status{
authenticated,
userInfo{
Username: username,
UID: uid,
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
server := httptest.NewUnstartedServer(http.HandlerFunc(serveHTTP))
server.Start()
return server
}