Repository: skupperproject/skupper
Pull request #1611 (2024-08-22 13:36:27)

improvements to grant configuration
Also includes some refactoring/renaming and some tests.

Main change is to the automatic configuration of grant server, which now only requires a single boolean flag to be set with required skupper resources created automatically.


Patch:
diff --git a/cmd/controller/controller.go b/cmd/controller/controller.go
index 6248f8d1..a71c74da 100644
--- a/cmd/controller/controller.go
+++ b/cmd/controller/controller.go
@@ -22,10 +22,9 @@ import (
 	skupperv1alpha1 "github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
 	"github.com/skupperproject/skupper/pkg/kube"
 	"github.com/skupperproject/skupper/pkg/kube/certificates"
-	"github.com/skupperproject/skupper/pkg/kube/claims"
+	"github.com/skupperproject/skupper/pkg/kube/grants"
 	"github.com/skupperproject/skupper/pkg/kube/securedaccess"
 	"github.com/skupperproject/skupper/pkg/kube/site"
-	"github.com/skupperproject/skupper/pkg/kube/tokens"
 	"github.com/skupperproject/skupper/pkg/network"
 )
 
@@ -38,7 +37,7 @@ type Controller struct {
 	linkAccessWatcher    *kube.RouterAccessWatcher
 	grantWatcher         *kube.AccessGrantWatcher
 	sites                map[string]*site.Site
-	grants               claims.GrantManager
+	startGrantServer     func()
 	accessMgr            *securedaccess.SecuredAccessManager
 	accessRecovery       AccessRecovery
 	certMgr              *certificates.CertificateManagerImpl
@@ -107,7 +106,7 @@ func dynamicSecuredAccess() dynamicinformer.TweakListOptionsFunc {
 	return dynamicWatcherOptions("internal.skupper.io/secured-access")
 }
 
-func NewController(cli internalclient.Clients, watchNamespace string, currentNamespace string) (*Controller, error) {
+func NewController(cli internalclient.Clients, grantConfig *grants.GrantConfig, watchNamespace string, currentNamespace string) (*Controller, error) {
 	controller := &Controller{
 		controller:           kube.NewController("Controller", cli),
 		sites:                map[string]*site.Site{},
@@ -135,9 +134,7 @@ func NewController(cli internalclient.Clients, watchNamespace string, currentNam
 	controller.accessRecovery.routeWatcher = controller.controller.WatchRoutes(routeSecuredAccess(), watchNamespace, controller.checkSecuredAccessRoute)
 	controller.accessRecovery.httpProxyWatcher = controller.controller.WatchContourHttpProxies(dynamicSecuredAccess(), watchNamespace, controller.checkSecuredAccessHttpProxy)
 
-	controller.grants = claims.NewGrantManager(controller.controller, claims.GrantConfigFromEnv(), controller.generateLinkConfig)
-	controller.grantWatcher = controller.controller.WatchAccessGrants(watchNamespace, controller.grants.GrantChanged)
-	controller.grants.Watch(controller.controller, currentNamespace)
+	controller.startGrantServer = grants.Initialise(controller.controller, currentNamespace, watchNamespace, grantConfig, controller.generateLinkConfig)
 
 	return controller, nil
 }
@@ -174,12 +171,11 @@ func (c *Controller) Run(stopCh <-chan struct{}) error {
 		site := c.getSite(la.ObjectMeta.Namespace)
 		site.CheckRouterAccess(la.ObjectMeta.Name, la)
 	}
-	for _, grant := range c.grantWatcher.List() {
-		c.grants.GrantChanged(fmt.Sprintf("%s/%s", grant.Namespace, grant.Name), grant)
-	}
 	c.certMgr.Recover()
 	c.accessRecovery.recoverAll(c.accessMgr)
-	c.grants.Start()
+	if c.startGrantServer != nil {
+		c.startGrantServer()
+	}
 
 	log.Println("Starting event loop")
 	c.controller.Start(stopCh)
@@ -265,7 +261,7 @@ func (c *Controller) checkAccessToken(key string, token *skupperv1alpha1.AccessT
 	if site == nil {
 		return nil
 	}
-	return claims.RedeemAccessToken(token, site, c.controller)
+	return grants.RedeemAccessToken(token, site, c.controller)
 }
 
 func (c *Controller) routerPodEvent(key string, pod *corev1.Pod) error {
@@ -281,7 +277,7 @@ func (c *Controller) generateLinkConfig(namespace string, name string, subject s
 	if site == nil {
 		return fmt.Errorf("Site not yet defined for %s", namespace)
 	}
-	generator, err := tokens.NewTokenGeneratorForSite(site, c.controller)
+	generator, err := grants.NewTokenGenerator(site, c.controller)
 	if err != nil {
 		return err
 	}
@@ -294,7 +290,6 @@ func (c *Controller) checkSecuredAccess(key string, se *skupperv1alpha1.SecuredA
 		return c.accessMgr.SecuredAccessDeleted(key)
 	}
 	c.getSite(se.ObjectMeta.Namespace).CheckSecuredAccess(se)
-	c.grants.SecuredAccessChanged(key, se)
 	return c.accessMgr.SecuredAccessChanged(key, se)
 }
 
diff --git a/cmd/controller/deploy_cluster_scope.yaml b/cmd/controller/deploy_cluster_scope.yaml
index 63ef8ecc..e681b0ef 100644
--- a/cmd/controller/deploy_cluster_scope.yaml
+++ b/cmd/controller/deploy_cluster_scope.yaml
@@ -174,6 +174,10 @@ spec:
         application: skupper-controller
     spec:
       serviceAccountName: skupper-controller
+      # Prevent kubernetes from injecting env vars for grant service
+      # as these then collide with those that actually configure the
+      # controller:
+      enableServiceLinks: false
       # Please ensure that you can use SeccompProfile and do not use
       # if your project must work on old Kubernetes
       # versions < 1.19 or on vendors versions which
@@ -186,13 +190,9 @@ spec:
       - name: controller
         image: quay.io/skupper/controller:v2-latest
         imagePullPolicy: Always
+        command: ["/app/controller"]
+        args: ["-enable-grants", "-grant-server-autoconfigure"]
         env:
-        - name: SKUPPER_ENABLE_GRANTS
-          value: "true"
-        - name: SKUPPER_CLAIMS_GET_BASE_URL_FROM
-          value: skupper/skupper-grant-server
-        - name: SKUPPER_CLAIMS_TLS_CREDENTIALS_SECRET
-          value: skupper-grant-server
         - name: SKUPPER_CONFIG_SYNC_IMAGE
           value: quay.io/skupper/config-sync:v2-latest
         securityContext:
@@ -207,27 +207,3 @@ spec:
       volumes:
       - name: tls-credentials
         emptyDir: {}
----
-apiVersion: skupper.io/v1alpha1
-kind: Certificate
-metadata:
-  name: skupper-grant-server-ca
-  namespace: skupper
-spec:
-  signing: true
-  ca:      ""
-  subject: "grant server CA"
----
-apiVersion: skupper.io/v1alpha1
-kind: SecuredAccess
-metadata:
-  name: skupper-grant-server
-  namespace: skupper
-spec:
-  selector:
-      application: skupper-controller
-  ports:
-  - name: https
-    port: 9090
-  issuer: skupper-grant-server-ca
-  certificate: skupper-grant-server
diff --git a/cmd/controller/deploy_namespace_scope.yaml b/cmd/controller/deploy_namespace_scope.yaml
index accf3249..ac7d6d19 100644
--- a/cmd/controller/deploy_namespace_scope.yaml
+++ b/cmd/controller/deploy_namespace_scope.yaml
@@ -166,6 +166,10 @@ spec:
         application: skupper-controller
     spec:
       serviceAccountName: skupper-controller
+      # Prevent kubernetes from injecting env vars for grant service
+      # as these then collide with those that actually configure the
+      # controller:
+      enableServiceLinks: false
       securityContext:
         runAsNonRoot: true
         seccompProfile:
@@ -174,13 +178,9 @@ spec:
       - name: controller
         image: quay.io/skupper/controller:v2-latest
         imagePullPolicy: Always
+        command: ["/app/controller"]
+        args: ["-enable-grants", "-grant-server-autoconfigure"]
         env:
-        - name: SKUPPER_ENABLE_GRANTS
-          value: "true"
-        - name: SKUPPER_CLAIMS_GET_BASE_URL_FROM
-          value: grs/skupper-grant-server
-        - name: SKUPPER_CLAIMS_TLS_CREDENTIALS_SECRET
-          value: skupper-grant-server
         - name: SKUPPER_CONFIG_SYNC_IMAGE
           value: quay.io/skupper/config-sync:v2-latest
         - name: WATCH_NAMESPACE
@@ -199,25 +199,3 @@ spec:
       volumes:
       - name: tls-credentials
         emptyDir: {}
----
-apiVersion: skupper.io/v1alpha1
-kind: Certificate
-metadata:
-  name: skupper-grant-server-ca
-spec:
-  signing: true
-  ca:      ""
-  subject: "grant server CA"
----
-apiVersion: skupper.io/v1alpha1
-kind: SecuredAccess
-metadata:
-  name: skupper-grant-server
-spec:
-  selector:
-      application: skupper-controller
-  ports:
-  - name: https
-    port: 9090
-  issuer: skupper-grant-server-ca
-  certificate: skupper-grant-server
diff --git a/cmd/controller/main.go b/cmd/controller/main.go
index e31090dd..56092d9e 100644
--- a/cmd/controller/main.go
+++ b/cmd/controller/main.go
@@ -10,7 +10,9 @@ import (
 
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 
+	iflag "github.com/skupperproject/skupper/internal/flag"
 	internalclient "github.com/skupperproject/skupper/internal/kube/client"
+	"github.com/skupperproject/skupper/pkg/kube/grants"
 	"github.com/skupperproject/skupper/pkg/version"
 )
 
@@ -39,37 +41,43 @@ func SetupSignalHandler() (stopCh <-chan struct{}) {
 }
 
 func main() {
+	flags := flag.NewFlagSet("", flag.ExitOnError)
+	grantConfig, err := grants.BoundGrantConfig(flags)
+	if err != nil {
+		fmt.Println(err.Error())
+		os.Exit(1)
+	}
+
+	var namespace string
+	var kubeconfig string
+	iflag.StringVar(flags, &namespace, "namespace", "NAMESPACE", "", "The Kubernetes namespace scope for the controller")
+	iflag.StringVar(flags, &kubeconfig, "kubeconfig", "KUBECONFIG", "", "A path to the kubeconfig file to use")
+
+	var watchNamespace string
+	iflag.StringVar(flags, &watchNamespace, "watch-namespace", "WATCH_NAMESPACE", metav1.NamespaceAll, "The Kubernetes namespace the controller should monitor for controlled resources (will monitor all if not specified)")
 	// if -version used, report and exit
-	isVersion := flag.Bool("version", false, "Report the version of the Skupper Site Controller")
-	flag.Parse()
+	isVersion := flags.Bool("version", false, "Report the version of the Skupper Controller")
+	flags.Parse(os.Args[1:])
 	if *isVersion {
 		fmt.Println(version.Version)
 		os.Exit(0)
 	}
-
-	namespace := os.Getenv("NAMESPACE")
-	kubeconfig := os.Getenv("KUBECONFIG")
+	log.Printf("Version: %s", version.Version)
+	if watchNamespace == metav1.NamespaceAll {
+		log.Println("Skupper controller watching all namespaces")
+	} else {
+		log.Println("Skupper controller watching namespace", watchNamespace)
+	}
 
 	// set up signals so we handle the first shutdown signal gracefully
 	stopCh := SetupSignalHandler()
 
-	// todo, get context from env?
 	cli, err := internalclient.NewClient(namespace, "", kubeconfig)
 	if err != nil {
 		log.Fatal("Error getting van client ", err.Error())
 	}
 
-	var watchNamespace string
-	if os.Getenv("WATCH_NAMESPACE") != "" {
-		watchNamespace = os.Getenv("WATCH_NAMESPACE")
-		log.Println("Skupper controller watching current namespace ", watchNamespace)
-	} else {
-		watchNamespace = metav1.NamespaceAll
-		log.Println("Skupper controller watching all namespaces")
-	}
-	log.Printf("Version: %s", version.Version)
-
-	controller, err := NewController(cli, watchNamespace, cli.Namespace)
+	controller, err := NewController(cli, grantConfig, watchNamespace, cli.Namespace)
 	if err != nil {
 		log.Fatal("Error getting new site controller ", err.Error())
 	}
diff --git a/internal/cmd/skupper/token/kube/token_issue.go b/internal/cmd/skupper/token/kube/token_issue.go
index 3d84c072..51864ed8 100644
--- a/internal/cmd/skupper/token/kube/token_issue.go
+++ b/internal/cmd/skupper/token/kube/token_issue.go
@@ -9,13 +9,11 @@ import (
 	"github.com/skupperproject/skupper/internal/cmd/skupper/utils"
 	"github.com/skupperproject/skupper/internal/kube/client"
 	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
-	"github.com/skupperproject/skupper/pkg/generated/client/clientset/versioned/scheme"
 	skupperv1alpha1 "github.com/skupperproject/skupper/pkg/generated/client/clientset/versioned/typed/skupper/v1alpha1"
 	"github.com/skupperproject/skupper/pkg/utils/validator"
 	"github.com/spf13/cobra"
 	"k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/runtime/serializer/json"
 )
 
 var (
@@ -167,24 +165,39 @@ func (cmd *CmdTokenIssue) Run() error {
 func (cmd *CmdTokenIssue) WaitUntil() error {
 	err := utils.NewSpinnerWithTimeout("Waiting for token status ...", int(cmd.flags.timeout.Seconds()), func() error {
 
-		token, err := cmd.client.AccessGrants(cmd.namespace).Get(context.TODO(), cmd.grantName, metav1.GetOptions{})
+		accessGrant, err := cmd.client.AccessGrants(cmd.namespace).Get(context.TODO(), cmd.grantName, metav1.GetOptions{})
 		if err != nil {
 			return err
 		}
 
-		if token != nil && token.IsReady() {
-			// write token to file
-			s := json.NewYAMLSerializer(json.DefaultMetaFactory, scheme.Scheme, scheme.Scheme)
-			out, err := os.Create(cmd.fileName)
-			if err != nil {
-				return fmt.Errorf("Could not write to file " + cmd.fileName + ": " + err.Error())
+		if accessGrant != nil && accessGrant.IsReady() {
+
+			accessToken := v1alpha1.AccessToken{
+				TypeMeta: metav1.TypeMeta{
+					APIVersion: "skupper.io/v1alpha1",
+					Kind:       "AccessToken",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name: accessGrant.Name,
+				},
+				Spec: v1alpha1.AccessTokenSpec{
+					Url:  accessGrant.Status.Url,
+					Code: accessGrant.Status.Code,
+					Ca:   accessGrant.Status.Ca,
+				},
 			}
-			err = s.Encode(token, out)
+
+			encodedResource, err := utils.Encode("yaml", accessToken)
 			if err != nil {
 				return fmt.Errorf("Could not write out generated token: " + err.Error())
-			} else {
-				return nil
 			}
+
+			err = os.WriteFile(cmd.fileName, []byte(encodedResource), 0644)
+			if err != nil {
+				return fmt.Errorf("Could not write to file " + cmd.fileName + ": " + err.Error())
+			}
+
+			return nil
 		}
 
 		return fmt.Errorf("error getting the resource")
diff --git a/internal/cmd/skupper/token/kube/token_redeem.go b/internal/cmd/skupper/token/kube/token_redeem.go
index 46afecd1..eaf657ec 100644
--- a/internal/cmd/skupper/token/kube/token_redeem.go
+++ b/internal/cmd/skupper/token/kube/token_redeem.go
@@ -121,7 +121,7 @@ func (cmd *CmdTokenRedeem) ValidateInput(args []string) []error {
 func (cmd *CmdTokenRedeem) Run() error {
 
 	// get data from token file
-	var grant v1alpha1.AccessGrant
+	var accessToken v1alpha1.AccessToken
 	var tokenFile []byte
 	tokenFile, err := os.ReadFile(cmd.fileName)
 	if err != nil {
@@ -129,27 +129,12 @@ func (cmd *CmdTokenRedeem) Run() error {
 		return err
 	}
 	yamlS := json.NewSerializerWithOptions(json.DefaultMetaFactory, scheme.Scheme, scheme.Scheme, json.SerializerOptions{Yaml: true})
-	yamlS.Decode(tokenFile, nil, &grant)
-	cmd.name = grant.Name
-
-	// create AccessToken
-	resource := v1alpha1.AccessToken{
-		TypeMeta: metav1.TypeMeta{
-			APIVersion: "skupper.io/v1alpha1",
-			Kind:       "AccessToken",
-		},
-		ObjectMeta: metav1.ObjectMeta{
-			Name:      cmd.name,
-			Namespace: cmd.namespace,
-		},
-		Spec: v1alpha1.AccessTokenSpec{
-			Url:  grant.Status.Url,
-			Ca:   grant.Status.Ca,
-			Code: grant.Status.Code,
-		},
-	}
+	yamlS.Decode(tokenFile, nil, &accessToken)
+
+	accessToken.Namespace = cmd.namespace
+	cmd.name = accessToken.Name
 
-	_, err = cmd.client.AccessTokens(cmd.namespace).Create(context.TODO(), &resource, metav1.CreateOptions{})
+	_, err = cmd.client.AccessTokens(cmd.namespace).Create(context.TODO(), &accessToken, metav1.CreateOptions{})
 	return err
 }
 
diff --git a/internal/flag/flag.go b/internal/flag/flag.go
new file mode 100644
index 00000000..807d14c6
--- /dev/null
+++ b/internal/flag/flag.go
@@ -0,0 +1,55 @@
+package flag
+
+import (
+	"flag"
+	"fmt"
+	"os"
+	"strconv"
+)
+
+func StringVar(flags *flag.FlagSet, output *string, flagName string, envVarName string, defaultValue string, usage string) {
+	flags.StringVar(output, flagName, stringEnvVar(envVarName, defaultValue), usage)
+}
+
+func BoolVar(flags *flag.FlagSet, output *bool, flagName string, envVarName string, defaultValue bool, usage string) error {
+	dval, err := boolEnvVar(envVarName, defaultValue)
+	//set flag inspite of error, caller can decide whether to ignore and go with default or not
+	flags.BoolVar(output, flagName, dval, usage)
+	return err
+}
+
+func IntVar(flags *flag.FlagSet, output *int, flagName string, envVarName string, defaultValue int, usage string) error {
+	dval, err := intEnvVar(envVarName, defaultValue)
+	//set flag inspite of error, caller can decide whether to ignore and go with default or not
+	flags.IntVar(output, flagName, dval, usage)
+	return err
+}
+
+func intEnvVar(name string, defaultValue int) (int, error) {
+	if svalue, ok := os.LookupEnv(name); ok {
+		value, err := strconv.Atoi(svalue)
+		if err != nil {
+			return defaultValue, fmt.Errorf("Bad value for %q: %s", name, err)
+		}
+		return value, nil
+	}
+	return defaultValue, nil
+}
+
+func boolEnvVar(name string, defaultValue bool) (bool, error) {
+	if svalue, ok := os.LookupEnv(name); ok {
+		value, err := strconv.ParseBool(svalue)
+		if err != nil {
+			return defaultValue, fmt.Errorf("Bad value for %q: %s", name, err)
+		}
+		return value, nil
+	}
+	return defaultValue, nil
+}
+
+func stringEnvVar(name string, defaultValue string) string {
+	if value, ok := os.LookupEnv(name); ok {
+		return value
+	}
+	return defaultValue
+}
diff --git a/pkg/apis/skupper/v1alpha1/types.go b/pkg/apis/skupper/v1alpha1/types.go
index 3ff6539d..28ae691a 100644
--- a/pkg/apis/skupper/v1alpha1/types.go
+++ b/pkg/apis/skupper/v1alpha1/types.go
@@ -54,6 +54,16 @@ func (s *Site) GetSiteId() string {
 	return string(s.ObjectMeta.UID)
 }
 
+func (s *Site) DefaultIssuer() string {
+	if s.Spec.DefaultIssuer != "" {
+		return s.Spec.DefaultIssuer
+	}
+	if s.Status.DefaultIssuer != "" {
+		return s.Status.DefaultIssuer
+	}
+	return "skupper-site-ca"
+}
+
 // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
 
 // SiteList contains a List of Site instances
diff --git a/pkg/certs/certs.go b/pkg/certs/certs.go
index 3312409e..c3321815 100644
--- a/pkg/certs/certs.go
+++ b/pkg/certs/certs.go
@@ -163,6 +163,8 @@ func generateSecret(name string, subject string, hosts string, ca *CertificateAu
 	secret.Data["tls.key"] = []byte(keyString)
 	if ca != nil {
 		secret.Data["ca.crt"] = ca.CrtData
+	} else {
+		secret.Data["ca.crt"] = secret.Data["tls.crt"] //self.signed
 	}
 
 	return secret
diff --git a/pkg/kube/claims/grant_config.go b/pkg/kube/claims/grant_config.go
deleted file mode 100644
index 25a50bfc..00000000
--- a/pkg/kube/claims/grant_config.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package claims
-
-import (
-	"os"
-	"path"
-	"strconv"
-)
-
-type GrantConfig struct {
-	Enabled              bool
-	BaseUrl              string
-	SecuredAccessKey     string
-	Addr                 string
-	TlsCredentialsPath   string
-	TlsCredentialsSecret string
-	CertPath             string
-	KeyPath              string
-	CaPath               string
-}
-
-func GrantConfigFromEnv() *GrantConfig {
-	c := &GrantConfig{
-		Enabled:              boolEnvVar("SKUPPER_ENABLE_GRANTS"),
-		BaseUrl:              os.Getenv("SKUPPER_CLAIMS_BASE_URL"),
-		SecuredAccessKey:     os.Getenv("SKUPPER_CLAIMS_GET_BASE_URL_FROM"),
-		TlsCredentialsPath:   os.Getenv("SKUPPER_CLAIMS_TLS_CREDENTIALS_PATH"),
-		TlsCredentialsSecret: os.Getenv("SKUPPER_CLAIMS_TLS_CREDENTIALS_SECRET"),
-	}
-
-	port := os.Getenv("SKUPPER_CLAIMS_PORT")
-	if port == "" {
-		port = "9090"
-	}
-	c.Addr = ":" + port
-
-	if c.TlsCredentialsSecret != "" && c.TlsCredentialsPath == "" {
-		c.TlsCredentialsPath = "/etc/controller/grant-server"
-	}
-	if c.TlsCredentialsPath != "" {
-		c.CertPath = path.Join(c.TlsCredentialsPath, "tls.crt")
-		c.KeyPath = path.Join(c.TlsCredentialsPath, "tls.key")
-		c.CaPath = path.Join(c.TlsCredentialsPath, "ca.crt")
-	}
-
-	return c
-}
-
-func (c *GrantConfig) scheme() string {
-	if c.TlsCredentialsSecret != "" {
-		return "https"
-	} else {
-		return "http"
-	}
-}
-
-func boolEnvVar(name string) bool {
-	value, _ := strconv.ParseBool(os.Getenv(name))
-	return value
-}
diff --git a/pkg/kube/claims/grant_manager.go b/pkg/kube/claims/grant_manager.go
deleted file mode 100644
index a3f1ea51..00000000
--- a/pkg/kube/claims/grant_manager.go
+++ /dev/null
@@ -1,102 +0,0 @@
-package claims
-
-import (
-	"context"
-	"log"
-
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-
-	internalclient "github.com/skupperproject/skupper/internal/kube/client"
-	skupperv1alpha1 "github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
-	"github.com/skupperproject/skupper/pkg/kube"
-)
-
-type GrantManager interface {
-	Watch(controller *kube.Controller, namespace string)
-	Start()
-	GrantChanged(key string, grant *skupperv1alpha1.AccessGrant) error
-	SecuredAccessChanged(key string, se *skupperv1alpha1.SecuredAccess)
-}
-
-func NewGrantManager(clients internalclient.Clients, config *GrantConfig, generator GrantResponse) GrantManager {
-	if !config.Enabled {
-		return &GrantsDisabled{
-			clients: clients,
-		}
-	}
-	if config.SecuredAccessKey != "" {
-		server := &UrlFromSecuredAccess{
-			key:                  config.SecuredAccessKey,
-			tlsCredentialsPath:   config.TlsCredentialsPath,
-			tlsCredentialsSecret: config.TlsCredentialsSecret,
-		}
-		server.configure(clients, config, generator)
-		return server
-	}
-	server := &UrlFromEnv{}
-	server.configure(clients, config, generator)
-	return server
-}
-
-type UrlFromEnv struct {
-	GrantServer
-}
-
-func (s *UrlFromEnv) SecuredAccessChanged(key string, se *skupperv1alpha1.SecuredAccess) {}
-func (s *UrlFromEnv) Watch(controller *kube.Controller, namespace string)                {}
-
-func (s *UrlFromEnv) Start() {
-	s.start()
-}
-
-type UrlFromSecuredAccess struct {
-	GrantServer
-	key                  string
-	ready                bool
-	started              bool
-	tlsCredentialsPath   string
-	tlsCredentialsSecret string
-}
-
-func (s *UrlFromSecuredAccess) SecuredAccessChanged(key string, se *skupperv1alpha1.SecuredAccess) {
-	log.Printf("Grant Manager handling SecuredAccess event for %s", key)
-	if se != nil && s.key == key && len(se.Status.Endpoints) > 0 && s.grants.getUrl() != se.Status.Endpoints[0].Url() {
-		if s.grants.setUrl(se.Status.Endpoints[0].Url()) {
-			s.grants.recheckUrl()
-		}
-		if s.ready && !s.started {
-			s.started = true
-			log.Print("Starting grant server")
-			s.start()
-		}
-	}
-}
-
-func (s *UrlFromSecuredAccess) Start() {
-	s.ready = true
-	if s.grants.url != "" {
-		s.start()
-	}
-}
-
-func (s *UrlFromSecuredAccess) Watch(controller *kube.Controller, namespace string) {
-	controller.WatchSecrets(kube.ByName(s.tlsCredentialsSecret), namespace, s.tlsCredentialsUpdated)
-}
-
-type GrantsDisabled struct {
-	clients internalclient.Clients
-}
-
-func (s *GrantsDisabled) GrantChanged(key string, grant *skupperv1alpha1.AccessGrant) error {
-	if grant == nil || !grant.Status.SetStatusMessage("AccessGrants are not enabled") {
-		return nil
-	}
-	if _, err := s.clients.GetSkupperClient().SkupperV1alpha1().AccessGrants(grant.ObjectMeta.Namespace).UpdateStatus(context.TODO(), grant, metav1.UpdateOptions{}); err != nil {
-		log.Printf("AccessGrants are not enabled. Error updating status for %s: %s", key, err)
-	}
-	return nil
-}
-
-func (s *GrantsDisabled) SecuredAccessChanged(key string, se *skupperv1alpha1.SecuredAccess) {}
-func (s *GrantsDisabled) Start()                                                             {}
-func (s *GrantsDisabled) Watch(controller *kube.Controller, namespace string)                {}
diff --git a/pkg/kube/claims/grant_server.go b/pkg/kube/claims/grant_server.go
deleted file mode 100644
index 2e3e0afb..00000000
--- a/pkg/kube/claims/grant_server.go
+++ /dev/null
@@ -1,95 +0,0 @@
-package claims
-
-import (
-	"crypto/tls"
-	"log"
-	"net/http"
-	"sync"
-	"time"
-
-	corev1 "k8s.io/api/core/v1"
-
-	internalclient "github.com/skupperproject/skupper/internal/kube/client"
-	skupperv1alpha1 "github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
-	"github.com/skupperproject/skupper/pkg/utils/tlscfg"
-)
-
-type GrantServer struct {
-	grants   *Grants
-	addr     string
-	keyPath  string
-	certPath string
-	lock     sync.RWMutex
-	cert     *tls.Certificate
-}
-
-func (s *GrantServer) GrantChanged(key string, grant *skupperv1alpha1.AccessGrant) error {
-	return s.grants.checkGrant(key, grant)
-}
-
-func (s *GrantServer) start() {
-	go s.listen()
-}
-
-func (s *GrantServer) configure(clients internalclient.Clients, config *GrantConfig, generator GrantResponse) {
-	s.addr = config.Addr
-	s.keyPath = config.KeyPath
-	s.certPath = config.CertPath
-	s.grants = newGrants(clients, generator, config.scheme(), config.BaseUrl)
-}
-
-func (s *GrantServer) getCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
-	s.lock.RLock()
-	defer s.lock.RUnlock()
-	return s.cert, nil
-}
-
-func (s *GrantServer) setCertificate(cert *tls.Certificate) {
-	s.lock.Lock()
-	defer s.lock.Unlock()
-	s.cert = cert
-}
-
-func (s *GrantServer) tlsCredentialsUpdated(key string, secret *corev1.Secret) error {
-	if secret == nil {
-		return nil
-	}
-	cert, err := tls.X509KeyPair(secret.Data["tls.crt"], secret.Data["tls.key"])
-	if err != nil {
-		log.Printf("Could not get X509 key pair for grant server from %s: %s", key, err)
-		return nil
-	}
-	s.setCertificate(&cert)
-	if s.grants.setCA(string(secret.Data["ca.crt"])) {
-		s.grants.recheckCa()
-	}
-	log.Print("Grant server tls credentials updated")
-	return nil
-}
-
-func (s *GrantServer) listen() {
-	server := &http.Server{
-		Addr:         s.addr,
-		Handler:      s.grants,
-		ReadTimeout:  60 * time.Second,
-		WriteTimeout: 60 * time.Second,
-		TLSConfig:    tlscfg.Modern(),
-	}
-
-	if s.certPath != "" && s.keyPath != "" {
-		server.TLSConfig.GetCertificate = s.getCertificate
-		err := server.ListenAndServeTLS("", "")
-		if err != nil {
-			log.Printf("Grant server failed to start on %s: %s", s.addr, err)
-		} else {
-			log.Printf("Grant server listening on %s (TLS enabled)", s.addr)
-		}
-	} else {
-		err := server.ListenAndServe()
-		if err != nil {
-			log.Printf("Grant server failed to start on %s: %s", s.addr, err)
-		} else {
-			log.Printf("Grant server listening on %s (TLS not enabled)", s.addr)
-		}
-	}
-}
diff --git a/pkg/kube/claims/redeem.go b/pkg/kube/claims/redeem.go
deleted file mode 100644
index 3a5913c4..00000000
--- a/pkg/kube/claims/redeem.go
+++ /dev/null
@@ -1,124 +0,0 @@
-package claims
-
-import (
-	"bytes"
-	"context"
-	"crypto/tls"
-	"crypto/x509"
-	"fmt"
-	"io"
-	"log"
-	"net/http"
-	"strings"
-
-	corev1 "k8s.io/api/core/v1"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/util/yaml"
-
-	internalclient "github.com/skupperproject/skupper/internal/kube/client"
-	skupperv1alpha1 "github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
-)
-
-func RedeemAccessToken(claim *skupperv1alpha1.AccessToken, site *skupperv1alpha1.Site, clients internalclient.Clients) error {
-	transport := &http.Transport{}
-	if claim.Spec.Ca != "" {
-		caPool := x509.NewCertPool()
-		caPool.AppendCertsFromPEM([]byte(claim.Spec.Ca))
-		transport.TLSClientConfig = &tls.Config{
-			RootCAs: caPool,
-		}
-	}
-	client := &http.Client{
-		Transport: transport,
-	}
-	request, err := http.NewRequest(http.MethodPost, claim.Spec.Url, bytes.NewReader([]byte(claim.Spec.Code)))
-	if err != nil {
-		return err
-	}
-	request.Header.Add("name", claim.Name)
-	request.Header.Add("subject", string(site.ObjectMeta.UID))
-	response, err := client.Do(request)
-	if err != nil {
-		return updateAccessTokenStatus(claim, err, clients)
-	}
-	if response.StatusCode != http.StatusOK {
-		body, err := io.ReadAll(response.Body)
-		if err == nil {
-			err = fmt.Errorf("Received HTTP Response %d: %s", response.StatusCode, strings.TrimSpace(string(body)))
-		} else {
-			err = fmt.Errorf("Received HTTP Response %d. Could not read body: %s", response.StatusCode, err)
-		}
-		return updateAccessTokenStatus(claim, err, clients)
-	}
-	log.Printf("HTTP Post to %s for %s/%s was sucessful, decoding response body", claim.Spec.Url, claim.Namespace, claim.Name)
-
-	decoder := newLinkDecoder(response.Body)
-	if err := decoder.decodeAll(); err != nil {
-		return updateAccessTokenStatus(claim, err, clients)
-	}
-	refs := []metav1.OwnerReference{
-		{
-			Kind:       "Site",
-			APIVersion: "skupper.io/v1alpha1",
-			Name:       site.Name,
-			UID:        site.ObjectMeta.UID,
-		},
-	}
-	decoder.secret.ObjectMeta.OwnerReferences = refs
-	if _, err := clients.GetKubeClient().CoreV1().Secrets(claim.ObjectMeta.Namespace).Create(context.TODO(), &decoder.secret, metav1.CreateOptions{}); err != nil {
-		return err
-	}
-	for _, link := range decoder.links {
-		link.ObjectMeta.OwnerReferences = refs
-		if _, err := clients.GetSkupperClient().SkupperV1alpha1().Links(claim.ObjectMeta.Namespace).Create(context.TODO(), &link, metav1.CreateOptions{}); err != nil {
-			return err
-		}
-	}
-
-	return updateAccessTokenStatus(claim, nil, clients)
-}
-
-func updateAccessTokenStatus(claim *skupperv1alpha1.AccessToken, err error, clients internalclient.Clients) error {
-	if claim.SetRedeemed(err) {
-		_, err = clients.GetSkupperClient().SkupperV1alpha1().AccessTokens(claim.ObjectMeta.Namespace).UpdateStatus(context.TODO(), claim, metav1.UpdateOptions{})
-		return err
-	}
-	return nil
-}
-
-type LinkDecoder struct {
-	decoder *yaml.YAMLOrJSONDecoder
-	secret  corev1.Secret
-	links   []skupperv1alpha1.Link
-}
-
-func newLinkDecoder(r io.Reader) *LinkDecoder {
-	return &LinkDecoder{
-		decoder: yaml.NewYAMLOrJSONDecoder(r, 1024),
-	}
-}
-
-func (d *LinkDecoder) decodeSecret() error {
-	return d.decoder.Decode(&d.secret)
-}
-
-func (d *LinkDecoder) decodeLink() error {
-	var link skupperv1alpha1.Link
-	if err := d.decoder.Decode(&link); err != nil {
-		return err
-	}
-	d.links = append(d.links, link)
-	return nil
-}
-
-func (d *LinkDecoder) decodeAll() error {
-	if err := d.decodeSecret(); err != nil {
-		return err
-	}
-	for err := d.decodeLink(); err != io.EOF; err = d.decodeLink() {
-		if err != nil {
-			return err
-		}
-	}
-	return nil
-}
diff --git a/pkg/kube/controller.go b/pkg/kube/controller.go
index 313cfe0a..f873111e 100644
--- a/pkg/kube/controller.go
+++ b/pkg/kube/controller.go
@@ -46,6 +46,7 @@ import (
 
 	skupperv1alpha1 "github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
 	skupperclient "github.com/skupperproject/skupper/pkg/generated/client/clientset/versioned"
+	skupperv1alpha1interfaces "github.com/skupperproject/skupper/pkg/generated/client/informers/externalversions/internalinterfaces"
 	skupperv1alpha1informer "github.com/skupperproject/skupper/pkg/generated/client/informers/externalversions/skupper/v1alpha1"
 )
 
@@ -160,6 +161,10 @@ func (c *Controller) run() {
 	}
 }
 
+func (c *Controller) TestProcess() bool {
+	return c.process()
+}
+
 func (c *Controller) process() bool {
 	obj, shutdown := c.queue.Get()
 
@@ -1241,6 +1246,22 @@ func (c *Controller) WatchSecuredAccesses(namespace string, handler SecuredAcces
 	return watcher
 }
 
+func (c *Controller) WatchSecuredAccessesWithOptions(options skupperv1alpha1interfaces.TweakListOptionsFunc, namespace string, handler SecuredAccessHandler) *SecuredAccessWatcher {
+	watcher := &SecuredAccessWatcher{
+		handler: handler,
+		informer: skupperv1alpha1informer.NewFilteredSecuredAccessInformer(
+			c.skupperClient,
+			namespace,
+			time.Second*30,
+			cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc},
+			options),
+		namespace: namespace,
+	}
+	watcher.informer.AddEventHandler(c.newEventHandler(watcher))
+	c.addWatcher(watcher)
+	return watcher
+}
+
 type SecuredAccessHandler func(string, *skupperv1alpha1.SecuredAccess) error
 
 type SecuredAccessWatcher struct {
@@ -1574,6 +1595,12 @@ func ByName(name string) internalinterfaces.TweakListOptionsFunc {
 	}
 }
 
+func SkupperResourceByName(name string) skupperv1alpha1interfaces.TweakListOptionsFunc {
+	return func(options *metav1.ListOptions) {
+		options.FieldSelector = "metadata.name=" + name
+	}
+}
+
 func (c *Controller) WatchAttachedConnectorAnchors(namespace string, handler AttachedConnectorAnchorHandler) *AttachedConnectorAnchorWatcher {
 	watcher := &AttachedConnectorAnchorWatcher{
 		handler: handler,
diff --git a/pkg/kube/grants/autoconfigure.go b/pkg/kube/grants/autoconfigure.go
new file mode 100644
index 00000000..b3f92b5a
--- /dev/null
+++ b/pkg/kube/grants/autoconfigure.go
@@ -0,0 +1,150 @@
+package grants
+
+import (
+	"context"
+	"fmt"
+	"reflect"
+
+	"k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	internalclient "github.com/skupperproject/skupper/internal/kube/client"
+	skupperv1alpha1 "github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
+	"github.com/skupperproject/skupper/pkg/kube"
+)
+
+type AutoConfigure struct {
+	port                 int
+	podname              string
+	tlsCredentialsSecret string
+	ownerRefs            []metav1.OwnerReference
+	selector             map[string]string
+}
+
+func (s *AutoConfigure) getConfigurationFromPod(clients internalclient.Clients, namespace string) error {
+	pod, err := clients.GetKubeClient().CoreV1().Pods(namespace).Get(context.TODO(), s.podname, metav1.GetOptions{})
+	if err != nil {
+		return err
+	}
+	s.ownerRefs = pod.ObjectMeta.OwnerReferences
+	s.selector = pod.ObjectMeta.Labels
+	return nil
+}
+
+func (s *AutoConfigure) ensureCert(clients internalclient.Clients, namespace string, desired *skupperv1alpha1.Certificate) error {
+	existing, err := clients.GetSkupperClient().SkupperV1alpha1().Certificates(namespace).Get(context.Background(), desired.Name, metav1.GetOptions{})
+	if errors.IsNotFound(err) {
+		_, err = clients.GetSkupperClient().SkupperV1alpha1().Certificates(namespace).Create(context.Background(), desired, metav1.CreateOptions{})
+		return err
+	} else if err != nil {
+		return err
+	}
+	changed := false
+	if !reflect.DeepEqual(existing.ObjectMeta.OwnerReferences, desired.ObjectMeta.OwnerReferences) {
+		changed = true
+		existing.ObjectMeta.OwnerReferences = desired.ObjectMeta.OwnerReferences
+	}
+	if !reflect.DeepEqual(existing.Spec, desired.Spec) {
+		changed = true
+		existing.Spec = desired.Spec
+	}
+	if !changed {
+		return nil
+	}
+	_, err = clients.GetSkupperClient().SkupperV1alpha1().Certificates(namespace).Update(context.Background(), existing, metav1.UpdateOptions{})
+	return err
+}
+
+func (s *AutoConfigure) ensureSecuredAccess(clients internalclient.Clients, namespace string, desired *skupperv1alpha1.SecuredAccess) error {
+	existing, err := clients.GetSkupperClient().SkupperV1alpha1().SecuredAccesses(namespace).Get(context.Background(), desired.Name, metav1.GetOptions{})
+	if errors.IsNotFound(err) {
+		_, err = clients.GetSkupperClient().SkupperV1alpha1().SecuredAccesses(namespace).Create(context.Background(), desired, metav1.CreateOptions{})
+		return err
+	} else if err != nil {
+		return err
+	}
+	changed := false
+	if !reflect.DeepEqual(existing.ObjectMeta.OwnerReferences, desired.ObjectMeta.OwnerReferences) {
+		changed = true
+		existing.ObjectMeta.OwnerReferences = desired.ObjectMeta.OwnerReferences
+	}
+	if !reflect.DeepEqual(existing.Spec, desired.Spec) {
+		changed = true
+		existing.Spec = desired.Spec
+	}
+	if !changed {
+		return nil
+	}
+	_, err = clients.GetSkupperClient().SkupperV1alpha1().SecuredAccesses(namespace).Update(context.Background(), existing, metav1.UpdateOptions{})
+	return err
+}
+
+func (s *AutoConfigure) configure(clients internalclient.Clients, namespace string) error {
+	if err := s.getConfigurationFromPod(clients, namespace); err != nil {
+		return err
+	}
+
+	cert := &skupperv1alpha1.Certificate{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "skupper.io/v1alpha1",
+			Kind:       "Certificate",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:            "skupper-grant-server-ca",
+			OwnerReferences: s.ownerRefs,
+		},
+		Spec: skupperv1alpha1.CertificateSpec{
+			Ca:      "",
+			Subject: "SkupperGrantServerCA",
+			Signing: true,
+			Client:  false,
+			Server:  false,
+		},
+	}
+	if err := s.ensureCert(clients, namespace, cert); err != nil {
+		return err
+	}
+
+	sa := &skupperv1alpha1.SecuredAccess{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "skupper.io/v1alpha1",
+			Kind:       "SecuredAccess",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:            "skupper-grant-server",
+			OwnerReferences: s.ownerRefs,
+		},
+		Spec: skupperv1alpha1.SecuredAccessSpec{
+			Selector: s.selector,
+			Ports: []skupperv1alpha1.SecuredAccessPort{
+				{
+					Name: "https",
+					Port: s.port,
+				},
+			},
+			Issuer:      "skupper-grant-server-ca",
+			Certificate: s.tlsCredentialsSecret,
+		},
+	}
+	if err := s.ensureSecuredAccess(clients, namespace, sa); err != nil {
+		return err
+	}
+	return nil
+}
+
+func newAutoConfigure(handler kube.SecuredAccessHandler, controller *kube.Controller, currentNamespace string, config *GrantConfig) (*AutoConfigure, error) {
+	ac := &AutoConfigure{
+		port:                 config.Port,
+		tlsCredentialsSecret: config.TlsCredentialsSecret,
+		podname:              config.Hostname,
+	}
+	if ac.tlsCredentialsSecret == "" {
+		//TODO: should setting TlsCredentialsSecret be allowed when auto configure is enabled?
+		ac.tlsCredentialsSecret = "skupper-grant-server"
+	}
+	if err := ac.configure(controller, currentNamespace); err != nil {
+		return nil, fmt.Errorf("Error creating resources for grant server: %s", err)
+	}
+	controller.WatchSecuredAccessesWithOptions(kube.SkupperResourceByName("skupper-grant-server"), currentNamespace, handler)
+	return ac, nil
+}
diff --git a/pkg/kube/grants/config.go b/pkg/kube/grants/config.go
new file mode 100644
index 00000000..660b0d77
--- /dev/null
+++ b/pkg/kube/grants/config.go
@@ -0,0 +1,55 @@
+package grants
+
+import (
+	"flag"
+	"fmt"
+	"strings"
+
+	iflag "github.com/skupperproject/skupper/internal/flag"
+)
+
+type GrantConfig struct {
+	Enabled              bool
+	AutoConfigure        bool
+	BaseUrl              string
+	Port                 int
+	TlsCredentialsSecret string
+	Hostname             string
+}
+
+func BoundGrantConfig(flags *flag.FlagSet) (*GrantConfig, error) {
+	c := &GrantConfig{}
+	var errors []string
+	if err := iflag.BoolVar(flags, &c.Enabled, "enable-grants", "SKUPPER_ENABLE_GRANTS", false, "Enable use of AccessGrants."); err != nil {
+		errors = append(errors, err.Error())
+	}
+	if err := iflag.BoolVar(flags, &c.AutoConfigure, "grant-server-autoconfigure", "SKUPPER_GRANT_SERVER_AUTOCONFIGURE", false, "Automatically configure the URL and TLS credentials for the AccessGrant Server."); err != nil {
+		errors = append(errors, err.Error())
+	}
+	iflag.StringVar(flags, &c.BaseUrl, "grant-server-base-url", "SKUPPER_GRANT_SERVER_BASE_URL", "", "The base url through which the AccessGrant server can be reached.")
+	if err := iflag.IntVar(flags, &c.Port, "grant-server-port", "SKUPPER_GRANT_SERVER_PORT", 9090, "The port on which the AccessGrant server should listen."); err != nil {
+		errors = append(errors, err.Error())
+	}
+	iflag.StringVar(flags, &c.TlsCredentialsSecret, "grant-server-tls-credentials", "SKUPPER_GRANT_SERVER_TLS_CREDENTIALS", "skupper-grant-server", "The name of a secret in which TLS credentials for the AccessGrant server are found.")
+	iflag.StringVar(flags, &c.Hostname, "grant-server-podname", "HOSTNAME", "", "The name of the pod in which the AccessGrant server is running (defaults to $HOSTNAME).")
+	if len(errors) > 0 {
+		return c, fmt.Errorf("Invalid environment variable(s): %s", strings.Join(errors, ", "))
+	}
+	return c, nil
+}
+
+func (c *GrantConfig) addr() string {
+	return fmt.Sprintf(":%d", c.Port)
+}
+
+func (c *GrantConfig) scheme() string {
+	if c.tlsEnabled() {
+		return "https"
+	} else {
+		return "http"
+	}
+}
+
+func (c *GrantConfig) tlsEnabled() bool {
+	return c.TlsCredentialsSecret != ""
+}
diff --git a/pkg/kube/grants/disabled.go b/pkg/kube/grants/disabled.go
new file mode 100644
index 00000000..4f7ff233
--- /dev/null
+++ b/pkg/kube/grants/disabled.go
@@ -0,0 +1,34 @@
+package grants
+
+import (
+	"context"
+	"log"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	internalclient "github.com/skupperproject/skupper/internal/kube/client"
+	skupperv1alpha1 "github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
+	"github.com/skupperproject/skupper/pkg/kube"
+)
+
+type GrantsDisabled struct {
+	clients internalclient.Clients
+}
+
+func (s *GrantsDisabled) markGrantNotEnabled(key string, grant *skupperv1alpha1.AccessGrant) error {
+	if grant == nil || !grant.Status.SetStatusMessage("AccessGrants are not enabled") {
+		return nil
+	}
+	if _, err := s.clients.GetSkupperClient().SkupperV1alpha1().AccessGrants(grant.ObjectMeta.Namespace).UpdateStatus(context.TODO(), grant, metav1.UpdateOptions{}); err != nil {
+		log.Printf("AccessGrants are not enabled. Error updating status for %s: %s", key, err)
+	}
+	return nil
+}
+
+func disabled(controller *kube.Controller, watchNamespace string) *GrantsDisabled {
+	mgr := &GrantsDisabled{
+		clients: controller,
+	}
+	controller.WatchAccessGrants(watchNamespace, mgr.markGrantNotEnabled)
+	return mgr
+}
diff --git a/pkg/kube/grants/enabled.go b/pkg/kube/grants/enabled.go
new file mode 100644
index 00000000..9f0beaf8
--- /dev/null
+++ b/pkg/kube/grants/enabled.go
@@ -0,0 +1,88 @@
+package grants
+
+import (
+	"fmt"
+	"log"
+
+	corev1 "k8s.io/api/core/v1"
+
+	skupperv1alpha1 "github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
+	"github.com/skupperproject/skupper/pkg/kube"
+)
+
+func enabled(controller *kube.Controller, currentNamespace string, watchNamespace string, config *GrantConfig, generator GrantResponse) *GrantsEnabled {
+	gc := &GrantsEnabled{
+		grants: newGrants(controller, generator, config.scheme(), config.BaseUrl),
+	}
+	gc.server = newServer(config.addr(), config.tlsEnabled(), gc.grants)
+
+	gc.grantWatcher = controller.WatchAccessGrants(watchNamespace, gc.grants.checkGrant)
+	gc.secretWatcher = controller.WatchSecrets(kube.ByName(config.TlsCredentialsSecret), watchNamespace, gc.tlsCredentialsUpdated)
+
+	if config.AutoConfigure {
+		ac, err := newAutoConfigure(gc.securedAccessChanged, controller, currentNamespace, config)
+		if err != nil {
+			log.Printf("Auto configuration of grant server failed: %s", err)
+		}
+		gc.autoConfigure = ac
+	}
+	return gc
+}
+
+type GrantsEnabled struct {
+	grants        *Grants
+	server        *Server
+	grantWatcher  *kube.AccessGrantWatcher
+	secretWatcher *kube.SecretWatcher
+	autoConfigure *AutoConfigure
+	started       bool
+}
+
+func (c *GrantsEnabled) Start() {
+	c.recoverGrants()
+	c.recoverSecrets()
+	if c.autoConfigure == nil {
+		c.server.start()
+	}
+}
+
+func (c *GrantsEnabled) recoverGrants() {
+	for _, grant := range c.grantWatcher.List() {
+		c.grants.checkGrant(fmt.Sprintf("%s/%s", grant.Namespace, grant.Name), grant)
+	}
+}
+
+func (c *GrantsEnabled) recoverSecrets() {
+	for _, secret := range c.secretWatcher.List() {
+		c.tlsCredentialsUpdated(fmt.Sprintf("%s/%s", secret.Namespace, secret.Name), secret)
+	}
+}
+
+func (s *GrantsEnabled) securedAccessChanged(key string, se *skupperv1alpha1.SecuredAccess) error {
+	if se != nil && len(se.Status.Endpoints) > 0 {
+		if s.grants.setUrl(se.Status.Endpoints[0].Url()) {
+			s.grants.recheckUrl()
+		}
+		if !s.started {
+			s.started = true
+			log.Print("Starting grant server")
+			s.server.start()
+		}
+	}
+	return nil
+}
+
+func (s *GrantsEnabled) tlsCredentialsUpdated(key string, secret *corev1.Secret) error {
+	if secret == nil {
+		return nil
+	}
+	if err := s.server.setCertificateFromSecret(secret); err != nil {
+		log.Printf("Could not set certificate from grant server from %s: %s", key, err)
+		return nil
+	}
+	if s.grants.setCA(string(secret.Data["ca.crt"])) {
+		s.grants.recheckCa()
+	}
+	log.Print("Grant server tls credentials updated")
+	return nil
+}
diff --git a/pkg/kube/claims/grants.go b/pkg/kube/grants/grants.go
similarity index 90%
rename from pkg/kube/claims/grants.go
rename to pkg/kube/grants/grants.go
index 4caee2b8..c9a9646d 100644
--- a/pkg/kube/claims/grants.go
+++ b/pkg/kube/grants/grants.go
@@ -1,4 +1,4 @@
-package claims
+package grants
 
 import (
 	"context"
@@ -123,15 +123,10 @@ func (g *Grants) getUrl() string {
 func (g *Grants) recheckUrl() {
 	for _, grant := range g.getAll() {
 		key := fmt.Sprintf("%s/%s", grant.Namespace, grant.Name)
-		if changed, message := g.checkUrl(key, grant); changed {
-			if message == "" {
-				message = skupperv1alpha1.STATUS_OK
-			}
+		if g.checkUrl(key, grant) {
 			grant.SetResolved()
-			if grant.Status.SetStatusMessage(message) {
-				if err := g.updateGrantStatus(grant); err != nil {
-					log.Printf("Error updating grant %s after setting url: %s", key, err)
-				}
+			if err := g.updateGrantStatus(grant); err != nil {
+				log.Printf("Error updating grant %s after setting url: %s", key, err)
 			}
 		}
 	}
@@ -156,18 +151,14 @@ func (g *Grants) claimUrl(grant *skupperv1alpha1.AccessGrant) string {
 	return ""
 }
 
-func (g *Grants) checkUrl(key string, grant *skupperv1alpha1.AccessGrant) (bool, string) {
+func (g *Grants) checkUrl(key string, grant *skupperv1alpha1.AccessGrant) bool {
 	url := g.claimUrl(grant)
-	if url == "" {
-		log.Printf("URL pending for AccessGrant %s", key)
-		return true, "Url pending"
-	} else if grant.Status.Url != url {
-		log.Printf("Setting URL for AccessGrant %s to %s", key, url)
-		grant.Status.Url = url
-		return true, ""
-	} else {
-		return false, ""
+	if grant.Status.Url == url {
+		return false
 	}
+	log.Printf("Setting URL for AccessGrant %s to %s", key, url)
+	grant.Status.Url = url
+	return true
 }
 
 func (g *Grants) checkCa(key string, grant *skupperv1alpha1.AccessGrant) bool {
@@ -187,11 +178,8 @@ func (g *Grants) checkGrant(key string, grant *skupperv1alpha1.AccessGrant) erro
 	g.record(key, grant)
 	changed := false
 	var status []string
-	if updated, message := g.checkUrl(key, grant); updated {
+	if g.checkUrl(key, grant) {
 		changed = true
-		if message != "" {
-			status = append(status, message)
-		}
 	}
 	if g.checkCa(key, grant) {
 		changed = true
@@ -237,7 +225,6 @@ func (g *Grants) checkGrant(key string, grant *skupperv1alpha1.AccessGrant) erro
 	if !changed {
 		return nil
 	}
-	log.Printf("Updating status for AccessGrant %s", key)
 	return g.updateGrantStatus(grant)
 }
 
diff --git a/pkg/kube/grants/init.go b/pkg/kube/grants/init.go
new file mode 100644
index 00000000..ee800756
--- /dev/null
+++ b/pkg/kube/grants/init.go
@@ -0,0 +1,14 @@
+package grants
+
+import (
+	"github.com/skupperproject/skupper/pkg/kube"
+)
+
+func Initialise(controller *kube.Controller, currentNamespace string, watchNamespace string, config *GrantConfig, generator GrantResponse) func() {
+	if !config.Enabled {
+		disabled(controller, watchNamespace)
+		return nil
+	}
+	ge := enabled(controller, currentNamespace, watchNamespace, config, generator)
+	return ge.Start
+}
diff --git a/pkg/kube/grants/redeem.go b/pkg/kube/grants/redeem.go
new file mode 100644
index 00000000..03a6465d
--- /dev/null
+++ b/pkg/kube/grants/redeem.go
@@ -0,0 +1,135 @@
+package grants
+
+import (
+	"bytes"
+	"context"
+	"crypto/tls"
+	"crypto/x509"
+	"errors"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"strings"
+
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/yaml"
+
+	internalclient "github.com/skupperproject/skupper/internal/kube/client"
+	skupperv1alpha1 "github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
+)
+
+func RedeemAccessToken(token *skupperv1alpha1.AccessToken, site *skupperv1alpha1.Site, clients internalclient.Clients) error {
+	transport := &http.Transport{
+		TLSClientConfig: tlsConfig(token),
+	}
+	body, err := postTokenRequest(token, site, transport)
+	if err != nil {
+		return updateAccessTokenStatus(token, err, clients)
+	}
+	log.Printf("HTTP Post to %s for %s/%s was sucessful, decoding response body", token.Spec.Url, token.Namespace, token.Name)
+	return handleTokenResponse(body, token, site, clients)
+}
+
+func tlsConfig(token *skupperv1alpha1.AccessToken) *tls.Config {
+	if token.Spec.Ca == "" {
+		return nil
+	}
+	caPool := x509.NewCertPool()
+	caPool.AppendCertsFromPEM([]byte(token.Spec.Ca))
+	return &tls.Config{
+		RootCAs: caPool,
+	}
+}
+
+func postTokenRequest(token *skupperv1alpha1.AccessToken, site *skupperv1alpha1.Site, transport http.RoundTripper) (io.Reader, error) {
+	client := &http.Client{
+		Transport: transport,
+	}
+	request, err := http.NewRequest(http.MethodPost, token.Spec.Url, bytes.NewReader([]byte(token.Spec.Code)))
+	if err != nil {
+		return nil, err
+	}
+	request.Header.Add("name", token.Name)
+	request.Header.Add("subject", string(site.ObjectMeta.UID))
+	response, err := client.Do(request)
+	if err != nil {
+		return nil, err
+	}
+	if response.StatusCode != http.StatusOK {
+		body, _ := io.ReadAll(response.Body)
+		return nil, fmt.Errorf("%d (%s) %s", response.StatusCode, http.StatusText(response.StatusCode), strings.TrimSpace(string(body)))
+	}
+	return response.Body, nil
+}
+
+func handleTokenResponse(body io.Reader, token *skupperv1alpha1.AccessToken, site *skupperv1alpha1.Site, clients internalclient.Clients) error {
+	decoder := newLinkDecoder(body)
+	if err := decoder.decodeAll(); err != nil {
+		log.Printf("Could not decode response for AccessToken %s/%s: %s", token.Namespace, token.Name, err)
+		return updateAccessTokenStatus(token, errors.New("Could not decode response"), clients)
+	}
+	refs := []metav1.OwnerReference{
+		{
+			Kind:       "Site",
+			APIVersion: "skupper.io/v1alpha1",
+			Name:       site.Name,
+			UID:        site.ObjectMeta.UID,
+		},
+	}
+	decoder.secret.ObjectMeta.OwnerReferences = refs
+	if _, err := clients.GetKubeClient().CoreV1().Secrets(token.ObjectMeta.Namespace).Create(context.TODO(), &decoder.secret, metav1.CreateOptions{}); err != nil {
+		return updateAccessTokenStatus(token, fmt.Errorf("Could not create received secret: %s", err), clients)
+	}
+	for _, link := range decoder.links {
+		link.ObjectMeta.OwnerReferences = refs
+		if _, err := clients.GetSkupperClient().SkupperV1alpha1().Links(token.ObjectMeta.Namespace).Create(context.TODO(), &link, metav1.CreateOptions{}); err != nil {
+			return updateAccessTokenStatus(token, fmt.Errorf("Could not create received link: %s", err), clients)
+		}
+	}
+
+	return updateAccessTokenStatus(token, nil, clients)
+}
+
+func updateAccessTokenStatus(token *skupperv1alpha1.AccessToken, err error, clients internalclient.Clients) error {
+	if token.SetRedeemed(err) {
+		_, err = clients.GetSkupperClient().SkupperV1alpha1().AccessTokens(token.ObjectMeta.Namespace).UpdateStatus(context.TODO(), token, metav1.UpdateOptions{})
+		return err
+	}
+	return nil
+}
+
+type LinkDecoder struct {
+	decoder *yaml.YAMLOrJSONDecoder
+	secret  corev1.Secret
+	links   []skupperv1alpha1.Link
+}
+
+func newLinkDecoder(r io.Reader) *LinkDecoder {
+	return &LinkDecoder{
+		decoder: yaml.NewYAMLOrJSONDecoder(r, 1024),
+	}
+}
+
+func (d *LinkDecoder) decodeSecret() error {
+	return d.decoder.Decode(&d.secret)
+}
+
+func (d *LinkDecoder) decodeLink() error {
+	var link skupperv1alpha1.Link
+	if err := d.decoder.Decode(&link); err != nil {
+		return err
+	}
+	d.links = append(d.links, link)
+	return nil
+}
+
+func (d *LinkDecoder) decodeAll() error {
+	if err := d.decodeSecret(); err != nil {
+		return err
+	}
+	for err := d.decodeLink(); err == nil; err = d.decodeLink() {
+	}
+	return nil
+}
diff --git a/pkg/kube/grants/server.go b/pkg/kube/grants/server.go
new file mode 100644
index 00000000..6d8565a3
--- /dev/null
+++ b/pkg/kube/grants/server.go
@@ -0,0 +1,106 @@
+package grants
+
+import (
+	"crypto/tls"
+	"fmt"
+	"log"
+	"net"
+	"net/http"
+	"sync"
+	"time"
+
+	corev1 "k8s.io/api/core/v1"
+
+	"github.com/skupperproject/skupper/pkg/utils/tlscfg"
+)
+
+type Server struct {
+	tlsEnabled bool
+	lock       sync.RWMutex
+	cert       *tls.Certificate
+	server     *http.Server
+	listener   net.Listener
+}
+
+func newServer(addr string, tlsEnabled bool, handler http.Handler) *Server {
+	return &Server{
+		server: &http.Server{
+			Addr:         addr,
+			Handler:      handler,
+			ReadTimeout:  60 * time.Second,
+			WriteTimeout: 60 * time.Second,
+			TLSConfig:    tlscfg.Modern(),
+		},
+		tlsEnabled: tlsEnabled,
+	}
+}
+
+func (s *Server) start() {
+	go s.listenAndServe()
+}
+
+func (s *Server) getCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
+	s.lock.RLock()
+	defer s.lock.RUnlock()
+	return s.cert, nil
+}
+
+func (s *Server) setCertificate(cert *tls.Certificate) {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+	s.cert = cert
+}
+
+func (s *Server) setCertificateFromSecret(secret *corev1.Secret) error {
+	cert, err := tls.X509KeyPair(secret.Data["tls.crt"], secret.Data["tls.key"])
+	if err != nil {
+		return err
+	}
+	s.setCertificate(&cert)
+	return nil
+}
+
+func (s *Server) listen() error {
+	listener, err := net.Listen("tcp", s.server.Addr)
+	if err != nil {
+		return err
+	}
+	log.Printf("Grant server listening on %s", listener.Addr())
+	s.listener = listener
+	return nil
+}
+
+func (s *Server) serve() error {
+	if s.listener == nil {
+		return fmt.Errorf("Cannot serve before listen() is called")
+	}
+	if s.tlsEnabled {
+		s.server.TLSConfig.GetCertificate = s.getCertificate
+		return s.server.ServeTLS(s.listener, "", "")
+	} else {
+		return s.server.Serve(s.listener)
+	}
+}
+
+func (s *Server) listenAndServe() error {
+	if err := s.listen(); err != nil {
+		log.Printf("Grant server failed to listen on %s: %s", s.server.Addr, err)
+		return err
+	}
+	defer s.listener.Close()
+	return s.serve()
+}
+
+func (s *Server) stop() error {
+	err := s.server.Close()
+	s.listener.Close()
+	s.listener = nil
+	return err
+}
+
+func (s *Server) port() int {
+	if s.listener == nil {
+		return 0
+	}
+	return s.listener.Addr().(*net.TCPAddr).Port
+}
diff --git a/pkg/kube/tokens/tokens.go b/pkg/kube/grants/tokens.go
similarity index 72%
rename from pkg/kube/tokens/tokens.go
rename to pkg/kube/grants/tokens.go
index 6a2cdffc..dce1aae9 100644
--- a/pkg/kube/tokens/tokens.go
+++ b/pkg/kube/grants/tokens.go
@@ -1,7 +1,8 @@
-package tokens
+package grants
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"io"
 	"log"
@@ -15,7 +16,6 @@ import (
 	internalclient "github.com/skupperproject/skupper/internal/kube/client"
 	skupperv1alpha1 "github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
 	"github.com/skupperproject/skupper/pkg/certs"
-	sitepkg "github.com/skupperproject/skupper/pkg/site"
 )
 
 type CertToken struct {
@@ -38,24 +38,18 @@ type TokenGenerator struct {
 	hosts     []string
 }
 
-func NewTokenGenerator(namespace string, clients internalclient.Clients) (*TokenGenerator, error) {
-	site, err := getActiveSite(namespace, clients)
-	if err != nil {
-		return nil, err
-	}
-	return NewTokenGeneratorForSite(site, clients)
-}
-
-func NewTokenGeneratorForSite(site *skupperv1alpha1.Site, clients internalclient.Clients) (*TokenGenerator, error) {
+func NewTokenGenerator(site *skupperv1alpha1.Site, clients internalclient.Clients) (*TokenGenerator, error) {
 	generator := &TokenGenerator{
 		namespace: site.Namespace,
 		clients:   clients,
 	}
-	if err := generator.loadCA(sitepkg.DefaultIssuer(site)); err != nil {
-		return nil, err
+	if err := generator.loadCA(site.DefaultIssuer()); err != nil {
+		log.Printf("Error retrieving default issuer %s for site %s in %s: %s", site.DefaultIssuer(), site.Name, site.Namespace, err)
+		return nil, errors.New("Could not get issuer for requested certficate")
 	}
-	if err := generator.setValidHostsFromSite(site); err != nil {
-		return nil, err
+	if ok := generator.setValidHostsFromSite(site); !ok {
+		log.Printf("Could not resolve any target endpoints for site %s in %s", site.Name, site.Namespace)
+		return nil, errors.New("Could not resolve any endpoints for requested link")
 	}
 	return generator, nil
 }
@@ -69,20 +63,7 @@ func (g *TokenGenerator) loadCA(name string) error {
 	return nil
 }
 
-func getActiveSite(namespace string, clients internalclient.Clients) (*skupperv1alpha1.Site, error) {
-	sites, err := clients.GetSkupperClient().SkupperV1alpha1().Sites(namespace).List(context.TODO(), metav1.ListOptions{})
-	if err != nil {
-		return nil, err
-	}
-	for _, site := range sites.Items {
-		if site.IsConfigured() {
-			return &site, nil
-		}
-	}
-	return nil, fmt.Errorf("No active site in %s", namespace)
-}
-
-func (g *TokenGenerator) setValidHostsFromSite(site *skupperv1alpha1.Site) error {
+func (g *TokenGenerator) setValidHostsFromSite(site *skupperv1alpha1.Site) bool {
 	//TODO: if site is edge site, then return an error as it
 	//cannot issue certificates
 	var hosts []string
@@ -90,7 +71,7 @@ func (g *TokenGenerator) setValidHostsFromSite(site *skupperv1alpha1.Site) error
 		hosts = append(hosts, endpoint.Host)
 	}
 	if len(hosts) == 0 {
-		return fmt.Errorf("Endpoints for site in %s not yet resolved", g.namespace)
+		return false
 	}
 	byGroup := map[string][]skupperv1alpha1.Endpoint{}
 	//TODO: should only include groups that are valid for the defined issuer
@@ -104,7 +85,7 @@ func (g *TokenGenerator) setValidHostsFromSite(site *skupperv1alpha1.Site) error
 	}
 	log.Printf("Endpoints for grant: %v (by group: %v)", g.endpoints, byGroup)
 	g.hosts = hosts
-	return nil
+	return true
 }
 
 func (g *TokenGenerator) NewCertToken(name string, subject string) Token {
diff --git a/pkg/kube/site/site.go b/pkg/kube/site/site.go
index 43f3daa6..59b644a1 100644
--- a/pkg/kube/site/site.go
+++ b/pkg/kube/site/site.go
@@ -728,7 +728,7 @@ func (s *Site) Deleted() {
 }
 
 func (s *Site) setDefaultIssuerInStatus() bool {
-	if issuer := s.defaultIssuer(); s.site.Status.DefaultIssuer != issuer {
+	if issuer := s.site.DefaultIssuer(); s.site.Status.DefaultIssuer != issuer {
 		s.site.Status.DefaultIssuer = issuer
 		return true
 	}
@@ -861,10 +861,6 @@ func (s *Site) updateRouterAccessStatus(la *skupperv1alpha1.RouterAccess) {
 	}
 }
 
-func (s *Site) defaultIssuer() string {
-	return site.DefaultIssuer(s.site)
-}
-
 func asSecuredAccessSpec(la *skupperv1alpha1.RouterAccess, group string, defaultIssuer string) skupperv1alpha1.SecuredAccessSpec {
 	issuer := la.Spec.Issuer
 	if issuer == "" {
@@ -906,7 +902,7 @@ func (s *Site) checkSecuredAccess() error {
 				"internal.skupper.io/controlled":   "true",
 				"internal.skupper.io/routeraccess": la.Name,
 			}
-			if err := s.access.Ensure(s.namespace, name, asSecuredAccessSpec(la, group, s.defaultIssuer()), annotations, s.ownerReferences()); err != nil {
+			if err := s.access.Ensure(s.namespace, name, asSecuredAccessSpec(la, group, s.site.DefaultIssuer()), annotations, s.ownerReferences()); err != nil {
 				//TODO: add message to site status
 				log.Printf("Error ensuring SecuredAccess for RouterAccess %s: %s", la.Key(), err)
 			}
@@ -947,7 +943,7 @@ func (s *Site) CheckRouterAccess(name string, la *skupperv1alpha1.RouterAccess)
 					"internal.skupper.io/controlled":   "true",
 					"internal.skupper.io/routeraccess": la.Name,
 				}
-				if err := s.access.Ensure(s.namespace, name, asSecuredAccessSpec(la, group, s.defaultIssuer()), annotations, s.ownerReferences()); err != nil {
+				if err := s.access.Ensure(s.namespace, name, asSecuredAccessSpec(la, group, s.site.DefaultIssuer()), annotations, s.ownerReferences()); err != nil {
 					log.Printf("Error ensuring SecuredAccess for RouterAccess %s: %s", la.Key(), err)
 					errors = append(errors, err.Error())
 				}
diff --git a/pkg/site/defaultissuer.go b/pkg/site/defaultissuer.go
deleted file mode 100644
index 10011f53..00000000
--- a/pkg/site/defaultissuer.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package site
-
-import (
-	skupperv1alpha1 "github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
-)
-
-func DefaultIssuer(site *skupperv1alpha1.Site) string {
-	if site != nil {
-		if site.Spec.DefaultIssuer != "" {
-			return site.Spec.DefaultIssuer
-		}
-		if site.Status.DefaultIssuer != "" {
-			return site.Status.DefaultIssuer
-		}
-	}
-	return "skupper-site-ca"
-}


Test patch:
diff --git a/internal/cmd/skupper/token/kube/token_redeem_test.go b/internal/cmd/skupper/token/kube/token_redeem_test.go
index 94d5863e..5a04b0fd 100644
--- a/internal/cmd/skupper/token/kube/token_redeem_test.go
+++ b/internal/cmd/skupper/token/kube/token_redeem_test.go
@@ -316,7 +316,7 @@ func TestCmdTokenRedeem_Run(t *testing.T) {
 
 	defer os.Remove("/tmp/tokenR.yaml") // clean up
 
-	err := newCmdCreateAccessGrantFile("/tmp/tokenR.yaml")
+	err := newCmdCreateAccessTokenFile("/tmp/tokenR.yaml")
 	assert.Check(t, err == nil)
 
 	testTable := []test{
@@ -438,17 +438,17 @@ func TestCmdTokenRedeem_WaitUntil(t *testing.T) {
 
 // --- helper methods
 
-func newCmdCreateAccessGrantFile(fileName string) error {
+func newCmdCreateAccessTokenFile(fileName string) error {
 
-	resource := v1alpha1.AccessGrant{
+	resource := v1alpha1.AccessToken{
 		TypeMeta: metav1.TypeMeta{
 			APIVersion: "skupper.io/v1alpha1",
-			Kind:       "AccessGrant",
+			Kind:       "AccessToken",
 		},
 		ObjectMeta: metav1.ObjectMeta{
-			Name: "grant",
+			Name: "token",
 		},
-		Status: v1alpha1.AccessGrantStatus{
+		Spec: v1alpha1.AccessTokenSpec{
 			Url:  "AAA",
 			Ca:   "BBB",
 			Code: "CCC",
diff --git a/internal/flag/flag_test.go b/internal/flag/flag_test.go
new file mode 100644
index 00000000..afb55e56
--- /dev/null
+++ b/internal/flag/flag_test.go
@@ -0,0 +1,231 @@
+package flag
+
+import (
+	"flag"
+	"testing"
+
+	"gotest.tools/assert"
+)
+
+func Test_StringVar(t *testing.T) {
+	tests := []struct {
+		name          string
+		defaultValue  string
+		args          []string
+		env           map[string]string
+		expectedValue string
+		expectedError string
+	}{
+		{
+			name:          "default value returned",
+			defaultValue:  "foo",
+			expectedValue: "foo",
+		},
+		{
+			name:          "flag specified as two args",
+			defaultValue:  "foo",
+			args:          []string{"-dummy", "bar"},
+			expectedValue: "bar",
+		},
+		{
+			name:          "flag specified as one arg",
+			defaultValue:  "foo",
+			args:          []string{"-dummy=bar"},
+			expectedValue: "bar",
+		},
+		{
+			name:         "env var returned",
+			defaultValue: "foo",
+			env: map[string]string{
+				"SKUPPER_DUMMY": "bar",
+			},
+			expectedValue: "bar",
+		},
+		{
+			name:          "invalid arg",
+			defaultValue:  "foo",
+			args:          []string{"-xyz=bar"},
+			expectedError: "flag provided but not defined: -xyz",
+			expectedValue: "foo",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			flags := &flag.FlagSet{}
+			var value string
+			for k, v := range tt.env {
+				t.Setenv(k, v)
+			}
+			StringVar(flags, &value, "dummy", "SKUPPER_DUMMY", tt.defaultValue, "Test of dummy config option")
+			err := flags.Parse(tt.args)
+			if tt.expectedError != "" {
+				assert.ErrorContains(t, err, tt.expectedError)
+			} else if err != nil {
+				t.Error(err)
+			}
+			assert.Equal(t, value, tt.expectedValue)
+		})
+	}
+}
+
+func Test_BoolVar(t *testing.T) {
+	tests := []struct {
+		name          string
+		defaultValue  bool
+		args          []string
+		env           map[string]string
+		expectedValue bool
+		expectedError string
+	}{
+		{
+			name:          "default value returned",
+			defaultValue:  true,
+			expectedValue: true,
+		},
+		{
+			name:          "flag specified as two args",
+			args:          []string{"-dummy", "true"},
+			expectedValue: true,
+		},
+		{
+			name:          "flag specified as one arg",
+			args:          []string{"-dummy=true"},
+			expectedValue: true,
+		},
+		{
+			name:          "flag overrides default",
+			defaultValue:  true,
+			args:          []string{"-dummy=false"},
+			expectedValue: false,
+		},
+		{
+			name: "env var returned",
+			env: map[string]string{
+				"SKUPPER_DUMMY": "true",
+			},
+			expectedValue: true,
+		},
+		{
+			name:         "env var overrides default",
+			defaultValue: true,
+			env: map[string]string{
+				"SKUPPER_DUMMY": "false",
+			},
+			expectedValue: false,
+		},
+		{
+			name:         "invalid env var",
+			defaultValue: true,
+			env: map[string]string{
+				"SKUPPER_DUMMY": "i am a bad value!",
+			},
+			expectedError: "i am a bad value",
+			expectedValue: true,
+		},
+		{
+			name: "error references env var name",
+			env: map[string]string{
+				"SKUPPER_DUMMY": "i am a bad value!",
+			},
+			expectedError: "SKUPPER_DUMMY",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			flags := &flag.FlagSet{}
+			var value bool
+			for k, v := range tt.env {
+				t.Setenv(k, v)
+			}
+			err := BoolVar(flags, &value, "dummy", "SKUPPER_DUMMY", tt.defaultValue, "Test of dummy config option")
+			flags.Parse(tt.args)
+			if tt.expectedError != "" {
+				assert.ErrorContains(t, err, tt.expectedError)
+			} else if err != nil {
+				t.Error(err)
+			}
+			assert.Equal(t, value, tt.expectedValue)
+		})
+	}
+}
+
+func Test_IntVar(t *testing.T) {
+	tests := []struct {
+		name          string
+		defaultValue  int
+		args          []string
+		env           map[string]string
+		expectedValue int
+		expectedError string
+	}{
+		{
+			name:          "default value returned",
+			defaultValue:  123,
+			expectedValue: 123,
+		},
+		{
+			name:          "flag specified as two args",
+			args:          []string{"-dummy", "123"},
+			expectedValue: 123,
+		},
+		{
+			name:          "flag specified as one arg",
+			args:          []string{"-dummy=456"},
+			expectedValue: 456,
+		},
+		{
+			name:          "flag overrides default",
+			defaultValue:  123,
+			args:          []string{"-dummy=321"},
+			expectedValue: 321,
+		},
+		{
+			name: "env var returned",
+			env: map[string]string{
+				"SKUPPER_DUMMY": "999",
+			},
+			expectedValue: 999,
+		},
+		{
+			name:         "env var overrides default",
+			defaultValue: 123,
+			env: map[string]string{
+				"SKUPPER_DUMMY": "789",
+			},
+			expectedValue: 789,
+		},
+		{
+			name:         "invalid env var",
+			defaultValue: 555,
+			env: map[string]string{
+				"SKUPPER_DUMMY": "i am a bad value!",
+			},
+			expectedError: "i am a bad value",
+			expectedValue: 555,
+		},
+		{
+			name: "error references env var name",
+			env: map[string]string{
+				"SKUPPER_DUMMY": "i am a bad value!",
+			},
+			expectedError: "SKUPPER_DUMMY",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			flags := &flag.FlagSet{}
+			var value int
+			for k, v := range tt.env {
+				t.Setenv(k, v)
+			}
+			err := IntVar(flags, &value, "dummy", "SKUPPER_DUMMY", tt.defaultValue, "Test of dummy config option")
+			flags.Parse(tt.args)
+			if tt.expectedError != "" {
+				assert.ErrorContains(t, err, tt.expectedError)
+			} else if err != nil {
+				t.Error(err)
+			}
+			assert.Equal(t, value, tt.expectedValue)
+		})
+	}
+}
diff --git a/pkg/kube/grants/autoconfigure_test.go b/pkg/kube/grants/autoconfigure_test.go
new file mode 100644
index 00000000..474b096e
--- /dev/null
+++ b/pkg/kube/grants/autoconfigure_test.go
@@ -0,0 +1,270 @@
+package grants
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"gotest.tools/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	k8stesting "k8s.io/client-go/testing"
+
+	internalclient "github.com/skupperproject/skupper/internal/kube/client"
+	"github.com/skupperproject/skupper/internal/kube/client/fake"
+	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
+	fakev1alpha1 "github.com/skupperproject/skupper/pkg/generated/client/clientset/versioned/typed/skupper/v1alpha1/fake"
+	"github.com/skupperproject/skupper/pkg/kube"
+)
+
+func Test_configure(t *testing.T) {
+	var tests = []struct {
+		name              string
+		namespace         string
+		podname           string
+		port              int
+		k8sObjects        []runtime.Object
+		skupperObjects    []runtime.Object
+		prepends          []SkupperClientError
+		expectedSelector  map[string]string
+		expectedOwnerRefs []metav1.OwnerReference
+		expectedError     string
+	}{
+		{
+			name:              "simple",
+			podname:           "my-pod",
+			port:              1234,
+			namespace:         "test",
+			k8sObjects:        []runtime.Object{tf.pod("my-pod", "test", map[string]string{"foo": "bar"}, ref1)},
+			expectedSelector:  map[string]string{"foo": "bar"},
+			expectedOwnerRefs: ref1,
+		},
+		{
+			name:              "cert already exists",
+			podname:           "my-pod",
+			port:              1234,
+			namespace:         "test",
+			k8sObjects:        []runtime.Object{tf.pod("my-pod", "test", map[string]string{"foo": "bar"}, ref1)},
+			skupperObjects:    []runtime.Object{tf.cert("skupper-grant-server-ca", "test", "SkupperGrantServerCA", "", true, false, false, ref1)},
+			expectedSelector:  map[string]string{"foo": "bar"},
+			expectedOwnerRefs: ref1,
+		},
+		{
+			name:              "cert has wrong owner refs",
+			podname:           "my-pod",
+			port:              1234,
+			namespace:         "test",
+			k8sObjects:        []runtime.Object{tf.pod("my-pod", "test", map[string]string{"foo": "bar"}, ref1)},
+			skupperObjects:    []runtime.Object{tf.cert("skupper-grant-server-ca", "test", "SkupperGrantServerCA", "", true, false, false, ref2)},
+			expectedSelector:  map[string]string{"foo": "bar"},
+			expectedOwnerRefs: ref1,
+		},
+		{
+			name:              "cert has wrong spec",
+			podname:           "my-pod",
+			port:              1234,
+			namespace:         "test",
+			k8sObjects:        []runtime.Object{tf.pod("my-pod", "test", map[string]string{"foo": "bar"}, ref1)},
+			skupperObjects:    []runtime.Object{tf.cert("skupper-grant-server-ca", "test", "ajkfhakjfh", "", false, true, true, ref1)},
+			expectedSelector:  map[string]string{"foo": "bar"},
+			expectedOwnerRefs: ref1,
+		},
+		{
+			name:              "securedaccess already exists",
+			podname:           "my-pod",
+			port:              1234,
+			namespace:         "test",
+			k8sObjects:        []runtime.Object{tf.pod("my-pod", "test", map[string]string{"foo": "bar"}, ref1)},
+			skupperObjects:    []runtime.Object{tf.securedAccess("skupper-grant-server", "test", map[string]string{"foo": "bar"}, 1234, ref1)},
+			expectedSelector:  map[string]string{"foo": "bar"},
+			expectedOwnerRefs: ref1,
+		},
+		{
+			name:              "securedaccess has wrong refs",
+			podname:           "my-pod",
+			port:              1234,
+			namespace:         "test",
+			k8sObjects:        []runtime.Object{tf.pod("my-pod", "test", map[string]string{"foo": "bar"}, ref1)},
+			skupperObjects:    []runtime.Object{tf.securedAccess("skupper-grant-server", "test", map[string]string{"foo": "bar"}, 1234, ref2)},
+			expectedSelector:  map[string]string{"foo": "bar"},
+			expectedOwnerRefs: ref1,
+		},
+		{
+			name:              "securedaccess has wrong port",
+			podname:           "my-pod",
+			port:              1234,
+			namespace:         "test",
+			k8sObjects:        []runtime.Object{tf.pod("my-pod", "test", map[string]string{"foo": "bar"}, ref1)},
+			skupperObjects:    []runtime.Object{tf.securedAccess("skupper-grant-server", "test", map[string]string{"foo": "bar"}, 9090, ref1)},
+			expectedSelector:  map[string]string{"foo": "bar"},
+			expectedOwnerRefs: ref1,
+		},
+		{
+			name:              "securedaccess has wrong selector",
+			podname:           "my-pod",
+			port:              1234,
+			namespace:         "test",
+			k8sObjects:        []runtime.Object{tf.pod("my-pod", "test", map[string]string{"foo": "bar"}, ref1)},
+			skupperObjects:    []runtime.Object{tf.securedAccess("skupper-grant-server", "test", map[string]string{"x": "y"}, 1234, ref1)},
+			expectedSelector:  map[string]string{"foo": "bar"},
+			expectedOwnerRefs: ref1,
+		},
+		{
+			name:          "pod not found",
+			podname:       "idontexist",
+			namespace:     "other",
+			expectedError: "not found",
+		},
+		{
+			name:          "error on cert get",
+			podname:       "my-pod",
+			port:          1234,
+			namespace:     "test",
+			k8sObjects:    []runtime.Object{tf.pod("my-pod", "test", map[string]string{"foo": "bar"}, ref1)},
+			prepends:      []SkupperClientError{skupperClientError("get", "certificates", "Can't get certificate")},
+			expectedError: "Can't get certificate",
+		},
+		{
+			name:          "error on securedaccess get",
+			podname:       "my-pod",
+			port:          1234,
+			namespace:     "test",
+			k8sObjects:    []runtime.Object{tf.pod("my-pod", "test", map[string]string{"foo": "bar"}, ref1)},
+			prepends:      []SkupperClientError{skupperClientError("get", "securedaccesses", "Can't get securedaccess")},
+			expectedError: "Can't get securedaccess",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			client, err := fake.NewFakeClient("test", tt.k8sObjects, tt.skupperObjects, "")
+			if err != nil {
+				t.Error(err)
+			}
+			for _, p := range tt.prepends {
+				p.prepend(client)
+			}
+			ac := &AutoConfigure{
+				podname:              tt.podname,
+				port:                 tt.port,
+				tlsCredentialsSecret: "skupper-grant-server",
+			}
+			err = ac.configure(client, tt.namespace)
+			if tt.expectedError != "" {
+				assert.ErrorContains(t, err, tt.expectedError)
+			} else if err != nil {
+				t.Error(err)
+			} else {
+				// verify that Certificate & SecuredAccess exist and match expectations
+				cert, err := client.GetSkupperClient().SkupperV1alpha1().Certificates(tt.namespace).Get(context.Background(), "skupper-grant-server-ca", metav1.GetOptions{})
+				if err != nil {
+					t.Error(err)
+				}
+				assert.Equal(t, cert.Spec.Ca, "")
+				assert.Equal(t, cert.Spec.Subject, "SkupperGrantServerCA")
+				assert.Equal(t, cert.Spec.Signing, true)
+				assert.Equal(t, cert.Spec.Client, false)
+				assert.Equal(t, cert.Spec.Server, false)
+				assert.DeepEqual(t, cert.ObjectMeta.OwnerReferences, tt.expectedOwnerRefs)
+
+				sa, err := client.GetSkupperClient().SkupperV1alpha1().SecuredAccesses(tt.namespace).Get(context.Background(), "skupper-grant-server", metav1.GetOptions{})
+				if err != nil {
+					t.Error(err)
+				}
+				assert.DeepEqual(t, sa.Spec.Selector, tt.expectedSelector)
+				assert.DeepEqual(t, sa.Spec.Ports[0].Port, tt.port)
+				assert.DeepEqual(t, sa.Spec.Issuer, "skupper-grant-server-ca")
+				assert.DeepEqual(t, sa.Spec.Certificate, "skupper-grant-server")
+				assert.DeepEqual(t, sa.ObjectMeta.OwnerReferences, tt.expectedOwnerRefs)
+			}
+		})
+	}
+}
+
+func Test_newAutoconfigure(t *testing.T) {
+	var tests = []struct {
+		name          string
+		podname       string
+		expectedError string
+	}{
+		{
+			name:    "simple",
+			podname: "my-pod",
+		},
+		{
+			name:          "failed",
+			podname:       "I don't exist",
+			expectedError: "not found",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			client, err := fake.NewFakeClient("test", []runtime.Object{tf.pod(tt.podname, "test", map[string]string{"foo": "bar"}, ref1)}, nil, "")
+			if err != nil {
+				t.Error(err)
+			}
+			controller := kube.NewController("Controller", client)
+
+			config := &GrantConfig{
+				Port:     9090,
+				Hostname: "my-pod",
+			}
+			var found *v1alpha1.SecuredAccess
+			handler := func(key string, sa *v1alpha1.SecuredAccess) error {
+				found = sa
+				return nil
+			}
+			_, err = newAutoConfigure(handler, controller, "test", config)
+			if tt.expectedError != "" {
+				assert.ErrorContains(t, err, tt.expectedError)
+			} else if err != nil {
+				t.Error(err)
+			} else {
+				stopCh := make(chan struct{})
+				defer close(stopCh)
+				controller.StartWatchers(stopCh)
+				assert.Assert(t, controller.WaitForCacheSync(stopCh))
+				assert.Assert(t, controller.TestProcess())
+				assert.Assert(t, found != nil)
+				assert.Equal(t, found.Name, "skupper-grant-server")
+				assert.Equal(t, found.Spec.Ports[0].Port, 9090)
+			}
+		})
+	}
+}
+
+var ref1 = []metav1.OwnerReference{
+	{
+		Kind:       "ReplicaSet",
+		APIVersion: "v1",
+		Name:       "parent1",
+		UID:        "0bde3bc8-a4a2-404a-bfbe-44fdf7bf3231",
+	},
+}
+var ref2 = []metav1.OwnerReference{
+	{
+		Kind:       "ReplicaSet",
+		APIVersion: "v1",
+		Name:       "parent2",
+		UID:        "a40fbe84-f276-4755-bf22-5ba980ab1661",
+	},
+}
+
+type SkupperClientError struct {
+	verb     string
+	resource string
+	err      string
+}
+
+func (e *SkupperClientError) prepend(client internalclient.Clients) {
+	client.GetSkupperClient().SkupperV1alpha1().(*fakev1alpha1.FakeSkupperV1alpha1).PrependReactor(e.verb, e.resource, func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) {
+		return true, nil, errors.New(e.err)
+	})
+}
+
+func skupperClientError(verb string, resource string, err string) SkupperClientError {
+	return SkupperClientError{
+		verb:     verb,
+		resource: resource,
+		err:      err,
+	}
+}
diff --git a/pkg/kube/grants/common_test.go b/pkg/kube/grants/common_test.go
new file mode 100644
index 00000000..c8b8c2fc
--- /dev/null
+++ b/pkg/kube/grants/common_test.go
@@ -0,0 +1,207 @@
+package grants
+
+import (
+	"errors"
+	"io"
+	"strings"
+
+	"github.com/google/uuid"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+
+	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
+	"github.com/skupperproject/skupper/pkg/certs"
+)
+
+type FailingReader struct {
+	reader io.Reader
+	read   int
+	fail   int
+}
+
+func (r *FailingReader) Read(p []byte) (int, error) {
+	if r.reader == nil {
+		return 0, errors.New("Failed Read")
+	}
+	n, err := r.reader.Read(p)
+	if r.read+n > r.fail {
+		return 0, errors.New("Failed Read")
+	}
+	r.read += n
+	return n, err
+}
+
+type FailingWriter struct {
+	writer io.Writer
+	calls  int
+	fail   int
+}
+
+func (r *FailingWriter) Write(p []byte) (int, error) {
+	if r.writer == nil {
+		return 0, errors.New("Failed Write")
+	}
+	n, err := r.writer.Write(p)
+	r.calls = r.calls + 1
+	if r.calls > r.fail {
+		return 0, errors.New("Failed Write")
+	}
+	return n, err
+}
+
+func (*factory) pod(name string, namespace string, labels map[string]string, ownerRefs []metav1.OwnerReference) *corev1.Pod {
+	return &corev1.Pod{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "v1",
+			Kind:       "Pod",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:            name,
+			Namespace:       namespace,
+			Labels:          labels,
+			OwnerReferences: ownerRefs,
+		},
+	}
+}
+
+func (*factory) cert(name string, namespace string, subject string, ca string, signing bool, client bool, server bool, ownerRefs []metav1.OwnerReference) *v1alpha1.Certificate {
+	return &v1alpha1.Certificate{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "skupper.io/v1alpha1",
+			Kind:       "Certificate",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:            name,
+			Namespace:       namespace,
+			OwnerReferences: ownerRefs,
+		},
+		Spec: v1alpha1.CertificateSpec{
+			Ca:      ca,
+			Subject: subject,
+			Signing: signing,
+			Client:  client,
+			Server:  server,
+		},
+	}
+}
+
+func (*factory) securedAccess(name string, namespace string, selector map[string]string, port int, ownerRefs []metav1.OwnerReference) *v1alpha1.SecuredAccess {
+	return &v1alpha1.SecuredAccess{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "skupper.io/v1alpha1",
+			Kind:       "SecuredAccess",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:            name,
+			Namespace:       namespace,
+			OwnerReferences: ownerRefs,
+		},
+		Spec: v1alpha1.SecuredAccessSpec{
+			Selector: selector,
+			Ports: []v1alpha1.SecuredAccessPort{
+				{
+					Name: "https",
+					Port: port,
+				},
+			},
+			Issuer:      "skupper-grant-server-ca",
+			Certificate: "skupper-grant-server",
+		},
+	}
+}
+
+func (*factory) grant(name string, namespace string, uid string) *v1alpha1.AccessGrant {
+	if uid == "" {
+		uid = uuid.NewString()
+	}
+	return &v1alpha1.AccessGrant{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "skupper.io/v1alpha1",
+			Kind:       "AccessGrant",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: namespace,
+			UID:       types.UID(uid),
+		},
+		Spec: v1alpha1.AccessGrantSpec{
+			RedemptionsAllowed: 1,
+		},
+	}
+
+}
+
+func (*factory) secret(name string, namespace string, subject string, hosts []string) *corev1.Secret {
+	secret := certs.GenerateSecret(name, subject, strings.Join(hosts, ","), nil)
+	secret.ObjectMeta.Namespace = namespace
+	return &secret
+}
+
+func (*factory) genericSecret(name string, namespace string) *corev1.Secret {
+	return &corev1.Secret{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "v1",
+			Kind:       "Secret",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: namespace,
+			UID:       types.UID(uuid.NewString()),
+		},
+	}
+}
+
+func (*factory) site(name string, namespace string) *v1alpha1.Site {
+	return &v1alpha1.Site{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "skupper.io/v1alpha1",
+			Kind:       "Site",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: namespace,
+			UID:       types.UID(uuid.NewString()),
+		},
+	}
+}
+
+func (*factory) link(name string, namespace string, endpoints []v1alpha1.Endpoint, tlsCredentials string) *v1alpha1.Link {
+	return &v1alpha1.Link{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "skupper.io/v1alpha1",
+			Kind:       "Link",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: namespace,
+			UID:       types.UID(uuid.NewString()),
+		},
+		Spec: v1alpha1.LinkSpec{
+			Endpoints:      endpoints,
+			TlsCredentials: tlsCredentials,
+		},
+	}
+}
+
+func (*factory) token(name string, namespace string, url string, code string, ca string) *v1alpha1.AccessToken {
+	return &v1alpha1.AccessToken{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "skupper.io/v1alpha1",
+			Kind:       "AccessToken",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: namespace,
+		},
+		Spec: v1alpha1.AccessTokenSpec{
+			Url:  url,
+			Code: code,
+			Ca:   ca,
+		},
+	}
+}
+
+type factory struct{}
+
+var tf = &factory{}
diff --git a/pkg/kube/grants/config_test.go b/pkg/kube/grants/config_test.go
new file mode 100644
index 00000000..e3891ff9
--- /dev/null
+++ b/pkg/kube/grants/config_test.go
@@ -0,0 +1,216 @@
+package grants
+
+import (
+	"flag"
+	"os"
+	"testing"
+
+	"gotest.tools/assert"
+)
+
+func Test_BoundGrantConfig(t *testing.T) {
+	tests := []struct {
+		name           string
+		args           []string
+		env            map[string]string
+		expectedValue  *GrantConfig
+		expectedErrors []string
+	}{
+		{
+			name: "defaults",
+			expectedValue: &GrantConfig{
+				Port:                 9090,
+				TlsCredentialsSecret: "skupper-grant-server",
+				Hostname:             os.Getenv("HOSTNAME"),
+			},
+		},
+		{
+			name: "env vars",
+			env: map[string]string{
+				"SKUPPER_ENABLE_GRANTS":                "true",
+				"SKUPPER_GRANT_SERVER_AUTOCONFIGURE":   "true",
+				"SKUPPER_GRANT_SERVER_BASE_URL":        "https://acme.org:8888/grants",
+				"SKUPPER_GRANT_SERVER_PORT":            "1234",
+				"SKUPPER_GRANT_SERVER_TLS_CREDENTIALS": "my-secret",
+				"HOSTNAME":                             "my-host",
+			},
+			expectedValue: &GrantConfig{
+				Enabled:              true,
+				AutoConfigure:        true,
+				BaseUrl:              "https://acme.org:8888/grants",
+				Port:                 1234,
+				TlsCredentialsSecret: "my-secret",
+				Hostname:             "my-host",
+			},
+		},
+		{
+			name: "args",
+			env: map[string]string{
+				"SKUPPER_ENABLE_GRANTS":                "true",
+				"SKUPPER_GRANT_SERVER_AUTOCONFIGURE":   "true",
+				"SKUPPER_GRANT_SERVER_BASE_URL":        "https://acme.org:8888/grants",
+				"SKUPPER_GRANT_SERVER_PORT":            "1234",
+				"SKUPPER_GRANT_SERVER_TLS_CREDENTIALS": "my-secret",
+				"HOSTNAME":                             "my-host",
+			},
+			args: []string{
+				"--enable-grants=false",
+				"--grant-server-autoconfigure=false",
+				"--grant-server-base-url=https://anotherhost:8080/blah",
+				"--grant-server-port=9876",
+				"--grant-server-tls-credentials=a-different-secret",
+				"--grant-server-podname=a-different-host",
+			},
+			expectedValue: &GrantConfig{
+				Enabled:              false,
+				AutoConfigure:        false,
+				BaseUrl:              "https://anotherhost:8080/blah",
+				Port:                 9876,
+				TlsCredentialsSecret: "a-different-secret",
+				Hostname:             "a-different-host",
+			},
+		},
+		{
+			name: "args override env vars",
+			args: []string{
+				"--enable-grants",
+				"--grant-server-autoconfigure",
+				"--grant-server-base-url=https://acme.org:8888/grants",
+				"--grant-server-port=1234",
+				"--grant-server-tls-credentials=my-secret",
+				"--grant-server-podname=my-host",
+			},
+			expectedValue: &GrantConfig{
+				Enabled:              true,
+				AutoConfigure:        true,
+				BaseUrl:              "https://acme.org:8888/grants",
+				Port:                 1234,
+				TlsCredentialsSecret: "my-secret",
+				Hostname:             "my-host",
+			},
+		},
+		{
+			name: "invalid env var for enabled",
+			env: map[string]string{
+				"SKUPPER_ENABLE_GRANTS":                "this is not a bool",
+				"SKUPPER_GRANT_SERVER_AUTOCONFIGURE":   "true",
+				"SKUPPER_GRANT_SERVER_BASE_URL":        "https://acme.org:8888/grants",
+				"SKUPPER_GRANT_SERVER_PORT":            "1234",
+				"SKUPPER_GRANT_SERVER_TLS_CREDENTIALS": "my-secret",
+				"HOSTNAME":                             "my-host",
+			},
+			expectedErrors: []string{
+				"Invalid environment variable(s)",
+				"SKUPPER_ENABLE_GRANTS",
+				"this is not a bool",
+			},
+			expectedValue: &GrantConfig{
+				Enabled:              false,
+				AutoConfigure:        true,
+				BaseUrl:              "https://acme.org:8888/grants",
+				Port:                 1234,
+				TlsCredentialsSecret: "my-secret",
+				Hostname:             "my-host",
+			},
+		},
+		{
+			name: "invalid env var for autoconfigure",
+			env: map[string]string{
+				"SKUPPER_ENABLE_GRANTS":                "true",
+				"SKUPPER_GRANT_SERVER_AUTOCONFIGURE":   "this is not a bool",
+				"SKUPPER_GRANT_SERVER_BASE_URL":        "https://acme.org:8888/grants",
+				"SKUPPER_GRANT_SERVER_PORT":            "1234",
+				"SKUPPER_GRANT_SERVER_TLS_CREDENTIALS": "my-secret",
+				"HOSTNAME":                             "my-host",
+			},
+			expectedErrors: []string{
+				"Invalid environment variable(s)",
+				"SKUPPER_GRANT_SERVER_AUTOCONFIGURE",
+				"this is not a bool",
+			},
+			expectedValue: &GrantConfig{
+				Enabled:              true,
+				AutoConfigure:        false,
+				BaseUrl:              "https://acme.org:8888/grants",
+				Port:                 1234,
+				TlsCredentialsSecret: "my-secret",
+				Hostname:             "my-host",
+			},
+		},
+		{
+			name: "invalid env var for port",
+			env: map[string]string{
+				"SKUPPER_ENABLE_GRANTS":                "true",
+				"SKUPPER_GRANT_SERVER_AUTOCONFIGURE":   "true",
+				"SKUPPER_GRANT_SERVER_BASE_URL":        "https://acme.org:8888/grants",
+				"SKUPPER_GRANT_SERVER_PORT":            "foo/bar/baz",
+				"SKUPPER_GRANT_SERVER_TLS_CREDENTIALS": "my-secret",
+				"HOSTNAME":                             "my-host",
+			},
+			expectedErrors: []string{
+				"Invalid environment variable(s)",
+				"SKUPPER_GRANT_SERVER_PORT",
+				"foo/bar/baz",
+			},
+			expectedValue: &GrantConfig{
+				Enabled:              true,
+				AutoConfigure:        true,
+				BaseUrl:              "https://acme.org:8888/grants",
+				Port:                 9090,
+				TlsCredentialsSecret: "my-secret",
+				Hostname:             "my-host",
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			for k, v := range tt.env {
+				t.Setenv(k, v)
+			}
+			flags := &flag.FlagSet{}
+			config, err := BoundGrantConfig(flags)
+			flags.Parse(tt.args)
+			if len(tt.expectedErrors) > 0 {
+				for _, text := range tt.expectedErrors {
+					assert.ErrorContains(t, err, text)
+				}
+			} else if err != nil {
+				t.Error(err)
+			}
+			assert.DeepEqual(t, config, tt.expectedValue)
+		})
+	}
+}
+
+func Test_GrantConfigMethods(t *testing.T) {
+	tests := []struct {
+		name           string
+		input          *GrantConfig
+		expectedAddr   string
+		expectedScheme string
+	}{
+		{
+			name: "case 1",
+			input: &GrantConfig{
+				Port: 1234,
+			},
+			expectedAddr:   ":1234",
+			expectedScheme: "http",
+		},
+		{
+			name: "case 2",
+			input: &GrantConfig{
+				Port:                 9090,
+				TlsCredentialsSecret: "foo",
+			},
+			expectedAddr:   ":9090",
+			expectedScheme: "https",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.input.addr(), tt.expectedAddr)
+			assert.Equal(t, tt.input.scheme(), tt.expectedScheme)
+		})
+	}
+}
diff --git a/pkg/kube/grants/disabled_test.go b/pkg/kube/grants/disabled_test.go
new file mode 100644
index 00000000..44e0b9a1
--- /dev/null
+++ b/pkg/kube/grants/disabled_test.go
@@ -0,0 +1,93 @@
+package grants
+
+import (
+	"context"
+	"testing"
+
+	"gotest.tools/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+
+	"github.com/skupperproject/skupper/internal/kube/client/fake"
+	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
+	"github.com/skupperproject/skupper/pkg/kube"
+)
+
+func Test_markGrantNotEnabled(t *testing.T) {
+	grant := &v1alpha1.AccessGrant{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "my-grant",
+			Namespace: "test",
+		},
+		Spec: v1alpha1.AccessGrantSpec{},
+		Status: v1alpha1.AccessGrantStatus{
+			Status: v1alpha1.Status{
+				StatusMessage: "",
+			},
+		},
+	}
+	badGrant := &v1alpha1.AccessGrant{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "i-do-not-exist-in-api-server",
+			Namespace: "test",
+		},
+	}
+	skupperObjects := []runtime.Object{
+		grant,
+	}
+	client, err := fake.NewFakeClient("test", nil, skupperObjects, "")
+	if err != nil {
+		t.Error(err)
+	}
+	disabled := &GrantsDisabled{
+		clients: client,
+	}
+
+	err = disabled.markGrantNotEnabled("", grant)
+	if err != nil {
+		t.Error(err)
+	}
+	assert.Equal(t, grant.Status.StatusMessage, "AccessGrants are not enabled")
+	err = disabled.markGrantNotEnabled("", nil)
+	if err != nil {
+		t.Error(err)
+	}
+	err = disabled.markGrantNotEnabled("", badGrant)
+	if err != nil {
+		t.Error(err)
+	}
+}
+
+func Test_disabled(t *testing.T) {
+	grant := &v1alpha1.AccessGrant{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "my-grant",
+			Namespace: "test",
+		},
+		Spec: v1alpha1.AccessGrantSpec{},
+		Status: v1alpha1.AccessGrantStatus{
+			Status: v1alpha1.Status{
+				StatusMessage: "",
+			},
+		},
+	}
+	skupperObjects := []runtime.Object{
+		grant,
+	}
+	client, err := fake.NewFakeClient("test", nil, skupperObjects, "")
+	if err != nil {
+		t.Error(err)
+	}
+	controller := kube.NewController("Controller", client)
+	disabled(controller, "test")
+	stopCh := make(chan struct{})
+	defer close(stopCh)
+	controller.StartWatchers(stopCh)
+	assert.Assert(t, controller.WaitForCacheSync(stopCh))
+	assert.Assert(t, controller.TestProcess())
+	latest, err := client.GetSkupperClient().SkupperV1alpha1().AccessGrants(grant.Namespace).Get(context.TODO(), grant.Name, metav1.GetOptions{})
+	if err != nil {
+		t.Error(err)
+	}
+	assert.Equal(t, latest.Status.StatusMessage, "AccessGrants are not enabled")
+}
diff --git a/pkg/kube/grants/enabled_test.go b/pkg/kube/grants/enabled_test.go
new file mode 100644
index 00000000..85a950d7
--- /dev/null
+++ b/pkg/kube/grants/enabled_test.go
@@ -0,0 +1,42 @@
+package grants
+
+import (
+	"testing"
+
+	corev1 "k8s.io/api/core/v1"
+)
+
+func Test_tlsCredentialsUpdated(t *testing.T) {
+	var tests = []struct {
+		name   string
+		key    string
+		secret *corev1.Secret
+	}{
+		{
+			name:   "simple",
+			key:    "test/simple",
+			secret: tf.secret("simple", "test", "My Subject", []string{"foo.com", "bar.org"}),
+		},
+		{
+			name:   "non tls secret",
+			key:    "test/simple",
+			secret: tf.genericSecret("simple", "test"),
+		},
+		{
+			name: "nil secret",
+			key:  "test/simple",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gc := &GrantsEnabled{
+				grants: newGrants(nil, nil, "https", ""),
+			}
+			gc.server = newServer(":0", true, gc.grants)
+			err := gc.tlsCredentialsUpdated(tt.key, tt.secret)
+			if err != nil {
+				t.Error(err)
+			}
+		})
+	}
+}
diff --git a/pkg/kube/grants/grant_test.go b/pkg/kube/grants/grant_test.go
new file mode 100644
index 00000000..432be914
--- /dev/null
+++ b/pkg/kube/grants/grant_test.go
@@ -0,0 +1,519 @@
+package grants
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"gotest.tools/assert"
+	meta "k8s.io/apimachinery/pkg/api/meta"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+
+	"github.com/skupperproject/skupper/internal/kube/client/fake"
+	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
+)
+
+func dummyGenerator(namespace string, name string, subject string, writer io.Writer) error {
+	io.WriteString(writer, namespace+",")
+	io.WriteString(writer, name+",")
+	io.WriteString(writer, subject)
+	return nil
+}
+
+func dummyGeneratorWithError(namespace string, name string, subject string, writer io.Writer) error {
+	return errors.New("Failed")
+}
+
+func TestGrantRegistryGeneral(t *testing.T) {
+	grants := []*v1alpha1.AccessGrant{
+		&v1alpha1.AccessGrant{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "one",
+				Namespace: "test",
+				UID:       "0bde3bc8-a4a2-404a-bfbe-44fdf7bf3231",
+			},
+			Spec: v1alpha1.AccessGrantSpec{
+				Code:               "supersecret",
+				RedemptionsAllowed: 1,
+			},
+		},
+		&v1alpha1.AccessGrant{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "two",
+				Namespace: "test",
+				UID:       "a40fbe84-f276-4755-bf22-5ba980ab1661",
+			},
+			Spec: v1alpha1.AccessGrantSpec{
+				ExpirationWindow:   "30m",
+				RedemptionsAllowed: 3,
+			},
+		},
+	}
+	var skupperObjects []runtime.Object
+	for _, grant := range grants {
+		skupperObjects = append(skupperObjects, grant)
+	}
+	client, err := fake.NewFakeClient("test", nil, skupperObjects, "")
+	if err != nil {
+		t.Error(err)
+	}
+	registry := newGrants(client, dummyGenerator, "https", "")
+	for _, grant := range grants {
+		key := grant.Namespace + "/" + grant.Name
+		err = registry.checkGrant(key, grant)
+		if err != nil {
+			t.Error(err)
+		}
+		latest, err := client.GetSkupperClient().SkupperV1alpha1().AccessGrants(grant.Namespace).Get(context.TODO(), grant.Name, metav1.GetOptions{})
+		if err != nil {
+			t.Error(err)
+		}
+		if grant.Spec.Code != "" {
+			assert.Equal(t, latest.Status.Code, latest.Spec.Code)
+		} else {
+			assert.Assert(t, latest.Status.Code != "")
+			assert.Assert(t, len(latest.Status.Code) == 24)
+		}
+		assert.Assert(t, latest.Status.Expiration != "")
+		_, err = time.Parse(time.RFC3339, grant.Status.Expiration)
+		if err != nil {
+			t.Error(err)
+		}
+		assert.Assert(t, meta.IsStatusConditionTrue(latest.Status.Conditions, v1alpha1.CONDITION_TYPE_PROCESSED))
+	}
+	for _, ca := range []string{"dummydataformyCA", "dummydataformyCA", "changedCAdata"} {
+		if registry.setCA(ca) {
+			registry.recheckCa()
+		}
+		for _, grant := range grants {
+			latest, err := client.GetSkupperClient().SkupperV1alpha1().AccessGrants(grant.Namespace).Get(context.TODO(), grant.Name, metav1.GetOptions{})
+			if err != nil {
+				t.Error(err)
+			}
+			assert.Equal(t, latest.Status.Ca, ca)
+		}
+	}
+	for _, url := range []string{"my-host:8080", "my-host:8080", "new-host:1234"} {
+		if registry.setUrl(url) {
+			registry.recheckUrl()
+		}
+		for _, grant := range grants {
+			latest, err := client.GetSkupperClient().SkupperV1alpha1().AccessGrants(grant.Namespace).Get(context.TODO(), grant.Name, metav1.GetOptions{})
+			if err != nil {
+				t.Error(err)
+			}
+			expectedUrl := "https://" + url + "/" + string(latest.ObjectMeta.UID)
+			assert.Equal(t, latest.Status.Url, expectedUrl)
+			assert.Assert(t, meta.IsStatusConditionTrue(latest.Status.Conditions, v1alpha1.CONDITION_TYPE_RESOLVED))
+			assert.Assert(t, meta.IsStatusConditionTrue(latest.Status.Conditions, v1alpha1.CONDITION_TYPE_READY))
+		}
+	}
+
+	for _, grant := range grants {
+		req := httptest.NewRequest(http.MethodPost, "/"+string(grant.ObjectMeta.UID), bytes.NewBufferString(grant.Status.Code))
+		res := httptest.NewRecorder()
+		registry.ServeHTTP(res, req)
+		assert.Equal(t, res.Code, http.StatusOK)
+		latest, err := client.GetSkupperClient().SkupperV1alpha1().AccessGrants(grant.Namespace).Get(context.TODO(), grant.Name, metav1.GetOptions{})
+		if err != nil {
+			t.Error(err)
+		}
+		assert.Equal(t, latest.Status.Redeemed, 1)
+	}
+	//TODO: test bad input values in spec
+}
+
+func Test_ServeHttp(t *testing.T) {
+	good := &v1alpha1.AccessGrant{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "good",
+			Namespace: "test",
+			UID:       "0bde3bc8-a4a2-404a-bfbe-44fdf7bf3231",
+		},
+		Spec: v1alpha1.AccessGrantSpec{
+			RedemptionsAllowed: 4,
+		},
+		Status: v1alpha1.AccessGrantStatus{
+			Code:       "supersecret",
+			Expiration: time.Date(2124, time.January, 0, 0, 0, 0, 0, time.UTC).Format(time.RFC3339),
+		},
+	}
+	expired := &v1alpha1.AccessGrant{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "expired",
+			Namespace: "test",
+			UID:       "a40fbe84-f276-4755-bf22-5ba980ab1661",
+		},
+		Spec: v1alpha1.AccessGrantSpec{
+			RedemptionsAllowed: 1,
+		},
+		Status: v1alpha1.AccessGrantStatus{
+			Code:       "supersecret",
+			Expiration: time.Date(2024, time.January, 0, 0, 0, 0, 0, time.UTC).Format(time.RFC3339),
+		},
+	}
+	used := &v1alpha1.AccessGrant{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "used-up",
+			Namespace: "test",
+			UID:       "cec708de-b907-48a0-b6e2-6ee64ca11f08",
+		},
+		Spec: v1alpha1.AccessGrantSpec{
+			RedemptionsAllowed: 1,
+		},
+		Status: v1alpha1.AccessGrantStatus{
+			Code:       "supersecret",
+			Redeemed:   1,
+			Expiration: time.Date(2124, time.January, 0, 0, 0, 0, 0, time.UTC).Format(time.RFC3339),
+		},
+	}
+	badExpiration := &v1alpha1.AccessGrant{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "bad-expiration",
+			Namespace: "test",
+			UID:       "6904f3e1-6802-4428-9b49-1b006114c496",
+		},
+		Spec: v1alpha1.AccessGrantSpec{
+			RedemptionsAllowed: 1,
+		},
+		Status: v1alpha1.AccessGrantStatus{
+			Code:       "supersecret",
+			Redeemed:   1,
+			Expiration: "iamnotadate",
+		},
+	}
+	deleted := &v1alpha1.AccessGrant{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "deleted",
+			Namespace: "test",
+			UID:       "cd125f09-d14a-4bef-b53b-fe7edd80908b",
+		},
+		Spec: v1alpha1.AccessGrantSpec{
+			RedemptionsAllowed: 1,
+		},
+		Status: v1alpha1.AccessGrantStatus{
+			Code:       "supersecret",
+			Expiration: time.Date(2124, time.January, 0, 0, 0, 0, 0, time.UTC).Format(time.RFC3339),
+		},
+	}
+
+	skupperObjects := []runtime.Object{
+		good,
+		expired,
+		used,
+		badExpiration,
+		deleted,
+	}
+
+	var tests = []struct {
+		name         string
+		method       string
+		path         string
+		body         io.Reader
+		expectedCode int
+		generator    GrantResponse
+	}{
+		{
+			name:         "bad method",
+			method:       http.MethodGet,
+			path:         "/",
+			expectedCode: http.StatusMethodNotAllowed,
+		},
+		{
+			name:         "successful redeem",
+			method:       http.MethodPost,
+			path:         "/" + string(good.ObjectMeta.UID),
+			body:         bytes.NewBufferString(good.Status.Code),
+			expectedCode: http.StatusOK,
+		},
+		{
+			name:         "expired grant",
+			method:       http.MethodPost,
+			path:         "/" + string(expired.ObjectMeta.UID),
+			body:         bytes.NewBufferString(expired.Status.Code),
+			expectedCode: http.StatusNotFound,
+		},
+		{
+			name:         "used grant",
+			method:       http.MethodPost,
+			path:         "/" + string(used.ObjectMeta.UID),
+			body:         bytes.NewBufferString(used.Status.Code),
+			expectedCode: http.StatusNotFound,
+		},
+		{
+			name:         "wrong code",
+			method:       http.MethodPost,
+			path:         "/" + string(good.ObjectMeta.UID),
+			body:         bytes.NewBufferString("opensesame"),
+			expectedCode: http.StatusForbidden,
+		},
+		{
+			name:         "invalid path",
+			method:       http.MethodPost,
+			path:         "/Idonotexist",
+			body:         bytes.NewBufferString("opensesame"),
+			expectedCode: http.StatusNotFound,
+		},
+		{
+			name:         "generator error",
+			method:       http.MethodPost,
+			path:         "/" + string(good.ObjectMeta.UID),
+			body:         bytes.NewBufferString(good.Status.Code),
+			expectedCode: http.StatusInternalServerError,
+			generator:    dummyGeneratorWithError,
+		},
+		{
+			name:         "bad body",
+			method:       http.MethodPost,
+			path:         "/" + string(good.ObjectMeta.UID),
+			body:         &FailingReader{},
+			expectedCode: http.StatusBadRequest,
+		},
+		{
+			name:         "bad expiration",
+			method:       http.MethodPost,
+			path:         "/" + string(badExpiration.ObjectMeta.UID),
+			body:         bytes.NewBufferString(badExpiration.Status.Code),
+			expectedCode: http.StatusInternalServerError,
+		},
+		{
+			name:         "error updating status",
+			method:       http.MethodPost,
+			path:         "/" + string(deleted.ObjectMeta.UID),
+			body:         bytes.NewBufferString(deleted.Status.Code),
+			expectedCode: http.StatusServiceUnavailable,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			client, err := fake.NewFakeClient("test", nil, skupperObjects, "")
+			if err != nil {
+				t.Error(err)
+			}
+			generator := tt.generator
+			if generator == nil {
+				generator = dummyGenerator
+			}
+			registry := newGrants(client, generator, "https", "")
+			for _, grant := range []*v1alpha1.AccessGrant{good, expired, used, badExpiration, deleted} {
+				err = registry.checkGrant(grant.Namespace+"/"+grant.Name, grant)
+				if err != nil {
+					t.Error(err)
+				}
+			}
+			err = client.GetSkupperClient().SkupperV1alpha1().AccessGrants(deleted.Namespace).Delete(context.TODO(), deleted.Name, metav1.DeleteOptions{})
+			if err != nil {
+				t.Error(err)
+			}
+			req := httptest.NewRequest(tt.method, tt.path, tt.body)
+			res := httptest.NewRecorder()
+			registry.ServeHTTP(res, req)
+			assert.Equal(t, res.Code, tt.expectedCode)
+		})
+	}
+
+}
+
+type CheckGrantTestInvocation struct {
+	key           string
+	grant         *v1alpha1.AccessGrant
+	expectedError string
+	url           string //set url before invocation
+	ca            string //set ca before invocation
+	recheckUrl    string //set and recheck url after invocation
+	recheckCa     string //set and recheck ca after invocation
+}
+
+func Test_checkGrant(t *testing.T) {
+	good := &v1alpha1.AccessGrant{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "good",
+			Namespace: "test",
+			UID:       "0bde3bc8-a4a2-404a-bfbe-44fdf7bf3231",
+		},
+	}
+	badExpirationWindow := &v1alpha1.AccessGrant{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "bad-expiration",
+			Namespace: "test",
+			UID:       "6904f3e1-6802-4428-9b49-1b006114c496",
+		},
+		Spec: v1alpha1.AccessGrantSpec{
+			ExpirationWindow: "iamnotaduration",
+		},
+	}
+
+	skupperObjects := []runtime.Object{
+		good,
+		badExpirationWindow,
+	}
+	var tests = []struct {
+		name  string
+		calls []CheckGrantTestInvocation
+	}{
+		{
+			name: "simple",
+			calls: []CheckGrantTestInvocation{
+				{
+					key:   good.Namespace + "/" + good.Name,
+					grant: good,
+				},
+			},
+		},
+		{
+			name: "simple repeat",
+			calls: []CheckGrantTestInvocation{
+				{
+					key:   good.Namespace + "/" + good.Name,
+					grant: good,
+				},
+				{
+					key:   good.Namespace + "/" + good.Name,
+					grant: good,
+				},
+			},
+		},
+		{
+			name: "bad expiration",
+			calls: []CheckGrantTestInvocation{
+				{
+					key:   badExpirationWindow.Namespace + "/" + badExpirationWindow.Name,
+					grant: badExpirationWindow,
+				},
+			},
+		},
+		{
+			name: "delete good",
+			calls: []CheckGrantTestInvocation{
+				{
+					key:   good.Namespace + "/" + good.Name,
+					grant: good,
+				},
+				{
+					key: good.Namespace + "/" + good.Name,
+				},
+			},
+		},
+		{
+			name: "url changed",
+			calls: []CheckGrantTestInvocation{
+				{
+					key:   good.Namespace + "/" + good.Name,
+					grant: good,
+					url:   "foo",
+				},
+				{
+					key:   good.Namespace + "/" + good.Name,
+					grant: good,
+					url:   "foo",
+				},
+			},
+		},
+		{
+			name: "ca changed",
+			calls: []CheckGrantTestInvocation{
+				{
+					key:   good.Namespace + "/" + good.Name,
+					grant: good,
+					ca:    "foo",
+				},
+				{
+					key:   good.Namespace + "/" + good.Name,
+					grant: good,
+					ca:    "foo",
+				},
+			},
+		},
+		{
+			name: "failed update",
+			calls: []CheckGrantTestInvocation{
+				{
+					key: "test/failedUpdate",
+					grant: &v1alpha1.AccessGrant{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "failedUpdate",
+							Namespace: "test",
+							UID:       "a40fbe84-f276-4755-bf22-5ba980ab1661",
+						},
+					},
+					expectedError: "failedUpdate",
+				},
+			},
+		},
+		{
+			name: "name reused with different UID",
+			calls: []CheckGrantTestInvocation{
+				{
+					key: "test/good",
+					grant: &v1alpha1.AccessGrant{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "good",
+							Namespace: "test",
+							UID:       "a40fbe84-f276-4755-bf22-5ba980ab1661",
+						},
+					},
+				},
+				{
+					key:   "test/good",
+					grant: good,
+					ca:    "foo",
+				},
+			},
+		},
+		{
+			name: "failed update on rechecking ca and url",
+			calls: []CheckGrantTestInvocation{
+				{
+					key: "test/failedUpdate",
+					grant: &v1alpha1.AccessGrant{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "failedUpdate",
+							Namespace: "test",
+							UID:       "a40fbe84-f276-4755-bf22-5ba980ab1661",
+						},
+					},
+					expectedError: "failedUpdate",
+					recheckUrl:    "foo",
+					recheckCa:     "bar",
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			client, err := fake.NewFakeClient("test", nil, skupperObjects, "")
+			if err != nil {
+				t.Error(err)
+			}
+			registry := newGrants(client, nil, "https", "")
+			for _, call := range tt.calls {
+				if call.url != "" {
+					registry.setUrl(call.url)
+				}
+				if call.ca != "" {
+					registry.setCA(call.ca)
+				}
+				err = registry.checkGrant(call.key, call.grant)
+				if call.expectedError != "" {
+					assert.ErrorContains(t, err, call.expectedError)
+				} else if err != nil {
+					t.Error(err)
+				}
+				if call.recheckUrl != "" {
+					registry.setUrl(call.recheckUrl)
+					registry.recheckUrl()
+				}
+				if call.recheckCa != "" {
+					registry.setCA(call.recheckCa)
+					registry.recheckCa()
+				}
+			}
+		})
+	}
+}
diff --git a/pkg/kube/grants/init_test.go b/pkg/kube/grants/init_test.go
new file mode 100644
index 00000000..a9db2888
--- /dev/null
+++ b/pkg/kube/grants/init_test.go
@@ -0,0 +1,124 @@
+package grants
+
+import (
+	"context"
+	"testing"
+
+	"gotest.tools/assert"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+
+	"github.com/skupperproject/skupper/internal/kube/client/fake"
+	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
+	"github.com/skupperproject/skupper/pkg/kube"
+)
+
+func Test_Initialise(t *testing.T) {
+	grantUid := "0bde3bc8-a4a2-404a-bfbe-44fdf7bf3231"
+	var tests = []struct {
+		name           string
+		config         GrantConfig
+		k8sObjects     []runtime.Object
+		extraSteps     int
+		expectedStatus string
+		expectedUrl    string
+		endpoint       *v1alpha1.Endpoint
+	}{
+		{
+			name:           "disabled",
+			expectedStatus: "AccessGrants are not enabled",
+		},
+		{
+			name: "manual",
+			config: GrantConfig{
+				Enabled:              true,
+				BaseUrl:              "foo:5432",
+				TlsCredentialsSecret: "my-creds",
+			},
+			k8sObjects:     []runtime.Object{tf.secret("my-creds", "test", "grant server", nil)},
+			expectedStatus: "OK",
+			expectedUrl:    "https://foo:5432/" + grantUid,
+		},
+		{
+			name: "auto",
+			config: GrantConfig{
+				Enabled:              true,
+				AutoConfigure:        true,
+				Hostname:             "my-pod",
+				TlsCredentialsSecret: "skupper-grant-server",
+			},
+			//add pregenerated secret as the certificate controller is not in action in this test
+			k8sObjects: []runtime.Object{tf.pod("my-pod", "test", map[string]string{"foo": "bar"}, ref1), tf.secret("skupper-grant-server", "test", "grant server", nil)},
+			endpoint: &v1alpha1.Endpoint{
+				Host: "my-host",
+				Port: "1234",
+			},
+			expectedStatus: "OK",
+			expectedUrl:    "https://my-host:1234/" + grantUid,
+		},
+		{
+			name: "failed auto",
+			config: GrantConfig{
+				Enabled:              true,
+				AutoConfigure:        true,
+				Hostname:             "my-pod",
+				TlsCredentialsSecret: "skupper-grant-server",
+			},
+			//add pregenerated secret as the certificate controller is not in action in this test
+			k8sObjects:     []runtime.Object{tf.secret("skupper-grant-server", "test", "grant server", nil)},
+			expectedStatus: "Pending",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			client, err := fake.NewFakeClient("test", tt.k8sObjects, []runtime.Object{tf.grant("my-grant", "test", grantUid)}, "")
+			if err != nil {
+				t.Error(err)
+			}
+			controller := kube.NewController("Controller", client)
+
+			start := Initialise(controller, "test", metav1.NamespaceAll, &tt.config, nil)
+			if tt.endpoint != nil {
+				err = updateSecuredAccessEndpoint(controller, "skupper-grant-server", "test", tt.endpoint)
+				if err != nil {
+					t.Error(err)
+				}
+			}
+			stopCh := make(chan struct{})
+			defer close(stopCh)
+			controller.StartWatchers(stopCh)
+			assert.Assert(t, controller.WaitForCacheSync(stopCh))
+			if tt.config.Enabled {
+				assert.Assert(t, start != nil)
+				start()
+			}
+			for range tt.k8sObjects {
+				assert.Assert(t, controller.TestProcess())
+			}
+			assert.Assert(t, controller.TestProcess())
+
+			latest, err := client.GetSkupperClient().SkupperV1alpha1().AccessGrants("test").Get(context.TODO(), "my-grant", metav1.GetOptions{})
+			if err != nil {
+				t.Error(err)
+			}
+			assert.Equal(t, latest.Status.StatusMessage, tt.expectedStatus)
+			if tt.expectedUrl != "" {
+				assert.Equal(t, latest.Status.Url, tt.expectedUrl)
+			}
+
+		})
+	}
+
+}
+
+func updateSecuredAccessEndpoint(controller *kube.Controller, name string, namespace string, endpoint *v1alpha1.Endpoint) error {
+	sa, err := controller.GetSkupperClient().SkupperV1alpha1().SecuredAccesses(namespace).Get(context.TODO(), name, metav1.GetOptions{})
+	if err != nil {
+		return err
+	}
+	if sa.Status.UpdateEndpoint(endpoint) {
+		_, err = controller.GetSkupperClient().SkupperV1alpha1().SecuredAccesses(sa.Namespace).UpdateStatus(context.TODO(), sa, metav1.UpdateOptions{})
+		return err
+	}
+	return nil
+}
diff --git a/pkg/kube/grants/redeem_test.go b/pkg/kube/grants/redeem_test.go
new file mode 100644
index 00000000..6eb33c2a
--- /dev/null
+++ b/pkg/kube/grants/redeem_test.go
@@ -0,0 +1,518 @@
+package grants
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"testing"
+
+	"gotest.tools/assert"
+	meta "k8s.io/apimachinery/pkg/api/meta"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+
+	internalclient "github.com/skupperproject/skupper/internal/kube/client"
+	"github.com/skupperproject/skupper/internal/kube/client/fake"
+	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
+)
+
+func Test_postTokenRequest(t *testing.T) {
+	var tests = []struct {
+		name          string
+		token         *v1alpha1.AccessToken
+		site          *v1alpha1.Site
+		code          int
+		body          string
+		err           string
+		expectedError string
+	}{
+		{
+			name:  "simple",
+			token: tf.token("my-token", "x", "http://foo/xyz", "mycode", ""),
+			site:  tf.site("my-site", "x"),
+			code:  200,
+			body:  "OK",
+		},
+		{
+			name:          "not found",
+			token:         tf.token("my-token", "x", "http://foo/xyz", "mycode", ""),
+			site:          tf.site("my-site", "x"),
+			code:          404,
+			body:          "no such grant",
+			expectedError: "404 (Not Found) no such grant",
+		},
+		{
+			name:          "bad url",
+			token:         tf.token("my-token", "x", "foo://a:b", "mycode", ""),
+			site:          tf.site("my-site", "x"),
+			expectedError: "foo://a:b",
+		},
+		{
+			name:          "bad url",
+			token:         tf.token("my-token", "x", "http://foo", "mycode", ""),
+			site:          tf.site("my-site", "x"),
+			err:           "some kind of failure",
+			expectedError: "some kind of failure",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			reader, err := postTokenRequest(tt.token, tt.site, tripper(tt.code, tt.body, tt.err))
+			if tt.expectedError != "" {
+				assert.ErrorContains(t, err, tt.expectedError)
+			} else if err != nil {
+				t.Error(err)
+			} else {
+				body, _ := io.ReadAll(reader)
+				assert.Equal(t, string(body), tt.body)
+			}
+		})
+	}
+}
+
+type TestTripper struct {
+	code int
+	body string
+	err  string
+}
+
+func tripper(code int, body string, err string) http.RoundTripper {
+	return &TestTripper{
+		code: code,
+		body: body,
+		err:  err,
+	}
+}
+
+func (t *TestTripper) RoundTrip(request *http.Request) (*http.Response, error) {
+	if t.err != "" {
+		return nil, errors.New(t.err)
+	}
+	return &http.Response{
+		StatusCode: t.code,
+		Body:       ioutil.NopCloser(bytes.NewBufferString(t.body)),
+		Header:     make(http.Header),
+	}, nil
+}
+
+func Test_handleTokenResponse(t *testing.T) {
+	var tests = []struct {
+		name                 string
+		token                *v1alpha1.AccessToken
+		site                 *v1alpha1.Site
+		body                 *CertToken
+		failReadAt           int
+		expectedStatus       string
+		expectStatusContains string
+		expectedError        string
+		expectedLinks        []string
+		expectedSecret       string
+		extraK8sObjects      []runtime.Object
+		extraSkupperObjects  []runtime.Object
+	}{
+		{
+			name:  "simple",
+			token: tf.token("my-token", "test", "http://foo/xyz", "mycode", ""),
+			site:  tf.site("my-site", "test"),
+			body: &CertToken{
+				tlsCredentials: tf.secret("my-token", "", "My Subject", nil),
+				links: []*v1alpha1.Link{
+					tf.link("my-token", "", []v1alpha1.Endpoint{
+						{
+							Host: "foo",
+							Port: "1234",
+						},
+					}, "my-token"),
+				},
+			},
+			expectedStatus: "OK",
+			expectedLinks:  []string{"my-token"},
+			expectedSecret: "my-token",
+		},
+		{
+			name:           "no data",
+			token:          tf.token("my-token", "test", "http://foo/xyz", "mycode", ""),
+			site:           tf.site("my-site", "test"),
+			expectedStatus: "Could not decode response",
+		},
+		{
+			name:  "failed read",
+			token: tf.token("my-token", "test", "http://foo/xyz", "mycode", ""),
+			site:  tf.site("my-site", "test"),
+			body: &CertToken{
+				tlsCredentials: tf.secret("my-token", "", "My Subject", nil),
+				links: []*v1alpha1.Link{
+					tf.link("my-token", "", []v1alpha1.Endpoint{
+						{
+							Host: "foo",
+							Port: "1234",
+						},
+					}, "my-token"),
+				},
+			},
+			failReadAt:     1025,
+			expectedStatus: "Could not decode response",
+		},
+		{
+			name:  "secret collision",
+			token: tf.token("my-token", "test", "http://foo/xyz", "mycode", ""),
+			site:  tf.site("my-site", "test"),
+			body: &CertToken{
+				tlsCredentials: tf.secret("my-token", "", "My Subject", nil),
+				links: []*v1alpha1.Link{
+					tf.link("my-token", "", []v1alpha1.Endpoint{
+						{
+							Host: "foo",
+							Port: "1234",
+						},
+					}, "my-token"),
+				},
+			},
+			expectedStatus:  "Could not create received secret: secrets \"my-token\" already exists",
+			extraK8sObjects: []runtime.Object{tf.secret("my-token", "test", "Another Subject", nil)},
+		},
+		{
+			name:  "link collision",
+			token: tf.token("my-token", "test", "http://foo/xyz", "mycode", ""),
+			site:  tf.site("my-site", "test"),
+			body: &CertToken{
+				tlsCredentials: tf.secret("my-token", "", "My Subject", nil),
+				links: []*v1alpha1.Link{
+					tf.link("my-token", "", []v1alpha1.Endpoint{
+						{
+							Host: "foo",
+							Port: "1234",
+						},
+					}, "my-token"),
+				},
+			},
+			expectedStatus:      "Could not create received link: links.skupper.io \"my-token\" already exists",
+			extraSkupperObjects: []runtime.Object{tf.link("my-token", "test", nil, "")},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			skupperObjects := []runtime.Object{tt.site, tt.token}
+			if tt.extraSkupperObjects != nil {
+				skupperObjects = append(skupperObjects, tt.extraSkupperObjects...)
+			}
+			client, _ := fake.NewFakeClient("test", tt.extraK8sObjects, skupperObjects, "")
+			var buffer bytes.Buffer
+			if tt.body != nil {
+				tt.body.Write(&buffer)
+			}
+			var reader io.Reader
+			reader = &buffer
+			if tt.failReadAt != 0 {
+				reader = &FailingReader{
+					reader: reader,
+					fail:   tt.failReadAt,
+				}
+			}
+			err := handleTokenResponse(reader, tt.token, tt.site, client)
+			if tt.expectedError != "" {
+				assert.ErrorContains(t, err, tt.expectedError)
+			} else if err != nil {
+				t.Error(err)
+			} else {
+				token, err := client.GetSkupperClient().SkupperV1alpha1().AccessTokens("test").Get(context.TODO(), tt.token.Name, metav1.GetOptions{})
+				if err != nil {
+					t.Error(err)
+				} else {
+					assert.Equal(t, token.Status.StatusMessage, tt.expectedStatus)
+					if tt.expectedSecret != "" {
+						secret, err := client.GetKubeClient().CoreV1().Secrets("test").Get(context.TODO(), tt.expectedSecret, metav1.GetOptions{})
+						if err != nil {
+							t.Error(err)
+						}
+						assert.Assert(t, secret.Data["tls.crt"] != nil)
+					}
+					for _, name := range tt.expectedLinks {
+						link, err := client.GetSkupperClient().SkupperV1alpha1().Links("test").Get(context.TODO(), name, metav1.GetOptions{})
+						if err != nil {
+							t.Error(err)
+						} else {
+							assert.Assert(t, len(link.Spec.Endpoints) > 0)
+						}
+					}
+				}
+			}
+		})
+	}
+}
+
+func Test_RedeemAccessToken(t *testing.T) {
+	var tests = []struct {
+		name           string
+		scheme         string
+		tokenName      string
+		grantUID       string
+		defaultIssuer  string
+		endpoints      []v1alpha1.Endpoint
+		expectedError  string
+		expectedStatus string
+		expectedLinks  []string
+		expectedSecret string
+		expectRedeemed bool
+	}{
+		{
+			name:      "simple",
+			scheme:    "https",
+			tokenName: "my-token",
+			endpoints: []v1alpha1.Endpoint{
+				{
+					Name: "inter-router",
+					Host: "my-link-host",
+					Port: "1111",
+				},
+				{
+					Name: "edge",
+					Host: "my-link-host",
+					Port: "2222",
+				},
+			},
+			expectedStatus: "OK",
+			expectRedeemed: true,
+			expectedLinks:  []string{"my-token"},
+			expectedSecret: "my-token",
+		},
+		{
+			name:      "ha",
+			scheme:    "https",
+			tokenName: "my-token",
+			endpoints: []v1alpha1.Endpoint{
+				{
+					Name:  "inter-router",
+					Host:  "my-link-host-1",
+					Port:  "1111",
+					Group: "one",
+				},
+				{
+					Name:  "edge",
+					Host:  "my-link-host-1",
+					Port:  "2222",
+					Group: "one",
+				},
+				{
+					Name:  "inter-router",
+					Host:  "my-link-host-2",
+					Port:  "1111",
+					Group: "two",
+				},
+				{
+					Name:  "edge",
+					Host:  "my-link-host-2",
+					Port:  "2222",
+					Group: "two",
+				},
+			},
+			expectedStatus: "OK",
+			expectRedeemed: true,
+			expectedLinks:  []string{"my-token-1", "my-token-2"},
+			expectedSecret: "my-token",
+		},
+		{
+			name:      "tls disabled",
+			scheme:    "http",
+			tokenName: "my-token",
+			endpoints: []v1alpha1.Endpoint{
+				{
+					Name: "inter-router",
+					Host: "my-link-host",
+					Port: "1111",
+				},
+				{
+					Name: "edge",
+					Host: "my-link-host",
+					Port: "2222",
+				},
+			},
+			expectedStatus: "OK",
+			expectRedeemed: true,
+			expectedLinks:  []string{"my-token"},
+			expectedSecret: "my-token",
+		},
+		{
+			name:           "not known",
+			scheme:         "https",
+			tokenName:      "my-token",
+			grantUID:       "a40fbe84-f276-4755-bf22-5ba980ab1661",
+			expectedStatus: "404 (Not Found) No such claim",
+			expectRedeemed: false,
+		},
+		{
+			name:           "no resolved endpoints",
+			scheme:         "https",
+			tokenName:      "my-token",
+			expectedStatus: "500 (Internal Server Error) Could not resolve any endpoints for requested link",
+		},
+		{
+			name:           "bad default issuer",
+			scheme:         "https",
+			tokenName:      "my-token",
+			defaultIssuer:  "i-dont-exist",
+			expectedStatus: "500 (Internal Server Error) Could not get issuer for requested certficate",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			client, err := fake.NewFakeClient("test", []runtime.Object{tf.secret("skupper-site-ca", "test", "Test Site CA", nil)}, []runtime.Object{tf.site("my-site", "test"), tf.grant("my-grant", "test", "")}, "")
+			site, err := client.GetSkupperClient().SkupperV1alpha1().Sites("test").Get(context.TODO(), "my-site", metav1.GetOptions{})
+			if err != nil {
+				t.Error(err)
+			}
+			if tt.defaultIssuer != "" {
+				site.Status.DefaultIssuer = tt.defaultIssuer
+			} else {
+				site.Status.DefaultIssuer = "skupper-site-ca"
+			}
+			site.Status.Endpoints = tt.endpoints
+			site, err = client.GetSkupperClient().SkupperV1alpha1().Sites("test").UpdateStatus(context.TODO(), site, metav1.UpdateOptions{})
+			if err != nil {
+				t.Error(err)
+			}
+
+			grants := newGrants(client, generator(site, client), tt.scheme, "")
+			server := newServer(":0", tt.scheme == "https", grants)
+			server.listen()
+			grants.setUrl(fmt.Sprintf("localhost:%d", server.port()))
+			go server.serve()
+			defer server.stop()
+			if tt.scheme == "https" {
+				secret := tf.secret("my-creds", "test", "grant server", []string{"localhost"})
+				err = server.setCertificateFromSecret(secret)
+				if err != nil {
+					t.Error(err)
+				}
+				if grants.setCA(string(secret.Data["ca.crt"])) {
+					grants.recheckCa()
+				}
+			}
+			grant, err := client.GetSkupperClient().SkupperV1alpha1().AccessGrants("test").Get(context.TODO(), "my-grant", metav1.GetOptions{})
+			if err != nil {
+				t.Error(err)
+			}
+			err = grants.checkGrant(grant.Namespace+"/"+grant.Name, grant)
+			if err != nil {
+				t.Error(err)
+			}
+
+			token := tf.token(tt.tokenName, "test", grant.Status.Url, grant.Status.Code, grant.Status.Ca)
+			if tt.grantUID != "" {
+				token.Spec.Url = fmt.Sprintf("%s://localhost:%d/%s", tt.scheme, server.port(), tt.grantUID)
+			}
+			token, err = client.GetSkupperClient().SkupperV1alpha1().AccessTokens("test").Create(context.TODO(), token, metav1.CreateOptions{})
+			if err != nil {
+				t.Error(err)
+			}
+			err = RedeemAccessToken(token, site, client)
+			if tt.expectedError != "" {
+				assert.ErrorContains(t, err, tt.expectedError)
+			} else if err != nil {
+				t.Error(err)
+			} else {
+				// verify the access token status is redeemed
+				token, err = client.GetSkupperClient().SkupperV1alpha1().AccessTokens("test").Get(context.TODO(), tt.tokenName, metav1.GetOptions{})
+				if err != nil {
+					t.Error(err)
+				} else {
+					assert.Equal(t, token.Status.StatusMessage, tt.expectedStatus)
+					if tt.expectRedeemed {
+						assert.Assert(t, meta.IsStatusConditionTrue(token.Status.Conditions, v1alpha1.CONDITION_TYPE_REDEEMED))
+						// verify we have link(s) and secret as expected
+						for _, name := range tt.expectedLinks {
+							link, err := client.GetSkupperClient().SkupperV1alpha1().Links("test").Get(context.TODO(), name, metav1.GetOptions{})
+							if err != nil {
+								t.Error(err)
+							} else {
+								assert.Assert(t, len(link.Spec.Endpoints) > 0)
+							}
+						}
+						secret, err := client.GetKubeClient().CoreV1().Secrets("test").Get(context.TODO(), tt.expectedSecret, metav1.GetOptions{})
+						if err != nil {
+							t.Error(err)
+						}
+						assert.Assert(t, secret.Data["tls.crt"] != nil)
+					}
+				}
+			}
+		})
+	}
+}
+
+type TestTokenGenerator struct {
+	site    *v1alpha1.Site
+	clients internalclient.Clients
+}
+
+func (g *TestTokenGenerator) generate(namespace string, name string, subject string, writer io.Writer) error {
+	generator, err := NewTokenGenerator(g.site, g.clients)
+	if err != nil {
+		return err
+	}
+	token := generator.NewCertToken(name, subject)
+	return token.Write(writer)
+}
+
+func newTestTokenGenerator(site *v1alpha1.Site, clients internalclient.Clients) *TestTokenGenerator {
+	return &TestTokenGenerator{
+		site:    site,
+		clients: clients,
+	}
+}
+
+func generator(site *v1alpha1.Site, clients internalclient.Clients) GrantResponse {
+	return newTestTokenGenerator(site, clients).generate
+}
+
+func Test_updateAccessTokenStatus(t *testing.T) {
+	var tests = []struct {
+		name           string
+		errs           []error
+		expectedStatus string
+	}{
+		{
+			name:           "simple",
+			errs:           []error{nil},
+			expectedStatus: "OK",
+		},
+		{
+			name:           "failure",
+			errs:           []error{errors.New("something bad happened")},
+			expectedStatus: "something bad happened",
+		},
+		{
+			name:           "repeated failure",
+			errs:           []error{errors.New("something bad happened"), errors.New("it happened again"), errors.New("it happened again")},
+			expectedStatus: "it happened again",
+		},
+		{
+			name:           "recovered sequence",
+			errs:           []error{errors.New("something bad happened"), nil, nil},
+			expectedStatus: "OK",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			client, _ := fake.NewFakeClient("test", nil, []runtime.Object{tf.token("my-token", "test", "", "", "")}, "")
+			for _, err := range tt.errs {
+				token, apiError := client.GetSkupperClient().SkupperV1alpha1().AccessTokens("test").Get(context.TODO(), "my-token", metav1.GetOptions{})
+				if apiError != nil {
+					t.Error(apiError)
+				} else {
+					updateAccessTokenStatus(token, err, client)
+				}
+			}
+			token, apiError := client.GetSkupperClient().SkupperV1alpha1().AccessTokens("test").Get(context.TODO(), "my-token", metav1.GetOptions{})
+			if apiError != nil {
+				t.Error(apiError)
+			} else {
+				assert.Equal(t, token.Status.StatusMessage, tt.expectedStatus)
+			}
+		})
+	}
+}
diff --git a/pkg/kube/grants/server_test.go b/pkg/kube/grants/server_test.go
new file mode 100644
index 00000000..5181e011
--- /dev/null
+++ b/pkg/kube/grants/server_test.go
@@ -0,0 +1,82 @@
+package grants
+
+import (
+	"crypto/x509"
+	"fmt"
+	"net/http"
+	"testing"
+
+	"gotest.tools/assert"
+	corev1 "k8s.io/api/core/v1"
+)
+
+func Test_setCertificateFromSecret(t *testing.T) {
+	var tests = []struct {
+		name             string
+		secret           *corev1.Secret
+		expectedError    string
+		expectedSubject  string
+		expectedDNSNames []string
+	}{
+		{
+			name:             "simple",
+			secret:           tf.secret("simple", "", "My Subject", []string{"foo.com", "bar.org"}),
+			expectedSubject:  "My Subject",
+			expectedDNSNames: []string{"foo.com", "bar.org"},
+		},
+		{
+			name:          "non tls secret",
+			secret:        tf.genericSecret("simple", ""),
+			expectedError: "failed to find any PEM data",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			server := newServer(":0", true, &TestHandler{})
+			err := server.setCertificateFromSecret(tt.secret)
+			if tt.expectedError != "" {
+				assert.ErrorContains(t, err, tt.expectedError)
+			} else if err != nil {
+				t.Error(err)
+			} else {
+				cert, _ := server.getCertificate(nil)
+				assert.Assert(t, cert != nil)
+				cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0])
+				if err != nil {
+					t.Error(err)
+				}
+				assert.Equal(t, cert.Leaf.Subject.CommonName, tt.expectedSubject)
+				if tt.expectedDNSNames != nil {
+					assert.DeepEqual(t, cert.Leaf.DNSNames, tt.expectedDNSNames)
+				}
+			}
+		})
+	}
+}
+
+func Test_handlesErrorOnListen(t *testing.T) {
+	server1 := newServer(":0", true, &TestHandler{})
+	err := server1.listen()
+	if err != nil {
+		t.Error(err)
+	}
+	defer server1.stop()
+	server2 := newServer(fmt.Sprintf(":%d", server1.port()), true, &TestHandler{})
+	err = server2.listenAndServe()
+	assert.ErrorContains(t, err, server2.server.Addr)
+}
+
+func Test_handlesServeBeforeListen(t *testing.T) {
+	server := newServer(":0", true, &TestHandler{})
+	err := server.serve()
+	assert.ErrorContains(t, err, "Cannot serve before listen() is called")
+}
+
+func Test_handlesPortBeforeListen(t *testing.T) {
+	server := newServer(":1234", true, &TestHandler{})
+	assert.Equal(t, 0, server.port())
+}
+
+type TestHandler struct{}
+
+func (h *TestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {}
diff --git a/pkg/kube/grants/tokens_test.go b/pkg/kube/grants/tokens_test.go
new file mode 100644
index 00000000..14db531f
--- /dev/null
+++ b/pkg/kube/grants/tokens_test.go
@@ -0,0 +1,89 @@
+package grants
+
+import (
+	"bytes"
+	"io"
+	"testing"
+
+	"gotest.tools/assert"
+
+	"github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
+)
+
+func Test_CertTokenWrite(t *testing.T) {
+	var tests = []struct {
+		name          string
+		cert          *CertToken
+		failWrite     int
+		expectedError string
+	}{
+		{
+			name: "good",
+			cert: &CertToken{
+				tlsCredentials: tf.secret("my-token", "", "My Subject", nil),
+				links: []*v1alpha1.Link{
+					tf.link("my-token", "", []v1alpha1.Endpoint{
+						{
+							Host: "foo",
+							Port: "1234",
+						},
+					}, "my-token"),
+				},
+			},
+		},
+		{
+			name: "bad",
+			cert: &CertToken{
+				tlsCredentials: tf.secret("my-token", "", "My Subject", nil),
+				links: []*v1alpha1.Link{
+					tf.link("my-token", "", []v1alpha1.Endpoint{
+						{
+							Host: "foo",
+							Port: "1234",
+						},
+					}, "my-token"),
+				},
+			},
+			failWrite:     1,
+			expectedError: "Failed Write",
+		},
+		{
+			name: "later bad",
+			cert: &CertToken{
+				tlsCredentials: tf.secret("my-token", "", "My Subject", nil),
+				links: []*v1alpha1.Link{
+					tf.link("my-token", "", []v1alpha1.Endpoint{
+						{
+							Host: "foo",
+							Port: "1234",
+						},
+					}, "my-token"),
+				},
+			},
+			failWrite:     2,
+			expectedError: "Failed Write",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var buffer bytes.Buffer
+			var writer io.Writer
+			writer = &buffer
+			if tt.failWrite != 0 {
+				writer = &FailingWriter{
+					writer: writer,
+					fail:   tt.failWrite,
+				}
+			}
+
+			err := tt.cert.Write(writer)
+			if tt.expectedError != "" {
+				assert.ErrorContains(t, err, tt.expectedError)
+			} else if err != nil {
+				t.Error(err)
+			} else {
+
+			}
+		})
+	}
+}
diff --git a/pkg/site/defaultissuer_test.go b/pkg/site/defaultissuer_test.go
deleted file mode 100644
index 3d03ab13..00000000
--- a/pkg/site/defaultissuer_test.go
+++ /dev/null
@@ -1,70 +0,0 @@
-package site
-
-import (
-	"testing"
-
-	skupperv1alpha1 "github.com/skupperproject/skupper/pkg/apis/skupper/v1alpha1"
-	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-)
-
-func TestDefaultIssuer(t *testing.T) {
-	type args struct {
-		site *skupperv1alpha1.Site
-	}
-	tests := []struct {
-		name string
-		args args
-		want string
-	}{
-		{
-			name: "nil site",
-			args: args{
-				site: nil,
-			},
-			want: "skupper-site-ca",
-		},
-		{
-			name: "spec issuer",
-			args: args{
-				site: &skupperv1alpha1.Site{
-					ObjectMeta: v1.ObjectMeta{
-						Name:      "site1",
-						Namespace: "test",
-					},
-					Spec: skupperv1alpha1.SiteSpec{
-						DefaultIssuer: "skupper-spec-issuer-ca",
-					},
-					Status: skupperv1alpha1.SiteStatus{
-						DefaultIssuer: "skupper-status-issuer-ca",
-					},
-				},
-			},
-			want: "skupper-spec-issuer-ca",
-		},
-		{
-			name: "status issuer",
-			args: args{
-				site: &skupperv1alpha1.Site{
-					ObjectMeta: v1.ObjectMeta{
-						Name:      "site1",
-						Namespace: "test",
-					},
-					Spec: skupperv1alpha1.SiteSpec{
-						DefaultIssuer: "",
-					},
-					Status: skupperv1alpha1.SiteStatus{
-						DefaultIssuer: "skupper-status-issuer-ca",
-					},
-				},
-			},
-			want: "skupper-status-issuer-ca",
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			if got := DefaultIssuer(tt.args.site); got != tt.want {
-				t.Errorf("DefaultIssuer() = %v, want %v", got, tt.want)
-			}
-		})
-	}
-}