Deny watchers for cert_authority_override on OSS Auth (#66644)

* Make the cache resilient CA override NotImplemented

* Don't apply overrides on OSS builds

* Use modulestest.EnterpriseModules()

* Revert "Make the cache resilient CA override NotImplemented"

This reverts commit d07c9db8e0.

* Deny watchers for cert_authority_override in OSS Auth

* Tweak watcher debug message

* Re-create watcher on events other than OpPut or OpDelete

* Fix verifyOpInitKindsForCAEvents return when `e.Resource==nil`

* Mention older control plane in comments

* Add SubKind to Windows CA override watchers

* Restore ForWindows watcher

* Revert "Add SubKind to Windows CA override watchers"

This reverts commit 458f93f5fd.
This commit is contained in:
Alan Parra
2026-05-15 20:56:52 +00:00
committed by GitHub
parent 423a964eaf
commit d638173c1b
10 changed files with 301 additions and 58 deletions
+1 -1
View File
@@ -11609,7 +11609,7 @@ func TestWatchAllCacheKinds(t *testing.T) {
t.Parallel()
ctx := t.Context()
srv := newTestTLSServer(t)
srv := newTestTLSServer(t, withModules(modulestest.EnterpriseModules()))
for role, watchKinds := range map[types.SystemRole][]types.WatchKind{
types.RoleProxy: cache.ForProxy(cache.Config{}).Watches,
+1 -1
View File
@@ -91,7 +91,7 @@ func (a *Server) GenerateWindowsDesktopCert(ctx context.Context, req *proto.Wind
}
// Calculate CA override.
subCAResolver, err := subca.NewCAOverrideResolver(a.Cache, a.subCAEnabled)
subCAResolver, err := subca.NewCAOverrideResolver(a.Cache, a.modules.IsEnterpriseBuild(), a.subCAEnabled)
if err != nil {
return nil, trace.Wrap(err)
}
+1 -1
View File
@@ -62,7 +62,7 @@ func TestDesktopAccessDisabled(t *testing.T) {
func TestDesktopAccessCAOverrides(t *testing.T) {
t.Parallel()
tlsServer := newTestTLSServer(t)
tlsServer := newTestTLSServer(t, withModules(modulestest.EnterpriseModules()))
authServer := tlsServer.Auth()
authServer.SetSubCAEnabled(true)
+32 -8
View File
@@ -155,6 +155,7 @@ import (
"github.com/gravitational/teleport/lib/join/joinv1"
"github.com/gravitational/teleport/lib/join/legacyjoin"
"github.com/gravitational/teleport/lib/limiter"
"github.com/gravitational/teleport/lib/modules"
"github.com/gravitational/teleport/lib/observability/metrics"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/services/local"
@@ -556,12 +557,13 @@ func (g *GRPCServer) WatchEvents(watch *authpb.Watch, stream authpb.AuthService_
stream,
scopedAuth.scopedContext.User.GetName(),
scopedAuth,
g.AuthServer.modules,
))
}
return trace.Wrap(err)
}
return trace.Wrap(WatchEvents(watch, stream, auth.User.GetName(), auth))
return trace.Wrap(WatchEvents(watch, stream, auth.User.GetName(), auth, g.AuthServer.modules))
}
// WatchEvent is a stream interface for sending events.
@@ -574,14 +576,18 @@ type Watcher interface {
NewStream(ctx context.Context, watch types.Watch) (stream.Stream[types.Event], error)
}
// WatchEvents watches for events and streams them to the provided stream.
func WatchEvents(watch *authpb.Watch, stream WatchEvent, componentName string, auth Watcher) error {
servicesWatch := types.Watch{
Name: componentName,
Kinds: watch.Kinds,
AllowPartialSuccess: watch.AllowPartialSuccess,
}
var enterpriseOnlyWatchKinds = []string{
types.KindCertAuthorityOverride,
}
// WatchEvents watches for events and streams them to the provided stream.
func WatchEvents(
watch *authpb.Watch,
stream WatchEvent,
componentName string,
auth Watcher,
mods modules.Modules,
) error {
// KindNamespace is being removed but v17 agents will still try to include
// it in their cache and they will occasionally do a GetNamespace, so we
// pretend to support it as a resource kind here; it's sound to do so
@@ -596,10 +602,28 @@ func WatchEvents(watch *authpb.Watch, stream WatchEvent, componentName string, a
removedNamespaceWatch = true
continue
}
// Deny watchers for Enteprise-only Kinds if we are running OSS.
// This simplifies remote event streams that would otherwise try to hit
// unimplemented endpoints (Enterprise-only services tend to only bind in
// Enterprise builds).
if mods.IsOSSBuild() && goslices.Contains(enterpriseOnlyWatchKinds, k.Kind) {
if watch.AllowPartialSuccess {
continue
}
return trace.BadParameter("watch for kind %s only available in Teleport Enterprise", k.Kind)
}
filteredKinds = append(filteredKinds, k)
}
watch.Kinds = filteredKinds
servicesWatch := types.Watch{
Name: componentName,
Kinds: watch.Kinds,
AllowPartialSuccess: watch.AllowPartialSuccess,
}
events, err := auth.NewStream(stream.Context(), servicesWatch)
if err != nil {
return trace.Wrap(err)
+134
View File
@@ -0,0 +1,134 @@
// Teleport
// Copyright (C) 2026 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package auth_test
import (
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/auth/authtest"
"github.com/gravitational/teleport/lib/modules/modulestest"
)
func TestWatchEvents_enterpriseOnlyKinds(t *testing.T) {
t.Parallel()
ossTestServer := newTestTLSServer(t)
entTestServer := newTestTLSServer(t, withModules(modulestest.EnterpriseModules()))
ossClient, err := ossTestServer.NewClient(authtest.TestAdmin())
require.NoError(t, err)
entClient, err := entTestServer.NewClient(authtest.TestAdmin())
require.NoError(t, err)
// kindOther is a watch kind we expect to always work.
const kindOther = types.KindCertAuthority
const wantErr = "only available in Teleport Enterprise"
confirmKinds := func(t *testing.T, watcher types.Watcher, wantKinds ...string) {
t.Helper()
select {
case <-t.Context().Done():
t.Fatal("t.Context() done before first event")
case <-watcher.Done():
t.Fatalf("watcher done before first event, err=%v", watcher.Error())
case e := <-watcher.Events():
require.Equal(t, types.OpInit, e.Type, "Unexpected event type")
s, ok := e.Resource.(types.WatchStatus)
require.True(t, ok, "e.Resource has unexpected type: %T", e.Resource)
watchKinds := s.GetKinds()
gotKinds := make([]string, len(watchKinds))
for i, k := range watchKinds {
gotKinds[i] = k.Kind
}
if diff := cmp.Diff(wantKinds, gotKinds); diff != "" {
t.Errorf("WatchStatus.GetKinds() mismatch (-want +got)\n%s", diff)
}
}
}
tests := []struct {
kind string
}{
{kind: types.KindCertAuthorityOverride},
}
for _, test := range tests {
t.Run(test.kind, func(t *testing.T) {
t.Parallel()
t.Run("OSS fails", func(t *testing.T) {
watcher, err := ossClient.NewWatcher(t.Context(), types.Watch{
Name: "oss",
Kinds: []types.WatchKind{
{Kind: test.kind},
},
})
// The stream is expected to start and fail before the first event.
require.NoError(t, err)
t.Cleanup(func() { watcher.Close() })
// Wait for stream to close and assert failure.
select {
case <-watcher.Done():
case <-time.After(5 * time.Second):
t.Fatal("Timed out waiting for stream to close")
}
assert.ErrorContains(t, watcher.Error(), wantErr, "Watcher error mismatch")
})
t.Run("OSS with allow partial succeeds", func(t *testing.T) {
watcher, err := ossClient.NewWatcher(t.Context(), types.Watch{
Name: "oss-allow-partial",
Kinds: []types.WatchKind{
{Kind: kindOther},
{Kind: test.kind},
},
AllowPartialSuccess: true,
})
require.NoError(t, err)
t.Cleanup(func() { watcher.Close() })
confirmKinds(t, watcher, kindOther)
assert.NoError(t, watcher.Error(), "Watcher has unexpected error")
})
t.Run("Ent succeeds", func(t *testing.T) {
watcher, err := entClient.NewWatcher(t.Context(), types.Watch{
Name: "ent-allow-partial",
Kinds: []types.WatchKind{
{Kind: kindOther},
{Kind: test.kind},
},
})
require.NoError(t, err)
t.Cleanup(func() { watcher.Close() })
confirmKinds(t, watcher, kindOther, test.kind)
assert.NoError(t, watcher.Error(), "Watcher has unexpected error")
})
})
}
}
+47 -1
View File
@@ -1541,6 +1541,7 @@ func (s *WindowsService) watchCAEvents(
// Filters not supported for KindCertAuthorityOverride.
},
},
AllowPartialSuccess: true, // CAOverride watch allowed to fail.
})
if err != nil {
logger.WarnContext(ctx,
@@ -1602,6 +1603,16 @@ func runCAWatcherLoop(
// * https://github.com/gravitational/teleport/blob/1f0ca9e4ae66a47f39d10c40f35e55d5ac5e15ac/lib/services/watcher.go#L336-L338
switch {
case e.Type == types.OpInit && isFirstEvent:
confirmedCAOverrides, err := verifyOpInitKindsForCAEvents(e)
if err != nil {
return trace.Wrap(err)
}
if !confirmedCAOverrides {
eLog.DebugContext(ctx, "WatchStatus did not confirm CA overrides. Overrides are not watched. This is expected if the agent is connected to a non-Enterprise control plane or a control plane that does not support CA overrides.")
// Important: "Teleport OSS" here means, specifically, the Auth Server.
// An OSS Desktop Service can be paired with an Ent Auth, so checking
// the current binary build won't work.
}
isFirstEvent = false
continue // OK, expected.
@@ -1612,8 +1623,12 @@ func runCAWatcherLoop(
)
return nil
case e.Type != types.OpPut:
case e.Type == types.OpDelete:
continue // OK, we only care about mutating events.
case e.Type != types.OpPut:
eLog.DebugContext(ctx, "Received unexpected event type. Attempting to re-create the watcher.")
return nil
}
if e.Resource.GetKind() == types.KindCertAuthorityOverride {
@@ -1647,6 +1662,37 @@ func runCAWatcherLoop(
}
}
// verifyOpInitKindsForCAEvents verifies the OpInit WatchStatus payload.
// - KindCertAuthority is mandatory.
// - KindCertAuthorityOverride is optional.
func verifyOpInitKindsForCAEvents(e types.Event) (confirmedCAOverrides bool, _ error) {
if e.Resource == nil {
// Consider all kinds confirmed. See types.Watch.AllowPartialSuccess.
// https://github.com/gravitational/teleport/blob/77e56f05a4172b04b386d8b56f4842dd4b3b870d/api/types/events.go#L105-L106
return true, nil
}
status, ok := e.Resource.(types.WatchStatus)
if !ok {
return false, trace.BadParameter("event OpInit has unexpected Resource: %T (expected WatchStatus)", e.Resource)
}
var hasKindCA, hasKindCAOverride bool
for _, kind := range status.GetKinds() {
switch kind.Kind {
case types.KindCertAuthority:
hasKindCA = true
case types.KindCertAuthorityOverride:
hasKindCAOverride = true
}
}
if !hasKindCA {
return false, trace.BadParameter("watch status failed to confirm kind %s, aborting watcher", types.KindCertAuthority)
}
return hasKindCAOverride, nil
}
// getKDCAddress gets the KDC address that should be used for NLA in
// this priority order:
// 1. Explicitly specified kdc_address
+4 -3
View File
@@ -51,14 +51,15 @@ type CAOverrideResolver struct {
// caGetter a CA override source, likely a cached services.SubCAServiceGetter
// implementation.
//
// Production callers should use [Enabled] as the featureEnabled value.
func NewCAOverrideResolver(caGetter CAOverrideGetter, featureEnabled bool) (*CAOverrideResolver, error) {
// Production callers should use `modules.Modules.IsEnterpriseBuild()` and
// [Enabled] as the boolean values, respectively.
func NewCAOverrideResolver(caGetter CAOverrideGetter, isEnterpriseBuild, featureEnabled bool) (*CAOverrideResolver, error) {
if caGetter == nil {
return nil, trace.BadParameter("nil caGetter")
}
return &CAOverrideResolver{
caGetter: caGetter,
featureEnabled: featureEnabled,
featureEnabled: isEnterpriseBuild && featureEnabled,
}, nil
}
+21 -8
View File
@@ -162,7 +162,9 @@ func TestCAOverrideResolver_CalculateOverride(t *testing.T) {
}
t.Run("ok: empty CA override", func(t *testing.T) {
r, err := subca.NewCAOverrideResolver(subCA, true)
const isEnterpriseBuild = true
const featureEnabled = true
r, err := subca.NewCAOverrideResolver(subCA, isEnterpriseBuild, featureEnabled)
require.NoError(t, err, "NewCAOverrideResolver errored")
got, err := r.CalculateOverride(t.Context(), caID, subca.Certificate{PEM: caCert1PEM})
require.NoError(t, err, "CalculateCAOverride errored")
@@ -201,12 +203,13 @@ func TestCAOverrideResolver_CalculateOverride(t *testing.T) {
caOtherCertPEM := []byte("Pretend this is a PEM. Contents not parsed.")
tests := []struct {
name string
featureDisabled bool // Inverse because most tests want enabled.
id types.CertAuthorityOverrideID
caCert subca.Certificate
wantErr string
want *subca.CalculateOverrideResult
name string
notEntepriseBuild bool // Inverse because most tests want enterprise.
featureDisabled bool // Inverse because most tests want enabled.
id types.CertAuthorityOverrideID
caCert subca.Certificate
wantErr string
want *subca.CalculateOverrideResult
}{
{
name: "ok: no CA override resource exists",
@@ -243,6 +246,16 @@ func TestCAOverrideResolver_CalculateOverride(t *testing.T) {
},
},
},
{
name: "ok: build is not Enterprise",
notEntepriseBuild: true,
id: caID,
caCert: subca.Certificate{PEM: caCert1PEM},
want: &subca.CalculateOverrideResult{
// If enabled then o1 would apply, per test above.
CACertificate: subca.Certificate{PEM: caCert1PEM},
},
},
{
name: "ok: feature disabled",
featureDisabled: true,
@@ -316,7 +329,7 @@ func TestCAOverrideResolver_CalculateOverride(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
r, err := subca.NewCAOverrideResolver(subCA, !test.featureDisabled)
r, err := subca.NewCAOverrideResolver(subCA, !test.notEntepriseBuild, !test.featureDisabled)
require.NoError(t, err, "NewCAOverrideResolver errored")
got, err := r.CalculateOverride(t.Context(), test.id, test.caCert)
+4 -2
View File
@@ -155,8 +155,10 @@ func (c *CertificateStoreClient) updateCAOverrideCRLs(ctx context.Context, tc *t
ClusterName: clusterName,
CAType: string(caType),
})
if trace.IsNotFound(err) {
return nil // OK, overrides are optional.
if trace.IsNotFound(err) || trace.IsNotImplemented(err) {
// - NotFound: Overrides are optional.
// - NotImplemented: Running against OSS Teleport or an older control plane.
return nil
}
if err != nil {
return trace.Wrap(err, "read CA overrides")
+56 -33
View File
@@ -24,6 +24,7 @@ import (
"encoding/pem"
"errors"
"fmt"
"maps"
"testing"
"github.com/google/go-cmp/cmp"
@@ -115,16 +116,12 @@ func TestCertificateStoreClient_Update(t *testing.T) {
},
}
ap := &fakeAccessPoint{
cas: []types.CertAuthority{ca},
}
ldap := newFakeLDAP()
csc := NewCertificateStoreClient(CertificateStoreConfig{
AccessPoint: &fakeAccessPoint{
cas: []types.CertAuthority{
ca,
},
caOverrides: []*subcav1.CertAuthorityOverride{
caOverride,
},
},
AccessPoint: ap,
Domain: domain,
Logger: logtest.NewLogger(),
ClusterName: clusterName,
@@ -134,20 +131,12 @@ func TestCertificateStoreClient_Update(t *testing.T) {
},
})
tc := &tls.Config{} // Unused, LDAP is faked.
require.NoError(t,
csc.Update(t.Context(), tc),
"Update errored",
)
// Prepare wanted state.
// Prepare wanted state (CAs only).
containerDN, err := crlContainerDN(domain, types.WindowsCA)
require.NoError(t, err)
caCRLDN, err := CRLDN(caCert.Subject.CommonName, caCert.SubjectKeyId, domain, types.WindowsCA)
require.NoError(t, err)
overrideCRLDN, err := CRLDN(overrideCert.Subject.CommonName, overrideCert.SubjectKeyId, domain, types.WindowsCA)
require.NoError(t, err)
want := map[string]*ldapEntry{
wantCAs := map[string]*ldapEntry{
containerDN: {
ObjectClass: "container",
},
@@ -157,38 +146,68 @@ func TestCertificateStoreClient_Update(t *testing.T) {
"certificateRevocationList": {caCRL},
},
},
overrideCRLDN: {
ObjectClass: "cRLDistributionPoint",
Attrs: map[string][]string{
"certificateRevocationList": {overrideCRL},
},
}
// Prepare wanted state (CAs + CA overrides).
overrideCRLDN, err := CRLDN(overrideCert.Subject.CommonName, overrideCert.SubjectKeyId, domain, types.WindowsCA)
require.NoError(t, err)
wantOverrides := maps.Clone(wantCAs)
wantOverrides[overrideCRLDN] = &ldapEntry{
ObjectClass: "cRLDistributionPoint",
Attrs: map[string][]string{
"certificateRevocationList": {overrideCRL},
},
}
// Assert LDAP state.
if diff := cmp.Diff(want, ldap.entries); diff != "" {
t.Fatalf("LDAP entries mismatch (-want +got)\n%s", diff)
}
runUpdate := func(t *testing.T, wantEntries map[string]*ldapEntry) {
t.Helper()
// Exercise entry update flow.
t.Run("update", func(t *testing.T) {
// Reset state after running.
defer func() { clear(ldap.entries) }()
tc := &tls.Config{} // Unused, LDAP is faked.
require.NoError(t,
csc.Update(t.Context(), tc),
"Update errored",
)
// Assert LDAP state.
if diff := cmp.Diff(want, ldap.entries); diff != "" {
if diff := cmp.Diff(wantEntries, ldap.entries); diff != "" {
t.Fatalf("LDAP entries mismatch (-want +got)\n%s", diff)
}
}
t.Run("CAs only", func(t *testing.T) {
runUpdate(t, wantCAs)
})
// Prepare wanted state (CAs + overrides).
ap.caOverrides = []*subcav1.CertAuthorityOverride{caOverride}
t.Run("CAs and CA overrides", func(t *testing.T) {
runUpdate(t, wantOverrides)
})
// Exercise entry update flow.
t.Run("update", func(t *testing.T) {
// Same state.
runUpdate(t, wantOverrides)
})
// Exercise CA overrides NotImplemented (OSS).
t.Run("CA overrides not found", func(t *testing.T) {
ap.caOverridesNotImplemented = true
t.Cleanup(func() { ap.caOverridesNotImplemented = false })
// CA overrides not synced.
runUpdate(t, wantCAs)
})
}
type fakeAccessPoint struct {
CRLGenerator
cas []types.CertAuthority
caOverrides []*subcav1.CertAuthorityOverride
cas []types.CertAuthority
caOverrides []*subcav1.CertAuthorityOverride
caOverridesNotImplemented bool
}
func (f *fakeAccessPoint) GetCertAuthorities(ctx context.Context, caType types.CertAuthType, loadKeys bool) ([]types.CertAuthority, error) {
@@ -206,6 +225,10 @@ func (f *fakeAccessPoint) GetCertAuthorities(ctx context.Context, caType types.C
}
func (f *fakeAccessPoint) GetCertAuthorityOverride(ctx context.Context, id types.CertAuthorityOverrideID) (*subcav1.CertAuthorityOverride, error) {
if f.caOverridesNotImplemented {
return nil, trace.NotImplemented("not implemented")
}
for _, caOverride := range f.caOverrides {
if caOverride.Metadata.Name == id.ClusterName && caOverride.SubKind == id.CAType {
return caOverride, nil