// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package integration

import (
	"context"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	authorizationv1 "k8s.io/api/authorization/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apiserver/pkg/authentication/serviceaccount"
	"k8s.io/apiserver/pkg/authentication/user"
	v1 "k8s.io/client-go/kubernetes/typed/authorization/v1"
	"k8s.io/client-go/rest"

	"go.pinniped.dev/test/testlib"
)

func TestServiceAccountPermissions(t *testing.T) {
	// TODO: update this test to check the permissions of all service accounts
	//  For now it just checks the permissions of the impersonation proxy SA

	env := testlib.IntegrationEnv(t)

	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
	defer cancel()

	// impersonate the SA since it is easier than fetching a token and lets us control the group memberships
	config := rest.CopyConfig(testlib.NewClientConfig(t))
	config.Impersonate = rest.ImpersonationConfig{
		UserName: serviceaccount.MakeUsername(env.ConciergeNamespace, env.ConciergeAppName+"-impersonation-proxy"),
		// avoid permissions assigned to system:serviceaccounts by explicitly impersonating system:serviceaccounts:<namespace>
		// as not all clusters will have the system:service-account-issuer-discovery binding
		// system:authenticated is required for us to create selfsubjectrulesreviews
		// TODO remove this once we stop supporting Kube clusters before v1.19
		Groups: []string{serviceaccount.MakeNamespaceGroupName(env.ConciergeNamespace), user.AllAuthenticated},
	}

	ssrrClient := testlib.NewKubeclient(t, config).Kubernetes.AuthorizationV1().SelfSubjectRulesReviews()

	// the impersonation proxy SA has the same permissions for all checks because it should only be authorized via cluster role bindings

	expectedResourceRules := []authorizationv1.ResourceRule{
		// the expected impersonation permissions
		{Verbs: []string{"impersonate"}, APIGroups: []string{""}, Resources: []string{"users", "groups", "serviceaccounts"}},
		{Verbs: []string{"impersonate"}, APIGroups: []string{"authentication.k8s.io"}, Resources: []string{"*"}},

		// we bind these to system:authenticated
		{Verbs: []string{"create", "list"}, APIGroups: []string{"login.concierge." + env.APIGroupSuffix}, Resources: []string{"tokencredentialrequests"}},
		{Verbs: []string{"create", "list"}, APIGroups: []string{"identity.concierge." + env.APIGroupSuffix}, Resources: []string{"whoamirequests"}},
	}

	// system:basic-user is bound to system:authenticated by default, so the SA gets these permissions too.
	// See https://kubernetes.io/docs/reference/access-authn-authz/rbac/#discovery-roles.
	// Note that this list previously only included "selfsubjectaccessreviews" and "selfsubjectrulesreviews",
	// but later was updated in Kubernetes to also include "selfsubjectreviews".
	// Rather than explicitly listing them all as expectations, dynamically append them here, so this test
	// can pass against all versions of Kubernetes.
	basicUserClusterRole, err := testlib.NewKubernetesClientset(t).RbacV1().ClusterRoles().Get(ctx, "system:basic-user", metav1.GetOptions{})
	require.NoError(t, err)
	for _, policyRule := range basicUserClusterRole.Rules {
		expectedResourceRules = append(expectedResourceRules, authorizationv1.ResourceRule{
			Verbs:         policyRule.Verbs,
			APIGroups:     policyRule.APIGroups,
			Resources:     policyRule.Resources,
			ResourceNames: policyRule.ResourceNames,
		})
	}

	if otherPinnipedGroupSuffix := getOtherPinnipedGroupSuffix(t); len(otherPinnipedGroupSuffix) > 0 {
		expectedResourceRules = append(expectedResourceRules,
			// we bind these to system:authenticated in the other instance of pinniped
			authorizationv1.ResourceRule{Verbs: []string{"create", "list"}, APIGroups: []string{"login.concierge." + otherPinnipedGroupSuffix}, Resources: []string{"tokencredentialrequests"}},
			authorizationv1.ResourceRule{Verbs: []string{"create", "list"}, APIGroups: []string{"identity.concierge." + otherPinnipedGroupSuffix}, Resources: []string{"whoamirequests"}},
		)
	}

	crbs, err := testlib.NewKubernetesClientset(t).RbacV1().ClusterRoleBindings().List(ctx, metav1.ListOptions{LabelSelector: "eks.amazonaws.com/component=pod-security-policy"})
	require.NoError(t, err)
	if len(crbs.Items) > 0 {
		expectedResourceRules = append(expectedResourceRules,
			// EKS binds these to system:authenticated
			authorizationv1.ResourceRule{Verbs: []string{"use"}, APIGroups: []string{"policy"}, Resources: []string{"podsecuritypolicies"}, ResourceNames: []string{"eks.privileged"}},
		)
	}

	expectedNonResourceRules := []authorizationv1.NonResourceRule{
		// system:public-info-viewer is bound to system:authenticated and system:unauthenticated by default
		{Verbs: []string{"get"}, NonResourceURLs: []string{"/healthz", "/livez", "/readyz", "/version", "/version/"}},

		// system:discovery is bound to system:authenticated by default
		{Verbs: []string{"get"}, NonResourceURLs: []string{"/api", "/api/*", "/apis", "/apis/*",
			"/healthz", "/livez", "/openapi", "/openapi/*", "/readyz", "/version", "/version/",
		}},
	}

	// check permissions in concierge namespace
	testPermissionsInNamespace(ctx, t, ssrrClient, env.ConciergeNamespace, expectedResourceRules, expectedNonResourceRules)

	// check permissions in supervisor namespace
	testPermissionsInNamespace(ctx, t, ssrrClient, env.SupervisorNamespace, expectedResourceRules, expectedNonResourceRules)

	// check permissions in kube-system namespace
	testPermissionsInNamespace(ctx, t, ssrrClient, metav1.NamespaceSystem, expectedResourceRules, expectedNonResourceRules)

	// check permissions in kube-public namespace
	testPermissionsInNamespace(ctx, t, ssrrClient, metav1.NamespacePublic, expectedResourceRules, expectedNonResourceRules)

	// check permissions in default namespace
	testPermissionsInNamespace(ctx, t, ssrrClient, metav1.NamespaceDefault, expectedResourceRules, expectedNonResourceRules)

	// we fake a cluster scoped selfsubjectrulesreviews check by picking a nonsense namespace
	testPermissionsInNamespace(ctx, t, ssrrClient, "some-namespace-invalid-name||pandas-are-the-best", expectedResourceRules, expectedNonResourceRules)
}

