From 5e5fc85f95aae27b589054c4e015b39cadde351e Mon Sep 17 00:00:00 2001 From: Doug Goldstein Date: Wed, 1 Apr 2026 11:22:36 -0500 Subject: [PATCH] feat(nautobotop): determine the username from the supplied token Avoid an issue where the username supplied is different than the token the user is for by always looking up the user that the token belongs to for the queries. Purposefully did not run: - make manifests - make copy-crds-to-helm So that we have a transition period in removing the username. --- docs/operator-guide/nautobotop.md | 4 +- go/nautobotop/api/v1alpha1/secret_type.go | 8 ---- .../controller/nautobot_controller.go | 36 +++++++-------- .../internal/nautobot/client/client.go | 14 ++++-- .../internal/nautobot/client/user.go | 44 +++++++++++++++++++ 5 files changed, 74 insertions(+), 32 deletions(-) create mode 100644 go/nautobotop/internal/nautobot/client/user.go diff --git a/docs/operator-guide/nautobotop.md b/docs/operator-guide/nautobotop.md index f004d2960..257b77e70 100644 --- a/docs/operator-guide/nautobotop.md +++ b/docs/operator-guide/nautobotop.md @@ -61,7 +61,6 @@ metadata: namespace: nautobotop type: Opaque stringData: - username: admin token: your-nautobot-api-token ``` @@ -90,7 +89,6 @@ spec: nautobotSecretRef: name: nautobot-token namespace: nautobotop - usernameKey: username tokenKey: token nautobotServiceRef: @@ -186,7 +184,7 @@ spec: | `requeueAfter` | int | 600 | Seconds between reconciliation attempts | | `syncIntervalSeconds` | int | 172800 | Minimum seconds between full syncs | | `cacheMaxSize` | int | 70000 | Maximum number of entries in the Nautobot object cache | -| `nautobotSecretRef` | SecretKeySelector | | Reference to the Secret holding Nautobot credentials | +| `nautobotSecretRef` | SecretKeySelector | | Reference to the Secret holding the Nautobot API token | | `nautobotServiceRef` | ServiceSelector | | Reference to the Nautobot Kubernetes Service | | `locationTypesRef` | []ConfigMapRef | | ConfigMaps containing location type definitions | | `locationRef` | []ConfigMapRef | | ConfigMaps containing location definitions | diff --git a/go/nautobotop/api/v1alpha1/secret_type.go b/go/nautobotop/api/v1alpha1/secret_type.go index 24dc813a9..a8f79a7c1 100644 --- a/go/nautobotop/api/v1alpha1/secret_type.go +++ b/go/nautobotop/api/v1alpha1/secret_type.go @@ -15,14 +15,6 @@ type SecretKeySelector struct { // +kubebuilder:validation:Pattern:=^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ Namespace *string `json:"namespace,omitempty"` - // A UsernameKey in the referenced Secret. - // Some instances of this field may be defaulted, in others it may be required. - // +optional - // +kubebuilder:validation:MinLength:=1 - // +kubebuilder:validation:MaxLength:=253 - // +kubebuilder:validation:Pattern:=^[-._a-zA-Z0-9]+$ - UsernameKey string `json:"usernameKey,omitempty"` - // A key in the referenced Secret. // Some instances of this field may be defaulted, in others it may be required. // +optional diff --git a/go/nautobotop/internal/controller/nautobot_controller.go b/go/nautobotop/internal/controller/nautobot_controller.go index 1685682f2..176a05233 100644 --- a/go/nautobotop/internal/controller/nautobot_controller.go +++ b/go/nautobotop/internal/controller/nautobot_controller.go @@ -45,7 +45,8 @@ import ( // NautobotReconciler reconciles a Nautobot object type NautobotReconciler struct { client.Client - Scheme *runtime.Scheme + Scheme *runtime.Scheme + resolvedUsername string } // +kubebuilder:rbac:groups=sync.rax.io,resources=nautobots,verbs=get;list;watch;create;update;patch;delete @@ -143,17 +144,26 @@ func (r *NautobotReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c } // Create Nautobot client - username, token, err := r.getAuthTokenFromSecretRef(ctx, nautobotCR) + token, err := r.getAuthTokenFromSecretRef(ctx, nautobotCR) if err != nil { log.Error(err, "failed to get nautobot auth token") return ctrl.Result{}, err } nautobotURL := fmt.Sprintf("http://%s.%s.svc.cluster.local/api", nautobotCR.Spec.NautobotServiceRef.Name, nautobotCR.Spec.NautobotServiceRef.Namespace) - nautobotClient, err := nbClient.NewNautobotClient(nautobotURL, username, token, nautobotCR.Spec.CacheMaxSize) + nautobotClient, err := nbClient.NewNautobotClient(nautobotURL, token, nautobotCR.Spec.CacheMaxSize) if err != nil { log.Error(err, "failed to create nautobot client") return ctrl.Result{}, err } + if r.resolvedUsername != "" { + nautobotClient.Username = r.resolvedUsername + } else { + if err := nautobotClient.ResolveUsername(ctx); err != nil { + log.Error(err, "failed to resolve nautobot username from token") + return ctrl.Result{}, err + } + r.resolvedUsername = nautobotClient.Username + } if err := nautobotClient.PreLoadCacheForLookup(ctx); err != nil { log.Error(err, "failed to warmup cache") @@ -470,27 +480,17 @@ func (r *NautobotReconciler) syncTenant(ctx context.Context, return nil } -// getAuthTokenFromSecretRef: this will fetch Nautobot auth token from the given refer. -func (r *NautobotReconciler) getAuthTokenFromSecretRef(ctx context.Context, nautobotCR syncv1alpha1.Nautobot) (string, string, error) { - var username, token string +// getAuthTokenFromSecretRef fetches the Nautobot API token from the referenced Kubernetes Secret. +func (r *NautobotReconciler) getAuthTokenFromSecretRef(ctx context.Context, nautobotCR syncv1alpha1.Nautobot) (string, error) { secret := &corev1.Secret{} err := r.Get(ctx, types.NamespacedName{Name: nautobotCR.Spec.NautobotSecretRef.Name, Namespace: *nautobotCR.Spec.NautobotSecretRef.Namespace}, secret) if err != nil { - return "", "", err - } - // Read the secret value - if valBytes, ok := secret.Data[nautobotCR.Spec.NautobotSecretRef.UsernameKey]; ok { - username = string(valBytes) + return "", err } if valBytes, ok := secret.Data[nautobotCR.Spec.NautobotSecretRef.TokenKey]; ok { - token = string(valBytes) - } - - if username != "" || token != "" { - return username, token, nil + return string(valBytes), nil } - - return "", "", fmt.Errorf("secret keys not found in provide secret") + return "", fmt.Errorf("token key %q not found in secret %s", nautobotCR.Spec.NautobotSecretRef.TokenKey, nautobotCR.Spec.NautobotSecretRef.Name) } // SetupWithManager sets up the controller with the Manager. diff --git a/go/nautobotop/internal/nautobot/client/client.go b/go/nautobotop/internal/nautobot/client/client.go index e215386fb..c1c979105 100644 --- a/go/nautobotop/internal/nautobot/client/client.go +++ b/go/nautobotop/internal/nautobot/client/client.go @@ -20,6 +20,8 @@ type NautobotClient struct { Username string Report map[string][]string Cache *cache.Cache + reqClient *req.Client + apiURL string } // AddReport appends one or more lines to the current reconciliation report. @@ -33,7 +35,8 @@ func (n *NautobotClient) AddReport(key string, line ...string) { // apiURL: The base URL of the Nautobot API (e.g., "http://localhost:8000"). // authToken: The API token for authentication. // cacheMaxSize: The maximum size of the cache (0 uses default of 70,000). -func NewNautobotClient(apiURL string, username, authToken string, cacheMaxSize int) (*NautobotClient, error) { +// The username is resolved from the token via ResolveUsername after construction. +func NewNautobotClient(apiURL string, authToken string, cacheMaxSize int) (*NautobotClient, error) { // Configure req client with retry and backoff reqClient := req.C(). SetTimeout(30*time.Second). @@ -41,9 +44,13 @@ func NewNautobotClient(apiURL string, username, authToken string, cacheMaxSize i SetCommonRetryBackoffInterval(1*time.Second, 5*time.Second). SetCommonRetryCondition(func(resp *req.Response, err error) bool { return err != nil || resp.StatusCode >= 500 - }) + }). + SetCommonHeader("Authorization", fmt.Sprintf("Token %s", authToken)) config := nb.NewConfiguration() + // reqClient.GetClient() returns the underlying *http.Client for the SDK to use. + // The SDK applies auth via config.AddDefaultHeader for its own calls. + // Direct calls via reqClient.R() (e.g. ResolveUsername) apply auth via SetCommonHeader above. config.HTTPClient = reqClient.GetClient() config.Servers = nb.ServerConfigurations{ { @@ -58,11 +65,12 @@ func NewNautobotClient(apiURL string, username, authToken string, cacheMaxSize i } return &NautobotClient{ - Username: username, Config: config, APIClient: client, Report: make(map[string][]string), Cache: c, + reqClient: reqClient, + apiURL: apiURL, }, nil } diff --git a/go/nautobotop/internal/nautobot/client/user.go b/go/nautobotop/internal/nautobot/client/user.go new file mode 100644 index 000000000..68cbf77f6 --- /dev/null +++ b/go/nautobotop/internal/nautobot/client/user.go @@ -0,0 +1,44 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" +) + +type tokenListResponse struct { + Count int `json:"count"` + Results []tokenResult `json:"results"` +} + +type tokenResult struct { + User tokenUser `json:"user"` +} + +type tokenUser struct { + Username string `json:"username"` +} + +// ResolveUsername fetches the username associated with the configured API token +// from Nautobot's /api/users/tokens/ endpoint and stores it in n.Username. +func (n *NautobotClient) ResolveUsername(ctx context.Context) error { + url := fmt.Sprintf("%s/users/tokens/", n.apiURL) + resp, err := n.reqClient.R().SetContext(ctx).Get(url) + if err != nil { + return fmt.Errorf("failed to call users/tokens API: %w", err) + } + if resp.StatusCode != 200 { + return fmt.Errorf("users/tokens API returned status %d", resp.StatusCode) + } + + var result tokenListResponse + if err := json.Unmarshal(resp.Bytes(), &result); err != nil { + return fmt.Errorf("failed to parse users/tokens response: %w", err) + } + if result.Count == 0 || len(result.Results) == 0 { + return fmt.Errorf("users/tokens API returned no tokens") + } + + n.Username = result.Results[0].User.Username + return nil +}