From e2c22ebd547476fbbd07c54ba604ab52ce42847f Mon Sep 17 00:00:00 2001 From: Hugo Shaka Date: Fri, 22 May 2026 16:48:45 -0400 Subject: [PATCH] Add scoped mode for the operator (#66762) This work is not production ready yet. While this work makes the operator support running with a scoped token, this is not enough for a public release. Several failure modes must be addressed, including scoped name conflicts, and operators fighting for ownership of a resource. --- .../zz_generated.teleport-operator.mdx | 12 ++ .../templates/deployment.yaml | 3 + .../charts/teleport-operator/values.yaml | 7 + integrations/operator/config.go | 2 + .../operator/controllers/resources/setup.go | 56 +++++-- .../controllers/resources/setup_test.go | 156 ++++++++++++++++++ .../controllers/resources/testlib/env.go | 4 +- integrations/operator/main.go | 3 +- 8 files changed, 224 insertions(+), 19 deletions(-) create mode 100644 integrations/operator/controllers/resources/setup_test.go diff --git a/docs/pages/includes/helm-reference/zz_generated.teleport-operator.mdx b/docs/pages/includes/helm-reference/zz_generated.teleport-operator.mdx index b8817cdc905..0047a0da66f 100644 --- a/docs/pages/includes/helm-reference/zz_generated.teleport-operator.mdx +++ b/docs/pages/includes/helm-reference/zz_generated.teleport-operator.mdx @@ -85,6 +85,18 @@ Setting this value is required when joining via the `token` is the name of the token used by the operator to join the Teleport cluster. +## `scoped` + +| Type | Default | +|------|---------| +| `bool` | `false` | + +`scoped` makes the operator run in scoped mode. +In scoped mode, the operator can only reconcile scoped resource such as +`TeleportScopedTokenV1`, `TeleportScopedRoleV1`, or `TeleportScopedRoleAssignmentV1`. +Scoped mode requires the operator to join using a scoped token and the experimental scope +feature to be enabled on both Teleport Auth Service instances and Proxy Service instances. + ## `teleportVersionOverride` | Type | Default | diff --git a/examples/chart/teleport-cluster/charts/teleport-operator/templates/deployment.yaml b/examples/chart/teleport-cluster/charts/teleport-operator/templates/deployment.yaml index d22e8beddf5..e7da60ff561 100644 --- a/examples/chart/teleport-cluster/charts/teleport-operator/templates/deployment.yaml +++ b/examples/chart/teleport-cluster/charts/teleport-operator/templates/deployment.yaml @@ -56,6 +56,9 @@ spec: - '{{ .Values.joinMethod }}' - -token - '{{ .Values.token }}' + {{- if .Values.scoped }} + - -scoped + {{- end }} {{- if .Values.caPins }} - -ca-pin - '{{ join "," .Values.caPins }}' diff --git a/examples/chart/teleport-cluster/charts/teleport-operator/values.yaml b/examples/chart/teleport-cluster/charts/teleport-operator/values.yaml index f6e74cfd5c5..b9a905b002d 100644 --- a/examples/chart/teleport-cluster/charts/teleport-operator/values.yaml +++ b/examples/chart/teleport-cluster/charts/teleport-operator/values.yaml @@ -47,6 +47,13 @@ teleportClusterName: "" # token(string) -- is the name of the token used by the operator to join the Teleport cluster. token: "" +# scoped(bool) -- makes the operator run in scoped mode. +# In scoped mode, the operator can only reconcile scoped resource such as +# `TeleportScopedTokenV1`, `TeleportScopedRoleV1`, or `TeleportScopedRoleAssignmentV1`. +# Scoped mode requires the operator to join using a scoped token and the experimental scope +# feature to be enabled on both Teleport Auth Service instances and Proxy Service instances. +scoped: false + # teleportVersionOverride(string) -- controls the Teleport Kubernetes Operator # image version deployed by the chart. # diff --git a/integrations/operator/config.go b/integrations/operator/config.go index f9725ce005a..02286c80052 100644 --- a/integrations/operator/config.go +++ b/integrations/operator/config.go @@ -36,6 +36,7 @@ type operatorConfig struct { syncPeriod time.Duration namespace string logLevel string + scoped bool } // BindFlags binds operatorConfig fields to CLI flags. @@ -52,6 +53,7 @@ func (c *operatorConfig) BindFlags(fs *flag.FlagSet) { fs.StringVar(&c.namespace, "namespace", "", "The namespace containing the Teleport CRs.") fs.DurationVar(&c.syncPeriod, "sync-period", defaultSyncPeriod, "Operator sync period (format: https://pkg.go.dev/time#ParseDuration)") fs.StringVar(&c.logLevel, "log-level", "INFO", "Log level (DEBUG, INFO, WARN, ERROR).") + fs.BoolVar(&c.scoped, "scoped", false, "Run operator in scoped mode. Only scoped resources will be reconciled.") } // CheckAndSetDefaults checks the operatorConfig and populates unspecified diff --git a/integrations/operator/controllers/resources/setup.go b/integrations/operator/controllers/resources/setup.go index 39a13a01791..12a53b56fd7 100644 --- a/integrations/operator/controllers/resources/setup.go +++ b/integrations/operator/controllers/resources/setup.go @@ -39,7 +39,44 @@ type reconcilerFactory struct { } // SetupAllControllers sets up all controllers -func SetupAllControllers(log logr.Logger, mgr manager.Manager, teleportClient *client.Client, features *proto.Features) error { +func SetupAllControllers(log logr.Logger, mgr manager.Manager, teleportClient *client.Client, features *proto.Features, scoped bool) error { + kubeClient := mgr.GetClient() + for _, reconciler := range enabledReconcilers(log, features, scoped) { + r, err := reconciler.factory(kubeClient, teleportClient) + if err != nil { + return trace.Wrap(err, "failed to create controller for %s", reconciler.cr) + } + err = r.SetupWithManager(mgr) + if err != nil { + return trace.Wrap(err, "failed to setup controller for: %s", reconciler.cr) + } + } + + return nil +} + +func enabledReconcilers(log logr.Logger, features *proto.Features, scoped bool) []reconcilerFactory { + var reconcilers []reconcilerFactory + + // We always run + reconcilers = append(reconcilers, scopedReconcilers(log, features)...) + if scoped { + log.Info("Running in scoped mode. Unscoped resources will not be reconciled.") + } else { + reconcilers = append(reconcilers, unscopedReconcilers(log, features)...) + } + return reconcilers +} + +func scopedReconcilers(log logr.Logger, features *proto.Features) []reconcilerFactory { + return []reconcilerFactory{ + {"TeleportScopedTokenV1", NewScopedTokenV1Reconciler}, + {"TeleportScopedRoleV1", NewScopedRoleV1Reconciler}, + {"TeleportScopedRoleAssignmentV1", NewScopedRoleAssignmentV1Reconciler}, + } +} + +func unscopedReconcilers(log logr.Logger, features *proto.Features) []reconcilerFactory { reconcilers := []reconcilerFactory{ {"TeleportRole", NewRoleReconciler}, {"TeleportRoleV6", NewRoleV6Reconciler}, @@ -65,9 +102,6 @@ func SetupAllControllers(log logr.Logger, mgr manager.Manager, teleportClient *c // saml_idp_service_provider objects using tctl for any build. We // therefore enable it here unconditionally to mirror tctl behavior. {"TeleportSAMLIdPServiceProviderV1", NewSAMLIdPServiceProviderV1Reconciler}, - {"TeleportScopedTokenV1", NewScopedTokenV1Reconciler}, - {"TeleportScopedRoleV1", NewScopedRoleV1Reconciler}, - {"TeleportScopedRoleAssignmentV1", NewScopedRoleAssignmentV1Reconciler}, } oidc := modules.GetProtoEntitlement(features, entitlements.OIDC) @@ -110,17 +144,5 @@ func SetupAllControllers(log logr.Logger, mgr manager.Manager, teleportClient *c log.Info("The cluster license does not contain advanced workflows. TeleportAccessList, TeleportOktaImportRule resources won't be reconciled") } - kubeClient := mgr.GetClient() - for _, reconciler := range reconcilers { - r, err := reconciler.factory(kubeClient, teleportClient) - if err != nil { - return trace.Wrap(err, "failed to create controller for %s", reconciler.cr) - } - err = r.SetupWithManager(mgr) - if err != nil { - return trace.Wrap(err, "failed to setup controller for: %s", reconciler.cr) - } - } - - return nil + return reconcilers } diff --git a/integrations/operator/controllers/resources/setup_test.go b/integrations/operator/controllers/resources/setup_test.go new file mode 100644 index 00000000000..17dddbd59b7 --- /dev/null +++ b/integrations/operator/controllers/resources/setup_test.go @@ -0,0 +1,156 @@ +/* +Copyright 2026 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package resources + +import ( + "testing" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/entitlements" + "github.com/gravitational/teleport/lib/modules" + "github.com/gravitational/teleport/lib/modules/modulestest" + "github.com/gravitational/teleport/lib/utils/log/logtest" +) + +func TestEnabledReconcilers(t *testing.T) { + // Test setup: create the fixtures + ossFeatures := modulestest.OSSModules().Features() + entFeatures := modulestest.EnterpriseModules().Features() + entFeatures.Entitlements[entitlements.OIDC] = modules.EntitlementInfo{Enabled: true} + entFeatures.Entitlements[entitlements.SAML] = modules.EntitlementInfo{Enabled: true} + entFeatures.Entitlements[entitlements.Policy] = modules.EntitlementInfo{Enabled: true} + entFeatures.AdvancedAccessWorkflows = true + + tests := []struct { + name string + scoped bool + features modules.Features + expectedReconcilers map[string]struct{} + }{ + { + name: "Unscoped, OSS", + scoped: false, + features: ossFeatures, + expectedReconcilers: map[string]struct{}{ + // scoped + "TeleportScopedTokenV1": {}, + "TeleportScopedRoleV1": {}, + "TeleportScopedRoleAssignmentV1": {}, + // unscoped oss + "TeleportRole": {}, + "TeleportRoleV6": {}, + "TeleportRoleV7": {}, + "TeleportRoleV8": {}, + "TeleportUser": {}, + "TeleportGithubConnector": {}, + "TeleportLockV2": {}, + "TeleportProvisionToken": {}, + "TeleportOpenSSHServerV2": {}, + "TeleportOpenSSHEICEServerV2": {}, + "TeleportTrustedClusterV2": {}, + "TeleportBotV1": {}, + "TeleportWorkloadIdentityV1": {}, + "TeleportAutoupdateConfigV1": {}, + "TeleportAutoupdateVersionV1": {}, + "TeleportAppV3": {}, + "TeleportDatabaseV3": {}, + "TeleportAccessMonitoringRuleV1": {}, + "TeleportSAMLIdPServiceProviderV1": {}, + }, + }, + { + name: "Scoped, OSS", + scoped: true, + features: ossFeatures, + expectedReconcilers: map[string]struct{}{ + "TeleportScopedTokenV1": {}, + "TeleportScopedRoleV1": {}, + "TeleportScopedRoleAssignmentV1": {}, + }, + }, + { + name: "Unscoped, enterprise", + scoped: false, + features: entFeatures, + expectedReconcilers: map[string]struct{}{ + // scoped + "TeleportScopedTokenV1": {}, + "TeleportScopedRoleV1": {}, + "TeleportScopedRoleAssignmentV1": {}, + // unscoped oss + "TeleportRole": {}, + "TeleportRoleV6": {}, + "TeleportRoleV7": {}, + "TeleportRoleV8": {}, + "TeleportUser": {}, + "TeleportGithubConnector": {}, + "TeleportLockV2": {}, + "TeleportProvisionToken": {}, + "TeleportOpenSSHServerV2": {}, + "TeleportOpenSSHEICEServerV2": {}, + "TeleportTrustedClusterV2": {}, + "TeleportBotV1": {}, + "TeleportWorkloadIdentityV1": {}, + "TeleportAutoupdateConfigV1": {}, + "TeleportAutoupdateVersionV1": {}, + "TeleportAppV3": {}, + "TeleportDatabaseV3": {}, + "TeleportAccessMonitoringRuleV1": {}, + "TeleportSAMLIdPServiceProviderV1": {}, + // unscoped enterprise + "TeleportOIDCConnector": {}, + "TeleportSAMLConnector": {}, + "TeleportInferenceModel": {}, + "TeleportInferencePolicy": {}, + "TeleportInferenceSecret": {}, + "TeleportRetrievalModelV1": {}, + "TeleportLoginRule": {}, + "TeleportAccessList": {}, + "TeleportOktaImportRule": {}, + }, + }, + + { + name: "Scoped, enterprise", + scoped: true, + features: entFeatures, + expectedReconcilers: map[string]struct{}{ + "TeleportScopedTokenV1": {}, + "TeleportScopedRoleV1": {}, + "TeleportScopedRoleAssignmentV1": {}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + log := logr.FromSlogHandler(logtest.NewLogger().Handler()) + features := tt.features.ToProto() + reconcilers := enabledReconcilers(log, features, tt.scoped) + + // Test validation: check that the expected reconcilers were enabled + reconcilersSet := make(map[string]struct{}, len(reconcilers)) + for _, reconciler := range reconcilers { + reconcilersSet[reconciler.cr] = struct{}{} + } + require.Len(t, reconcilersSet, len(tt.expectedReconcilers)) + require.Equal(t, tt.expectedReconcilers, reconcilersSet) + }) + } +} diff --git a/integrations/operator/controllers/resources/testlib/env.go b/integrations/operator/controllers/resources/testlib/env.go index d8fb71b156c..b266c55db3c 100644 --- a/integrations/operator/controllers/resources/testlib/env.go +++ b/integrations/operator/controllers/resources/testlib/env.go @@ -230,7 +230,9 @@ func (s *TestSetup) StartKubernetesOperator(t *testing.T) { pong, err := s.TeleportClient.Ping(context.Background()) require.NoError(t, err) - err = resources.SetupAllControllers(setupLog, k8sManager, s.TeleportClient, pong.ServerFeatures) + + const scoped = false + err = resources.SetupAllControllers(setupLog, k8sManager, s.TeleportClient, pong.ServerFeatures, scoped) require.NoError(t, err) ctx, ctxCancel := context.WithCancel(context.Background()) diff --git a/integrations/operator/main.go b/integrations/operator/main.go index 067498eb681..a4f2b8725e1 100644 --- a/integrations/operator/main.go +++ b/integrations/operator/main.go @@ -86,6 +86,7 @@ func main() { os.Exit(1) } + botConfig.Scoped = config.scoped bot, err := embeddedtbot.New(botConfig, slogLogger.With(teleport.ComponentLabel, "embedded-tbot")) if err != nil { setupLog.Error(err, "unable to build tbot") @@ -132,7 +133,7 @@ func main() { os.Exit(1) } - if err = resources.SetupAllControllers(setupLog, mgr, client, pong.ServerFeatures); err != nil { + if err = resources.SetupAllControllers(setupLog, mgr, client, pong.ServerFeatures, config.scoped); err != nil { setupLog.Error(err, "failed to setup controllers") os.Exit(1) }