diff --git a/integration/hsm/helpers.go b/integration/hsm/helpers.go index 216d784e87800..bf2589a98c8cc 100644 --- a/integration/hsm/helpers.go +++ b/integration/hsm/helpers.go @@ -166,11 +166,11 @@ func (t *teleportService) waitForLocalAdditionalKeys(ctx context.Context) error if err != nil { return trace.Wrap(err) } - hasUsableKeys, err := t.process.GetAuthServer().GetKeyStore().HasUsableAdditionalKeys(ctx, ca) + usableKeysResult, err := t.process.GetAuthServer().GetKeyStore().HasUsableAdditionalKeys(ctx, ca) if err != nil { return trace.Wrap(err) } - if hasUsableKeys { + if usableKeysResult.CAHasUsableKeys { break } } diff --git a/integration/hsm/hsm_test.go b/integration/hsm/hsm_test.go index 7afa0499e7ce7..511c2103da61a 100644 --- a/integration/hsm/hsm_test.go +++ b/integration/hsm/hsm_test.go @@ -28,6 +28,7 @@ import ( "github.com/google/uuid" "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -40,6 +41,7 @@ import ( "github.com/gravitational/teleport/lib/backend" "github.com/gravitational/teleport/lib/backend/etcdbk" "github.com/gravitational/teleport/lib/backend/lite" + "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/modules" "github.com/gravitational/teleport/lib/service/servicecfg" "github.com/gravitational/teleport/lib/utils" @@ -523,6 +525,16 @@ func TestHSMMigrate(t *testing.T) { testClient(t) + // Make sure a cluster alert is created. + alerts, err := auth1.process.GetAuthServer().GetClusterAlerts(ctx, types.GetClusterAlertsRequest{}) + require.NoError(t, err) + require.Len(t, alerts, 1) + alert := alerts[0] + assert.Equal(t, types.AlertSeverity_MEDIUM, alert.Spec.Severity) + assert.Contains(t, alert.Spec.Message, "configured to use PKCS#11 HSM keys") + assert.Contains(t, alert.Spec.Message, "the following CAs do not contain any keys of that type:") + assert.Contains(t, alert.Spec.Message, "host") + authServices := teleportServices{auth1, auth2} allServices := teleportServices{auth1, auth2, proxy} @@ -572,6 +584,13 @@ func TestHSMMigrate(t *testing.T) { stage.verify(t) } + // Make sure the cluster alert no longer mentions the host CA. + alerts, err = auth1.process.GetAuthServer().GetClusterAlerts(ctx, types.GetClusterAlertsRequest{}) + require.NoError(t, err) + require.Len(t, alerts, 1) + alert = alerts[0] + assert.NotContains(t, alert.Spec.Message, "host") + // Phase 2: migrate auth2 to HSM auth2.process.Close() require.NoError(t, auth2.waitForShutdown(ctx)) @@ -584,6 +603,11 @@ func TestHSMMigrate(t *testing.T) { testClient(t) + // There should now be 2 cluster alerts (one for each auth using HSM). + alerts, err = auth1.process.GetAuthServer().GetClusterAlerts(ctx, types.GetClusterAlertsRequest{}) + require.NoError(t, err) + assert.Len(t, alerts, 2) + // Do another full rotation to get HSM keys for auth2 into the CA. for _, stage := range stages { log.Debugf("TestHSMMigrate: Sending rotate request %s", stage.targetPhase) @@ -597,3 +621,99 @@ func TestHSMMigrate(t *testing.T) { testClient(t) } + +// TestHSMRevert tests a single-auth server migration from HSM keys back to +// software keys. +func TestHSMRevert(t *testing.T) { + requireHSMAvailable(t) + + clock := clockwork.NewFakeClock() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + log := utils.NewLoggerForTests() + + log.Debug("TestHSMRevert: starting auth server") + auth1Config := newHSMAuthConfig(t, liteBackendConfig(t), log) + auth1Config.Clock = clock + auth1 := newTeleportService(t, auth1Config, "auth1") + + log.Debug("TestHSMRevert: waiting for auth server to start") + err := auth1.start(ctx) + require.NoError(t, err, trace.DebugReport(err)) + t.Cleanup(func() { + require.NoError(t, auth1.process.GetAuthServer().GetKeyStore().DeleteUnusedKeys(ctx, nil)) + }) + + // Switch config back to default (software) and restart. + auth1.process.Close() + require.NoError(t, auth1.waitForShutdown(ctx)) + auth1Config.Auth.KeyStore = keystore.Config{} + auth1 = newTeleportService(t, auth1Config, "auth1") + require.NoError(t, auth1.start(ctx)) + + // Make sure a cluster alert is created. + alerts, err := auth1.process.GetAuthServer().GetClusterAlerts(ctx, types.GetClusterAlertsRequest{}) + require.NoError(t, err) + require.Len(t, alerts, 1) + alert := alerts[0] + assert.Equal(t, types.AlertSeverity_HIGH, alert.Spec.Severity) + assert.Contains(t, alert.Spec.Message, "configured to use raw software keys") + assert.Contains(t, alert.Spec.Message, "the following CAs do not contain any keys of that type:") + assert.Contains(t, alert.Spec.Message, "The Auth Service is currently unable to sign certificates") + + for _, caType := range types.CertAuthTypes { + log.Debugf("TestHSMRevert: sending rotation request init for CA %s", caType) + err = auth1.process.GetAuthServer().RotateCertAuthority(ctx, types.RotateRequest{ + Type: caType, + TargetPhase: types.RotationPhaseInit, + Mode: types.RotationModeManual, + }) + require.NoError(t, err) + if caType == types.HostCA { + require.NoError(t, auth1.waitForPhaseChange(ctx)) + } + + log.Debugf("TestHSMRevert: sending rotation request update_clients for CA %s", caType) + err = auth1.process.GetAuthServer().RotateCertAuthority(ctx, types.RotateRequest{ + Type: caType, + TargetPhase: types.RotationPhaseUpdateClients, + Mode: types.RotationModeManual, + }) + require.NoError(t, err) + if caType == types.HostCA { + require.NoError(t, auth1.waitForRestart(ctx)) + } + + log.Debugf("TestHSMRevert: sending rotation request update_servers for CA %s", caType) + err = auth1.process.GetAuthServer().RotateCertAuthority(ctx, types.RotateRequest{ + Type: caType, + TargetPhase: types.RotationPhaseUpdateServers, + Mode: types.RotationModeManual, + }) + require.NoError(t, err) + if caType == types.HostCA { + require.NoError(t, auth1.waitForRestart(ctx)) + } + + log.Debugf("TestHSMRevert: sending rotation request standby for CA %s", caType) + err = auth1.process.GetAuthServer().RotateCertAuthority(ctx, types.RotateRequest{ + Type: caType, + TargetPhase: types.RotationPhaseStandby, + Mode: types.RotationModeManual, + }) + require.NoError(t, err) + if caType == types.HostCA { + require.NoError(t, auth1.waitForRestart(ctx)) + } + } + + // Make sure the cluster alert gets cleared. + // Advance far enough for auth.runPeriodicOperations to call + // auth.autoRotateCertAuthorities which reconciles the alert state. + clock.Advance(2 * defaults.HighResPollingPeriod) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + alerts, err = auth1.process.GetAuthServer().GetClusterAlerts(ctx, types.GetClusterAlertsRequest{}) + require.NoError(t, err) + assert.Empty(t, alerts) + }, 5*time.Second, 100*time.Millisecond) +} diff --git a/lib/auth/auth.go b/lib/auth/auth.go index a66967ba27739..a382100e0b895 100644 --- a/lib/auth/auth.go +++ b/lib/auth/auth.go @@ -6071,12 +6071,12 @@ func newKeySet(ctx context.Context, keyStore *keystore.Manager, caID types.CertA // ensureLocalAdditionalKeys adds additional trusted keys to the CA if they are not // already present. func (a *Server) ensureLocalAdditionalKeys(ctx context.Context, ca types.CertAuthority) error { - hasUsableKeys, err := a.keyStore.HasUsableAdditionalKeys(ctx, ca) + usableKeysResult, err := a.keyStore.HasUsableAdditionalKeys(ctx, ca) if err != nil { return trace.Wrap(err) } - if hasUsableKeys { - // nothing to do + if usableKeysResult.CAHasPreferredKeyType { + // Nothing to do. return nil } @@ -6085,11 +6085,11 @@ func (a *Server) ensureLocalAdditionalKeys(ctx context.Context, ca types.CertAut return trace.Wrap(err) } - // The CA still needs an update while the keystore does not have any usable - // keys in the CA. + // The CA still needs an update while the CA does not contain any keys of + // the preferred type. needsUpdate := func(ca types.CertAuthority) (bool, error) { - hasUsableKeys, err := a.keyStore.HasUsableAdditionalKeys(ctx, ca) - return !hasUsableKeys, trace.Wrap(err) + usableKeysResult, err := a.keyStore.HasUsableAdditionalKeys(ctx, ca) + return !usableKeysResult.CAHasPreferredKeyType, trace.Wrap(err) } err = a.addAdditionalTrustedKeysAtomic(ctx, ca, newKeySet, needsUpdate) if err != nil { diff --git a/lib/auth/init.go b/lib/auth/init.go index 94b738a99ea4e..5e74cc6e0c870 100644 --- a/lib/auth/init.go +++ b/lib/auth/init.go @@ -484,113 +484,10 @@ func initCluster(ctx context.Context, cfg InitConfig, asrv *Server) error { span.AddEvent("completed migration db_client_authority") // generate certificate authorities if they don't exist - var ( - mu sync.Mutex - activeKeys [][]byte - ) - g, gctx = errgroup.WithContext(ctx) - for _, caType := range types.CertAuthTypes { - caType := caType - g.Go(func() error { - ctx, span := cfg.Tracer.Start(gctx, "auth/initializeAuthority", oteltrace.WithAttributes(attribute.String("type", string(caType)))) - defer span.End() - - caID := types.CertAuthID{Type: caType, DomainName: cfg.ClusterName.GetClusterName()} - ca, err := asrv.Services.GetCertAuthority(ctx, caID, true) - if err != nil { - if !trace.IsNotFound(err) { - return trace.Wrap(err) - } - - log.Infof("First start: generating %s certificate authority.", caID.Type) - if ca, err = generateAuthority(ctx, asrv, caID); err != nil { - return trace.Wrap(err) - } - - if err := asrv.CreateCertAuthority(ctx, ca); err != nil { - return trace.Wrap(err) - } - } else { - // Already have a CA. Make sure the keyStore has usable keys. - hasUsableActiveKeys, err := asrv.keyStore.HasUsableActiveKeys(ctx, ca) - if err != nil { - return trace.Wrap(err) - } - if !hasUsableActiveKeys { - // This could be one of a few cases: - // 1. A new auth server with an HSM being added to an HA cluster. - // 2. A new auth server with no HSM being added to an HA cluster - // where all current auth servers have HSMs. - // 3. An existing auth server has restarted with a new HSM configured. - // 4. An existing HSM auth server has restarted no HSM configured. - // 5. An existing HSM auth server has restarted with a new UUID. - if ca.GetType() == types.HostCA { - // We need local keys to sign the Admin identity to support - // tctl. For this special case we add AdditionalTrustedKeys - // without any active keys. These keys will not be used for - // any signing operations until a CA rotation. Only the Host - // CA is necessary to issue the Admin identity. - if err := asrv.ensureLocalAdditionalKeys(ctx, ca); err != nil { - return trace.Wrap(err) - } - // reload updated CA for below checks - if ca, err = asrv.Services.GetCertAuthority(ctx, caID, true); err != nil { - return trace.Wrap(err) - } - } - } - hasUsableActiveKeys, err = asrv.keyStore.HasUsableActiveKeys(ctx, ca) - if err != nil { - return trace.Wrap(err) - } - hasUsableAdditionalKeys, err := asrv.keyStore.HasUsableAdditionalKeys(ctx, ca) - if err != nil { - return trace.Wrap(err) - } - if !hasUsableActiveKeys && hasUsableAdditionalKeys { - log.Warn("This auth server has a newly added or removed HSM and will not " + - "be able to perform any signing operations. You must rotate all CAs " + - "before routing traffic to this auth server. See https://goteleport.com/docs/management/operations/ca-rotation/") - } - allKeyTypes := ca.AllKeyTypes() - numKeyTypes := len(allKeyTypes) - if numKeyTypes > 1 { - log.Warnf("%s CA contains a combination of %s and %s keys. If you are attempting to"+ - " configure HSM or KMS support, make sure it is configured on all auth servers in"+ - " this cluster and then perform a CA rotation: https://goteleport.com/docs/management/operations/ca-rotation/", - caID.Type, strings.Join(allKeyTypes[:numKeyTypes-1], ", "), allKeyTypes[numKeyTypes-1]) - } - } - - mu.Lock() - defer mu.Unlock() - for _, keySet := range []types.CAKeySet{ca.GetActiveKeys(), ca.GetAdditionalTrustedKeys()} { - for _, sshKeyPair := range keySet.SSH { - activeKeys = append(activeKeys, sshKeyPair.PrivateKey) - } - for _, tlsKeyPair := range keySet.TLS { - activeKeys = append(activeKeys, tlsKeyPair.Key) - } - for _, jwtKeyPair := range keySet.JWT { - activeKeys = append(activeKeys, jwtKeyPair.PrivateKey) - } - } - return nil - }) - } - if err := g.Wait(); err != nil { + if err := initializeAuthorities(ctx, asrv, &cfg); err != nil { return trace.Wrap(err) } - // Delete any unused keys from the keyStore. This is to avoid exhausting - // (or wasting) HSM resources. - if err := asrv.keyStore.DeleteUnusedKeys(ctx, activeKeys); err != nil { - // Key deletion is best-effort, log a warning if it fails and carry on. - // We don't want to prevent a CA rotation, which may be necessary in - // some cases where this would fail. - log.Warnf("An attempt to clean up unused HSM or KMS CA keys has failed unexpectedly: %v", err) - } - if lib.IsInsecureDevMode() { warningMessage := "Starting teleport in insecure mode. This is " + "dangerous! Sensitive information will be logged to console and " + @@ -628,6 +525,130 @@ func initCluster(ctx context.Context, cfg InitConfig, asrv *Server) error { return nil } +func initializeAuthorities(ctx context.Context, asrv *Server, cfg *InitConfig) error { + var ( + mu sync.Mutex + allKeysInUse [][]byte + ) + usableKeysResults := make(map[types.CertAuthType]*keystore.UsableKeysResult) + g, gctx := errgroup.WithContext(ctx) + for _, caType := range types.CertAuthTypes { + caType := caType + g.Go(func() error { + tctx, span := cfg.Tracer.Start(gctx, "auth/initializeAuthority", oteltrace.WithAttributes(attribute.String("type", string(caType)))) + defer span.End() + + caID := types.CertAuthID{Type: caType, DomainName: cfg.ClusterName.GetClusterName()} + usableKeysResult, keysInUse, err := initializeAuthority(tctx, asrv, caID) + if err != nil { + return trace.Wrap(err) + } + + mu.Lock() + defer mu.Unlock() + usableKeysResults[caType] = usableKeysResult + allKeysInUse = append(allKeysInUse, keysInUse...) + return nil + }) + } + if err := g.Wait(); err != nil { + return trace.Wrap(err) + } + + if err := asrv.syncUsableKeysAlert(ctx, usableKeysResults); err != nil { + return trace.Wrap(err) + } + + // Delete any unused keys from the keyStore. This is to avoid exhausting + // (or wasting) HSM resources. + if err := asrv.keyStore.DeleteUnusedKeys(ctx, allKeysInUse); err != nil { + // Key deletion is best-effort, log a warning if it fails and carry on. + // We don't want to prevent a CA rotation, which may be necessary in + // some cases where this would fail. + log.Warnf("An attempt to clean up unused HSM or KMS CA keys has failed unexpectedly: %v", err) + } + return nil +} + +func initializeAuthority(ctx context.Context, asrv *Server, caID types.CertAuthID) (usableKeysResult *keystore.UsableKeysResult, keysInUse [][]byte, err error) { + ca, err := asrv.Services.GetCertAuthority(ctx, caID, true) + if err != nil { + if !trace.IsNotFound(err) { + return nil, nil, trace.Wrap(err) + } + + log.Infof("First start: generating %s certificate authority.", caID.Type) + if ca, err = generateAuthority(ctx, asrv, caID); err != nil { + return nil, nil, trace.Wrap(err) + } + + if err := asrv.CreateCertAuthority(ctx, ca); err != nil { + return nil, nil, trace.Wrap(err) + } + } + + // Make sure the keystore has usable keys. This is a bit redundant if the CA + // was just generated above, but cheap relative to generating the CA, and + // it's nice to get the usableKeysResult. + usableKeysResult, err = asrv.keyStore.HasUsableActiveKeys(ctx, ca) + if err != nil { + return nil, nil, trace.Wrap(err) + } + if !usableKeysResult.CAHasUsableKeys { + if ca.GetType() == types.HostCA { + // We need to sign the local Admin identity to support auth startup + // and local tctl. For this special case we add new + // AdditionalTrustedKeys without any active keys. These keys will + // sign the local Admin identity but nothing else (until a CA + // rotation). Only the Host CA is necessary to issue the Admin + // identity. + // + // We can only get here if all the active keys for this CA are in an + // HSM or KMS that this auth is not configured to use. Because the + // auth will not use PKCS#11 keys created by a different host UUID, + // for clusters using HSM keys this includes cases where a new auth is + // added to an HA cluster, or an existing auth's host UUID is reset. + if err := asrv.ensureLocalAdditionalKeys(ctx, ca); err != nil { + return nil, nil, trace.Wrap(err) + } + } + log.Warnf("This Auth Service is configured to use %s but the %s CA contains only %s. "+ + "No new certificates can be signed with the existing keys. "+ + "You must perform a CA rotation to generate new keys, or adjust your configuration to use the existing keys.", + usableKeysResult.PreferredKeyType, + caID.Type, + strings.Join(usableKeysResult.CAKeyTypes, " and ")) + } else if !usableKeysResult.CAHasPreferredKeyType { + log.Warnf("This Auth Service is configured to use %s but the %s CA contains only %s. "+ + "New certificates will continue to be signed with raw software keys but you must perform a CA rotation to begin using %s.", + usableKeysResult.PreferredKeyType, + caID.Type, + strings.Join(usableKeysResult.CAKeyTypes, " and "), + usableKeysResult.PreferredKeyType) + } + allKeyTypes := ca.AllKeyTypes() + numKeyTypes := len(allKeyTypes) + if numKeyTypes > 1 { + log.Warnf("%s CA contains a combination of %s and %s keys. If you are attempting to"+ + " configure HSM or KMS key storage, make sure it is configured on all auth servers in"+ + " this cluster and then perform a CA rotation: https://goteleport.com/docs/management/operations/ca-rotation/", + caID.Type, strings.Join(allKeyTypes[:numKeyTypes-1], ", "), allKeyTypes[numKeyTypes-1]) + } + + for _, keySet := range []types.CAKeySet{ca.GetActiveKeys(), ca.GetAdditionalTrustedKeys()} { + for _, sshKeyPair := range keySet.SSH { + keysInUse = append(keysInUse, sshKeyPair.PrivateKey) + } + for _, tlsKeyPair := range keySet.TLS { + keysInUse = append(keysInUse, tlsKeyPair.Key) + } + for _, jwtKeyPair := range keySet.JWT { + keysInUse = append(keysInUse, jwtKeyPair.PrivateKey) + } + } + return usableKeysResult, keysInUse, nil +} + // generateAuthority creates a new self-signed authority of the provided type // and returns it to the caller. It is the responsibility of callers to persist // the authority. diff --git a/lib/auth/keystore/aws_kms.go b/lib/auth/keystore/aws_kms.go index f164846498fca..5a2899042815c 100644 --- a/lib/auth/keystore/aws_kms.go +++ b/lib/auth/keystore/aws_kms.go @@ -21,6 +21,7 @@ import ( "crypto" "crypto/x509" "errors" + "fmt" "io" "slices" "strings" @@ -123,6 +124,12 @@ func newAWSKMSKeystore(ctx context.Context, cfg *AWSKMSConfig, logger logrus.Fie }, nil } +// keyTypeDescription returns a human-readable description of the types of keys +// this backend uses. +func (a *awsKMSKeystore) keyTypeDescription() string { + return fmt.Sprintf("AWS KMS keys in account %s and region %s", a.awsAccount, a.awsRegion) +} + // generateRSA creates a new RSA private key and returns its identifier and // a crypto.Signer. The returned identifier can be passed to getSigner // later to get the same crypto.Signer. diff --git a/lib/auth/keystore/gcp_kms.go b/lib/auth/keystore/gcp_kms.go index f204df91cfdc9..3d24c1e7ad87a 100644 --- a/lib/auth/keystore/gcp_kms.go +++ b/lib/auth/keystore/gcp_kms.go @@ -135,6 +135,12 @@ func newGCPKMSKeyStore(ctx context.Context, cfg *GCPKMSConfig, logger logrus.Fie }, nil } +// keyTypeDescription returns a human-readable description of the types of keys +// this backend uses. +func (g *gcpKMSKeyStore) keyTypeDescription() string { + return fmt.Sprintf("GCP KMS keys in keyring %s", g.keyRing) +} + // generateRSA creates a new RSA private key and returns its identifier and a // crypto.Signer. The returned identifier for gcpKMSKeyStore encoded the full // GCP KMS key version name, and can be passed to getSigner later to get the same @@ -405,6 +411,19 @@ func (g gcpKMSKeyID) marshal() []byte { return []byte(gcpkmsPrefix + g.keyVersionName) } +func (g gcpKMSKeyID) keyring() (string, error) { + // keyVersionName has this format: + // projects/*/locations/*/keyRings/*/cryptoKeys/*/cryptoKeyVersions/1 + // want to extract: + // projects/*/locations/*/keyRings/* + // project name, location, and keyRing name can't contain '/' + splits := strings.SplitN(g.keyVersionName, "/", 7) + if len(splits) < 7 { + return "", trace.BadParameter("GCP KMS keyVersionName has bad format") + } + return strings.Join(splits[:6], "/"), nil +} + func parseGCPKMSKeyID(key []byte) (gcpKMSKeyID, error) { var keyID gcpKMSKeyID if keyType(key) != types.PrivateKeyType_GCP_KMS { diff --git a/lib/auth/keystore/keystore_test.go b/lib/auth/keystore/keystore_test.go index 0b6caed6f004a..3d95c78858239 100644 --- a/lib/auth/keystore/keystore_test.go +++ b/lib/auth/keystore/keystore_test.go @@ -250,135 +250,143 @@ func TestManager(t *testing.T) { const clusterName = "test-cluster" for _, backendDesc := range pack.backends { - manager, err := NewManager(ctx, backendDesc.config) - require.NoError(t, err) + t.Run(backendDesc.name, func(t *testing.T) { + manager, err := NewManager(ctx, backendDesc.config) + require.NoError(t, err) - // Delete all keys to clean up the test. - t.Cleanup(func() { - require.NoError(t, manager.DeleteUnusedKeys(context.Background(), nil /*activeKeys*/)) - }) + // Delete all keys to clean up the test. + t.Cleanup(func() { + require.NoError(t, manager.DeleteUnusedKeys(context.Background(), nil /*activeKeys*/)) + }) - sshKeyPair, err := manager.NewSSHKeyPair(ctx) - require.NoError(t, err) - require.Equal(t, backendDesc.expectedKeyType, sshKeyPair.PrivateKeyType) + sshKeyPair, err := manager.NewSSHKeyPair(ctx) + require.NoError(t, err) + require.Equal(t, backendDesc.expectedKeyType, sshKeyPair.PrivateKeyType) - tlsKeyPair, err := manager.NewTLSKeyPair(ctx, clusterName) - require.NoError(t, err) - require.Equal(t, backendDesc.expectedKeyType, tlsKeyPair.KeyType) + tlsKeyPair, err := manager.NewTLSKeyPair(ctx, clusterName) + require.NoError(t, err) + require.Equal(t, backendDesc.expectedKeyType, tlsKeyPair.KeyType) - jwtKeyPair, err := manager.NewJWTKeyPair(ctx) - require.NoError(t, err) - require.Equal(t, backendDesc.expectedKeyType, jwtKeyPair.PrivateKeyType) - - // Test a CA with multiple active keypairs. Each element of ActiveKeys - // includes a keypair generated above and a PKCS11 keypair with a - // different hostID that this manager should not be able to use. - ca, err := types.NewCertAuthority(types.CertAuthoritySpecV2{ - Type: types.HostCA, - ClusterName: clusterName, - ActiveKeys: types.CAKeySet{ - SSH: []*types.SSHKeyPair{ - testPKCS11SSHKeyPair, - sshKeyPair, - }, - TLS: []*types.TLSKeyPair{ - testPKCS11TLSKeyPair, - tlsKeyPair, - }, - JWT: []*types.JWTKeyPair{ - testPKCS11JWTKeyPair, - jwtKeyPair, + jwtKeyPair, err := manager.NewJWTKeyPair(ctx) + require.NoError(t, err) + require.Equal(t, backendDesc.expectedKeyType, jwtKeyPair.PrivateKeyType) + + // Test a CA with multiple active keypairs. Each element of ActiveKeys + // includes a keypair generated above and a PKCS11 keypair with a + // different hostID that this manager should not be able to use. + ca, err := types.NewCertAuthority(types.CertAuthoritySpecV2{ + Type: types.HostCA, + ClusterName: clusterName, + ActiveKeys: types.CAKeySet{ + SSH: []*types.SSHKeyPair{ + testPKCS11SSHKeyPair, + sshKeyPair, + }, + TLS: []*types.TLSKeyPair{ + testPKCS11TLSKeyPair, + tlsKeyPair, + }, + JWT: []*types.JWTKeyPair{ + testPKCS11JWTKeyPair, + jwtKeyPair, + }, }, - }, - }) - require.NoError(t, err) + }) + require.NoError(t, err) - // Test that the manager is able to select the correct key and get a - // signer. - sshSigner, err := manager.GetSSHSigner(ctx, ca) - require.NoError(t, err, trace.DebugReport(err)) - require.Equal(t, sshKeyPair.PublicKey, ssh.MarshalAuthorizedKey(sshSigner.PublicKey())) + // Test that the manager is able to select the correct key and get a + // signer. + sshSigner, err := manager.GetSSHSigner(ctx, ca) + require.NoError(t, err, trace.DebugReport(err)) + require.Equal(t, sshKeyPair.PublicKey, ssh.MarshalAuthorizedKey(sshSigner.PublicKey())) - tlsCert, tlsSigner, err := manager.GetTLSCertAndSigner(ctx, ca) - require.NoError(t, err) - require.Equal(t, tlsKeyPair.Cert, tlsCert) - require.NotNil(t, tlsSigner) + tlsCert, tlsSigner, err := manager.GetTLSCertAndSigner(ctx, ca) + require.NoError(t, err) + require.Equal(t, tlsKeyPair.Cert, tlsCert) + require.NotNil(t, tlsSigner) - jwtSigner, err := manager.GetJWTSigner(ctx, ca) - require.NoError(t, err, trace.DebugReport(err)) - pubkeyPem, err := utils.MarshalPublicKey(jwtSigner) - require.NoError(t, err) - require.Equal(t, jwtKeyPair.PublicKey, pubkeyPem) - - // Test what happens when the CA has only raw keys, which will be the - // initial state when migrating from software to a HSM/KMS backend. - ca, err = types.NewCertAuthority(types.CertAuthoritySpecV2{ - Type: types.HostCA, - ClusterName: clusterName, - ActiveKeys: types.CAKeySet{ - SSH: []*types.SSHKeyPair{ - testRawSSHKeyPair, - }, - TLS: []*types.TLSKeyPair{ - testRawTLSKeyPair, - }, - JWT: []*types.JWTKeyPair{ - testRawJWTKeyPair, + jwtSigner, err := manager.GetJWTSigner(ctx, ca) + require.NoError(t, err, trace.DebugReport(err)) + pubkeyPem, err := utils.MarshalPublicKey(jwtSigner) + require.NoError(t, err) + require.Equal(t, jwtKeyPair.PublicKey, pubkeyPem) + + // Test what happens when the CA has only raw keys, which will be the + // initial state when migrating from software to a HSM/KMS backend. + ca, err = types.NewCertAuthority(types.CertAuthoritySpecV2{ + Type: types.HostCA, + ClusterName: clusterName, + ActiveKeys: types.CAKeySet{ + SSH: []*types.SSHKeyPair{ + testRawSSHKeyPair, + }, + TLS: []*types.TLSKeyPair{ + testRawTLSKeyPair, + }, + JWT: []*types.JWTKeyPair{ + testRawJWTKeyPair, + }, }, - }, - }) - require.NoError(t, err) + }) + require.NoError(t, err) - // Manager should always be able to get a signer for software keys. - hasUsableKeys, err := manager.HasUsableActiveKeys(ctx, ca) - require.NoError(t, err) - require.True(t, hasUsableKeys) + // Manager should always be able to get a signer for software keys. + usableKeysResult, err := manager.HasUsableActiveKeys(ctx, ca) + require.NoError(t, err) + require.True(t, usableKeysResult.CAHasUsableKeys) + if backendDesc.expectedKeyType == types.PrivateKeyType_RAW { + require.True(t, usableKeysResult.CAHasPreferredKeyType) + } else { + require.False(t, usableKeysResult.CAHasPreferredKeyType) + } - sshSigner, err = manager.GetSSHSigner(ctx, ca) - require.NoError(t, err) - require.NotNil(t, sshSigner) + sshSigner, err = manager.GetSSHSigner(ctx, ca) + require.NoError(t, err) + require.NotNil(t, sshSigner) - tlsCert, tlsSigner, err = manager.GetTLSCertAndSigner(ctx, ca) - require.NoError(t, err) - require.NotNil(t, tlsCert) - require.NotNil(t, tlsSigner) + tlsCert, tlsSigner, err = manager.GetTLSCertAndSigner(ctx, ca) + require.NoError(t, err) + require.NotNil(t, tlsCert) + require.NotNil(t, tlsSigner) - jwtSigner, err = manager.GetJWTSigner(ctx, ca) - require.NoError(t, err) - require.NotNil(t, jwtSigner) - - // Test a CA with only unusable keypairs - PKCS11 keypairs with a - // different hostID that this manager should not be able to use. - ca, err = types.NewCertAuthority(types.CertAuthoritySpecV2{ - Type: types.HostCA, - ClusterName: clusterName, - ActiveKeys: types.CAKeySet{ - SSH: []*types.SSHKeyPair{ - testPKCS11SSHKeyPair, - }, - TLS: []*types.TLSKeyPair{ - testPKCS11TLSKeyPair, - }, - JWT: []*types.JWTKeyPair{ - testPKCS11JWTKeyPair, + jwtSigner, err = manager.GetJWTSigner(ctx, ca) + require.NoError(t, err) + require.NotNil(t, jwtSigner) + + // Test a CA with only unusable keypairs - PKCS11 keypairs with a + // different hostID that this manager should not be able to use. + ca, err = types.NewCertAuthority(types.CertAuthoritySpecV2{ + Type: types.HostCA, + ClusterName: clusterName, + ActiveKeys: types.CAKeySet{ + SSH: []*types.SSHKeyPair{ + testPKCS11SSHKeyPair, + }, + TLS: []*types.TLSKeyPair{ + testPKCS11TLSKeyPair, + }, + JWT: []*types.JWTKeyPair{ + testPKCS11JWTKeyPair, + }, }, - }, - }) - require.NoError(t, err) + }) + require.NoError(t, err) - // The manager should not be able to select a key. - hasUsableKeys, err = manager.HasUsableActiveKeys(ctx, ca) - require.NoError(t, err) - require.False(t, hasUsableKeys) + // The manager should not be able to select a key. + usableKeysResult, err = manager.HasUsableActiveKeys(ctx, ca) + require.NoError(t, err) + require.False(t, usableKeysResult.CAHasUsableKeys) + require.False(t, usableKeysResult.CAHasPreferredKeyType) - _, err = manager.GetSSHSigner(ctx, ca) - require.True(t, trace.IsNotFound(err), "expected NotFound error, got %v", err) + _, err = manager.GetSSHSigner(ctx, ca) + require.True(t, trace.IsNotFound(err), "expected NotFound error, got %v", err) - _, _, err = manager.GetTLSCertAndSigner(ctx, ca) - require.True(t, trace.IsNotFound(err), "expected NotFound error, got %v", err) + _, _, err = manager.GetTLSCertAndSigner(ctx, ca) + require.True(t, trace.IsNotFound(err), "expected NotFound error, got %v", err) - _, err = manager.GetJWTSigner(ctx, ca) - require.True(t, trace.IsNotFound(err), "expected NotFound error, got %v", err) + _, err = manager.GetJWTSigner(ctx, ca) + require.True(t, trace.IsNotFound(err), "expected NotFound error, got %v", err) + }) } } diff --git a/lib/auth/keystore/manager.go b/lib/auth/keystore/manager.go index c0bf84d081a79..a28bb0298fa6e 100644 --- a/lib/auth/keystore/manager.go +++ b/lib/auth/keystore/manager.go @@ -29,6 +29,7 @@ import ( "github.com/gravitational/trace" "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" + "golang.org/x/exp/maps" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/defaults" @@ -89,6 +90,10 @@ type backend interface { // 2. Created in the backend by this Teleport cluster. // 3. Each backend may apply extra restrictions to which keys may be deleted. deleteUnusedKeys(ctx context.Context, activeKeys [][]byte) error + + // keyTypeDescription returns a human-readable description of the types of + // keys this backend uses. + keyTypeDescription() string } // Config holds configuration parameters for the keystore. A software keystore @@ -371,50 +376,96 @@ func (m *Manager) NewJWTKeyPair(ctx context.Context) (*types.JWTKeyPair, error) }, nil } +// UsableKeysResult holds the result of a call to HasUsableActiveKeys or +// HasUsableAdditionalTrustedKeys. +type UsableKeysResult struct { + // CAHasPreferredKeyType is true if the CA contains any key matching the key + // type the keystore is currently configured to use when generating new + // keys. + CAHasPreferredKeyType bool + // CAHasUsableKeys is true if the CA contains any key that the keystore as + // currently configured can use for signatures. + CAHasUsableKeys bool + // PreferredKeyType is a description of the key type the keystore is + // currently configured to use when generating new keys. + PreferredKeyType string + // CAKeyTypes is a list of descriptions of all the keys types currently + // stored in the CA. It is only guaranteed to be valid if + // CAHasPreferredKeyType is false. + CAKeyTypes []string +} + // HasUsableActiveKeys returns true if the given CA has any usable active keys. -func (m *Manager) HasUsableActiveKeys(ctx context.Context, ca types.CertAuthority) (bool, error) { - usable, err := m.hasUsableKeys(ctx, ca.GetActiveKeys()) - return usable, trace.Wrap(err) +func (m *Manager) HasUsableActiveKeys(ctx context.Context, ca types.CertAuthority) (*UsableKeysResult, error) { + return m.hasUsableKeys(ctx, ca.GetActiveKeys()) } // HasUsableActiveKeys returns true if the given CA has any usable additional // trusted keys. -func (m *Manager) HasUsableAdditionalKeys(ctx context.Context, ca types.CertAuthority) (bool, error) { - usable, err := m.hasUsableKeys(ctx, ca.GetAdditionalTrustedKeys()) - return usable, trace.Wrap(err) +func (m *Manager) HasUsableAdditionalKeys(ctx context.Context, ca types.CertAuthority) (*UsableKeysResult, error) { + return m.hasUsableKeys(ctx, ca.GetAdditionalTrustedKeys()) } -func (m *Manager) hasUsableKeys(ctx context.Context, keySet types.CAKeySet) (bool, error) { - for _, backend := range m.usableSigningBackends { +func (m *Manager) hasUsableKeys(ctx context.Context, keySet types.CAKeySet) (*UsableKeysResult, error) { + result := &UsableKeysResult{ + PreferredKeyType: m.backendForNewKeys.keyTypeDescription(), + } + var allRawKeys [][]byte + for i, backend := range m.usableSigningBackends { + preferredBackend := i == 0 for _, sshKeyPair := range keySet.SSH { usable, err := backend.canSignWithKey(ctx, sshKeyPair.PrivateKey, sshKeyPair.PrivateKeyType) if err != nil { - return false, trace.Wrap(err) + return nil, trace.Wrap(err) } if usable { - return true, nil + result.CAHasUsableKeys = true + if preferredBackend { + result.CAHasPreferredKeyType = true + return result, nil + } } + allRawKeys = append(allRawKeys, sshKeyPair.PrivateKey) } for _, tlsKeyPair := range keySet.TLS { usable, err := backend.canSignWithKey(ctx, tlsKeyPair.Key, tlsKeyPair.KeyType) if err != nil { - return false, trace.Wrap(err) + return nil, trace.Wrap(err) } if usable { - return true, nil + result.CAHasUsableKeys = true + if preferredBackend { + result.CAHasPreferredKeyType = true + return result, nil + } } + allRawKeys = append(allRawKeys, tlsKeyPair.Key) } for _, jwtKeyPair := range keySet.JWT { usable, err := backend.canSignWithKey(ctx, jwtKeyPair.PrivateKey, jwtKeyPair.PrivateKeyType) if err != nil { - return false, trace.Wrap(err) + return nil, trace.Wrap(err) } if usable { - return true, nil + result.CAHasUsableKeys = true + if preferredBackend { + result.CAHasPreferredKeyType = true + return result, nil + } } + allRawKeys = append(allRawKeys, jwtKeyPair.PrivateKey) } } - return false, nil + caKeyTypes := make(map[string]struct{}) + for _, rawKey := range allRawKeys { + desc, err := keyDescription(rawKey) + if err != nil { + return nil, trace.Wrap(err) + } + caKeyTypes[desc] = struct{}{} + } + result.CAKeyTypes = maps.Keys(caKeyTypes) + return result, nil } func (m *Manager) DeleteUnusedKeys(ctx context.Context, activeKeys [][]byte) error { @@ -434,3 +485,32 @@ func keyType(key []byte) types.PrivateKeyType { } return types.PrivateKeyType_RAW } + +func keyDescription(key []byte) (string, error) { + switch keyType(key) { + case types.PrivateKeyType_PKCS11: + keyID, err := parsePKCS11KeyID(key) + if err != nil { + return "", trace.Wrap(err) + } + return "PKCS#11 HSM keys created by " + keyID.HostID, nil + case types.PrivateKeyType_GCP_KMS: + keyID, err := parseGCPKMSKeyID(key) + if err != nil { + return "", trace.Wrap(err) + } + keyring, err := keyID.keyring() + if err != nil { + return "", trace.Wrap(err) + } + return "GCP KMS keys in keyring " + keyring, nil + case types.PrivateKeyType_AWS_KMS: + keyID, err := parseAWSKMSKeyID(key) + if err != nil { + return "", trace.Wrap(err) + } + return "AWS KMS keys in account " + keyID.account + " and region " + keyID.region, nil + default: + return "raw software keys", nil + } +} diff --git a/lib/auth/keystore/pkcs11.go b/lib/auth/keystore/pkcs11.go index 8dddb08566a7d..b3a602ab0c619 100644 --- a/lib/auth/keystore/pkcs11.go +++ b/lib/auth/keystore/pkcs11.go @@ -102,6 +102,12 @@ func newPKCS11KeyStore(config *PKCS11Config, logger logrus.FieldLogger) (*pkcs11 }, nil } +// keyTypeDescription returns a human-readable description of the types of keys +// this backend uses. +func (p *pkcs11KeyStore) keyTypeDescription() string { + return fmt.Sprintf("PKCS#11 HSM keys created by %s", p.hostUUID) +} + func (p *pkcs11KeyStore) findUnusedID() (keyID, error) { if !p.isYubiHSM { id, err := uuid.NewRandom() @@ -179,7 +185,7 @@ func (p *pkcs11KeyStore) getSignerWithoutPublicKey(ctx context.Context, rawKey [ if t := keyType(rawKey); t != types.PrivateKeyType_PKCS11 { return nil, trace.BadParameter("pkcs11KeyStore cannot get signer for key type %s", t.String()) } - keyID, err := parseKeyID(rawKey) + keyID, err := parsePKCS11KeyID(rawKey) if err != nil { return nil, trace.Wrap(err) } @@ -208,7 +214,7 @@ func (p *pkcs11KeyStore) canSignWithKey(ctx context.Context, raw []byte, keyType if keyType != types.PrivateKeyType_PKCS11 { return false, nil } - keyID, err := parseKeyID(raw) + keyID, err := parsePKCS11KeyID(raw) if err != nil { return false, trace.Wrap(err) } @@ -217,7 +223,7 @@ func (p *pkcs11KeyStore) canSignWithKey(ctx context.Context, raw []byte, keyType // deleteKey deletes the given key from the HSM func (p *pkcs11KeyStore) deleteKey(_ context.Context, rawKey []byte) error { - keyID, err := parseKeyID(rawKey) + keyID, err := parsePKCS11KeyID(rawKey) if err != nil { return trace.Wrap(err) } @@ -254,7 +260,7 @@ func (p *pkcs11KeyStore) deleteUnusedKeys(ctx context.Context, activeKeys [][]by if keyType(activeKey) != types.PrivateKeyType_PKCS11 { continue } - keyID, err := parseKeyID(activeKey) + keyID, err := parsePKCS11KeyID(activeKey) if err != nil { return trace.Wrap(err) } @@ -347,7 +353,7 @@ func (k keyID) pkcs11Key(isYubiHSM bool) ([]byte, error) { return id[:], nil } -func parseKeyID(key []byte) (keyID, error) { +func parsePKCS11KeyID(key []byte) (keyID, error) { var keyID keyID if keyType(key) != types.PrivateKeyType_PKCS11 { return keyID, trace.BadParameter("unable to parse invalid pkcs11 key") diff --git a/lib/auth/keystore/software.go b/lib/auth/keystore/software.go index a460ed6cd4257..9f4d44a64dd87 100644 --- a/lib/auth/keystore/software.go +++ b/lib/auth/keystore/software.go @@ -54,6 +54,12 @@ func newSoftwareKeyStore(config *SoftwareConfig, logger logrus.FieldLogger) *sof } } +// keyTypeDescription returns a human-readable description of the types of keys +// this backend uses. +func (s *softwareKeyStore) keyTypeDescription() string { + return "raw software keys" +} + // generateRSA creates a new RSA private key and returns its identifier and a // crypto.Signer. The returned identifier for softwareKeyStore is a pem-encoded // private key, and can be passed to getSigner later to get the same diff --git a/lib/auth/rotate.go b/lib/auth/rotate.go index bc660f77bf1ad..181cae03c40a5 100644 --- a/lib/auth/rotate.go +++ b/lib/auth/rotate.go @@ -22,6 +22,7 @@ import ( "context" "crypto/rsa" "crypto/x509/pkix" + "fmt" "time" "github.com/google/uuid" @@ -31,7 +32,9 @@ import ( "golang.org/x/crypto/ssh" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/auth/keystore" "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/modules" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/tlsca" "github.com/gravitational/teleport/lib/utils" @@ -239,6 +242,7 @@ func (a *Server) autoRotateCertAuthorities(ctx context.Context) error { if err != nil { return trace.Wrap(err) } + usableKeysResults := make(map[types.CertAuthType]*keystore.UsableKeysResult) for _, caType := range types.CertAuthTypes { ca, err := a.Services.GetCertAuthority(ctx, types.CertAuthID{ Type: caType, @@ -256,6 +260,13 @@ func (a *Server) autoRotateCertAuthorities(ctx context.Context) error { return trace.Wrap(err) } } + usableKeysResults[caType], err = a.keyStore.HasUsableActiveKeys(ctx, ca) + if err != nil { + return trace.Wrap(err) + } + } + if err := a.syncUsableKeysAlert(ctx, usableKeysResults); err != nil { + return trace.Wrap(err) } return nil } @@ -479,17 +490,18 @@ func (a *Server) startNewRotation(ctx context.Context, req rotationReq, ca types // invalidating the current Admin identity. newKeys = additionalKeys.Clone() } - hasUsableAdditionalKeys, err := a.keyStore.HasUsableAdditionalKeys(ctx, ca) + usableKeysResult, err := a.keyStore.HasUsableAdditionalKeys(ctx, ca) if err != nil { return trace.Wrap(err) } - if !hasUsableAdditionalKeys { - // This auth server has no usable AdditionalTrustedKeys in this CA. + if !usableKeysResult.CAHasPreferredKeyType { + // There are no AdditionalTrustedKeys in this CA that match the + // configured key type of this auth server. // This is one of 2 cases: // 1. There are no AdditionalTrustedKeys at all. // 2. There are AdditionalTrustedKeys which were added by a - // different HSM-enabled auth server. - // In either case, we need to add newly generated local keys. + // different HSM-enabled auth server or one using a different KMS. + // In either case, we need to add newly generated keys. newLocalKeys, err := newKeySet(ctx, a.keyStore, ca.GetID()) if err != nil { return trace.Wrap(err) @@ -594,3 +606,84 @@ func completeRotation(clock clockwork.Clock, ca types.CertAuthority) { rotation.Schedule = types.RotationSchedule{} ca.SetRotation(rotation) } + +// syncUsableKeysAlert creates a cluster alert if any of the stored CAs do not +// contain keys matching the type of key (HSM, KMS, software) this auth server +// is configured to use. The [usableKeysResults] arguments is expected to +// contain the results of [keystore.(*Manager).HasUsableActiveKeys] for all CA +// types. +func (a *Server) syncUsableKeysAlert(ctx context.Context, usableKeysResults map[types.CertAuthType]*keystore.UsableKeysResult) error { + // Alert ID contains server ID because multiple auth servers can be + // configured differently and may be able to use different key types. + // If the auth servers are ephemeral, the alert will expire. + alertID := "ca-key-types/" + a.ServerID + var casWithoutPreferredKeyType []types.CertAuthType + unableToSign := false + var preferredKeyType string + for caType, usableKeysResult := range usableKeysResults { + if !usableKeysResult.CAHasPreferredKeyType { + casWithoutPreferredKeyType = append(casWithoutPreferredKeyType, caType) + } + if !usableKeysResult.CAHasUsableKeys { + unableToSign = true + } + // Should be identical for all results, just take any one. + preferredKeyType = usableKeysResult.PreferredKeyType + } + + if len(casWithoutPreferredKeyType) == 0 { + // Every CA contains keys matching the preferred type, delete the alert + // if it exists. + if err := a.DeleteClusterAlert(ctx, alertID); err != nil && !trace.IsNotFound(err) { + return trace.Wrap(err) + } + return nil + } + + alertOptions := []types.AlertOption{ + types.WithAlertLabel(types.AlertOnLogin, "yes"), + // This is called by a.runPeriodicOperations via + // a.autoRotateCertAuthorities on a random period between 1-2x + // defaults.HighResPollingPeriod, the alert will be renewed before it + // expires if it's still relevant. + types.WithAlertExpires(a.clock.Now().Add(defaults.HighResPollingPeriod * 3)), + } + msg := fmt.Sprintf( + "Auth Service %s is configured to use %s, but the following CAs do not contain any keys of that type: %v. ", + a.ServerID, + preferredKeyType, + casWithoutPreferredKeyType) + if unableToSign { + alertOptions = append(alertOptions, + types.WithAlertSeverity(types.AlertSeverity_HIGH), + types.WithAlertLabel(types.AlertPermitAll, "yes")) + msg += "The Auth Service is currently unable to sign certificates and degraded service is expected. " + } else { + if modules.GetModules().Features().Cloud { + // Don't create this alert on Cloud. This avoids alerting all + // customers if Cloud ends up enabling an HSM/KMS by default in + // existing configurations. It's fine to never rotate in this case + // and continue using software keys. But if this is an on-prem + // cluster where the admin manually configured an HSM or KMS, they + // probably want to use it, so hopefully they'll appreciate the + // alert reminding them to rotate the CAs. + return nil + } + alertOptions = append(alertOptions, + types.WithAlertSeverity(types.AlertSeverity_MEDIUM), + types.WithAlertLabel(types.AlertVerbPermit, + fmt.Sprintf("%s:%s", types.KindCertAuthority, types.VerbUpdate))) + msg += "The Auth Service will continue signing certificates with raw software keys. " + } + msg += "These CAs must be rotated to begin using the configured key type. " + + "See https://goteleport.com/docs/management/operations/ca-rotation/" + + alert, err := types.NewClusterAlert("ca-key-types/"+a.ServerID, msg, alertOptions...) + if err != nil { + return trace.Wrap(err) + } + if err := a.UpsertClusterAlert(ctx, alert); err != nil { + return trace.Wrap(err) + } + return nil +} diff --git a/lib/services/status.go b/lib/services/status.go index 440b5fae4184d..326fcc486e31a 100644 --- a/lib/services/status.go +++ b/lib/services/status.go @@ -30,7 +30,7 @@ type Status interface { // GetClusterAlerts loads all matching cluster alerts. GetClusterAlerts(ctx context.Context, query types.GetClusterAlertsRequest) ([]types.ClusterAlert, error) - // UpsertClusterAlert creates the specified alert, overwriting any preexising alert with the same ID. + // UpsertClusterAlert creates the specified alert, overwriting any preexisting alert with the same ID. UpsertClusterAlert(ctx context.Context, alert types.ClusterAlert) error // CreateAlertAck marks a cluster alert as acknowledged.