mirror of
https://github.com/vmware-tanzu/pinniped.git
synced 2026-01-07 14:05:50 +00:00
impersonation proxy: add nested impersonation support
This change updates the impersonator logic to use the delegated authorizer for all non-rest verbs such as impersonate. This allows it to correctly perform authorization checks for incoming requests that set impersonation headers while not performing unnecessary checks that are already handled by KAS. The audit layer is enabled to track the original user who made the request. This information is then included in a reserved extra field original-user-info.impersonation-proxy.concierge.pinniped.dev as a JSON blob. Signed-off-by: Monis Khan <mok@vmware.com>
This commit is contained in:
42
internal/concierge/impersonator/doc.go
Normal file
42
internal/concierge/impersonator/doc.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/*
|
||||
Package impersonator implements an HTTP server that reverse proxies all requests
|
||||
to the Kubernetes API server with impersonation headers set to match the calling
|
||||
user. Since impersonation cannot be disabled, this allows us to dynamically
|
||||
configure authentication on any cluster, even the cloud hosted ones.
|
||||
|
||||
The specifics of how it is implemented are of interest. The most novel detail
|
||||
about the implementation is that we use the "front-end" of the aggregated API
|
||||
server logic, mainly the DefaultBuildHandlerChain func, to handle how incoming
|
||||
requests are authenticated, authorized, etc. The "back-end" of the proxy is a
|
||||
reverse proxy that impersonates the user (instead of serving REST APIs).
|
||||
|
||||
In terms of authentication, we aim to handle every type of authentication that
|
||||
the Kubernetes API server supports by delegating most of the checks to it. We
|
||||
also honor client certs from a CA that is specific to the impersonation proxy.
|
||||
This approach allows clients to use the Token Credential Request API even when
|
||||
we do not have the cluster's signing key.
|
||||
|
||||
In terms of authorization, we rely mostly on the Kubernetes API server. Since we
|
||||
impersonate the user, the proxied request will be authorized against that user.
|
||||
Thus for all regular REST verbs, we perform no authorization checks.
|
||||
|
||||
Nested impersonation is handled by performing the same authorization checks the
|
||||
Kubernetes API server would (we get this mostly for free by using the aggregated
|
||||
API server code). We preserve the original user in the reserved extra key
|
||||
original-user-info.impersonation-proxy.concierge.pinniped.dev as a JSON blob of
|
||||
the authenticationv1.UserInfo struct. This is necessary to make sure that the
|
||||
Kubernetes audit log contains all three identities (original user, impersonated
|
||||
user and the impersonation proxy's service account). Capturing the original
|
||||
user information requires that we enable the auditing stack (WithImpersonation
|
||||
only shares this information with the audit stack). To keep things simple,
|
||||
we use the fake audit backend at the Metadata level for all requests. This
|
||||
guarantees that we always have an audit event on every request.
|
||||
|
||||
For all normal requests, we only use http/2.0 when proxying to the API server.
|
||||
For upgrade requests, we only use http/1.1 since these always go from http/1.1
|
||||
to either websockets or SPDY.
|
||||
*/
|
||||
package impersonator
|
||||
@@ -4,11 +4,14 @@
|
||||
package impersonator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -21,6 +24,8 @@ import (
|
||||
"k8s.io/apimachinery/pkg/util/httpstream"
|
||||
utilnet "k8s.io/apimachinery/pkg/util/net"
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
auditinternal "k8s.io/apiserver/pkg/apis/audit"
|
||||
"k8s.io/apiserver/pkg/audit/policy"
|
||||
"k8s.io/apiserver/pkg/authentication/user"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"k8s.io/apiserver/pkg/endpoints/filterlatency"
|
||||
@@ -31,6 +36,7 @@ import (
|
||||
"k8s.io/apiserver/pkg/server/dynamiccertificates"
|
||||
"k8s.io/apiserver/pkg/server/filters"
|
||||
genericoptions "k8s.io/apiserver/pkg/server/options"
|
||||
auditfake "k8s.io/apiserver/plugin/pkg/audit/fake"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/transport"
|
||||
|
||||
@@ -100,7 +106,6 @@ func newInternal( //nolint:funlen // yeah, it's kind of long.
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recommendedOptions.Authentication.ClientCert.ClientCA = "---irrelevant-but-needs-to-be-non-empty---" // drop when we pick up https://github.com/kubernetes/kubernetes/pull/100055
|
||||
recommendedOptions.Authentication.ClientCert.CAContentProvider = dynamiccertificates.NewUnionCAContentProvider(
|
||||
impersonationProxySignerCA, kubeClientCA,
|
||||
)
|
||||
@@ -163,35 +168,55 @@ func newInternal( //nolint:funlen // yeah, it's kind of long.
|
||||
}))
|
||||
handler = filterlatency.TrackStarted(handler, "impersonationproxy")
|
||||
|
||||
handler = filterlatency.TrackCompleted(handler)
|
||||
handler = deleteKnownImpersonationHeaders(handler)
|
||||
handler = filterlatency.TrackStarted(handler, "deleteimpersonationheaders")
|
||||
|
||||
// The standard Kube handler chain (authn, authz, impersonation, audit, etc).
|
||||
// See the genericapiserver.DefaultBuildHandlerChain func for details.
|
||||
handler = defaultBuildHandlerChainFunc(handler, c)
|
||||
|
||||
// Always set security headers so browsers do the right thing.
|
||||
handler = filterlatency.TrackCompleted(handler)
|
||||
handler = securityheader.Wrap(handler)
|
||||
handler = filterlatency.TrackStarted(handler, "securityheaders")
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
// Overwrite the delegating authorizer with one that only cares about impersonation.
|
||||
// Empty string is disallowed because request info has had bugs in the past where it would leave it empty.
|
||||
disallowedVerbs := sets.NewString("", "impersonate")
|
||||
noImpersonationAuthorizer := &comparableAuthorizer{
|
||||
AuthorizerFunc: func(a authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
// Supporting impersonation is not hard, it would just require a bunch of testing
|
||||
// and configuring the audit layer (to preserve the caller) which we can do later.
|
||||
// We would also want to delete the incoming impersonation headers
|
||||
// instead of overwriting the delegating authorizer, we would
|
||||
// actually use it to make the impersonation authorization checks.
|
||||
if disallowedVerbs.Has(a.GetVerb()) {
|
||||
return authorizer.DecisionDeny, "impersonation is not allowed or invalid verb", nil
|
||||
}
|
||||
// wire up a fake audit backend at the metadata level so we can preserve the original user during nested impersonation
|
||||
// TODO: wire up the real std out logging audit backend based on plog log level
|
||||
serverConfig.AuditPolicyChecker = policy.FakeChecker(auditinternal.LevelMetadata, nil)
|
||||
serverConfig.AuditBackend = &auditfake.Backend{}
|
||||
|
||||
return authorizer.DecisionAllow, "deferring authorization to kube API server", nil
|
||||
delegatingAuthorizer := serverConfig.Authorization.Authorizer
|
||||
nestedImpersonationAuthorizer := &comparableAuthorizer{
|
||||
authorizerFunc: func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
switch a.GetVerb() {
|
||||
case "":
|
||||
// Empty string is disallowed because request info has had bugs in the past where it would leave it empty.
|
||||
return authorizer.DecisionDeny, "invalid verb", nil
|
||||
case "create",
|
||||
"update",
|
||||
"delete",
|
||||
"deletecollection",
|
||||
"get",
|
||||
"list",
|
||||
"watch",
|
||||
"patch",
|
||||
"proxy":
|
||||
// we know these verbs are from the request info parsing which is safe to delegate to KAS
|
||||
return authorizer.DecisionAllow, "deferring standard verb authorization to kube API server", nil
|
||||
default:
|
||||
// assume everything else is internal SAR checks that we need to run against the requesting user
|
||||
// because when KAS does the check, it may run the check against our service account and not the
|
||||
// requesting user. This also handles the impersonate verb to allow for nested impersonation.
|
||||
return delegatingAuthorizer.Authorize(ctx, a)
|
||||
}
|
||||
},
|
||||
}
|
||||
// Set our custom authorizer before calling Compete(), which will use it.
|
||||
serverConfig.Authorization.Authorizer = noImpersonationAuthorizer
|
||||
serverConfig.Authorization.Authorizer = nestedImpersonationAuthorizer
|
||||
|
||||
impersonationProxyServer, err := serverConfig.Complete().New("impersonation-proxy", genericapiserver.NewEmptyDelegate())
|
||||
if err != nil {
|
||||
@@ -201,7 +226,7 @@ func newInternal( //nolint:funlen // yeah, it's kind of long.
|
||||
preparedRun := impersonationProxyServer.PrepareRun()
|
||||
|
||||
// Sanity check. Make sure that our custom authorizer is still in place and did not get changed or wrapped.
|
||||
if preparedRun.Authorizer != noImpersonationAuthorizer {
|
||||
if preparedRun.Authorizer != nestedImpersonationAuthorizer {
|
||||
return nil, constable.Error("invalid mutation of impersonation authorizer detected")
|
||||
}
|
||||
|
||||
@@ -225,9 +250,44 @@ func newInternal( //nolint:funlen // yeah, it's kind of long.
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func deleteKnownImpersonationHeaders(delegate http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// remove known impersonation headers while avoiding mutation of input request
|
||||
// unknown future impersonation headers will still get caught by our later checks
|
||||
if ensureNoImpersonationHeaders(r) != nil {
|
||||
r = r.Clone(r.Context())
|
||||
|
||||
impersonationHeaders := []string{
|
||||
transport.ImpersonateUserHeader,
|
||||
transport.ImpersonateGroupHeader,
|
||||
}
|
||||
|
||||
for k := range r.Header {
|
||||
if !strings.HasPrefix(k, transport.ImpersonateUserExtraHeaderPrefix) {
|
||||
continue
|
||||
}
|
||||
impersonationHeaders = append(impersonationHeaders, k)
|
||||
}
|
||||
|
||||
for _, header := range impersonationHeaders {
|
||||
r.Header.Del(header) // delay mutation until the end when we are done iterating over the map
|
||||
}
|
||||
}
|
||||
|
||||
delegate.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// No-op wrapping around AuthorizerFunc to allow for comparisons.
|
||||
type comparableAuthorizer struct {
|
||||
authorizer.AuthorizerFunc
|
||||
authorizerFunc
|
||||
}
|
||||
|
||||
// TODO: delete when we pick up https://github.com/kubernetes/kubernetes/pull/100963
|
||||
type authorizerFunc func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error)
|
||||
|
||||
func (f authorizerFunc) Authorize(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
return f(ctx, a)
|
||||
}
|
||||
|
||||
func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapiserver.Config) http.Handler, error) {
|
||||
@@ -258,7 +318,7 @@ func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapi
|
||||
}
|
||||
|
||||
if err := ensureNoImpersonationHeaders(r); err != nil {
|
||||
plog.Error("noImpersonationAuthorizer logic did not prevent nested impersonation but it is always supposed to do so",
|
||||
plog.Error("unknown impersonation header seen",
|
||||
err,
|
||||
"url", r.URL.String(),
|
||||
"method", r.Method,
|
||||
@@ -277,6 +337,16 @@ func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapi
|
||||
return
|
||||
}
|
||||
|
||||
ae := request.AuditEventFrom(r.Context())
|
||||
if ae == nil {
|
||||
plog.Warning("aggregated API server logic did not set audit event but it is always supposed to do so",
|
||||
"url", r.URL.String(),
|
||||
"method", r.Method,
|
||||
)
|
||||
newInternalErrResponse(w, r, c.Serializer, "invalid audit event")
|
||||
return
|
||||
}
|
||||
|
||||
// KAS only supports upgrades via http/1.1 to websockets/SPDY (upgrades never use http/2.0)
|
||||
// Thus we default to using http/2.0 when the request is not an upgrade, otherwise we use http/1.1
|
||||
baseRT := http2RoundTripper
|
||||
@@ -285,7 +355,7 @@ func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapi
|
||||
baseRT = http1RoundTripper
|
||||
}
|
||||
|
||||
rt, err := getTransportForUser(userInfo, baseRT)
|
||||
rt, err := getTransportForUser(userInfo, baseRT, ae)
|
||||
if err != nil {
|
||||
plog.WarningErr("rejecting request as we cannot act as the current user", err,
|
||||
"url", r.URL.String(),
|
||||
@@ -332,6 +402,9 @@ func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapi
|
||||
|
||||
func ensureNoImpersonationHeaders(r *http.Request) error {
|
||||
for key := range r.Header {
|
||||
// even though we have unit tests that try to cover this case, it is hard to tell if Go does
|
||||
// client side canonicalization on encode, server side canonicalization on decode, or both
|
||||
key := http.CanonicalHeaderKey(key)
|
||||
if strings.HasPrefix(key, "Impersonate") {
|
||||
return fmt.Errorf("%q header already exists", key)
|
||||
}
|
||||
@@ -340,12 +413,17 @@ func ensureNoImpersonationHeaders(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getTransportForUser(userInfo user.Info, delegate http.RoundTripper) (http.RoundTripper, error) {
|
||||
func getTransportForUser(userInfo user.Info, delegate http.RoundTripper, ae *auditinternal.Event) (http.RoundTripper, error) {
|
||||
if len(userInfo.GetUID()) == 0 {
|
||||
extra, err := buildExtra(userInfo.GetExtra(), ae)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
impersonateConfig := transport.ImpersonationConfig{
|
||||
UserName: userInfo.GetName(),
|
||||
Groups: userInfo.GetGroups(),
|
||||
Extra: userInfo.GetExtra(),
|
||||
Extra: extra,
|
||||
}
|
||||
// transport.NewImpersonatingRoundTripper clones the request before setting headers
|
||||
// thus it will not accidentally mutate the input request (see http.Handler docs)
|
||||
@@ -365,6 +443,44 @@ func getTransportForUser(userInfo user.Info, delegate http.RoundTripper) (http.R
|
||||
return nil, constable.Error("unexpected uid")
|
||||
}
|
||||
|
||||
func buildExtra(extra map[string][]string, ae *auditinternal.Event) (map[string][]string, error) {
|
||||
const reservedImpersonationProxySuffix = ".impersonation-proxy.concierge.pinniped.dev"
|
||||
|
||||
// always validate that the extra is something we support irregardless of nested impersonation
|
||||
for k := range extra {
|
||||
if !extraKeyRegexp.MatchString(k) {
|
||||
return nil, fmt.Errorf("disallowed extra key seen: %s", k)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(k, reservedImpersonationProxySuffix) {
|
||||
return nil, fmt.Errorf("disallowed extra key with reserved prefix seen: %s", k)
|
||||
}
|
||||
}
|
||||
|
||||
if ae.ImpersonatedUser == nil {
|
||||
return extra, nil // just return the given extra since nested impersonation is not being used
|
||||
}
|
||||
|
||||
// avoid mutating input map, preallocate new map to store original user info
|
||||
out := make(map[string][]string, len(extra)+1)
|
||||
|
||||
for k, v := range extra {
|
||||
out[k] = v // shallow copy of slice since we are not going to mutate it
|
||||
}
|
||||
|
||||
origUserInfoJSON, err := json.Marshal(ae.User)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out["original-user-info"+reservedImpersonationProxySuffix] = []string{string(origUserInfoJSON)}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// extraKeyRegexp is a very conservative regex to handle impersonation's extra key fidelity limitations such as casing and escaping.
|
||||
var extraKeyRegexp = regexp.MustCompile(`^[a-z0-9/\-._]+$`)
|
||||
|
||||
func newInternalErrResponse(w http.ResponseWriter, r *http.Request, s runtime.NegotiatedSerializer, msg string) {
|
||||
newStatusErrResponse(w, r, s, apierrors.NewInternalError(constable.Error(msg)))
|
||||
}
|
||||
|
||||
@@ -15,11 +15,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
authenticationv1 "k8s.io/api/authentication/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||
"k8s.io/apimachinery/pkg/util/httpstream"
|
||||
auditinternal "k8s.io/apiserver/pkg/apis/audit"
|
||||
"k8s.io/apiserver/pkg/authentication/user"
|
||||
"k8s.io/apiserver/pkg/endpoints/request"
|
||||
"k8s.io/apiserver/pkg/features"
|
||||
@@ -39,8 +41,6 @@ import (
|
||||
)
|
||||
|
||||
func TestImpersonator(t *testing.T) {
|
||||
const port = 9444
|
||||
|
||||
ca, err := certauthority.New("ca", time.Hour)
|
||||
require.NoError(t, err)
|
||||
caKey, err := ca.PrivateKeyToPEM()
|
||||
@@ -58,13 +58,7 @@ func TestImpersonator(t *testing.T) {
|
||||
unrelatedCA, err := certauthority.New("ca", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 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.CoreAPI = nil
|
||||
options.Admission = nil
|
||||
}
|
||||
// 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 {
|
||||
@@ -140,7 +134,7 @@ func TestImpersonator(t *testing.T) {
|
||||
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")
|
||||
header["x-FORWARDED-for"] = append(header["x-FORWARDED-for"], "example.com")
|
||||
},
|
||||
wantKubeAPIServerRequestHeaders: http.Header{
|
||||
"Impersonate-User": {"test-username2"},
|
||||
@@ -189,20 +183,128 @@ func TestImpersonator(t *testing.T) {
|
||||
wantError: "Unauthorized",
|
||||
},
|
||||
{
|
||||
name: "double impersonation is not allowed by regular users",
|
||||
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: impersonation is not allowed or invalid verb`,
|
||||
`cannot impersonate resource "users" in API group "" at the cluster scope`,
|
||||
},
|
||||
{
|
||||
name: "double impersonation is not allowed by admin users",
|
||||
clientCert: newClientCert(t, ca, "test-admin", []string{"system:masters", "test-group2"}),
|
||||
clientImpersonateUser: rest.ImpersonationConfig{UserName: "some-other-username"},
|
||||
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",
|
||||
wantError: `users "some-other-username" is forbidden: User "test-admin" ` +
|
||||
`cannot impersonate resource "users" in API group "" at the cluster scope: impersonation is not allowed or invalid verb`,
|
||||
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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
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: invalid impersonation",
|
||||
},
|
||||
{
|
||||
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: invalid impersonation",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
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"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no bearer token file in Kube API server client config",
|
||||
@@ -212,17 +314,17 @@ func TestImpersonator(t *testing.T) {
|
||||
name: "header canonicalization user header",
|
||||
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
|
||||
clientMutateHeaders: func(header http.Header) {
|
||||
header.Set("imPerSonaTE-USer", "PANDA")
|
||||
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: impersonation is not allowed or invalid verb`,
|
||||
`cannot impersonate resource "users" in API group "" at the cluster scope`,
|
||||
},
|
||||
{
|
||||
name: "header canonicalization future UID header",
|
||||
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
|
||||
clientMutateHeaders: func(header http.Header) {
|
||||
header.Set("imPerSonaTE-uid", "007")
|
||||
header["imPerSonaTE-uid"] = []string{"007"}
|
||||
},
|
||||
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
||||
wantError: "Internal error occurred: invalid impersonation",
|
||||
@@ -231,7 +333,7 @@ func TestImpersonator(t *testing.T) {
|
||||
name: "future UID header",
|
||||
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
|
||||
clientMutateHeaders: func(header http.Header) {
|
||||
header.Set("Impersonate-Uid", "008")
|
||||
header["Impersonate-Uid"] = []string{"008"}
|
||||
},
|
||||
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
||||
wantError: "Internal error occurred: invalid impersonation",
|
||||
@@ -239,8 +341,14 @@ func TestImpersonator(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
// This is a serial test because the production code binds to the port.
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -293,8 +401,17 @@ func TestImpersonator(t *testing.T) {
|
||||
}
|
||||
clientOpts := []kubeclient.Option{kubeclient.WithConfig(&testKubeAPIServerKubeconfig)}
|
||||
|
||||
// Create an impersonator.
|
||||
runner, constructionErr := newInternal(port, certKeyContent, caContent, clientOpts, recOpts)
|
||||
// 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.CoreAPI = nil
|
||||
options.Admission = nil
|
||||
options.SecureServing.Listener = listener // use our listener with the dynamic port
|
||||
}
|
||||
|
||||
// Create an impersonator. Use an invalid port number to make sure our listener override works.
|
||||
runner, constructionErr := newInternal(-1000, certKeyContent, caContent, clientOpts, recOpts)
|
||||
if len(tt.wantConstructionError) > 0 {
|
||||
require.EqualError(t, constructionErr, tt.wantConstructionError)
|
||||
require.Nil(t, runner)
|
||||
@@ -383,20 +500,30 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
|
||||
}
|
||||
|
||||
validURL, _ := url.Parse("http://pinniped.dev/blah")
|
||||
newRequest := func(h http.Header, userInfo user.Info) *http.Request {
|
||||
newRequest := func(h http.Header, userInfo user.Info, event *auditinternal.Event) *http.Request {
|
||||
ctx := context.Background()
|
||||
|
||||
if userInfo != nil {
|
||||
ctx = request.WithUser(ctx, userInfo)
|
||||
}
|
||||
r, err := http.NewRequestWithContext(ctx, http.MethodGet, validURL.String(), nil)
|
||||
require.NoError(t, err)
|
||||
r.Header = h
|
||||
|
||||
ae := &auditinternal.Event{Level: auditinternal.LevelMetadata}
|
||||
if event != nil {
|
||||
ae = event
|
||||
}
|
||||
ctx = request.WithAuditEvent(ctx, ae)
|
||||
|
||||
reqInfo := &request.RequestInfo{
|
||||
IsResourceRequest: false,
|
||||
Path: validURL.Path,
|
||||
Verb: "get",
|
||||
}
|
||||
r = r.WithContext(request.WithRequestInfo(ctx, reqInfo))
|
||||
ctx = request.WithRequestInfo(ctx, reqInfo)
|
||||
|
||||
r, err := http.NewRequestWithContext(ctx, http.MethodGet, validURL.String(), nil)
|
||||
require.NoError(t, err)
|
||||
r.Header = h
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -436,43 +563,123 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "Impersonate-User header already in request",
|
||||
request: newRequest(map[string][]string{"Impersonate-User": {"some-user"}}, nil),
|
||||
request: newRequest(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(map[string][]string{"Impersonate-Group": {"some-group"}}, nil),
|
||||
request: newRequest(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(map[string][]string{"Impersonate-Extra-something": {"something"}}, nil),
|
||||
request: newRequest(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(map[string][]string{"Impersonate-Something": {"some-newfangled-impersonate-header"}}, nil),
|
||||
request: newRequest(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(map[string][]string{"Authorization": {"panda"}}, nil),
|
||||
request: newRequest(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(map[string][]string{}, nil),
|
||||
request: newRequest(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(map[string][]string{}, &user.DefaultInfo{UID: "007"}),
|
||||
request: newRequest(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(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 := request.WithAuditEvent(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(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(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(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,
|
||||
},
|
||||
@@ -492,7 +699,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
|
||||
Name: testUser,
|
||||
Groups: testGroups,
|
||||
Extra: testExtra,
|
||||
}),
|
||||
}, nil),
|
||||
wantKubeAPIServerRequestHeaders: map[string][]string{
|
||||
"Authorization": {"Bearer some-service-account-token"},
|
||||
"Impersonate-Extra-Extra-1": {"some", "extra", "stuff"},
|
||||
@@ -510,6 +717,318 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
|
||||
wantHTTPBody: "successful proxied response",
|
||||
wantHTTPStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "authenticated gke user",
|
||||
request: newRequest(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(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(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(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(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(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(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(map[string][]string{
|
||||
@@ -518,7 +1037,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
|
||||
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
|
||||
@@ -623,6 +1142,7 @@ type clientCert struct {
|
||||
}
|
||||
|
||||
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{
|
||||
@@ -632,7 +1152,113 @@ func newClientCert(t *testing.T, ca *certauthority.CA, username string, groups [
|
||||
}
|
||||
|
||||
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_deleteKnownImpersonationHeaders(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
headers, want http.Header
|
||||
}{
|
||||
{
|
||||
name: "no impersonation",
|
||||
headers: map[string][]string{
|
||||
"a": {"b"},
|
||||
"Accept-Encoding": {"gzip"},
|
||||
"User-Agent": {"test-user-agent"},
|
||||
},
|
||||
want: map[string][]string{
|
||||
"a": {"b"},
|
||||
"Accept-Encoding": {"gzip"},
|
||||
"User-Agent": {"test-user-agent"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "impersonate user header is dropped",
|
||||
headers: map[string][]string{
|
||||
"a": {"b"},
|
||||
"Impersonate-User": {"panda"},
|
||||
"Accept-Encoding": {"gzip"},
|
||||
"User-Agent": {"test-user-agent"},
|
||||
},
|
||||
want: map[string][]string{
|
||||
"a": {"b"},
|
||||
"Accept-Encoding": {"gzip"},
|
||||
"User-Agent": {"test-user-agent"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all known impersonate headers are dropped",
|
||||
headers: map[string][]string{
|
||||
"Accept-Encoding": {"gzip"},
|
||||
"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"},
|
||||
},
|
||||
want: map[string][]string{
|
||||
"Accept-Encoding": {"gzip"},
|
||||
"Authorization": {"Bearer some-service-account-token"},
|
||||
"User-Agent": {"test-user-agent"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "future UID header is not dropped",
|
||||
headers: map[string][]string{
|
||||
"Accept-Encoding": {"gzip"},
|
||||
"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"},
|
||||
"Impersonate-Uid": {"008"},
|
||||
"User-Agent": {"test-user-agent"},
|
||||
},
|
||||
want: map[string][]string{
|
||||
"Accept-Encoding": {"gzip"},
|
||||
"Authorization": {"Bearer some-service-account-token"},
|
||||
"User-Agent": {"test-user-agent"},
|
||||
"Impersonate-Uid": {"008"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "future UID header is not dropped, no other headers",
|
||||
headers: map[string][]string{
|
||||
"Impersonate-Uid": {"009"},
|
||||
},
|
||||
want: map[string][]string{
|
||||
"Impersonate-Uid": {"009"},
|
||||
},
|
||||
},
|
||||
}
|
||||
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())
|
||||
|
||||
delegate := http.HandlerFunc(func(w http.ResponseWriter, outputReq *http.Request) {
|
||||
require.Nil(t, w)
|
||||
|
||||
// assert only headers mutated
|
||||
outputReqCopy := outputReq.Clone(outputReq.Context())
|
||||
outputReqCopy.Header = tt.headers
|
||||
require.Equal(t, inputReqCopy, outputReqCopy)
|
||||
|
||||
require.Equal(t, tt.want, outputReq.Header)
|
||||
|
||||
if ensureNoImpersonationHeaders(inputReq) == nil {
|
||||
require.True(t, inputReq == outputReq, "expect req to passed through when no modification needed")
|
||||
}
|
||||
})
|
||||
|
||||
deleteKnownImpersonationHeaders(delegate).ServeHTTP(nil, inputReq)
|
||||
require.Equal(t, inputReqCopy, inputReq) // assert no mutation occurred
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user