Add end-to-end integration test for CLI-based LDAP login
This commit is contained in:
parent
f98aa96ed3
commit
6723ed9fd8
1
go.mod
1
go.mod
@ -6,6 +6,7 @@ require (
|
||||
cloud.google.com/go v0.60.0 // indirect
|
||||
github.com/MakeNowJust/heredoc/v2 v2.0.1
|
||||
github.com/coreos/go-oidc/v3 v3.0.0
|
||||
github.com/creack/pty v1.1.11
|
||||
github.com/davecgh/go-spew v1.1.1
|
||||
github.com/go-ldap/ldap/v3 v3.3.0
|
||||
github.com/go-logr/logr v0.4.0
|
||||
|
1
go.sum
1
go.sum
@ -148,6 +148,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
|
||||
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/cucumber/godog v0.8.1/go.mod h1:vSh3r/lM+psC1BPXvdkSEuNjmXfpVqrMGYAElF6hxnA=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
@ -9,6 +9,7 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/url"
|
||||
"os"
|
||||
@ -21,6 +22,7 @@ import (
|
||||
"time"
|
||||
|
||||
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/creack/pty"
|
||||
"github.com/stretchr/testify/require"
|
||||
authorizationv1 "k8s.io/api/authorization/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
@ -30,6 +32,7 @@ import (
|
||||
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
|
||||
idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
|
||||
"go.pinniped.dev/internal/certauthority"
|
||||
"go.pinniped.dev/internal/here"
|
||||
"go.pinniped.dev/internal/oidc"
|
||||
"go.pinniped.dev/internal/testutil"
|
||||
"go.pinniped.dev/pkg/oidcclient"
|
||||
@ -92,6 +95,31 @@ func TestE2EFullIntegration(t *testing.T) {
|
||||
configv1alpha1.SuccessFederationDomainStatusCondition,
|
||||
)
|
||||
|
||||
// Create a JWTAuthenticator that will validate the tokens from the downstream issuer.
|
||||
clusterAudience := "test-cluster-" + library.RandHex(t, 8)
|
||||
authenticator := library.CreateTestJWTAuthenticator(ctx, t, authv1alpha.JWTAuthenticatorSpec{
|
||||
Issuer: downstream.Spec.Issuer,
|
||||
Audience: clusterAudience,
|
||||
TLS: &authv1alpha.TLSSpec{CertificateAuthorityData: testCABundleBase64},
|
||||
})
|
||||
|
||||
// Add an OIDC upstream IDP and try using it to authenticate during kubectl commands.
|
||||
t.Run("with Supervisor OIDC upstream IDP", func(t *testing.T) {
|
||||
expectedUsername := env.SupervisorUpstreamOIDC.Username
|
||||
expectedGroups := env.SupervisorUpstreamOIDC.ExpectedGroups
|
||||
|
||||
// Create a ClusterRoleBinding to give our test user from the upstream read-only access to the cluster.
|
||||
library.CreateTestClusterRoleBinding(t,
|
||||
rbacv1.Subject{Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: expectedUsername},
|
||||
rbacv1.RoleRef{Kind: "ClusterRole", APIGroup: rbacv1.GroupName, Name: "view"},
|
||||
)
|
||||
library.WaitForUserToHaveAccess(t, expectedUsername, []string{}, &authorizationv1.ResourceAttributes{
|
||||
Verb: "get",
|
||||
Group: "",
|
||||
Version: "v1",
|
||||
Resource: "namespaces",
|
||||
})
|
||||
|
||||
// Create upstream OIDC provider and wait for it to become ready.
|
||||
library.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{
|
||||
Issuer: env.SupervisorUpstreamOIDC.Issuer,
|
||||
@ -110,47 +138,18 @@ func TestE2EFullIntegration(t *testing.T) {
|
||||
},
|
||||
}, idpv1alpha1.PhaseReady)
|
||||
|
||||
// Create a JWTAuthenticator that will validate the tokens from the downstream issuer.
|
||||
clusterAudience := "test-cluster-" + library.RandHex(t, 8)
|
||||
authenticator := library.CreateTestJWTAuthenticator(ctx, t, authv1alpha.JWTAuthenticatorSpec{
|
||||
Issuer: downstream.Spec.Issuer,
|
||||
Audience: clusterAudience,
|
||||
TLS: &authv1alpha.TLSSpec{CertificateAuthorityData: testCABundleBase64},
|
||||
})
|
||||
|
||||
// Create a ClusterRoleBinding to give our test user from the upstream read-only access to the cluster.
|
||||
library.CreateTestClusterRoleBinding(t,
|
||||
rbacv1.Subject{Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: env.SupervisorUpstreamOIDC.Username},
|
||||
rbacv1.RoleRef{Kind: "ClusterRole", APIGroup: rbacv1.GroupName, Name: "view"},
|
||||
)
|
||||
library.WaitForUserToHaveAccess(t, env.SupervisorUpstreamOIDC.Username, []string{}, &authorizationv1.ResourceAttributes{
|
||||
Verb: "get",
|
||||
Group: "",
|
||||
Version: "v1",
|
||||
Resource: "namespaces",
|
||||
})
|
||||
|
||||
// Use a specific session cache for this test.
|
||||
sessionCachePath := tempDir + "/sessions.yaml"
|
||||
sessionCachePath := tempDir + "/oidc-test-sessions.yaml"
|
||||
|
||||
// Run "pinniped get kubeconfig" to get a kubeconfig YAML.
|
||||
envVarsWithProxy := append(os.Environ(), env.ProxyEnv()...)
|
||||
kubeconfigYAML, stderr := runPinnipedCLI(t, envVarsWithProxy, pinnipedExe, "get", "kubeconfig",
|
||||
kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{
|
||||
"get", "kubeconfig",
|
||||
"--concierge-api-group-suffix", env.APIGroupSuffix,
|
||||
"--concierge-authenticator-type", "jwt",
|
||||
"--concierge-authenticator-name", authenticator.Name,
|
||||
"--oidc-skip-browser",
|
||||
"--oidc-ca-bundle", testCABundlePath,
|
||||
"--oidc-session-cache", sessionCachePath,
|
||||
)
|
||||
t.Logf("stderr output from 'pinniped get kubeconfig':\n%s\n\n", stderr)
|
||||
t.Logf("test kubeconfig:\n%s\n\n", kubeconfigYAML)
|
||||
|
||||
restConfig := library.NewRestConfigFromKubeconfig(t, kubeconfigYAML)
|
||||
require.NotNil(t, restConfig.ExecProvider)
|
||||
require.Equal(t, []string{"login", "oidc"}, restConfig.ExecProvider.Args[:2])
|
||||
kubeconfigPath := filepath.Join(tempDir, "kubeconfig.yaml")
|
||||
require.NoError(t, ioutil.WriteFile(kubeconfigPath, []byte(kubeconfigYAML), 0600))
|
||||
})
|
||||
|
||||
// Run "kubectl get namespaces" which should trigger a browser login via the plugin.
|
||||
start := time.Now()
|
||||
@ -261,16 +260,157 @@ func TestE2EFullIntegration(t *testing.T) {
|
||||
require.Greaterf(t, len(strings.Split(kubectlOutput, "\n")), 2, "expected some namespaces to be returned, got %q", kubectlOutput)
|
||||
t.Logf("first kubectl command took %s", time.Since(start).String())
|
||||
|
||||
// Run kubectl again, which should work with no browser interaction.
|
||||
kubectlCmd2 := exec.CommandContext(ctx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath)
|
||||
kubectlCmd2.Env = append(os.Environ(), env.ProxyEnv()...)
|
||||
start = time.Now()
|
||||
kubectlOutput2, err := kubectlCmd2.CombinedOutput()
|
||||
requireUserCanUseKubectlWithoutAuthenticatingAgain(ctx, t, env,
|
||||
downstream,
|
||||
kubeconfigPath,
|
||||
sessionCachePath,
|
||||
pinnipedExe,
|
||||
expectedUsername,
|
||||
expectedGroups,
|
||||
)
|
||||
})
|
||||
|
||||
// Add an LDAP upstream IDP and try using it to authenticate during kubectl commands.
|
||||
t.Run("with Supervisor LDAP upstream IDP", func(t *testing.T) {
|
||||
expectedUsername := env.SupervisorUpstreamLDAP.TestUserMailAttributeValue
|
||||
expectedGroups := []string{} // LDAP groups are not implemented yet
|
||||
|
||||
// Create a ClusterRoleBinding to give our test user from the upstream read-only access to the cluster.
|
||||
library.CreateTestClusterRoleBinding(t,
|
||||
rbacv1.Subject{Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: expectedUsername},
|
||||
rbacv1.RoleRef{Kind: "ClusterRole", APIGroup: rbacv1.GroupName, Name: "view"},
|
||||
)
|
||||
library.WaitForUserToHaveAccess(t, expectedUsername, []string{}, &authorizationv1.ResourceAttributes{
|
||||
Verb: "get",
|
||||
Group: "",
|
||||
Version: "v1",
|
||||
Resource: "namespaces",
|
||||
})
|
||||
|
||||
// Put the bind service account's info into a Secret.
|
||||
bindSecret := library.CreateTestSecret(t, env.SupervisorNamespace, "ldap-service-account", corev1.SecretTypeBasicAuth,
|
||||
map[string]string{
|
||||
corev1.BasicAuthUsernameKey: env.SupervisorUpstreamLDAP.BindUsername,
|
||||
corev1.BasicAuthPasswordKey: env.SupervisorUpstreamLDAP.BindPassword,
|
||||
},
|
||||
)
|
||||
|
||||
// Create upstream LDAP provider and wait for it to become ready.
|
||||
library.CreateTestLDAPIdentityProvider(t, idpv1alpha1.LDAPIdentityProviderSpec{
|
||||
Host: env.SupervisorUpstreamLDAP.Host,
|
||||
TLS: &idpv1alpha1.TLSSpec{
|
||||
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)),
|
||||
},
|
||||
Bind: idpv1alpha1.LDAPIdentityProviderBind{
|
||||
SecretName: bindSecret.Name,
|
||||
},
|
||||
UserSearch: idpv1alpha1.LDAPIdentityProviderUserSearch{
|
||||
Base: env.SupervisorUpstreamLDAP.UserSearchBase,
|
||||
Filter: "",
|
||||
Attributes: idpv1alpha1.LDAPIdentityProviderUserSearchAttributes{
|
||||
Username: env.SupervisorUpstreamLDAP.TestUserMailAttributeName,
|
||||
UID: env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeName,
|
||||
},
|
||||
},
|
||||
}, idpv1alpha1.LDAPPhaseReady)
|
||||
|
||||
// Use a specific session cache for this test.
|
||||
sessionCachePath := tempDir + "/ldap-test-sessions.yaml"
|
||||
|
||||
kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{
|
||||
"get", "kubeconfig",
|
||||
"--concierge-api-group-suffix", env.APIGroupSuffix,
|
||||
"--concierge-authenticator-type", "jwt",
|
||||
"--concierge-authenticator-name", authenticator.Name,
|
||||
"--oidc-session-cache", sessionCachePath,
|
||||
})
|
||||
|
||||
// Run "kubectl get namespaces" which should trigger an LDAP-style login CLI prompt via the plugin.
|
||||
start := time.Now()
|
||||
kubectlCmd := exec.CommandContext(ctx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath)
|
||||
kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...)
|
||||
ptyFile, err := pty.Start(kubectlCmd)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for the subprocess to print the username prompt, then type the user's username.
|
||||
readFromFileUntilStringIsSeen(t, ptyFile, "Username: ")
|
||||
_, err = ptyFile.WriteString(expectedUsername + "\n")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for the subprocess to print the password prompt, then type the user's password.
|
||||
readFromFileUntilStringIsSeen(t, ptyFile, "Password: ")
|
||||
_, err = ptyFile.WriteString(env.SupervisorUpstreamLDAP.TestUserPassword + "\n")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Read all of the remaining output from the subprocess until EOF.
|
||||
remainingOutput, err := ioutil.ReadAll(ptyFile)
|
||||
require.NoError(t, err)
|
||||
require.Greaterf(t, len(strings.Split(string(remainingOutput), "\n")), 2, "expected some namespaces to be returned, got %q", string(remainingOutput))
|
||||
t.Logf("first kubectl command took %s", time.Since(start).String())
|
||||
|
||||
requireUserCanUseKubectlWithoutAuthenticatingAgain(ctx, t, env,
|
||||
downstream,
|
||||
kubeconfigPath,
|
||||
sessionCachePath,
|
||||
pinnipedExe,
|
||||
expectedUsername,
|
||||
expectedGroups,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func readFromFileUntilStringIsSeen(t *testing.T, f *os.File, until string) string {
|
||||
readFromFile := ""
|
||||
|
||||
library.RequireEventuallyWithoutError(t, func() (bool, error) {
|
||||
someOutput, foundEOF := readAvailableOutput(t, f)
|
||||
readFromFile += someOutput
|
||||
if strings.Contains(readFromFile, until) {
|
||||
return true, nil // found it! finished.
|
||||
}
|
||||
if foundEOF {
|
||||
return false, fmt.Errorf("reached EOF of subcommand's output without seeing expected string %q", until)
|
||||
}
|
||||
return false, nil // keep waiting and reading
|
||||
}, 1*time.Minute, 1*time.Second)
|
||||
|
||||
return readFromFile
|
||||
}
|
||||
|
||||
func readAvailableOutput(t *testing.T, r io.Reader) (string, bool) {
|
||||
buf := make([]byte, 1024)
|
||||
n, err := r.Read(buf)
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return string(buf[:n]), true
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
return string(buf[:n]), false
|
||||
}
|
||||
|
||||
func requireUserCanUseKubectlWithoutAuthenticatingAgain(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
env *library.TestEnv,
|
||||
downstream *configv1alpha1.FederationDomain,
|
||||
kubeconfigPath string,
|
||||
sessionCachePath string,
|
||||
pinnipedExe string,
|
||||
expectedUsername string,
|
||||
expectedGroups []string,
|
||||
) {
|
||||
// Run kubectl, which should work without any prompting for authentication.
|
||||
kubectlCmd := exec.CommandContext(ctx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath)
|
||||
kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...)
|
||||
startTime := time.Now()
|
||||
kubectlOutput2, err := kubectlCmd.CombinedOutput()
|
||||
require.NoError(t, err)
|
||||
require.Greaterf(t, len(bytes.Split(kubectlOutput2, []byte("\n"))), 2, "expected some namespaces to be returned again")
|
||||
t.Logf("second kubectl command took %s", time.Since(start).String())
|
||||
t.Logf("second kubectl command took %s", time.Since(startTime).String())
|
||||
|
||||
// probe our cache for the current ID token as a proxy for a whoami API
|
||||
// Probe our cache for the current ID token as a proxy for a whoami API.
|
||||
cache := filesession.New(sessionCachePath, filesession.WithErrorReporter(func(err error) {
|
||||
require.NoError(t, err)
|
||||
}))
|
||||
@ -286,49 +426,52 @@ func TestE2EFullIntegration(t *testing.T) {
|
||||
require.NotNil(t, token)
|
||||
|
||||
idTokenClaims := token.IDToken.Claims
|
||||
require.Equal(t, env.SupervisorUpstreamOIDC.Username, idTokenClaims[oidc.DownstreamUsernameClaim])
|
||||
require.Equal(t, expectedUsername, idTokenClaims[oidc.DownstreamUsernameClaim])
|
||||
|
||||
// The groups claim in the file ends up as an []interface{}, so adjust our expectation to match.
|
||||
expectedGroups := make([]interface{}, 0, len(env.SupervisorUpstreamOIDC.ExpectedGroups))
|
||||
for _, g := range env.SupervisorUpstreamOIDC.ExpectedGroups {
|
||||
expectedGroups = append(expectedGroups, g)
|
||||
expectedGroupsAsEmptyInterfaces := make([]interface{}, 0, len(expectedGroups))
|
||||
for _, g := range expectedGroups {
|
||||
expectedGroupsAsEmptyInterfaces = append(expectedGroupsAsEmptyInterfaces, g)
|
||||
}
|
||||
require.Equal(t, expectedGroups, idTokenClaims[oidc.DownstreamGroupsClaim])
|
||||
require.Equal(t, expectedGroupsAsEmptyInterfaces, idTokenClaims[oidc.DownstreamGroupsClaim])
|
||||
|
||||
// confirm we are the right user according to Kube
|
||||
expectedYAMLGroups := func() string {
|
||||
var b strings.Builder
|
||||
for _, g := range env.SupervisorUpstreamOIDC.ExpectedGroups {
|
||||
for _, g := range expectedGroups {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(` - `)
|
||||
b.WriteString(g)
|
||||
}
|
||||
return b.String()
|
||||
}()
|
||||
|
||||
// Confirm we are the right user according to Kube by calling the whoami API.
|
||||
kubectlCmd3 := exec.CommandContext(ctx, "kubectl", "create", "-f", "-", "-o", "yaml", "--kubeconfig", kubeconfigPath)
|
||||
kubectlCmd3.Env = append(os.Environ(), env.ProxyEnv()...)
|
||||
kubectlCmd3.Stdin = strings.NewReader(`
|
||||
apiVersion: identity.concierge.` + env.APIGroupSuffix + `/v1alpha1
|
||||
kind: WhoAmIRequest
|
||||
`)
|
||||
kubectlCmd3.Stdin = strings.NewReader(here.Docf(`
|
||||
apiVersion: identity.concierge.%s/v1alpha1
|
||||
kind: WhoAmIRequest
|
||||
`, env.APIGroupSuffix))
|
||||
|
||||
kubectlOutput3, err := kubectlCmd3.CombinedOutput()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t,
|
||||
`apiVersion: identity.concierge.`+env.APIGroupSuffix+`/v1alpha1
|
||||
kind: WhoAmIRequest
|
||||
metadata:
|
||||
|
||||
require.Equal(t, here.Docf(`
|
||||
apiVersion: identity.concierge.%s/v1alpha1
|
||||
kind: WhoAmIRequest
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
spec: {}
|
||||
status:
|
||||
spec: {}
|
||||
status:
|
||||
kubernetesUserInfo:
|
||||
user:
|
||||
groups:`+expectedYAMLGroups+`
|
||||
groups:%s
|
||||
- system:authenticated
|
||||
username: `+env.SupervisorUpstreamOIDC.Username+`
|
||||
`,
|
||||
username: %s
|
||||
`, env.APIGroupSuffix, expectedYAMLGroups, expectedUsername),
|
||||
string(kubectlOutput3))
|
||||
|
||||
expectedGroupsPlusAuthenticated := append([]string{}, env.SupervisorUpstreamOIDC.ExpectedGroups...)
|
||||
expectedGroupsPlusAuthenticated := append([]string{}, expectedGroups...)
|
||||
expectedGroupsPlusAuthenticated = append(expectedGroupsPlusAuthenticated, "system:authenticated")
|
||||
// Validate that `pinniped whoami` returns the correct identity.
|
||||
assertWhoami(
|
||||
@ -337,7 +480,24 @@ status:
|
||||
true,
|
||||
pinnipedExe,
|
||||
kubeconfigPath,
|
||||
env.SupervisorUpstreamOIDC.Username,
|
||||
expectedUsername,
|
||||
expectedGroupsPlusAuthenticated,
|
||||
)
|
||||
}
|
||||
|
||||
func runPinnipedGetKubeconfig(t *testing.T, env *library.TestEnv, pinnipedExe string, tempDir string, pinnipedCLICommand []string) string {
|
||||
// Run "pinniped get kubeconfig" to get a kubeconfig YAML.
|
||||
envVarsWithProxy := append(os.Environ(), env.ProxyEnv()...)
|
||||
kubeconfigYAML, stderr := runPinnipedCLI(t, envVarsWithProxy, pinnipedExe, pinnipedCLICommand...)
|
||||
t.Logf("stderr output from 'pinniped get kubeconfig':\n%s\n\n", stderr)
|
||||
t.Logf("test kubeconfig:\n%s\n\n", kubeconfigYAML)
|
||||
|
||||
restConfig := library.NewRestConfigFromKubeconfig(t, kubeconfigYAML)
|
||||
require.NotNil(t, restConfig.ExecProvider)
|
||||
require.Equal(t, []string{"login", "oidc"}, restConfig.ExecProvider.Args[:2])
|
||||
|
||||
kubeconfigPath := filepath.Join(tempDir, "kubeconfig.yaml")
|
||||
require.NoError(t, ioutil.WriteFile(kubeconfigPath, []byte(kubeconfigYAML), 0600))
|
||||
|
||||
return kubeconfigPath
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user