diff --git a/cmd/k8s-operator/deploy/chart/templates/deployment.yaml b/cmd/k8s-operator/deploy/chart/templates/deployment.yaml index 05317b6e5..8f835be39 100644 --- a/cmd/k8s-operator/deploy/chart/templates/deployment.yaml +++ b/cmd/k8s-operator/deploy/chart/templates/deployment.yaml @@ -81,6 +81,10 @@ spec: valueFrom: fieldRef: fieldPath: metadata.namespace + - name: OPERATOR_SERVICE_ACCOUNT_NAME + valueFrom: + fieldRef: + fieldPath: spec.serviceAccountName - name: OPERATOR_LOGIN_SERVER value: {{ .Values.loginServer }} - name: OPERATOR_INGRESS_CLASS_NAME diff --git a/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml b/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml index 4d59b4aad..08dea80a5 100644 --- a/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml +++ b/cmd/k8s-operator/deploy/chart/templates/operator-rbac.yaml @@ -76,6 +76,10 @@ rules: - apiGroups: [""] resources: ["secrets", "serviceaccounts", "configmaps"] verbs: ["create","delete","deletecollection","get","list","patch","update","watch"] +- apiGroups: [""] + resources: ["serviceaccounts/token"] + resourceNames: ["operator"] + verbs: ["create"] - apiGroups: [""] resources: ["pods"] verbs: ["get","list","watch", "update"] diff --git a/cmd/k8s-operator/deploy/crds/tailscale.com_tailnets.yaml b/cmd/k8s-operator/deploy/crds/tailscale.com_tailnets.yaml index 200d83943..5e6dd5880 100644 --- a/cmd/k8s-operator/deploy/crds/tailscale.com_tailnets.yaml +++ b/cmd/k8s-operator/deploy/crds/tailscale.com_tailnets.yaml @@ -58,15 +58,18 @@ spec: - credentials properties: credentials: - description: Denotes the location of the OAuth credentials to use for authenticating with this Tailnet. + description: Denotes the location of the credentials to use for authenticating with this Tailnet. type: object required: - secretName properties: secretName: description: |- - The name of the secret containing the OAuth credentials. This secret must contain two fields "client_id" and - "client_secret". + The name of the secret containing the credentials used to authenticate with this Tailnet. The secret must always + contain a "client_id" field. To authenticate with a static OAuth client, also set "client_secret". To authenticate + via workload identity federation, set "audience" to the audience value expected by the Tailscale OAuth + client; the operator will mint a ServiceAccount token for itself with that audience and exchange it for an API + token. "client_secret" and "audience" are mutually exclusive. type: string loginUrl: description: URL of the control plane to be used by all resources managed by the operator using this Tailnet. diff --git a/cmd/k8s-operator/deploy/manifests/operator.yaml b/cmd/k8s-operator/deploy/manifests/operator.yaml index 07c9f3af3..8068923d2 100644 --- a/cmd/k8s-operator/deploy/manifests/operator.yaml +++ b/cmd/k8s-operator/deploy/manifests/operator.yaml @@ -6151,12 +6151,15 @@ spec: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status properties: credentials: - description: Denotes the location of the OAuth credentials to use for authenticating with this Tailnet. + description: Denotes the location of the credentials to use for authenticating with this Tailnet. properties: secretName: description: |- - The name of the secret containing the OAuth credentials. This secret must contain two fields "client_id" and - "client_secret". + The name of the secret containing the credentials used to authenticate with this Tailnet. The secret must always + contain a "client_id" field. To authenticate with a static OAuth client, also set "client_secret". To authenticate + via workload identity federation, set "audience" to the audience value expected by the Tailscale OAuth + client; the operator will mint a ServiceAccount token for itself with that audience and exchange it for an API + token. "client_secret" and "audience" are mutually exclusive. type: string required: - secretName @@ -6409,6 +6412,14 @@ rules: - patch - update - watch + - apiGroups: + - "" + resourceNames: + - operator + resources: + - serviceaccounts/token + verbs: + - create - apiGroups: - "" resources: @@ -6560,6 +6571,10 @@ spec: valueFrom: fieldRef: fieldPath: metadata.namespace + - name: OPERATOR_SERVICE_ACCOUNT_NAME + valueFrom: + fieldRef: + fieldPath: spec.serviceAccountName - name: OPERATOR_LOGIN_SERVER value: null - name: OPERATOR_INGRESS_CLASS_NAME diff --git a/cmd/k8s-operator/operator.go b/cmd/k8s-operator/operator.go index 9f9c71997..3fc636367 100644 --- a/cmd/k8s-operator/operator.go +++ b/cmd/k8s-operator/operator.go @@ -97,6 +97,7 @@ func main() { isDefaultLoadBalancer = defaultBool("OPERATOR_DEFAULT_LOAD_BALANCER", false) loginServer = strings.TrimSuffix(defaultEnv("OPERATOR_LOGIN_SERVER", ""), "/") ingressClassName = defaultEnv("OPERATOR_INGRESS_CLASS_NAME", "tailscale") + operatorSAName = defaultEnv("OPERATOR_SERVICE_ACCOUNT_NAME", "operator") ) var opts []kzap.Opts @@ -157,6 +158,7 @@ func main() { tsServer: s, tsClient: tsc, tailscaleNamespace: tsNamespace, + operatorSAName: operatorSAName, restConfig: restConfig, proxyImage: image, k8sProxyImage: k8sProxyImage, @@ -349,6 +351,7 @@ func runReconcilers(opts reconcilerOpts) { tailnetOptions := tailnet.ReconcilerOptions{ Client: mgr.GetClient(), TailscaleNamespace: opts.tailscaleNamespace, + OperatorSAName: opts.operatorSAName, Clock: tstime.DefaultClock{}, Logger: opts.log, Registry: clients, @@ -818,6 +821,10 @@ type reconcilerOpts struct { // ingressClassName is the name of the ingress class used by reconcilers of Ingress resources. This defaults // to "tailscale" but can be customised. ingressClassName string + // operatorSAName is the name of the ServiceAccount that the operator pod runs as. It is used as the target + // ServiceAccount when minting tokens via the Kubernetes TokenRequest API for Tailnets that authenticate using + // workload identity federation. + operatorSAName string } // enqueueAllIngressEgressProxySvcsinNS returns a reconcile request for each diff --git a/k8s-operator/api.md b/k8s-operator/api.md index 9101c95ca..a32daec93 100644 --- a/k8s-operator/api.md +++ b/k8s-operator/api.md @@ -1273,7 +1273,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `secretName` _string_ | The name of the secret containing the OAuth credentials. This secret must contain two fields "client_id" and
"client_secret". | | | +| `secretName` _string_ | The name of the secret containing the credentials used to authenticate with this Tailnet. The secret must always
contain a "client_id" field. To authenticate with a static OAuth client, also set "client_secret". To authenticate
via workload identity federation, set "audience" to the audience value expected by the Tailscale OAuth
client; the operator will mint a ServiceAccount token for itself with that audience and exchange it for an API
token. "client_secret" and "audience" are mutually exclusive. | | | #### TailnetDevice @@ -1328,7 +1328,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | | `loginUrl` _string_ | URL of the control plane to be used by all resources managed by the operator using this Tailnet. | | | -| `credentials` _[TailnetCredentials](#tailnetcredentials)_ | Denotes the location of the OAuth credentials to use for authenticating with this Tailnet. | | | +| `credentials` _[TailnetCredentials](#tailnetcredentials)_ | Denotes the location of the credentials to use for authenticating with this Tailnet. | | | #### TailnetStatus diff --git a/k8s-operator/apis/v1alpha1/types_tailnet.go b/k8s-operator/apis/v1alpha1/types_tailnet.go index b11b2cf17..cb55fa077 100644 --- a/k8s-operator/apis/v1alpha1/types_tailnet.go +++ b/k8s-operator/apis/v1alpha1/types_tailnet.go @@ -48,13 +48,16 @@ type TailnetSpec struct { // URL of the control plane to be used by all resources managed by the operator using this Tailnet. // +optional LoginURL string `json:"loginUrl,omitempty"` - // Denotes the location of the OAuth credentials to use for authenticating with this Tailnet. + // Denotes the location of the credentials to use for authenticating with this Tailnet. Credentials TailnetCredentials `json:"credentials"` } type TailnetCredentials struct { - // The name of the secret containing the OAuth credentials. This secret must contain two fields "client_id" and - // "client_secret". + // The name of the secret containing the credentials used to authenticate with this Tailnet. The secret must always + // contain a "client_id" field. To authenticate with a static OAuth client, also set "client_secret". To authenticate + // via workload identity federation, set "audience" to the audience value expected by the Tailscale OAuth + // client; the operator will mint a ServiceAccount token for itself with that audience and exchange it for an API + // token. "client_secret" and "audience" are mutually exclusive. SecretName string `json:"secretName"` } diff --git a/k8s-operator/reconciler/tailnet/tailnet.go b/k8s-operator/reconciler/tailnet/tailnet.go index e30bb2170..d0c7432c9 100644 --- a/k8s-operator/reconciler/tailnet/tailnet.go +++ b/k8s-operator/reconciler/tailnet/tailnet.go @@ -4,8 +4,8 @@ //go:build !plan9 // Package tailnet provides reconciliation logic for the Tailnet custom resource definition. It is responsible for -// ensuring the referenced OAuth credentials are valid and have the required scopes to be able to generate authentication -// keys, manage devices & manage VIP services. +// ensuring the referenced credentials (either an OAuth client or a workload identity federation configuration) are +// valid and have the required scopes to be able to generate authentication keys, manage devices & manage VIP services. package tailnet import ( @@ -17,6 +17,7 @@ "time" "go.uber.org/zap" + authnv1 "k8s.io/api/authentication/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -45,6 +46,7 @@ client.Client tailscaleNamespace string + operatorSAName string clock tstime.Clock logger *zap.SugaredLogger clientFunc func(*tsapi.Tailnet, *corev1.Secret) tsclient.Client @@ -59,9 +61,13 @@ ReconcilerOptions struct { // The client for interacting with the Kubernetes API. Client client.Client - // The namespace the operator is installed in. This reconciler expects Tailnet OAuth credentials to be stored + // The namespace the operator is installed in. This reconciler expects Tailnet credentials to be stored // in Secret resources within this namespace. TailscaleNamespace string + // The name of the ServiceAccount the operator runs as, in TailscaleNamespace. This is used as the target + // ServiceAccount when minting tokens via the Kubernetes TokenRequest API for Tailnets that authenticate + // using workload identity federation. + OperatorSAName string // Controls which clock to use for performing time-based functions. This is typically modified for use // in tests. Clock tstime.Clock @@ -92,6 +98,7 @@ func NewReconciler(options ReconcilerOptions) *Reconciler { return &Reconciler{ Client: options.Client, tailscaleNamespace: options.TailscaleNamespace, + operatorSAName: options.OperatorSAName, clock: options.Clock, logger: options.Logger.Named(reconcilerName), clientFunc: options.ClientFunc, @@ -243,10 +250,11 @@ func (r *Reconciler) createOrUpdate(ctx context.Context, tailnet *tsapi.Tailnet) return reconcile.Result{}, nil } -// Constants for OAuth credential fields within the Secret referenced by the Tailnet. +// Constants for credential fields within the Secret referenced by the Tailnet. const ( clientIDKey = "client_id" clientSecretKey = "client_secret" + audienceKey = "audience" ) func (r *Reconciler) createClient(tailnet *tsapi.Tailnet, secret *corev1.Secret) (tsclient.Client, error) { @@ -264,16 +272,61 @@ func (r *Reconciler) createClient(tailnet *tsapi.Tailnet, secret *corev1.Secret) return nil, fmt.Errorf("failed to parse base URL %q: %w", baseURL, err) } + var auth tailscale.Auth + + clientID := string(secret.Data[clientIDKey]) + audience := string(secret.Data[audienceKey]) + clientSecret := string(secret.Data[clientSecretKey]) + + switch { + case audience != "": + // If the audience field is present, we assume workload identity as the authentication method. + auth = &tailscale.IdentityFederation{ + ClientID: clientID, + IDTokenFunc: r.createToken(audience), + } + case clientSecret != "": + // For a client secret, we assume oauth. + auth = &tailscale.OAuth{ + ClientID: clientID, + ClientSecret: clientSecret, + } + default: + // We shouldn't land here as previous functions will have ensured the validity of the secret, but we + // error here anyway as we won't know what to do: + return nil, errors.New("unable to determine authentication method") + } + return tsclient.Wrap(&tailscale.Client{ BaseURL: base, UserAgent: "tailscale-k8s-operator", - Auth: &tailscale.OAuth{ - ClientID: string(secret.Data[clientIDKey]), - ClientSecret: string(secret.Data[clientSecretKey]), - }, + Auth: auth, }), nil } +func (r *Reconciler) createToken(audience string) func() (string, error) { + return func() (string, error) { + serviceAccount := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: r.operatorSAName, + Namespace: r.tailscaleNamespace, + }, + } + + tokenRequest := &authnv1.TokenRequest{ + Spec: authnv1.TokenRequestSpec{ + Audiences: []string{audience}, + }, + } + + if err := r.SubResource("token").Create(context.Background(), serviceAccount, tokenRequest); err != nil { + return "", fmt.Errorf("failed to mint service account token for %q in namespace %q: %w", r.operatorSAName, r.tailscaleNamespace, err) + } + + return tokenRequest.Status.Token, nil + } +} + func (r *Reconciler) ensurePermissions(ctx context.Context, tsClient tsclient.Client, tailnet *tsapi.Tailnet) bool { // Perform basic list requests here to confirm that the OAuth credentials referenced on the Tailnet resource // can perform the basic operations required for the operator to function. This has a caveat of only performing @@ -281,15 +334,15 @@ func (r *Reconciler) ensurePermissions(ctx context.Context, tsClient tsclient.Cl // has completely forgotten an entire scope that's required. var errs error if _, err := tsClient.Devices().List(ctx); err != nil { - errs = errors.Join(errs, fmt.Errorf("failed to list devices: %w", err)) + errs = errors.Join(errs, fmt.Errorf("failed to list devices: %w (client may be missing the devices scope)", err)) } if _, err := tsClient.Keys().List(ctx, false); err != nil { - errs = errors.Join(errs, fmt.Errorf("failed to list auth keys: %w", err)) + errs = errors.Join(errs, fmt.Errorf("failed to list auth keys: %w (client may be missing the keys scope)", err)) } if _, err := tsClient.VIPServices().List(ctx); err != nil { - errs = errors.Join(errs, fmt.Errorf("failed to list tailscale services: %w", err)) + errs = errors.Join(errs, fmt.Errorf("failed to list tailscale services: %w (client may be missing the services scope)", err)) } if errs != nil { @@ -317,8 +370,8 @@ func (r *Reconciler) ensureSecret(tailnet *tsapi.Tailnet, secret *corev1.Secret) message = fmt.Sprintf("Secret %q is empty", secret.Name) case len(secret.Data[clientIDKey]) == 0: message = fmt.Sprintf("Secret %q is missing the client_id field", secret.Name) - case len(secret.Data[clientSecretKey]) == 0: - message = fmt.Sprintf("Secret %q is missing the client_secret field", secret.Name) + case len(secret.Data[clientSecretKey]) == 0 && len(secret.Data[audienceKey]) == 0: + message = fmt.Sprintf("Secret %q must contain either a client_secret or an audience field", secret.Name) } if message == "" { diff --git a/k8s-operator/reconciler/tailnet/tailnet_test.go b/k8s-operator/reconciler/tailnet/tailnet_test.go index 513ed7b84..36a7ea774 100644 --- a/k8s-operator/reconciler/tailnet/tailnet_test.go +++ b/k8s-operator/reconciler/tailnet/tailnet_test.go @@ -141,7 +141,7 @@ func TestReconciler_Reconcile(t *testing.T) { }, }, { - Name: "invalid-status-missing-client-secret", + Name: "invalid-status-missing-client-secret-and-audience", Request: reconcile.Request{ NamespacedName: types.NamespacedName{ Name: "test", @@ -171,7 +171,7 @@ func TestReconciler_Reconcile(t *testing.T) { Type: string(tsapi.TailnetReady), Status: metav1.ConditionFalse, Reason: tailnet.ReasonInvalidSecret, - Message: `Secret "test" is missing the client_secret field`, + Message: `Secret "test" must contain either a client_secret or an audience field`, }, }, }, @@ -210,7 +210,7 @@ func TestReconciler_Reconcile(t *testing.T) { Type: string(tsapi.TailnetReady), Status: metav1.ConditionFalse, Reason: tailnet.ReasonInvalidOAuth, - Message: `failed to list devices: EOF`, + Message: `failed to list devices: EOF (client may be missing the devices scope)`, }, }, }, @@ -249,7 +249,7 @@ func TestReconciler_Reconcile(t *testing.T) { Type: string(tsapi.TailnetReady), Status: metav1.ConditionFalse, Reason: tailnet.ReasonInvalidOAuth, - Message: `failed to list tailscale services: EOF`, + Message: `failed to list tailscale services: EOF (client may be missing the services scope)`, }, }, }, @@ -288,7 +288,7 @@ func TestReconciler_Reconcile(t *testing.T) { Type: string(tsapi.TailnetReady), Status: metav1.ConditionFalse, Reason: tailnet.ReasonInvalidOAuth, - Message: `failed to list auth keys: EOF`, + Message: `failed to list auth keys: EOF (client may be missing the keys scope)`, }, }, }, @@ -331,6 +331,45 @@ func TestReconciler_Reconcile(t *testing.T) { }, }, }, + { + Name: "ready-workload-identity", + Request: reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "default", + }, + }, + Tailnet: &tsapi.Tailnet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + }, + Spec: tsapi.TailnetSpec{ + Credentials: tsapi.TailnetCredentials{ + SecretName: "test", + }, + }, + }, + Secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "tailscale", + }, + Data: map[string][]byte{ + "client_id": []byte("test"), + "audience": []byte("https://tailscale.com"), + }, + }, + ClientFunc: func(_ *tsapi.Tailnet, _ *corev1.Secret) tsclient.Client { + return &MockTailnetClient{} + }, + ExpectedConditions: []metav1.Condition{ + { + Type: string(tsapi.TailnetReady), + Status: metav1.ConditionTrue, + Reason: tailnet.ReasonValid, + Message: tailnet.ReasonValid, + }, + }, + }, } for _, tc := range tt {