03f19da21c
Signed-off-by: Margo Crawford <margaretc@vmware.com>
2098 lines
97 KiB
Go
2098 lines
97 KiB
Go
// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package impersonator
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"fmt"
|
|
"math/rand"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
authenticationv1 "k8s.io/api/authentication/v1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured/unstructuredscheme"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
"k8s.io/apimachinery/pkg/runtime/serializer"
|
|
"k8s.io/apimachinery/pkg/util/httpstream"
|
|
auditinternal "k8s.io/apiserver/pkg/apis/audit"
|
|
"k8s.io/apiserver/pkg/audit"
|
|
"k8s.io/apiserver/pkg/authentication/authenticator"
|
|
"k8s.io/apiserver/pkg/authentication/request/bearertoken"
|
|
"k8s.io/apiserver/pkg/authentication/user"
|
|
"k8s.io/apiserver/pkg/authorization/authorizer"
|
|
"k8s.io/apiserver/pkg/endpoints/request"
|
|
"k8s.io/apiserver/pkg/features"
|
|
genericapiserver "k8s.io/apiserver/pkg/server"
|
|
genericoptions "k8s.io/apiserver/pkg/server/options"
|
|
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
|
"k8s.io/client-go/kubernetes"
|
|
"k8s.io/client-go/rest"
|
|
"k8s.io/client-go/tools/clientcmd/api"
|
|
featuregatetesting "k8s.io/component-base/featuregate/testing"
|
|
"k8s.io/utils/pointer"
|
|
|
|
loginv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/login/v1alpha1"
|
|
"go.pinniped.dev/internal/certauthority"
|
|
"go.pinniped.dev/internal/constable"
|
|
"go.pinniped.dev/internal/crypto/ptls"
|
|
"go.pinniped.dev/internal/dynamiccert"
|
|
"go.pinniped.dev/internal/groupsuffix"
|
|
"go.pinniped.dev/internal/here"
|
|
"go.pinniped.dev/internal/httputil/roundtripper"
|
|
"go.pinniped.dev/internal/kubeclient"
|
|
"go.pinniped.dev/internal/testutil/tlsserver"
|
|
)
|
|
|
|
func TestImpersonator(t *testing.T) {
|
|
ca, err := certauthority.New("ca", time.Hour)
|
|
require.NoError(t, err)
|
|
caKey, err := ca.PrivateKeyToPEM()
|
|
require.NoError(t, err)
|
|
caContent := dynamiccert.NewCA("ca")
|
|
err = caContent.SetCertKeyContent(ca.Bundle(), caKey)
|
|
require.NoError(t, err)
|
|
|
|
cert, key, err := ca.IssueServerCertPEM(nil, []net.IP{net.ParseIP("127.0.0.1")}, time.Hour)
|
|
require.NoError(t, err)
|
|
certKeyContent := dynamiccert.NewServingCert("cert-key")
|
|
err = certKeyContent.SetCertKeyContent(cert, key)
|
|
require.NoError(t, err)
|
|
|
|
unrelatedCA, err := certauthority.New("ca", time.Hour)
|
|
require.NoError(t, err)
|
|
|
|
// turn off this code path for all tests because it does not handle the config we remove correctly
|
|
defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.APIPriorityAndFairness, false)()
|
|
|
|
tests := []struct {
|
|
name string
|
|
clientCert *clientCert
|
|
clientImpersonateUser rest.ImpersonationConfig
|
|
clientMutateHeaders func(http.Header)
|
|
clientNextProtos []string
|
|
kubeAPIServerClientBearerTokenFile string
|
|
kubeAPIServerStatusCode int
|
|
kubeAPIServerHealthz http.Handler
|
|
anonymousAuthDisabled bool
|
|
wantKubeAPIServerRequestHeaders http.Header
|
|
wantError string
|
|
wantConstructionError string
|
|
wantAuthorizerAttributes []authorizer.AttributesRecord
|
|
}{
|
|
{
|
|
name: "happy path",
|
|
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantKubeAPIServerRequestHeaders: http.Header{
|
|
"Impersonate-User": {"test-username"},
|
|
"Impersonate-Group": {"test-group1", "test-group2", "system:authenticated"},
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"User-Agent": {"test-agent"},
|
|
"Accept": {"application/vnd.kubernetes.protobuf,application/json"},
|
|
"Accept-Encoding": {"gzip"},
|
|
"X-Forwarded-For": {"127.0.0.1"},
|
|
},
|
|
wantAuthorizerAttributes: []authorizer.AttributesRecord{
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-username", UID: "", Groups: []string{"test-group1", "test-group2", "system:authenticated"}, Extra: nil},
|
|
Verb: "list", Namespace: "", APIGroup: "", APIVersion: "v1", Resource: "namespaces", Subresource: "", Name: "", ResourceRequest: true, Path: "/api/v1/namespaces",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "happy path with forbidden healthz",
|
|
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
kubeAPIServerHealthz: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte("no healthz for you"))
|
|
}),
|
|
wantKubeAPIServerRequestHeaders: http.Header{
|
|
"Impersonate-User": {"test-username"},
|
|
"Impersonate-Group": {"test-group1", "test-group2", "system:authenticated"},
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"User-Agent": {"test-agent"},
|
|
"Accept": {"application/vnd.kubernetes.protobuf,application/json"},
|
|
"Accept-Encoding": {"gzip"},
|
|
"X-Forwarded-For": {"127.0.0.1"},
|
|
},
|
|
wantAuthorizerAttributes: []authorizer.AttributesRecord{
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-username", UID: "", Groups: []string{"test-group1", "test-group2", "system:authenticated"}, Extra: nil},
|
|
Verb: "list", Namespace: "", APIGroup: "", APIVersion: "v1", Resource: "namespaces", Subresource: "", Name: "", ResourceRequest: true, Path: "/api/v1/namespaces",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "happy path with unauthorized healthz",
|
|
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
kubeAPIServerHealthz: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte("no healthz for you"))
|
|
}),
|
|
anonymousAuthDisabled: true,
|
|
wantKubeAPIServerRequestHeaders: http.Header{
|
|
"Impersonate-User": {"test-username"},
|
|
"Impersonate-Group": {"test-group1", "test-group2", "system:authenticated"},
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"User-Agent": {"test-agent"},
|
|
"Accept": {"application/vnd.kubernetes.protobuf,application/json"},
|
|
"Accept-Encoding": {"gzip"},
|
|
"X-Forwarded-For": {"127.0.0.1"},
|
|
},
|
|
wantAuthorizerAttributes: []authorizer.AttributesRecord{
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-username", UID: "", Groups: []string{"test-group1", "test-group2", "system:authenticated"}, Extra: nil},
|
|
Verb: "list", Namespace: "", APIGroup: "", APIVersion: "v1", Resource: "namespaces", Subresource: "", Name: "", ResourceRequest: true, Path: "/api/v1/namespaces",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "happy path with upgrade",
|
|
clientCert: newClientCert(t, ca, "test-username2", []string{"test-group3", "test-group4"}),
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
clientMutateHeaders: func(header http.Header) {
|
|
header.Add("Connection", "Upgrade")
|
|
header.Add("Upgrade", "spdy/3.1")
|
|
|
|
if ok := httpstream.IsUpgradeRequest(&http.Request{Header: header}); !ok {
|
|
panic("request must be upgrade in this test")
|
|
}
|
|
},
|
|
clientNextProtos: []string{"http/1.1"}, // we need to use http1 as http2 does not support upgrades, see http2checkConnHeaders
|
|
wantKubeAPIServerRequestHeaders: http.Header{
|
|
"Impersonate-User": {"test-username2"},
|
|
"Impersonate-Group": {"test-group3", "test-group4", "system:authenticated"},
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"User-Agent": {"test-agent"},
|
|
"Accept": {"application/vnd.kubernetes.protobuf,application/json"},
|
|
"Accept-Encoding": {"gzip"},
|
|
"X-Forwarded-For": {"127.0.0.1"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"spdy/3.1"},
|
|
},
|
|
wantAuthorizerAttributes: []authorizer.AttributesRecord{
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-username2", UID: "", Groups: []string{"test-group3", "test-group4", "system:authenticated"}, Extra: nil},
|
|
Verb: "list", Namespace: "", APIGroup: "", APIVersion: "v1", Resource: "namespaces", Subresource: "", Name: "", ResourceRequest: true, Path: "/api/v1/namespaces",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "happy path ignores forwarded header",
|
|
clientCert: newClientCert(t, ca, "test-username2", []string{"test-group3", "test-group4"}),
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
clientMutateHeaders: func(header http.Header) {
|
|
header.Add("X-Forwarded-For", "example.com")
|
|
},
|
|
wantKubeAPIServerRequestHeaders: http.Header{
|
|
"Impersonate-User": {"test-username2"},
|
|
"Impersonate-Group": {"test-group3", "test-group4", "system:authenticated"},
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"User-Agent": {"test-agent"},
|
|
"Accept": {"application/vnd.kubernetes.protobuf,application/json"},
|
|
"Accept-Encoding": {"gzip"},
|
|
"X-Forwarded-For": {"127.0.0.1"},
|
|
},
|
|
wantAuthorizerAttributes: []authorizer.AttributesRecord{
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-username2", UID: "", Groups: []string{"test-group3", "test-group4", "system:authenticated"}, Extra: nil},
|
|
Verb: "list", Namespace: "", APIGroup: "", APIVersion: "v1", Resource: "namespaces", Subresource: "", Name: "", ResourceRequest: true, Path: "/api/v1/namespaces",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "happy path ignores forwarded header canonicalization",
|
|
clientCert: newClientCert(t, ca, "test-username2", []string{"test-group3", "test-group4"}),
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
clientMutateHeaders: func(header http.Header) {
|
|
header["x-FORWARDED-for"] = append(header["x-FORWARDED-for"], "example.com")
|
|
},
|
|
wantKubeAPIServerRequestHeaders: http.Header{
|
|
"Impersonate-User": {"test-username2"},
|
|
"Impersonate-Group": {"test-group3", "test-group4", "system:authenticated"},
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"User-Agent": {"test-agent"},
|
|
"Accept": {"application/vnd.kubernetes.protobuf,application/json"},
|
|
"Accept-Encoding": {"gzip"},
|
|
"X-Forwarded-For": {"127.0.0.1"},
|
|
},
|
|
wantAuthorizerAttributes: []authorizer.AttributesRecord{
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-username2", UID: "", Groups: []string{"test-group3", "test-group4", "system:authenticated"}, Extra: nil},
|
|
Verb: "list", Namespace: "", APIGroup: "", APIVersion: "v1", Resource: "namespaces", Subresource: "", Name: "", ResourceRequest: true, Path: "/api/v1/namespaces",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "user is authenticated but the kube API request returns an error",
|
|
kubeAPIServerStatusCode: http.StatusNotFound,
|
|
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantError: `the server could not find the requested resource (get namespaces)`,
|
|
wantKubeAPIServerRequestHeaders: http.Header{
|
|
"Impersonate-User": {"test-username"},
|
|
"Impersonate-Group": {"test-group1", "test-group2", "system:authenticated"},
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"User-Agent": {"test-agent"},
|
|
"Accept": {"application/vnd.kubernetes.protobuf,application/json"},
|
|
"Accept-Encoding": {"gzip"},
|
|
"X-Forwarded-For": {"127.0.0.1"},
|
|
},
|
|
wantAuthorizerAttributes: []authorizer.AttributesRecord{
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-username", UID: "", Groups: []string{"test-group1", "test-group2", "system:authenticated"}, Extra: nil},
|
|
Verb: "list", Namespace: "", APIGroup: "", APIVersion: "v1", Resource: "namespaces", Subresource: "", Name: "", ResourceRequest: true, Path: "/api/v1/namespaces",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "when there is no client cert on request, it is an anonymous request",
|
|
clientCert: &clientCert{},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantKubeAPIServerRequestHeaders: http.Header{
|
|
"Impersonate-User": {"system:anonymous"},
|
|
"Impersonate-Group": {"system:unauthenticated"},
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"User-Agent": {"test-agent"},
|
|
"Accept": {"application/vnd.kubernetes.protobuf,application/json"},
|
|
"Accept-Encoding": {"gzip"},
|
|
"X-Forwarded-For": {"127.0.0.1"},
|
|
},
|
|
wantAuthorizerAttributes: []authorizer.AttributesRecord{
|
|
{
|
|
User: &user.DefaultInfo{Name: "system:anonymous", UID: "", Groups: []string{"system:unauthenticated"}, Extra: nil},
|
|
Verb: "list", Namespace: "", APIGroup: "", APIVersion: "v1", Resource: "namespaces", Subresource: "", Name: "", ResourceRequest: true, Path: "/api/v1/namespaces",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "when there is no client cert on request but it has basic auth, it is still an anonymous request",
|
|
clientCert: &clientCert{},
|
|
clientMutateHeaders: func(header http.Header) {
|
|
header.Set("Test", "val")
|
|
req := &http.Request{Header: header}
|
|
req.SetBasicAuth("foo", "bar")
|
|
},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantKubeAPIServerRequestHeaders: http.Header{
|
|
"Impersonate-User": {"system:anonymous"},
|
|
"Impersonate-Group": {"system:unauthenticated"},
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"User-Agent": {"test-agent"},
|
|
"Accept": {"application/vnd.kubernetes.protobuf,application/json"},
|
|
"Accept-Encoding": {"gzip"},
|
|
"X-Forwarded-For": {"127.0.0.1"},
|
|
"Test": {"val"},
|
|
},
|
|
wantAuthorizerAttributes: []authorizer.AttributesRecord{
|
|
{
|
|
User: &user.DefaultInfo{Name: "system:anonymous", UID: "", Groups: []string{"system:unauthenticated"}, Extra: nil},
|
|
Verb: "list", Namespace: "", APIGroup: "", APIVersion: "v1", Resource: "namespaces", Subresource: "", Name: "", ResourceRequest: true, Path: "/api/v1/namespaces",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "failed client cert authentication",
|
|
clientCert: newClientCert(t, unrelatedCA, "test-username", []string{"test-group1"}),
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantError: "Unauthorized",
|
|
wantAuthorizerAttributes: nil,
|
|
},
|
|
{
|
|
name: "nested impersonation by regular users calls delegating authorizer",
|
|
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
|
|
clientImpersonateUser: rest.ImpersonationConfig{UserName: "some-other-username"},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
// this fails because the delegating authorizer in this test only allows system:masters and fails everything else
|
|
wantError: `users "some-other-username" is forbidden: User "test-username" ` +
|
|
`cannot impersonate resource "users" in API group "" at the cluster scope: ` +
|
|
`decision made by impersonation-proxy.concierge.pinniped.dev`,
|
|
wantAuthorizerAttributes: []authorizer.AttributesRecord{
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-username", UID: "", Groups: []string{"test-group1", "test-group2", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "", APIVersion: "", Resource: "users", Subresource: "", Name: "some-other-username", ResourceRequest: true, Path: "",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "nested impersonation by admin users calls delegating authorizer",
|
|
clientCert: newClientCert(t, ca, "test-admin", []string{"system:masters", "test-group2"}),
|
|
clientImpersonateUser: rest.ImpersonationConfig{
|
|
UserName: "fire",
|
|
Groups: []string{"elements"},
|
|
Extra: map[string][]string{
|
|
"colors": {"red", "orange", "blue"},
|
|
|
|
// gke
|
|
"iam.gke.io/user-assertion": {"good", "stuff"},
|
|
"user-assertion.cloud.google.com": {"smaller", "things"},
|
|
|
|
// openshift
|
|
"scopes.authorization.openshift.io": {"user:info", "user:full", "user:check-access"},
|
|
|
|
// openstack
|
|
"alpha.kubernetes.io/identity/roles": {"a-role1", "a-role2"},
|
|
"alpha.kubernetes.io/identity/project/id": {"a-project-id"},
|
|
"alpha.kubernetes.io/identity/project/name": {"a-project-name"},
|
|
"alpha.kubernetes.io/identity/user/domain/id": {"a-domain-id"},
|
|
"alpha.kubernetes.io/identity/user/domain/name": {"a-domain-name"},
|
|
},
|
|
},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantKubeAPIServerRequestHeaders: http.Header{
|
|
"Impersonate-User": {"fire"},
|
|
"Impersonate-Group": {"elements", "system:authenticated"},
|
|
"Impersonate-Extra-Colors": {"red", "orange", "blue"},
|
|
"Impersonate-Extra-Iam.gke.io%2fuser-Assertion": {"good", "stuff"},
|
|
"Impersonate-Extra-User-Assertion.cloud.google.com": {"smaller", "things"},
|
|
"Impersonate-Extra-Scopes.authorization.openshift.io": {"user:info", "user:full", "user:check-access"},
|
|
"Impersonate-Extra-Alpha.kubernetes.io%2fidentity%2froles": {"a-role1", "a-role2"},
|
|
"Impersonate-Extra-Alpha.kubernetes.io%2fidentity%2fproject%2fid": {"a-project-id"},
|
|
"Impersonate-Extra-Alpha.kubernetes.io%2fidentity%2fproject%2fname": {"a-project-name"},
|
|
"Impersonate-Extra-Alpha.kubernetes.io%2fidentity%2fuser%2fdomain%2fid": {"a-domain-id"},
|
|
"Impersonate-Extra-Alpha.kubernetes.io%2fidentity%2fuser%2fdomain%2fname": {"a-domain-name"},
|
|
"Impersonate-Extra-Original-User-Info.impersonation-Proxy.concierge.pinniped.dev": {`{"username":"test-admin","groups":["test-group2","system:masters","system:authenticated"]}`},
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"User-Agent": {"test-agent"},
|
|
"Accept": {"application/vnd.kubernetes.protobuf,application/json"},
|
|
"Accept-Encoding": {"gzip"},
|
|
"X-Forwarded-For": {"127.0.0.1"},
|
|
},
|
|
wantAuthorizerAttributes: []authorizer.AttributesRecord{
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "", APIVersion: "", Resource: "users", Subresource: "", Name: "fire", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "", APIVersion: "", Resource: "groups", Subresource: "", Name: "elements", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "userextras", Subresource: "iam.gke.io/user-assertion", Name: "good", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "userextras", Subresource: "iam.gke.io/user-assertion", Name: "stuff", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "userextras", Subresource: "alpha.kubernetes.io/identity/roles", Name: "a-role1", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "userextras", Subresource: "alpha.kubernetes.io/identity/roles", Name: "a-role2", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "userextras", Subresource: "user-assertion.cloud.google.com", Name: "smaller", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "userextras", Subresource: "user-assertion.cloud.google.com", Name: "things", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "userextras", Subresource: "colors", Name: "red", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "userextras", Subresource: "colors", Name: "orange", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "userextras", Subresource: "colors", Name: "blue", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "userextras", Subresource: "scopes.authorization.openshift.io", Name: "user:info", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "userextras", Subresource: "scopes.authorization.openshift.io", Name: "user:full", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "userextras", Subresource: "scopes.authorization.openshift.io", Name: "user:check-access", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "userextras", Subresource: "alpha.kubernetes.io/identity/project/name", Name: "a-project-name", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "userextras", Subresource: "alpha.kubernetes.io/identity/user/domain/id", Name: "a-domain-id", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "userextras", Subresource: "alpha.kubernetes.io/identity/user/domain/name", Name: "a-domain-name", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "userextras", Subresource: "alpha.kubernetes.io/identity/project/id", Name: "a-project-id", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "fire", UID: "", Groups: []string{"elements", "system:authenticated"},
|
|
Extra: map[string][]string{
|
|
"alpha.kubernetes.io/identity/project/id": {"a-project-id"},
|
|
"alpha.kubernetes.io/identity/project/name": {"a-project-name"},
|
|
"alpha.kubernetes.io/identity/roles": {"a-role1", "a-role2"},
|
|
"alpha.kubernetes.io/identity/user/domain/id": {"a-domain-id"},
|
|
"alpha.kubernetes.io/identity/user/domain/name": {"a-domain-name"},
|
|
"colors": {"red", "orange", "blue"},
|
|
"iam.gke.io/user-assertion": {"good", "stuff"},
|
|
"scopes.authorization.openshift.io": {"user:info", "user:full", "user:check-access"},
|
|
"user-assertion.cloud.google.com": {"smaller", "things"},
|
|
},
|
|
},
|
|
Verb: "list", Namespace: "", APIGroup: "", APIVersion: "v1", Resource: "namespaces", Subresource: "", Name: "", ResourceRequest: true, Path: "/api/v1/namespaces",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "nested impersonation by admin users cannot impersonate UID",
|
|
clientCert: newClientCert(t, ca, "test-admin", []string{"system:masters", "test-group2"}),
|
|
clientImpersonateUser: rest.ImpersonationConfig{UserName: "some-other-username"},
|
|
clientMutateHeaders: func(header http.Header) {
|
|
header["Impersonate-Uid"] = []string{"root"}
|
|
},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantError: "Internal error occurred: unimplemented functionality - unable to act as current user",
|
|
wantAuthorizerAttributes: []authorizer.AttributesRecord{
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "", APIVersion: "", Resource: "users", Subresource: "", Name: "some-other-username", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "uids", Subresource: "", Name: "root", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "some-other-username", UID: "root", Groups: []string{"system:authenticated"}, Extra: map[string][]string{}},
|
|
Verb: "list", Namespace: "", APIGroup: "", APIVersion: "v1", Resource: "namespaces", Subresource: "", Name: "", ResourceRequest: true, Path: "/api/v1/namespaces",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "nested impersonation by admin users cannot impersonate UID header canonicalization",
|
|
clientCert: newClientCert(t, ca, "test-admin", []string{"system:masters", "test-group2"}),
|
|
clientImpersonateUser: rest.ImpersonationConfig{UserName: "some-other-username"},
|
|
clientMutateHeaders: func(header http.Header) {
|
|
header["imPerSoNaTE-uid"] = []string{"magic"}
|
|
},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantError: "Internal error occurred: unimplemented functionality - unable to act as current user",
|
|
wantAuthorizerAttributes: []authorizer.AttributesRecord{
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "", APIVersion: "", Resource: "users", Subresource: "", Name: "some-other-username", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "uids", Subresource: "", Name: "magic", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "some-other-username", UID: "magic", Groups: []string{"system:authenticated"}, Extra: map[string][]string{}},
|
|
Verb: "list", Namespace: "", APIGroup: "", APIVersion: "v1", Resource: "namespaces", Subresource: "", Name: "", ResourceRequest: true, Path: "/api/v1/namespaces",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "nested impersonation by admin users cannot use reserved key",
|
|
clientCert: newClientCert(t, ca, "test-admin", []string{"system:masters", "test-group2"}),
|
|
clientImpersonateUser: rest.ImpersonationConfig{
|
|
UserName: "other-user-to-impersonate",
|
|
Groups: []string{"other-peeps"},
|
|
Extra: map[string][]string{
|
|
"key": {"good"},
|
|
"something.impersonation-proxy.concierge.pinniped.dev": {"bad data"},
|
|
},
|
|
},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantError: "Internal error occurred: unimplemented functionality - unable to act as current user",
|
|
wantAuthorizerAttributes: []authorizer.AttributesRecord{
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "", APIVersion: "", Resource: "users", Subresource: "", Name: "other-user-to-impersonate", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "", APIVersion: "", Resource: "groups", Subresource: "", Name: "other-peeps", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "userextras", Subresource: "something.impersonation-proxy.concierge.pinniped.dev", Name: "bad data", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "userextras", Subresource: "key", Name: "good", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "other-user-to-impersonate", UID: "", Groups: []string{"other-peeps", "system:authenticated"},
|
|
Extra: map[string][]string{
|
|
"key": {"good"},
|
|
"something.impersonation-proxy.concierge.pinniped.dev": {"bad data"},
|
|
},
|
|
},
|
|
Verb: "list", Namespace: "", APIGroup: "", APIVersion: "v1", Resource: "namespaces", Subresource: "", Name: "", ResourceRequest: true, Path: "/api/v1/namespaces",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "nested impersonation by admin users cannot use invalid key",
|
|
clientCert: newClientCert(t, ca, "test-admin", []string{"system:masters", "test-group2"}),
|
|
clientImpersonateUser: rest.ImpersonationConfig{
|
|
UserName: "panda",
|
|
Groups: []string{"other-peeps"},
|
|
Extra: map[string][]string{
|
|
"party~~time": {"danger"},
|
|
},
|
|
},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantError: "Internal error occurred: unimplemented functionality - unable to act as current user",
|
|
wantAuthorizerAttributes: []authorizer.AttributesRecord{
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "", APIVersion: "", Resource: "users", Subresource: "", Name: "panda", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "", APIVersion: "", Resource: "groups", Subresource: "", Name: "other-peeps", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "userextras", Subresource: "party~~time", Name: "danger", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "panda", UID: "", Groups: []string{"other-peeps", "system:authenticated"}, Extra: map[string][]string{"party~~time": {"danger"}}},
|
|
Verb: "list", Namespace: "", APIGroup: "", APIVersion: "v1", Resource: "namespaces", Subresource: "", Name: "", ResourceRequest: true, Path: "/api/v1/namespaces",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "nested impersonation by admin users can use uppercase key because impersonation is lossy",
|
|
clientCert: newClientCert(t, ca, "test-admin", []string{"system:masters", "test-group2"}),
|
|
clientImpersonateUser: rest.ImpersonationConfig{
|
|
UserName: "panda",
|
|
Groups: []string{"other-peeps"},
|
|
Extra: map[string][]string{
|
|
"ROAR": {"tiger"}, // by the time our code sees this key, it is lowercased to "roar"
|
|
},
|
|
},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantKubeAPIServerRequestHeaders: http.Header{
|
|
"Impersonate-User": {"panda"},
|
|
"Impersonate-Group": {"other-peeps", "system:authenticated"},
|
|
"Impersonate-Extra-Roar": {"tiger"},
|
|
"Impersonate-Extra-Original-User-Info.impersonation-Proxy.concierge.pinniped.dev": {`{"username":"test-admin","groups":["test-group2","system:masters","system:authenticated"]}`},
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"User-Agent": {"test-agent"},
|
|
"Accept": {"application/vnd.kubernetes.protobuf,application/json"},
|
|
"Accept-Encoding": {"gzip"},
|
|
"X-Forwarded-For": {"127.0.0.1"},
|
|
},
|
|
wantAuthorizerAttributes: []authorizer.AttributesRecord{
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "", APIVersion: "", Resource: "users", Subresource: "", Name: "panda", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "", APIVersion: "", Resource: "groups", Subresource: "", Name: "other-peeps", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "authentication.k8s.io", APIVersion: "v1", Resource: "userextras", Subresource: "roar", Name: "tiger", ResourceRequest: true, Path: "",
|
|
},
|
|
{
|
|
User: &user.DefaultInfo{Name: "panda", UID: "", Groups: []string{"other-peeps", "system:authenticated"}, Extra: map[string][]string{"roar": {"tiger"}}},
|
|
Verb: "list", Namespace: "", APIGroup: "", APIVersion: "v1", Resource: "namespaces", Subresource: "", Name: "", ResourceRequest: true, Path: "/api/v1/namespaces",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "no bearer token file in Kube API server client config",
|
|
wantConstructionError: "invalid impersonator loopback rest config has wrong bearer token semantics",
|
|
wantAuthorizerAttributes: nil,
|
|
},
|
|
{
|
|
name: "unexpected healthz response",
|
|
kubeAPIServerHealthz: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte("broken"))
|
|
}),
|
|
wantConstructionError: `could not detect if anonymous authentication is enabled: an error on the server ("broken") has prevented the request from succeeding`,
|
|
wantAuthorizerAttributes: nil,
|
|
},
|
|
{
|
|
name: "header canonicalization user header",
|
|
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
|
|
clientMutateHeaders: func(header http.Header) {
|
|
header["imPerSonaTE-USer"] = []string{"PANDA"}
|
|
},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantError: `users "PANDA" is forbidden: User "test-username" ` +
|
|
`cannot impersonate resource "users" in API group "" at the cluster scope: ` +
|
|
`decision made by impersonation-proxy.concierge.pinniped.dev`,
|
|
wantAuthorizerAttributes: []authorizer.AttributesRecord{
|
|
{
|
|
User: &user.DefaultInfo{Name: "test-username", UID: "", Groups: []string{"test-group1", "test-group2", "system:authenticated"}, Extra: nil},
|
|
Verb: "impersonate", Namespace: "", APIGroup: "", APIVersion: "", Resource: "users", Subresource: "", Name: "PANDA", ResourceRequest: true, Path: "",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "header canonicalization future UID header", // no longer future as it exists in Kube v1.22
|
|
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
|
|
clientMutateHeaders: func(header http.Header) {
|
|
header["imPerSonaTE-uid"] = []string{"007"}
|
|
},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantError: `an error on the server ("Internal Server Error: \"/api/v1/namespaces\": requested [{UID 007 authentication.k8s.io/v1 }] without impersonating a user") has prevented the request from succeeding (get namespaces)`,
|
|
wantAuthorizerAttributes: []authorizer.AttributesRecord{},
|
|
},
|
|
{
|
|
name: "future UID header", // no longer future as it exists in Kube v1.22
|
|
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
|
|
clientMutateHeaders: func(header http.Header) {
|
|
header["Impersonate-Uid"] = []string{"008"}
|
|
},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantError: `an error on the server ("Internal Server Error: \"/api/v1/namespaces\": requested [{UID 008 authentication.k8s.io/v1 }] without impersonating a user") has prevented the request from succeeding (get namespaces)`,
|
|
wantAuthorizerAttributes: []authorizer.AttributesRecord{},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
t.Cleanup(cancel)
|
|
|
|
// we need to create this listener ourselves because the API server
|
|
// code treats (port == 0 && listener == nil) to mean "do nothing"
|
|
listener, port, err := genericoptions.CreateListener("", "127.0.0.1:0", net.ListenConfig{})
|
|
require.NoError(t, err)
|
|
|
|
// After failing to start and after shutdown, the impersonator port should be available again.
|
|
defer requireCanBindToPort(t, port)
|
|
|
|
if tt.kubeAPIServerStatusCode == 0 {
|
|
tt.kubeAPIServerStatusCode = http.StatusOK
|
|
}
|
|
|
|
// Set up a fake Kube API server which will stand in for the real one. The impersonator
|
|
// will proxy incoming calls to this fake server.
|
|
testKubeAPIServerWasCalled := false
|
|
var testKubeAPIServerSawHeaders http.Header
|
|
testKubeAPIServer := tlsserver.TLSTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
tlsConfigFunc := func(rootCAs *x509.CertPool) *tls.Config {
|
|
// Requests to get configmaps, flowcontrol requests, and healthz requests
|
|
// are not done by our http round trippers that specify only one protocol
|
|
// (either http1.1 or http2, not both).
|
|
// For all other requests from the impersonator, if it is not an upgrade
|
|
// request it should only be using http2.
|
|
// If it is an upgrade request it should only be using http1.1, but that
|
|
// is covered by the AssertTLS function.
|
|
secure := ptls.Secure(rootCAs)
|
|
switch r.URL.Path {
|
|
case "/api/v1/namespaces/kube-system/configmaps",
|
|
"/apis/flowcontrol.apiserver.k8s.io/v1beta2/prioritylevelconfigurations",
|
|
"/apis/flowcontrol.apiserver.k8s.io/v1beta2/flowschemas",
|
|
"/healthz":
|
|
default:
|
|
if !httpstream.IsUpgradeRequest(r) {
|
|
secure.NextProtos = []string{secure.NextProtos[0]}
|
|
}
|
|
}
|
|
return secure
|
|
}
|
|
tlsserver.AssertTLS(t, r, tlsConfigFunc)
|
|
|
|
switch r.URL.Path {
|
|
case "/api/v1/namespaces/kube-system/configmaps":
|
|
require.Equal(t, http.MethodGet, r.Method)
|
|
|
|
// The production code uses NewDynamicCAFromConfigMapController which fetches a ConfigMap,
|
|
// so treat that differently. It wants to read the Kube API server CA from that ConfigMap
|
|
// to use it to validate client certs. We don't need it for this test, so return NotFound.
|
|
http.NotFound(w, r)
|
|
return
|
|
|
|
case "/apis/flowcontrol.apiserver.k8s.io/v1beta2/prioritylevelconfigurations",
|
|
"/apis/flowcontrol.apiserver.k8s.io/v1beta2/flowschemas":
|
|
// ignore requests related to priority and fairness logic
|
|
require.Equal(t, http.MethodGet, r.Method)
|
|
http.NotFound(w, r)
|
|
return
|
|
|
|
case "/api/v1/namespaces":
|
|
require.Equal(t, http.MethodGet, r.Method)
|
|
|
|
testKubeAPIServerWasCalled = true
|
|
testKubeAPIServerSawHeaders = r.Header
|
|
if tt.kubeAPIServerStatusCode != http.StatusOK {
|
|
w.WriteHeader(tt.kubeAPIServerStatusCode)
|
|
return
|
|
}
|
|
|
|
w.Header().Add("Content-Type", "application/json; charset=UTF-8")
|
|
_, _ = w.Write([]byte(here.Doc(`
|
|
{
|
|
"kind": "NamespaceList",
|
|
"apiVersion":"v1",
|
|
"items": [
|
|
{"metadata":{"name": "namespace1"}},
|
|
{"metadata":{"name": "namespace2"}}
|
|
]
|
|
}
|
|
`)))
|
|
return
|
|
|
|
case "/probe":
|
|
require.Equal(t, http.MethodGet, r.Method)
|
|
|
|
_, _ = fmt.Fprint(w, "probed")
|
|
return
|
|
|
|
case "/healthz":
|
|
require.Equal(t, http.MethodGet, r.Method)
|
|
require.Empty(t, r.Header.Get("Authorization"))
|
|
require.Contains(t, r.Header.Get("User-Agent"), "kubernetes")
|
|
|
|
if tt.kubeAPIServerHealthz != nil {
|
|
tt.kubeAPIServerHealthz.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// by default just match the KAS /healthz endpoint
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
_, _ = fmt.Fprint(w, "ok")
|
|
return
|
|
|
|
case "/apis/login.concierge.pinniped.dev/v1alpha1/tokencredentialrequests":
|
|
require.Equal(t, http.MethodPost, r.Method)
|
|
|
|
w.Header().Add("Content-Type", "application/json; charset=UTF-8")
|
|
_, _ = w.Write([]byte(`{}`))
|
|
return
|
|
|
|
case "/apis/login.concierge.walrus.tld/v1alpha1/tokencredentialrequests":
|
|
require.Equal(t, http.MethodPost, r.Method)
|
|
|
|
w.Header().Add("Content-Type", "application/json; charset=UTF-8")
|
|
_, _ = w.Write([]byte(`{}`))
|
|
return
|
|
|
|
case "/apis/not-concierge.walrus.tld/v1/tokencredentialrequests":
|
|
require.Equal(t, http.MethodGet, r.Method)
|
|
|
|
w.Header().Add("Content-Type", "application/json; charset=UTF-8")
|
|
_, _ = w.Write([]byte(`{"hello": "quack"}`))
|
|
return
|
|
|
|
case "/apis/not-concierge.walrus.tld/v1/ducks":
|
|
require.Equal(t, http.MethodGet, r.Method)
|
|
|
|
w.Header().Add("Content-Type", "application/json; charset=UTF-8")
|
|
_, _ = w.Write([]byte(`{"hello": "birds"}`))
|
|
return
|
|
|
|
default:
|
|
require.Fail(t, "fake Kube API server got an unexpected request", "path: %s", r.URL.Path)
|
|
return
|
|
}
|
|
}), tlsserver.RecordTLSHello)
|
|
|
|
// Create the client config that the impersonation server should use to talk to the Kube API server.
|
|
testKubeAPIServerKubeconfig := rest.Config{
|
|
Host: testKubeAPIServer.URL,
|
|
BearerToken: "some-service-account-token",
|
|
TLSClientConfig: rest.TLSClientConfig{CAData: tlsserver.TLSTestServerCA(testKubeAPIServer)},
|
|
BearerTokenFile: tt.kubeAPIServerClientBearerTokenFile,
|
|
}
|
|
clientOpts := []kubeclient.Option{kubeclient.WithConfig(&testKubeAPIServerKubeconfig)}
|
|
|
|
// Punch out just enough stuff to make New actually run without error.
|
|
recOpts := func(options *genericoptions.RecommendedOptions) {
|
|
options.Authentication.RemoteKubeConfigFileOptional = true
|
|
options.Authorization.RemoteKubeConfigFileOptional = true
|
|
options.Admission = nil
|
|
options.SecureServing.Listener = listener // use our listener with the dynamic port
|
|
}
|
|
|
|
recorder := &attributeRecorder{}
|
|
defer func() {
|
|
require.ElementsMatch(t, tt.wantAuthorizerAttributes, recorder.attributes)
|
|
require.Len(t, recorder.attributes, len(tt.wantAuthorizerAttributes))
|
|
}()
|
|
|
|
// Allow standard REST verbs to be authorized so that tests pass without invasive changes
|
|
recConfig := func(config *genericapiserver.RecommendedConfig) {
|
|
authz := config.Authorization.Authorizer.(*comparableAuthorizer)
|
|
delegate := authz.AuthorizerFunc
|
|
authz.AuthorizerFunc = func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
|
|
recorder.record(a)
|
|
switch a.GetVerb() {
|
|
case "create", "get", "list":
|
|
return authorizer.DecisionAllow, "standard verbs are allowed in tests", nil
|
|
default:
|
|
return delegate(ctx, a)
|
|
}
|
|
}
|
|
}
|
|
|
|
restConfigFunc := func(config *rest.Config) (kubernetes.Interface, *rest.Config, error) {
|
|
if config == nil {
|
|
config = &testKubeAPIServerKubeconfig
|
|
}
|
|
return kubeclient.Secure(config)
|
|
}
|
|
|
|
// Create an impersonator. Use an invalid port number to make sure our listener override works.
|
|
runner, constructionErr := newInternal(-1000, certKeyContent, caContent, restConfigFunc, clientOpts, recOpts, recConfig)
|
|
if len(tt.wantConstructionError) > 0 {
|
|
require.EqualError(t, constructionErr, tt.wantConstructionError)
|
|
require.Nil(t, runner)
|
|
// The rest of the test doesn't make sense when you expect a construction error, so stop here.
|
|
return
|
|
}
|
|
require.NoError(t, constructionErr)
|
|
require.NotNil(t, runner)
|
|
|
|
// Start the impersonator.
|
|
stopCh := make(chan struct{})
|
|
errCh := make(chan error)
|
|
go func() {
|
|
stopErr := runner(stopCh)
|
|
errCh <- stopErr
|
|
}()
|
|
|
|
// Create a kubeconfig to talk to the impersonator as a client.
|
|
clientKubeconfig := &rest.Config{
|
|
Host: "https://127.0.0.1:" + strconv.Itoa(port),
|
|
TLSClientConfig: rest.TLSClientConfig{
|
|
CAData: ca.Bundle(),
|
|
CertData: tt.clientCert.certPEM,
|
|
KeyData: tt.clientCert.keyPEM,
|
|
NextProtos: tt.clientNextProtos,
|
|
},
|
|
UserAgent: "test-agent",
|
|
// BearerToken should be ignored during auth when there are valid client certs,
|
|
// and it should not passed into the impersonator handler func as an authorization header.
|
|
BearerToken: "must-be-ignored",
|
|
Impersonate: tt.clientImpersonateUser,
|
|
WrapTransport: func(rt http.RoundTripper) http.RoundTripper {
|
|
if tt.clientMutateHeaders == nil {
|
|
return rt
|
|
}
|
|
|
|
return roundtripper.WrapFunc(rt, func(req *http.Request) (*http.Response, error) {
|
|
req = req.Clone(req.Context())
|
|
tt.clientMutateHeaders(req.Header)
|
|
return rt.RoundTrip(req)
|
|
})
|
|
},
|
|
}
|
|
|
|
// Create a real Kube client to make API requests to the impersonator.
|
|
client, err := kubeclient.New(kubeclient.WithConfig(clientKubeconfig))
|
|
require.NoError(t, err)
|
|
|
|
// The fake Kube API server knows how to to list namespaces, so make that request using the client
|
|
// through the impersonator.
|
|
listResponse, err := client.Kubernetes.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
|
|
if len(tt.wantError) > 0 {
|
|
require.EqualError(t, err, tt.wantError)
|
|
require.Equal(t, &corev1.NamespaceList{}, listResponse)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.Equal(t, &corev1.NamespaceList{
|
|
Items: []corev1.Namespace{
|
|
{ObjectMeta: metav1.ObjectMeta{Name: "namespace1"}},
|
|
{ObjectMeta: metav1.ObjectMeta{Name: "namespace2"}},
|
|
},
|
|
}, listResponse)
|
|
}
|
|
|
|
// If we expect to see some headers, then the fake KAS should have been called.
|
|
require.Equal(t, len(tt.wantKubeAPIServerRequestHeaders) != 0, testKubeAPIServerWasCalled)
|
|
// If the impersonator proxied the request to the fake Kube API server, we should see the headers
|
|
// of the original request mutated by the impersonator. Otherwise the headers should be nil.
|
|
require.Equal(t, tt.wantKubeAPIServerRequestHeaders, testKubeAPIServerSawHeaders)
|
|
|
|
// these authorization checks are caused by the anonymous auth checks below
|
|
tt.wantAuthorizerAttributes = append(tt.wantAuthorizerAttributes,
|
|
authorizer.AttributesRecord{
|
|
User: &user.DefaultInfo{Name: "system:anonymous", UID: "", Groups: []string{"system:unauthenticated"}, Extra: nil},
|
|
Verb: "create", Namespace: "", APIGroup: "login.concierge.pinniped.dev", APIVersion: "v1alpha1", Resource: "tokencredentialrequests", Subresource: "", Name: "", ResourceRequest: true, Path: "/apis/login.concierge.pinniped.dev/v1alpha1/tokencredentialrequests",
|
|
},
|
|
authorizer.AttributesRecord{
|
|
User: &user.DefaultInfo{Name: "system:anonymous", UID: "", Groups: []string{"system:unauthenticated"}, Extra: nil},
|
|
Verb: "create", Namespace: "", APIGroup: "login.concierge.walrus.tld", APIVersion: "v1alpha1", Resource: "tokencredentialrequests", Subresource: "", Name: "", ResourceRequest: true, Path: "/apis/login.concierge.walrus.tld/v1alpha1/tokencredentialrequests",
|
|
},
|
|
)
|
|
if !tt.anonymousAuthDisabled {
|
|
tt.wantAuthorizerAttributes = append(tt.wantAuthorizerAttributes,
|
|
authorizer.AttributesRecord{
|
|
User: &user.DefaultInfo{Name: "system:anonymous", UID: "", Groups: []string{"system:unauthenticated"}, Extra: nil},
|
|
Verb: "get", Namespace: "", APIGroup: "", APIVersion: "", Resource: "", Subresource: "", Name: "", ResourceRequest: false, Path: "/probe",
|
|
},
|
|
authorizer.AttributesRecord{
|
|
User: &user.DefaultInfo{Name: "system:anonymous", UID: "", Groups: []string{"system:unauthenticated"}, Extra: nil},
|
|
Verb: "list", Namespace: "", APIGroup: "not-concierge.walrus.tld", APIVersion: "v1", Resource: "tokencredentialrequests", Subresource: "", Name: "", ResourceRequest: true, Path: "/apis/not-concierge.walrus.tld/v1/tokencredentialrequests",
|
|
},
|
|
authorizer.AttributesRecord{
|
|
User: &user.DefaultInfo{Name: "system:anonymous", UID: "", Groups: []string{"system:unauthenticated"}, Extra: nil},
|
|
Verb: "list", Namespace: "", APIGroup: "not-concierge.walrus.tld", APIVersion: "v1", Resource: "ducks", Subresource: "", Name: "", ResourceRequest: true, Path: "/apis/not-concierge.walrus.tld/v1/ducks",
|
|
},
|
|
)
|
|
}
|
|
|
|
// anonymous TCR should always work
|
|
|
|
tcrRegGroup, err := kubeclient.New(kubeclient.WithConfig(kubeclient.SecureAnonymousClientConfig(clientKubeconfig)))
|
|
require.NoError(t, err)
|
|
|
|
tcrOtherGroup, err := kubeclient.New(kubeclient.WithConfig(kubeclient.SecureAnonymousClientConfig(clientKubeconfig)),
|
|
kubeclient.WithMiddleware(groupsuffix.New("walrus.tld")))
|
|
require.NoError(t, err)
|
|
|
|
_, errTCR := tcrRegGroup.PinnipedConcierge.LoginV1alpha1().TokenCredentialRequests().Create(ctx, &loginv1alpha1.TokenCredentialRequest{}, metav1.CreateOptions{})
|
|
require.NoError(t, errTCR)
|
|
|
|
_, errTCROtherGroup := tcrOtherGroup.PinnipedConcierge.LoginV1alpha1().TokenCredentialRequests().Create(ctx,
|
|
&loginv1alpha1.TokenCredentialRequest{
|
|
Spec: loginv1alpha1.TokenCredentialRequestSpec{
|
|
Authenticator: corev1.TypedLocalObjectReference{
|
|
APIGroup: pointer.String("anything.pinniped.dev"),
|
|
},
|
|
},
|
|
}, metav1.CreateOptions{})
|
|
require.NoError(t, errTCROtherGroup)
|
|
|
|
// these calls should only work when anonymous auth is enabled
|
|
|
|
anonymousConfig := kubeclient.SecureAnonymousClientConfig(clientKubeconfig)
|
|
anonymousConfig.GroupVersion = &schema.GroupVersion{
|
|
Group: "not-concierge.walrus.tld",
|
|
Version: "v1",
|
|
}
|
|
anonymousConfig.APIPath = "/apis"
|
|
anonymousConfig.NegotiatedSerializer = unstructuredscheme.NewUnstructuredNegotiatedSerializer()
|
|
rc, err := rest.RESTClientFor(anonymousConfig)
|
|
require.NoError(t, err)
|
|
|
|
probeBody, errProbe := rc.Get().AbsPath("/probe").DoRaw(ctx)
|
|
if tt.anonymousAuthDisabled {
|
|
require.True(t, errors.IsUnauthorized(errProbe), errProbe)
|
|
require.Equal(t, `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}`+"\n", string(probeBody))
|
|
} else {
|
|
require.NoError(t, errProbe)
|
|
require.Equal(t, "probed", string(probeBody))
|
|
}
|
|
|
|
notTCRBody, errNotTCR := rc.Get().Resource("tokencredentialrequests").DoRaw(ctx)
|
|
if tt.anonymousAuthDisabled {
|
|
require.True(t, errors.IsUnauthorized(errNotTCR), errNotTCR)
|
|
require.Equal(t, `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}`+"\n", string(notTCRBody))
|
|
} else {
|
|
require.NoError(t, errNotTCR)
|
|
require.Equal(t, `{"hello": "quack"}`, string(notTCRBody))
|
|
}
|
|
|
|
ducksBody, errDucks := rc.Get().Resource("ducks").DoRaw(ctx)
|
|
if tt.anonymousAuthDisabled {
|
|
require.True(t, errors.IsUnauthorized(errDucks), errDucks)
|
|
require.Equal(t, `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}`+"\n", string(ducksBody))
|
|
} else {
|
|
require.NoError(t, errDucks)
|
|
require.Equal(t, `{"hello": "birds"}`, string(ducksBody))
|
|
}
|
|
|
|
// this should always fail as unauthorized (even for TCR) because the cert is not valid
|
|
|
|
badCertConfig := kubeclient.SecureAnonymousClientConfig(clientKubeconfig)
|
|
badCert := newClientCert(t, unrelatedCA, "bad-user", []string{"bad-group"})
|
|
badCertConfig.TLSClientConfig.CertData = badCert.certPEM
|
|
badCertConfig.TLSClientConfig.KeyData = badCert.keyPEM
|
|
|
|
tcrBadCert, err := kubeclient.New(kubeclient.WithConfig(badCertConfig))
|
|
require.NoError(t, err)
|
|
|
|
_, errBadCert := tcrBadCert.PinnipedConcierge.LoginV1alpha1().TokenCredentialRequests().Create(ctx, &loginv1alpha1.TokenCredentialRequest{}, metav1.CreateOptions{})
|
|
require.True(t, errors.IsUnauthorized(errBadCert), errBadCert)
|
|
require.EqualError(t, errBadCert, "Unauthorized")
|
|
|
|
// Stop the impersonator server.
|
|
close(stopCh)
|
|
exitErr := <-errCh
|
|
require.NoError(t, exitErr)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestImpersonatorHTTPHandler(t *testing.T) {
|
|
const testUser = "test-user"
|
|
|
|
testGroups := []string{"test-group-1", "test-group-2"}
|
|
testExtra := map[string][]string{
|
|
"extra-1": {"some", "extra", "stuff"},
|
|
"extra-2": {"some", "more", "extra", "stuff"},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
restConfig *rest.Config
|
|
wantCreationErr string
|
|
request *http.Request
|
|
authenticator authenticator.Request
|
|
wantHTTPBody string
|
|
wantHTTPStatus int
|
|
wantKubeAPIServerRequestHeaders http.Header
|
|
kubeAPIServerStatusCode int
|
|
}{
|
|
{
|
|
name: "invalid kubeconfig host",
|
|
restConfig: &rest.Config{Host: ":"},
|
|
wantCreationErr: "could not parse host URL from in-cluster config: parse \":\": missing protocol scheme",
|
|
},
|
|
{
|
|
name: "invalid transport config",
|
|
restConfig: &rest.Config{
|
|
Host: "pinniped.dev/blah",
|
|
ExecProvider: &api.ExecConfig{},
|
|
AuthProvider: &api.AuthProviderConfig{},
|
|
},
|
|
wantCreationErr: "could not create secure client config: execProvider and authProvider cannot be used in combination",
|
|
},
|
|
{
|
|
name: "fail to get transport from config",
|
|
restConfig: &rest.Config{
|
|
Host: "pinniped.dev/blah",
|
|
BearerToken: "test-bearer-token",
|
|
Transport: http.DefaultTransport,
|
|
TLSClientConfig: rest.TLSClientConfig{Insecure: true},
|
|
},
|
|
wantCreationErr: "could not create secure client config: failed to build transport: using a custom transport with TLS certificate options or the insecure flag is not allowed",
|
|
},
|
|
{
|
|
name: "Impersonate-User header already in request",
|
|
request: newRequest(t, map[string][]string{"Impersonate-User": {"some-user"}}, nil, nil, ""),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: invalid impersonation","reason":"InternalError","details":{"causes":[{"message":"invalid impersonation"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "Impersonate-Group header already in request",
|
|
request: newRequest(t, map[string][]string{"Impersonate-Group": {"some-group"}}, nil, nil, ""),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: invalid impersonation","reason":"InternalError","details":{"causes":[{"message":"invalid impersonation"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "Impersonate-Extra header already in request",
|
|
request: newRequest(t, map[string][]string{"Impersonate-Extra-something": {"something"}}, nil, nil, ""),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: invalid impersonation","reason":"InternalError","details":{"causes":[{"message":"invalid impersonation"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "Impersonate-* header already in request",
|
|
request: newRequest(t, map[string][]string{"Impersonate-Something": {"some-newfangled-impersonate-header"}}, nil, nil, ""),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: invalid impersonation","reason":"InternalError","details":{"causes":[{"message":"invalid impersonation"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "unexpected authorization header",
|
|
request: newRequest(t, map[string][]string{"Authorization": {"panda"}}, nil, nil, ""),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: invalid authorization header","reason":"InternalError","details":{"causes":[{"message":"invalid authorization header"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "missing user",
|
|
request: newRequest(t, map[string][]string{}, nil, nil, ""),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: invalid user","reason":"InternalError","details":{"causes":[{"message":"invalid user"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "unexpected UID",
|
|
request: newRequest(t, map[string][]string{}, &user.DefaultInfo{UID: "007"}, nil, ""),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "authenticated user but missing audit event",
|
|
request: func() *http.Request {
|
|
req := newRequest(t, map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
}, &user.DefaultInfo{
|
|
Name: testUser,
|
|
Groups: testGroups,
|
|
Extra: testExtra,
|
|
}, nil, "")
|
|
ctx := audit.WithAuditContext(req.Context(), nil)
|
|
req = req.WithContext(ctx)
|
|
return req
|
|
}(),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: invalid audit event","reason":"InternalError","details":{"causes":[{"message":"invalid audit event"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "authenticated user with upper case extra",
|
|
request: newRequest(t, map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
}, &user.DefaultInfo{
|
|
Name: testUser,
|
|
Groups: testGroups,
|
|
Extra: map[string][]string{
|
|
"valid-key": {"valid-value"},
|
|
"Invalid-key": {"still-valid-value"},
|
|
},
|
|
}, nil, ""),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "authenticated user with upper case extra across multiple lines",
|
|
request: newRequest(t, map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
}, &user.DefaultInfo{
|
|
Name: testUser,
|
|
Groups: testGroups,
|
|
Extra: map[string][]string{
|
|
"valid-key": {"valid-value"},
|
|
"valid-data\nInvalid-key": {"still-valid-value"},
|
|
},
|
|
}, nil, ""),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "authenticated user with reserved extra key",
|
|
request: newRequest(t, map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
}, &user.DefaultInfo{
|
|
Name: testUser,
|
|
Groups: testGroups,
|
|
Extra: map[string][]string{
|
|
"valid-key": {"valid-value"},
|
|
"foo.impersonation-proxy.concierge.pinniped.dev": {"still-valid-value"},
|
|
},
|
|
}, nil, ""),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "authenticated user with UID but no bearer token",
|
|
request: newRequest(t, map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
}, &user.DefaultInfo{
|
|
UID: "-", // anything non-empty, rest of the fields get ignored in this code path
|
|
},
|
|
&auditinternal.Event{
|
|
User: authenticationv1.UserInfo{
|
|
Username: testUser,
|
|
UID: "fancy-uid",
|
|
Groups: testGroups,
|
|
Extra: map[string]authenticationv1.ExtraValue{
|
|
"extra-1": {"some", "extra", "stuff"},
|
|
"extra-2": {"some", "more", "extra", "stuff"},
|
|
},
|
|
},
|
|
ImpersonatedUser: nil,
|
|
},
|
|
"",
|
|
),
|
|
authenticator: nil,
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "authenticated user with UID and bearer token and nested impersonation",
|
|
request: newRequest(t, map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
}, &user.DefaultInfo{
|
|
UID: "-", // anything non-empty, rest of the fields get ignored in this code path
|
|
},
|
|
&auditinternal.Event{
|
|
User: authenticationv1.UserInfo{
|
|
Username: "dude",
|
|
UID: "--1--",
|
|
Groups: []string{"--a--", "--b--"},
|
|
Extra: map[string]authenticationv1.ExtraValue{
|
|
"--c--": {"--d--"},
|
|
"--e--": {"--f--"},
|
|
},
|
|
},
|
|
ImpersonatedUser: &authenticationv1.UserInfo{},
|
|
},
|
|
"token-from-user-nested",
|
|
),
|
|
authenticator: nil,
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "authenticated user with UID and bearer token results in error",
|
|
request: newRequest(t, map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
}, &user.DefaultInfo{
|
|
UID: "-", // anything non-empty, rest of the fields get ignored in this code path
|
|
},
|
|
&auditinternal.Event{
|
|
User: authenticationv1.UserInfo{
|
|
Username: "dude",
|
|
UID: "--1--",
|
|
Groups: []string{"--a--", "--b--"},
|
|
Extra: map[string]authenticationv1.ExtraValue{
|
|
"--c--": {"--d--"},
|
|
"--e--": {"--f--"},
|
|
},
|
|
},
|
|
ImpersonatedUser: nil,
|
|
},
|
|
"some-non-empty-token",
|
|
),
|
|
authenticator: testTokenAuthenticator(t, "", nil, constable.Error("some err")),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "authenticated user with UID and bearer token does not authenticate",
|
|
request: newRequest(t, map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
}, &user.DefaultInfo{
|
|
UID: "-", // anything non-empty, rest of the fields get ignored in this code path
|
|
},
|
|
&auditinternal.Event{
|
|
User: authenticationv1.UserInfo{
|
|
Username: "dude",
|
|
UID: "--1--",
|
|
Groups: []string{"--a--", "--b--"},
|
|
Extra: map[string]authenticationv1.ExtraValue{
|
|
"--c--": {"--d--"},
|
|
"--e--": {"--f--"},
|
|
},
|
|
},
|
|
ImpersonatedUser: nil,
|
|
},
|
|
"this-token-does-not-work",
|
|
),
|
|
authenticator: testTokenAuthenticator(t, "some-other-token-works", nil, nil),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "authenticated user with UID and bearer token authenticates as different user",
|
|
request: newRequest(t, map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
}, &user.DefaultInfo{
|
|
UID: "-", // anything non-empty, rest of the fields get ignored in this code path
|
|
},
|
|
&auditinternal.Event{
|
|
User: authenticationv1.UserInfo{
|
|
Username: "dude",
|
|
UID: "--1--",
|
|
Groups: []string{"--a--", "--b--"},
|
|
Extra: map[string]authenticationv1.ExtraValue{
|
|
"--c--": {"--d--"},
|
|
"--e--": {"--f--"},
|
|
},
|
|
},
|
|
ImpersonatedUser: nil,
|
|
},
|
|
"this-token-does-work",
|
|
),
|
|
authenticator: testTokenAuthenticator(t, "this-token-does-work", &user.DefaultInfo{Name: "someone-else"}, nil),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
// happy path
|
|
{
|
|
name: "authenticated user",
|
|
request: newRequest(t, map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"}, // the value "Upgrade" is handled in a special way by `httputil.NewSingleHostReverseProxy`
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"}, // this header will be passed through
|
|
}, &user.DefaultInfo{
|
|
Name: testUser,
|
|
Groups: testGroups,
|
|
Extra: testExtra,
|
|
}, nil, ""),
|
|
wantKubeAPIServerRequestHeaders: map[string][]string{
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"Impersonate-Extra-Extra-1": {"some", "extra", "stuff"},
|
|
"Impersonate-Extra-Extra-2": {"some", "more", "extra", "stuff"},
|
|
"Impersonate-Group": {"test-group-1", "test-group-2"},
|
|
"Impersonate-User": {"test-user"},
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
},
|
|
wantHTTPBody: "successful proxied response",
|
|
wantHTTPStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "authenticated user with UID and bearer token",
|
|
request: newRequest(t, map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
}, &user.DefaultInfo{
|
|
UID: "-", // anything non-empty, rest of the fields get ignored in this code path
|
|
},
|
|
&auditinternal.Event{
|
|
User: authenticationv1.UserInfo{
|
|
Username: testUser,
|
|
UID: "fancy-uid",
|
|
Groups: testGroups,
|
|
Extra: map[string]authenticationv1.ExtraValue{
|
|
"extra-1": {"some", "extra", "stuff"},
|
|
"extra-2": {"some", "more", "extra", "stuff"},
|
|
},
|
|
},
|
|
ImpersonatedUser: nil,
|
|
},
|
|
"token-from-user",
|
|
),
|
|
authenticator: testTokenAuthenticator(
|
|
t,
|
|
"token-from-user",
|
|
&user.DefaultInfo{
|
|
Name: testUser,
|
|
UID: "fancy-uid",
|
|
Groups: testGroups,
|
|
Extra: testExtra,
|
|
},
|
|
nil,
|
|
),
|
|
wantKubeAPIServerRequestHeaders: map[string][]string{
|
|
"Authorization": {"Bearer token-from-user"},
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
},
|
|
wantHTTPBody: "successful proxied response",
|
|
wantHTTPStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "authenticated gke user",
|
|
request: newRequest(t, map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"}, // the value "Upgrade" is handled in a special way by `httputil.NewSingleHostReverseProxy`
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"}, // this header will be passed through
|
|
}, &user.DefaultInfo{
|
|
Name: "username@company.com",
|
|
Groups: []string{"system:authenticated"},
|
|
Extra: map[string][]string{
|
|
// make sure we can handle these keys
|
|
"iam.gke.io/user-assertion": {"ABC"},
|
|
"user-assertion.cloud.google.com": {"XYZ"},
|
|
},
|
|
}, nil, ""),
|
|
wantKubeAPIServerRequestHeaders: map[string][]string{
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"Impersonate-Extra-Iam.gke.io%2fuser-Assertion": {"ABC"},
|
|
"Impersonate-Extra-User-Assertion.cloud.google.com": {"XYZ"},
|
|
"Impersonate-Group": {"system:authenticated"},
|
|
"Impersonate-User": {"username@company.com"},
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
},
|
|
wantHTTPBody: "successful proxied response",
|
|
wantHTTPStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "authenticated openshift/openstack user",
|
|
request: newRequest(t, map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"}, // the value "Upgrade" is handled in a special way by `httputil.NewSingleHostReverseProxy`
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"}, // this header will be passed through
|
|
}, &user.DefaultInfo{
|
|
Name: "kube:admin",
|
|
// both of these auth stacks set UID but we cannot handle it today
|
|
// UID: "user-id",
|
|
Groups: []string{"system:cluster-admins", "system:authenticated"},
|
|
Extra: map[string][]string{
|
|
// openshift
|
|
"scopes.authorization.openshift.io": {"user:info", "user:full"},
|
|
|
|
// openstack
|
|
"alpha.kubernetes.io/identity/roles": {"role1", "role2"},
|
|
"alpha.kubernetes.io/identity/project/id": {"project-id"},
|
|
"alpha.kubernetes.io/identity/project/name": {"project-name"},
|
|
"alpha.kubernetes.io/identity/user/domain/id": {"domain-id"},
|
|
"alpha.kubernetes.io/identity/user/domain/name": {"domain-name"},
|
|
},
|
|
}, nil, ""),
|
|
wantKubeAPIServerRequestHeaders: map[string][]string{
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"Impersonate-Extra-Scopes.authorization.openshift.io": {"user:info", "user:full"},
|
|
"Impersonate-Extra-Alpha.kubernetes.io%2fidentity%2froles": {"role1", "role2"},
|
|
"Impersonate-Extra-Alpha.kubernetes.io%2fidentity%2fproject%2fid": {"project-id"},
|
|
"Impersonate-Extra-Alpha.kubernetes.io%2fidentity%2fproject%2fname": {"project-name"},
|
|
"Impersonate-Extra-Alpha.kubernetes.io%2fidentity%2fuser%2fdomain%2fid": {"domain-id"},
|
|
"Impersonate-Extra-Alpha.kubernetes.io%2fidentity%2fuser%2fdomain%2fname": {"domain-name"},
|
|
"Impersonate-Group": {"system:cluster-admins", "system:authenticated"},
|
|
"Impersonate-User": {"kube:admin"},
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
},
|
|
wantHTTPBody: "successful proxied response",
|
|
wantHTTPStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "authenticated user with almost reserved key",
|
|
request: newRequest(t, map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"}, // the value "Upgrade" is handled in a special way by `httputil.NewSingleHostReverseProxy`
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"}, // this header will be passed through
|
|
}, &user.DefaultInfo{
|
|
Name: "username@company.com",
|
|
Groups: []string{"system:authenticated"},
|
|
Extra: map[string][]string{
|
|
"foo.iimpersonation-proxy.concierge.pinniped.dev": {"still-valid-value"},
|
|
},
|
|
}, nil, ""),
|
|
wantKubeAPIServerRequestHeaders: map[string][]string{
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"Impersonate-Extra-Foo.iimpersonation-Proxy.concierge.pinniped.dev": {"still-valid-value"},
|
|
"Impersonate-Group": {"system:authenticated"},
|
|
"Impersonate-User": {"username@company.com"},
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
},
|
|
wantHTTPBody: "successful proxied response",
|
|
wantHTTPStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "authenticated user with almost reserved key and nested impersonation",
|
|
request: newRequest(t, map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"}, // the value "Upgrade" is handled in a special way by `httputil.NewSingleHostReverseProxy`
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"}, // this header will be passed through
|
|
}, &user.DefaultInfo{
|
|
Name: "username@company.com",
|
|
Groups: []string{"system:authenticated"},
|
|
Extra: map[string][]string{
|
|
"original-user-info.impersonation-proxyy.concierge.pinniped.dev": {"log confusion stuff here"},
|
|
},
|
|
},
|
|
&auditinternal.Event{
|
|
User: authenticationv1.UserInfo{
|
|
Username: "panda",
|
|
UID: "0x001",
|
|
Groups: []string{"bears", "friends"},
|
|
Extra: map[string]authenticationv1.ExtraValue{
|
|
"original-user-info.impersonation-proxy.concierge.pinniped.dev": {"this is allowed"},
|
|
},
|
|
},
|
|
ImpersonatedUser: &authenticationv1.UserInfo{},
|
|
},
|
|
"",
|
|
),
|
|
wantKubeAPIServerRequestHeaders: map[string][]string{
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"Impersonate-Extra-Original-User-Info.impersonation-Proxyy.concierge.pinniped.dev": {"log confusion stuff here"},
|
|
"Impersonate-Extra-Original-User-Info.impersonation-Proxy.concierge.pinniped.dev": {`{"username":"panda","uid":"0x001","groups":["bears","friends"],"extra":{"original-user-info.impersonation-proxy.concierge.pinniped.dev":["this is allowed"]}}`},
|
|
"Impersonate-Group": {"system:authenticated"},
|
|
"Impersonate-User": {"username@company.com"},
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
},
|
|
wantHTTPBody: "successful proxied response",
|
|
wantHTTPStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "authenticated user with nested impersonation",
|
|
request: newRequest(t, map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"}, // the value "Upgrade" is handled in a special way by `httputil.NewSingleHostReverseProxy`
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"}, // this header will be passed through
|
|
}, &user.DefaultInfo{
|
|
Name: testUser,
|
|
Groups: testGroups,
|
|
Extra: testExtra,
|
|
},
|
|
&auditinternal.Event{
|
|
User: authenticationv1.UserInfo{
|
|
Username: "panda",
|
|
UID: "0x001",
|
|
Groups: []string{"bears", "friends"},
|
|
Extra: map[string]authenticationv1.ExtraValue{
|
|
"assertion": {"sha", "md5"},
|
|
"req-id": {"0123"},
|
|
},
|
|
},
|
|
ImpersonatedUser: &authenticationv1.UserInfo{},
|
|
},
|
|
"",
|
|
),
|
|
wantKubeAPIServerRequestHeaders: map[string][]string{
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"Impersonate-Extra-Extra-1": {"some", "extra", "stuff"},
|
|
"Impersonate-Extra-Extra-2": {"some", "more", "extra", "stuff"},
|
|
"Impersonate-Group": {"test-group-1", "test-group-2"},
|
|
"Impersonate-User": {"test-user"},
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
"Impersonate-Extra-Original-User-Info.impersonation-Proxy.concierge.pinniped.dev": {`{"username":"panda","uid":"0x001","groups":["bears","friends"],"extra":{"assertion":["sha","md5"],"req-id":["0123"]}}`},
|
|
},
|
|
wantHTTPBody: "successful proxied response",
|
|
wantHTTPStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "authenticated gke user with nested impersonation",
|
|
request: newRequest(t, map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"}, // the value "Upgrade" is handled in a special way by `httputil.NewSingleHostReverseProxy`
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"}, // this header will be passed through
|
|
}, &user.DefaultInfo{
|
|
Name: testUser,
|
|
Groups: testGroups,
|
|
Extra: testExtra,
|
|
},
|
|
&auditinternal.Event{
|
|
User: authenticationv1.UserInfo{
|
|
Username: "username@company.com",
|
|
Groups: []string{"system:authenticated"},
|
|
Extra: map[string]authenticationv1.ExtraValue{
|
|
// make sure we can handle these keys
|
|
"iam.gke.io/user-assertion": {"ABC"},
|
|
"user-assertion.cloud.google.com": {"999"},
|
|
},
|
|
},
|
|
ImpersonatedUser: &authenticationv1.UserInfo{},
|
|
},
|
|
"",
|
|
),
|
|
wantKubeAPIServerRequestHeaders: map[string][]string{
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"Impersonate-Extra-Extra-1": {"some", "extra", "stuff"},
|
|
"Impersonate-Extra-Extra-2": {"some", "more", "extra", "stuff"},
|
|
"Impersonate-Group": {"test-group-1", "test-group-2"},
|
|
"Impersonate-User": {"test-user"},
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
"Impersonate-Extra-Original-User-Info.impersonation-Proxy.concierge.pinniped.dev": {`{"username":"username@company.com","groups":["system:authenticated"],"extra":{"iam.gke.io/user-assertion":["ABC"],"user-assertion.cloud.google.com":["999"]}}`},
|
|
},
|
|
wantHTTPBody: "successful proxied response",
|
|
wantHTTPStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "authenticated user with nested impersonation of gke user",
|
|
request: newRequest(t, map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"}, // the value "Upgrade" is handled in a special way by `httputil.NewSingleHostReverseProxy`
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"}, // this header will be passed through
|
|
}, &user.DefaultInfo{
|
|
Name: "username@company.com",
|
|
Groups: []string{"system:authenticated"},
|
|
Extra: map[string][]string{
|
|
// make sure we can handle these keys
|
|
"iam.gke.io/user-assertion": {"DEF"},
|
|
"user-assertion.cloud.google.com": {"XYZ"},
|
|
},
|
|
},
|
|
&auditinternal.Event{
|
|
User: authenticationv1.UserInfo{
|
|
Username: "panda",
|
|
UID: "0x001",
|
|
Groups: []string{"bears", "friends"},
|
|
Extra: map[string]authenticationv1.ExtraValue{
|
|
"assertion": {"sha", "md5"},
|
|
"req-id": {"0123"},
|
|
},
|
|
},
|
|
ImpersonatedUser: &authenticationv1.UserInfo{},
|
|
},
|
|
"",
|
|
),
|
|
wantKubeAPIServerRequestHeaders: map[string][]string{
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"Impersonate-Extra-Iam.gke.io%2fuser-Assertion": {"DEF"},
|
|
"Impersonate-Extra-User-Assertion.cloud.google.com": {"XYZ"},
|
|
"Impersonate-Group": {"system:authenticated"},
|
|
"Impersonate-User": {"username@company.com"},
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
"Impersonate-Extra-Original-User-Info.impersonation-Proxy.concierge.pinniped.dev": {`{"username":"panda","uid":"0x001","groups":["bears","friends"],"extra":{"assertion":["sha","md5"],"req-id":["0123"]}}`},
|
|
},
|
|
wantHTTPBody: "successful proxied response",
|
|
wantHTTPStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "user is authenticated but the kube API request returns an error",
|
|
request: newRequest(t, map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
}, &user.DefaultInfo{
|
|
Name: testUser,
|
|
Groups: testGroups,
|
|
Extra: testExtra,
|
|
}, nil, ""),
|
|
kubeAPIServerStatusCode: http.StatusNotFound,
|
|
wantKubeAPIServerRequestHeaders: map[string][]string{
|
|
"Accept-Encoding": {"gzip"}, // because the rest client used in this test does not disable compression
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"Impersonate-Extra-Extra-1": {"some", "extra", "stuff"},
|
|
"Impersonate-Extra-Extra-2": {"some", "more", "extra", "stuff"},
|
|
"Impersonate-Group": {"test-group-1", "test-group-2"},
|
|
"Impersonate-User": {"test-user"},
|
|
"User-Agent": {"test-user-agent"},
|
|
},
|
|
wantHTTPStatus: http.StatusNotFound,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if tt.kubeAPIServerStatusCode == 0 {
|
|
tt.kubeAPIServerStatusCode = http.StatusOK
|
|
}
|
|
|
|
testKubeAPIServerWasCalled := false
|
|
testKubeAPIServerSawHeaders := http.Header{}
|
|
testKubeAPIServer := tlsserver.TLSTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
tlsConfigFunc := func(rootCAs *x509.CertPool) *tls.Config {
|
|
// Requests to get configmaps, flowcontrol requests, and healthz requests
|
|
// are not done by our http round trippers that specify only one protocol
|
|
// (either http1.1 or http2, not both).
|
|
// For all other requests from the impersonator, if it is not an upgrade
|
|
// request it should only be using http2.
|
|
// If it is an upgrade request it should only be using http1.1, but that
|
|
// is covered by the AssertTLS function.
|
|
secure := ptls.Secure(rootCAs)
|
|
switch r.URL.Path {
|
|
case "/api/v1/namespaces/kube-system/configmaps",
|
|
"/apis/flowcontrol.apiserver.k8s.io/v1beta2/prioritylevelconfigurations",
|
|
"/apis/flowcontrol.apiserver.k8s.io/v1beta2/flowschemas",
|
|
"/healthz":
|
|
default:
|
|
if !httpstream.IsUpgradeRequest(r) {
|
|
secure.NextProtos = []string{secure.NextProtos[0]}
|
|
}
|
|
}
|
|
return secure
|
|
}
|
|
tlsserver.AssertTLS(t, r, tlsConfigFunc)
|
|
|
|
testKubeAPIServerWasCalled = true
|
|
testKubeAPIServerSawHeaders = r.Header
|
|
if tt.kubeAPIServerStatusCode != http.StatusOK {
|
|
w.WriteHeader(tt.kubeAPIServerStatusCode)
|
|
} else {
|
|
_, _ = w.Write([]byte("successful proxied response"))
|
|
}
|
|
}), tlsserver.RecordTLSHello)
|
|
|
|
testKubeAPIServerKubeconfig := rest.Config{
|
|
Host: testKubeAPIServer.URL,
|
|
BearerToken: "some-service-account-token",
|
|
TLSClientConfig: rest.TLSClientConfig{CAData: tlsserver.TLSTestServerCA(testKubeAPIServer)},
|
|
}
|
|
if tt.restConfig == nil {
|
|
tt.restConfig = &testKubeAPIServerKubeconfig
|
|
}
|
|
|
|
// mimic how newInternal would call newImpersonationReverseProxyFunc
|
|
impersonatorHTTPHandlerFunc, err := func() (func(*genericapiserver.Config) http.Handler, error) {
|
|
kubeClientForProxy, err := kubeclient.New(kubeclient.WithConfig(tt.restConfig))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return newImpersonationReverseProxyFunc(rest.CopyConfig(kubeClientForProxy.ProtoConfig))
|
|
}()
|
|
|
|
if tt.wantCreationErr != "" {
|
|
require.EqualError(t, err, tt.wantCreationErr)
|
|
require.Nil(t, impersonatorHTTPHandlerFunc)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
require.NotNil(t, impersonatorHTTPHandlerFunc)
|
|
|
|
// this is not a valid way to get a server config, but it is good enough for a unit test
|
|
scheme := runtime.NewScheme()
|
|
metav1.AddToGroupVersion(scheme, metav1.Unversioned)
|
|
codecs := serializer.NewCodecFactory(scheme)
|
|
serverConfig := genericapiserver.NewRecommendedConfig(codecs)
|
|
serverConfig.Authentication.Authenticator = tt.authenticator
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
r := tt.request
|
|
wantKubeAPIServerRequestHeaders := tt.wantKubeAPIServerRequestHeaders
|
|
|
|
// take the isUpgradeRequest branch randomly to make sure we exercise both branches
|
|
forceUpgradeRequest := rand.Int()%2 == 0 //nolint:gosec // we do not care if this is cryptographically secure
|
|
if forceUpgradeRequest && len(r.Header.Get("Upgrade")) == 0 {
|
|
r = r.Clone(r.Context())
|
|
r.Header.Add("Connection", "Upgrade")
|
|
r.Header.Add("Upgrade", "spdy/3.1")
|
|
|
|
wantKubeAPIServerRequestHeaders = wantKubeAPIServerRequestHeaders.Clone()
|
|
if wantKubeAPIServerRequestHeaders == nil {
|
|
wantKubeAPIServerRequestHeaders = http.Header{}
|
|
}
|
|
wantKubeAPIServerRequestHeaders.Add("Connection", "Upgrade")
|
|
wantKubeAPIServerRequestHeaders.Add("Upgrade", "spdy/3.1")
|
|
}
|
|
|
|
requestBeforeServe := r.Clone(r.Context())
|
|
impersonatorHTTPHandlerFunc(&serverConfig.Config).ServeHTTP(w, r)
|
|
|
|
require.Equal(t, requestBeforeServe, r, "ServeHTTP() mutated the request, and it should not per http.Handler docs")
|
|
if tt.wantHTTPStatus != 0 {
|
|
require.Equalf(t, tt.wantHTTPStatus, w.Code, "fyi, response body was %q", w.Body.String())
|
|
}
|
|
if tt.wantHTTPBody != "" {
|
|
require.Equal(t, tt.wantHTTPBody, w.Body.String())
|
|
}
|
|
|
|
if tt.wantHTTPStatus == http.StatusOK || tt.kubeAPIServerStatusCode != http.StatusOK {
|
|
require.True(t, testKubeAPIServerWasCalled, "Should have proxied the request to the Kube API server, but didn't")
|
|
require.Equal(t, wantKubeAPIServerRequestHeaders, testKubeAPIServerSawHeaders)
|
|
} else {
|
|
require.False(t, testKubeAPIServerWasCalled, "Should not have proxied the request to the Kube API server, but did")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func newRequest(t *testing.T, h http.Header, userInfo user.Info, event *auditinternal.Event, token string) *http.Request {
|
|
t.Helper()
|
|
|
|
validURL, err := url.Parse("http://pinniped.dev/blah")
|
|
require.NoError(t, err)
|
|
|
|
ctx := context.Background()
|
|
|
|
if userInfo != nil {
|
|
ctx = request.WithUser(ctx, userInfo)
|
|
}
|
|
|
|
ae := &auditinternal.Event{Level: auditinternal.LevelMetadata}
|
|
if event != nil {
|
|
ae = event
|
|
}
|
|
ctx = audit.WithAuditContext(ctx, &audit.AuditContext{Event: ae})
|
|
|
|
reqInfo := &request.RequestInfo{
|
|
IsResourceRequest: false,
|
|
Path: validURL.Path,
|
|
Verb: "get",
|
|
}
|
|
ctx = request.WithRequestInfo(ctx, reqInfo)
|
|
|
|
ctx = authenticator.WithAudiences(ctx, authenticator.Audiences{"must-be-ignored"})
|
|
|
|
if len(token) != 0 {
|
|
ctx = context.WithValue(ctx, tokenKey, token)
|
|
}
|
|
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithDeadline(ctx, time.Now().Add(time.Hour))
|
|
t.Cleanup(cancel)
|
|
|
|
r, err := http.NewRequestWithContext(ctx, http.MethodGet, validURL.String(), nil)
|
|
require.NoError(t, err)
|
|
|
|
r.Header = h
|
|
|
|
return r
|
|
}
|
|
|
|
func testTokenAuthenticator(t *testing.T, token string, userInfo user.Info, err error) authenticator.Request {
|
|
t.Helper()
|
|
|
|
return authenticator.RequestFunc(func(r *http.Request) (*authenticator.Response, bool, error) {
|
|
if auds, ok := authenticator.AudiencesFrom(r.Context()); ok || len(auds) != 0 {
|
|
t.Errorf("unexpected audiences on request: %v", auds)
|
|
}
|
|
|
|
if ctxToken := tokenFrom(r.Context()); len(ctxToken) != 0 {
|
|
t.Errorf("unexpected token on request: %v", ctxToken)
|
|
}
|
|
|
|
if _, ok := r.Context().Deadline(); !ok {
|
|
t.Error("request should always have deadline")
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
var reqToken string
|
|
_, _, _ = bearertoken.New(authenticator.TokenFunc(func(_ context.Context, token string) (*authenticator.Response, bool, error) {
|
|
reqToken = token
|
|
return nil, false, nil
|
|
})).AuthenticateRequest(r)
|
|
|
|
if reqToken != token {
|
|
return nil, false, nil
|
|
}
|
|
|
|
return &authenticator.Response{User: userInfo}, true, nil
|
|
})
|
|
}
|
|
|
|
type clientCert struct {
|
|
certPEM, keyPEM []byte
|
|
}
|
|
|
|
func newClientCert(t *testing.T, ca *certauthority.CA, username string, groups []string) *clientCert {
|
|
t.Helper()
|
|
certPEM, keyPEM, err := ca.IssueClientCertPEM(username, groups, time.Hour)
|
|
require.NoError(t, err)
|
|
return &clientCert{
|
|
certPEM: certPEM,
|
|
keyPEM: keyPEM,
|
|
}
|
|
}
|
|
|
|
func requireCanBindToPort(t *testing.T, port int) {
|
|
t.Helper()
|
|
ln, _, listenErr := genericoptions.CreateListener("", "0.0.0.0:"+strconv.Itoa(port), net.ListenConfig{})
|
|
require.NoError(t, listenErr)
|
|
require.NoError(t, ln.Close())
|
|
}
|
|
|
|
func Test_withBearerTokenPreservation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
headers http.Header
|
|
want string
|
|
}{
|
|
{
|
|
name: "has bearer token",
|
|
headers: map[string][]string{
|
|
"Authorization": {"Bearer thingy"},
|
|
},
|
|
want: "thingy",
|
|
},
|
|
{
|
|
name: "has bearer token but too many preceding spaces",
|
|
headers: map[string][]string{
|
|
"Authorization": {"Bearer 1"},
|
|
},
|
|
want: "",
|
|
},
|
|
{
|
|
name: "has bearer token with space, only keeps first part",
|
|
headers: map[string][]string{
|
|
"Authorization": {"Bearer panda man"},
|
|
},
|
|
want: "panda",
|
|
},
|
|
{
|
|
name: "has bearer token with surrounding whitespace",
|
|
headers: map[string][]string{
|
|
"Authorization": {" Bearer cool beans "},
|
|
},
|
|
want: "cool",
|
|
},
|
|
{
|
|
name: "has multiple bearer tokens",
|
|
headers: map[string][]string{
|
|
"Authorization": {"Bearer this thing", "what does this mean?"},
|
|
},
|
|
want: "this",
|
|
},
|
|
{
|
|
name: "no bearer token",
|
|
headers: map[string][]string{
|
|
"Not-Authorization": {"Bearer not a token"},
|
|
},
|
|
want: "",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
inputReq := (&http.Request{Header: tt.headers}).WithContext(context.Background())
|
|
inputReqCopy := inputReq.Clone(inputReq.Context())
|
|
|
|
var called bool
|
|
delegate := http.HandlerFunc(func(w http.ResponseWriter, outputReq *http.Request) {
|
|
called = true
|
|
require.Nil(t, w)
|
|
|
|
// assert only context is mutated
|
|
outputReqCopy := outputReq.Clone(inputReq.Context())
|
|
require.Equal(t, inputReqCopy, outputReqCopy)
|
|
|
|
require.Equal(t, tt.want, tokenFrom(outputReq.Context()))
|
|
|
|
if len(tt.want) == 0 {
|
|
require.True(t, inputReq == outputReq, "expect req to passed through when no token expected")
|
|
}
|
|
})
|
|
|
|
withBearerTokenPreservation(delegate).ServeHTTP(nil, inputReq)
|
|
require.Equal(t, inputReqCopy, inputReq) // assert no mutation occurred
|
|
require.True(t, called)
|
|
})
|
|
}
|
|
}
|
|
|
|
type attributeRecorder struct {
|
|
lock sync.Mutex
|
|
attributes []authorizer.AttributesRecord
|
|
}
|
|
|
|
func (r *attributeRecorder) record(attributes authorizer.Attributes) {
|
|
r.lock.Lock()
|
|
defer r.lock.Unlock()
|
|
r.attributes = append(r.attributes, *attributes.(*authorizer.AttributesRecord))
|
|
}
|