func testPermissionsInNamespace(ctx context.Context, t *testing.T, ssrrClient v1.SelfSubjectRulesReviewInterface, namespace string,
	expectedResourceRules []authorizationv1.ResourceRule, expectedNonResourceRules []authorizationv1.NonResourceRule) {
	t.Helper()

	ssrr, err := ssrrClient.Create(ctx, &authorizationv1.SelfSubjectRulesReview{
		Spec: authorizationv1.SelfSubjectRulesReviewSpec{Namespace: namespace},
	}, metav1.CreateOptions{})
	assert.NoError(t, err)

	assert.ElementsMatch(t, expectedResourceRules, ssrr.Status.ResourceRules)
	assert.ElementsMatch(t, expectedNonResourceRules, ssrr.Status.NonResourceRules)
}

func getOtherPinnipedGroupSuffix(t *testing.T) string {
	t.Helper()

	env := testlib.IntegrationEnv(t)

	var resources []*metav1.APIResourceList

	testlib.RequireEventuallyWithoutError(t, func() (bool, error) {
		// we need a complete discovery listing for the check we are trying to make below
		// loop since tests like TestAPIServingCertificateAutoCreationAndRotation can break discovery
		_, r, err := testlib.NewKubernetesClientset(t).Discovery().ServerGroupsAndResources()
		if err != nil {
			t.Logf("retrying due to partial discovery failure: %v", err)
			return false, nil
		}

		resources = r
		return true, nil
	}, 3*time.Minute, time.Second)

	var otherPinnipedGroupSuffix string

	for _, resource := range resources {
		gv, err := schema.ParseGroupVersion(resource.GroupVersion)
		require.NoError(t, err)
		for _, apiResource := range resource.APIResources {
			if apiResource.Name == "tokencredentialrequests" && gv.Group != "login.concierge."+env.APIGroupSuffix {
				require.Empty(t, otherPinnipedGroupSuffix, "only expected at most one other instance of pinniped")
				otherPinnipedGroupSuffix = strings.TrimPrefix(gv.Group, "login.concierge.")
			}
		}
	}

	return otherPinnipedGroupSuffix
}
