-
Notifications
You must be signed in to change notification settings - Fork 1.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Extract Kerberos functionality from SQL Server engine #51352
Open
Tener
wants to merge
1
commit into
master
Choose a base branch
from
tener/kerberos-move
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+397
−328
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,155 @@ | ||
// Teleport | ||
// Copyright (C) 2025 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 kerberos | ||
|
||
import ( | ||
"context" | ||
"crypto/x509" | ||
"encoding/pem" | ||
"errors" | ||
"strings" | ||
|
||
"github.com/gravitational/trace" | ||
"github.com/jcmturner/gokrb5/v8/client" | ||
"github.com/jcmturner/gokrb5/v8/config" | ||
"github.com/jcmturner/gokrb5/v8/keytab" | ||
|
||
"github.com/gravitational/teleport/lib/auth/windows" | ||
"github.com/gravitational/teleport/lib/srv/db/common" | ||
"github.com/gravitational/teleport/lib/srv/db/common/kerberos/kinit" | ||
) | ||
|
||
type clientProvider struct { | ||
AuthClient windows.AuthInterface | ||
DataDir string | ||
|
||
kinitCommandGenerator kinit.CommandGenerator | ||
} | ||
|
||
// ClientProvider can create Kerberos client appropriate for given database session. | ||
type ClientProvider interface { | ||
// GetKerberosClient returns Kerberos client for given user and active directory configuration. | ||
GetKerberosClient(ctx context.Context, sessionCtx *common.Session) (*client.Client, error) | ||
} | ||
|
||
func NewClientProvider(authClient windows.AuthInterface, dataDir string) ClientProvider { | ||
return newClientProvider(authClient, dataDir) | ||
} | ||
|
||
func newClientProvider(authClient windows.AuthInterface, dataDir string) *clientProvider { | ||
return &clientProvider{ | ||
AuthClient: authClient, | ||
DataDir: dataDir, | ||
} | ||
} | ||
|
||
var errBadCertificate = errors.New("invalid certificate was provided via AD configuration") | ||
var errBadKerberosConfig = errors.New("configuration must have either keytab or kdc_host_name and ldap_cert") | ||
|
||
func (c *clientProvider) GetKerberosClient(ctx context.Context, sessionCtx *common.Session) (*client.Client, error) { | ||
switch { | ||
case sessionCtx.Database.GetAD().KeytabFile != "": | ||
kt, err := c.keytabClient(sessionCtx) | ||
if err != nil { | ||
return nil, trace.Wrap(err) | ||
} | ||
return kt, nil | ||
case sessionCtx.Database.GetAD().KDCHostName != "" && sessionCtx.Database.GetAD().LDAPCert != "": | ||
kt, err := c.kinitClient(ctx, sessionCtx, c.AuthClient, c.DataDir) | ||
if err != nil { | ||
return nil, trace.Wrap(err) | ||
} | ||
return kt, nil | ||
|
||
} | ||
return nil, trace.Wrap(errBadKerberosConfig) | ||
} | ||
|
||
// keytabClient returns a kerberos client using a keytab file | ||
func (c *clientProvider) keytabClient(session *common.Session) (*client.Client, error) { | ||
// Load keytab. | ||
kt, err := keytab.Load(session.Database.GetAD().KeytabFile) | ||
if err != nil { | ||
return nil, trace.Wrap(err) | ||
} | ||
|
||
// Load krb5.conf. | ||
conf, err := config.Load(session.Database.GetAD().Krb5File) | ||
if err != nil { | ||
return nil, trace.Wrap(err) | ||
} | ||
|
||
// Create Kerberos client. | ||
kbClient := client.NewWithKeytab( | ||
session.DatabaseUser, | ||
session.Database.GetAD().Domain, | ||
kt, | ||
conf, | ||
// Active Directory does not commonly support FAST negotiation. | ||
client.DisablePAFXFAST(true)) | ||
|
||
// Login. | ||
err = kbClient.Login() | ||
return kbClient, err | ||
} | ||
|
||
// kinitClient returns a kerberos client using a kinit ccache | ||
func (c *clientProvider) kinitClient(ctx context.Context, session *common.Session, auth windows.AuthInterface, dataDir string) (*client.Client, error) { | ||
ldapPem, _ := pem.Decode([]byte(session.Database.GetAD().LDAPCert)) | ||
|
||
if ldapPem == nil { | ||
return nil, trace.Wrap(errBadCertificate) | ||
} | ||
|
||
cert, err := x509.ParseCertificate(ldapPem.Bytes) | ||
if err != nil { | ||
return nil, trace.Wrap(err) | ||
} | ||
|
||
certGetter := &kinit.DBCertGetter{ | ||
Auth: auth, | ||
KDCHostName: strings.ToUpper(session.Database.GetAD().KDCHostName), | ||
RealmName: session.Database.GetAD().Domain, | ||
AdminServerName: session.Database.GetAD().KDCHostName, | ||
UserName: session.DatabaseUser, | ||
LDAPCA: cert, | ||
} | ||
|
||
realmName := strings.ToUpper(session.Database.GetAD().Domain) | ||
k := kinit.New(kinit.NewCommandLineInitializer( | ||
kinit.CommandConfig{ | ||
AuthClient: auth, | ||
User: session.DatabaseUser, | ||
Realm: realmName, | ||
KDCHost: session.Database.GetAD().KDCHostName, | ||
AdminServer: session.Database.GetAD().Domain, | ||
DataDir: dataDir, | ||
LDAPCA: cert, | ||
LDAPCAPEM: session.Database.GetAD().LDAPCert, | ||
Command: c.kinitCommandGenerator, | ||
CertGetter: certGetter, | ||
})) | ||
|
||
// create the kinit credentials cache using the previously prepared cert/key pair | ||
cc, conf, err := k.UseOrCreateCredentialsCache(ctx) | ||
if err != nil { | ||
return nil, trace.Wrap(err) | ||
} | ||
|
||
// Create Kerberos client from ccache. No need to login, `kinit` will have already done that. | ||
return client.NewFromCCache(cc, conf, client.DisablePAFXFAST(true)) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,224 @@ | ||
/* | ||
* Teleport | ||
* Copyright (C) 2023 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 kerberos | ||
|
||
import ( | ||
"context" | ||
_ "embed" | ||
"errors" | ||
"fmt" | ||
"os" | ||
"os/exec" | ||
"path/filepath" | ||
"testing" | ||
"time" | ||
|
||
"github.com/gravitational/trace" | ||
"github.com/stretchr/testify/require" | ||
|
||
"github.com/gravitational/teleport/api/client/proto" | ||
"github.com/gravitational/teleport/api/types" | ||
"github.com/gravitational/teleport/lib/defaults" | ||
"github.com/gravitational/teleport/lib/services" | ||
"github.com/gravitational/teleport/lib/srv/db/common" | ||
) | ||
|
||
//go:embed kinit/testdata/kinit.cache | ||
var cacheData []byte | ||
|
||
type staticCache struct { | ||
t *testing.T | ||
pass bool | ||
} | ||
|
||
func (s *staticCache) CommandContext(ctx context.Context, name string, args ...string) *exec.Cmd { | ||
cachePath := args[len(args)-1] | ||
require.NotEmpty(s.t, cachePath) | ||
err := os.WriteFile(cachePath, cacheData, 0664) | ||
require.NoError(s.t, err) | ||
|
||
if s.pass { | ||
return exec.Command("echo") | ||
} | ||
cmd := exec.Command("") | ||
cmd.Err = errors.New("bad command") | ||
return cmd | ||
} | ||
|
||
const ( | ||
mockCA = `-----BEGIN CERTIFICATE----- | ||
MIIECzCCAvOgAwIBAgIRAPEVuzVonTAvpOMyNii7nOAwDQYJKoZIhvcNAQELBQAw | ||
gZ4xNDAyBgNVBAoTK2NlcmVicm8uYWxpc3RhbmlzLmdpdGh1Yi5iZXRhLnRhaWxz | ||
Y2FsZS5uZXQxNDAyBgNVBAMTK2NlcmVicm8uYWxpc3RhbmlzLmdpdGh1Yi5iZXRh | ||
LnRhaWxzY2FsZS5uZXQxMDAuBgNVBAUTJzMyMDQ1Njc4MjI2MDI1ODkyMjc5NTk2 | ||
NDc0MTEyOTU0ODMwNzY4MDAeFw0yMjA2MDcwNDQ4MzhaFw0zMjA2MDQwNDQ4Mzha | ||
MIGeMTQwMgYDVQQKEytjZXJlYnJvLmFsaXN0YW5pcy5naXRodWIuYmV0YS50YWls | ||
c2NhbGUubmV0MTQwMgYDVQQDEytjZXJlYnJvLmFsaXN0YW5pcy5naXRodWIuYmV0 | ||
YS50YWlsc2NhbGUubmV0MTAwLgYDVQQFEyczMjA0NTY3ODIyNjAyNTg5MjI3OTU5 | ||
NjQ3NDExMjk1NDgzMDc2ODAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB | ||
AQDJVqHTgx9pdPHCrDJ0UtbZMVL/xhihuR44AY8aqSebJbKc/WrYLIJxqO1q8L4c | ||
B+sfblIMMz/Em1IZ3ZF7AajiJFSn8VfGx5xtxC06YWPY3HfflcuY5kGVWtYl8ReD | ||
7j3FJjNq4Rvv+NoYwmQXYw6Nwu90cWHerDY3G0fQOsjgUAipnTS4+/H36pBakNoK | ||
9pipl3Kb6YVtjdxY6KY0gSy0k8NiRUx8sCpxJOwfUSAvtsGd1tw1388ZfWr2Bl2d | ||
st2H+q1ozLZ3IQXSgSl6s63JmvWpsElg8+nXZKB3CNTIhrOvvyV33Ok5uAQ44nel | ||
vLy5r3o2OguPjvC+SrkHn1avAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBpjAPBgNV | ||
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR0fa5/2sVguUfn8MHmC7DoFl58fzANBgkq | ||
hkiG9w0BAQsFAAOCAQEAAOEBowwaigoFG3rxM5euIyfax2gWPXN63YF3vd5IN75C | ||
gzimkq9c6MRsvaS053xbRF5NncectmBzTY3WQscJ30+tHD84fA5VQCt//lA+G9gi | ||
g8Co+YPraQe8kbZEcAFceGpWrKjCEwiWlrlM56VfmKmGws21N/PBIb5aO0aEHuWs | ||
HOhXH/n0dKrb7IJcpUh0/w02qiUQ6I0usjGwRlE3xkPyWgEkKUcy+eBrfVVV++8e | ||
HDKyflZ05nt/zvM6W/WIeMI7VMPw/Ryr7iynMqAYAhJhTFKdSwuNLDY8eFbOUnbw | ||
21sZcc/b5g+C9N+0lbFxUUF99bt6jLOVUwpR7LRP2g== | ||
-----END CERTIFICATE-----` | ||
|
||
krb5Conf = `[libdefaults] | ||
default_realm = example.com | ||
rdns = false | ||
[realms] | ||
example.com = { | ||
kdc = host.example.com | ||
admin_server = host.example.com | ||
pkinit_eku_checking = kpServerAuth | ||
pkinit_kdc_hostname = host.example.com | ||
}` | ||
) | ||
|
||
type mockAuth struct{} | ||
|
||
func (m *mockAuth) GenerateWindowsDesktopCert(ctx context.Context, request *proto.WindowsDesktopCertRequest) (*proto.WindowsDesktopCertResponse, error) { | ||
return nil, nil | ||
} | ||
|
||
func (m *mockAuth) GetCertAuthority(ctx context.Context, id types.CertAuthID, loadKeys bool) (types.CertAuthority, error) { | ||
return nil, nil | ||
} | ||
|
||
func (m *mockAuth) GetClusterName(opts ...services.MarshalOption) (types.ClusterName, error) { | ||
return types.NewClusterName(types.ClusterNameSpecV2{ | ||
ClusterName: "TestCluster", | ||
ClusterID: "TestClusterID", | ||
}) | ||
} | ||
|
||
func (m *mockAuth) GenerateDatabaseCert(_ context.Context, req *proto.DatabaseCertRequest) (*proto.DatabaseCertResponse, error) { | ||
if req.GetRequesterName() != proto.DatabaseCertRequest_UNSPECIFIED { | ||
return nil, trace.BadParameter("db agent should not specify requester name") | ||
} | ||
return &proto.DatabaseCertResponse{Cert: []byte(mockCA), CACerts: [][]byte{[]byte(mockCA)}}, nil | ||
} | ||
|
||
func TestConnectorKInitClient(t *testing.T) { | ||
t.Parallel() | ||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | ||
defer cancel() | ||
|
||
dir := t.TempDir() | ||
|
||
provider := newClientProvider(&mockAuth{}, dir) | ||
provider.kinitCommandGenerator = &staticCache{t: t, pass: true} | ||
|
||
krbConfPath := filepath.Join(dir, "krb5.conf") | ||
err := os.WriteFile(krbConfPath, []byte(krb5Conf), 0664) | ||
require.NoError(t, err) | ||
|
||
for i, tt := range []struct { | ||
desc string | ||
databaseSpec types.DatabaseSpecV3 | ||
errAssertion require.ErrorAssertionFunc | ||
}{ | ||
{ | ||
desc: "AD-x509-Loads_and_fails_with_expired_cache", | ||
databaseSpec: types.DatabaseSpecV3{ | ||
Protocol: defaults.ProtocolSQLServer, | ||
URI: "sqlserver:1443", | ||
AD: types.AD{ | ||
LDAPCert: mockCA, | ||
KDCHostName: "kdc.example.com", | ||
Krb5File: krbConfPath, | ||
}, | ||
}, | ||
// When using a non-Azure database, the connector should attempt to get a kinit client | ||
errAssertion: func(t require.TestingT, err error, _ ...interface{}) { | ||
require.Error(t, err) | ||
// we can't get a new TGT without an actual kerberos implementation, so we are relying on the existing | ||
// credentials cache being expired | ||
require.ErrorContains(t, err, "cannot login, no user credentials available and no valid existing session") | ||
}, | ||
}, | ||
{ | ||
desc: "AD-x509-Fails_to_load_with_bad_config", | ||
databaseSpec: types.DatabaseSpecV3{ | ||
Protocol: defaults.ProtocolSQLServer, | ||
URI: "sqlserver:1443", | ||
AD: types.AD{}, | ||
}, | ||
// When using a non-Azure database, the connector should attempt to get a kinit client | ||
errAssertion: func(t require.TestingT, err error, _ ...interface{}) { | ||
require.Error(t, err) | ||
// we can't get a new TGT without an actual kerberos implementation, so we are relying on the existing | ||
// credentials cache being expired | ||
require.ErrorIs(t, err, errBadKerberosConfig) | ||
}, | ||
}, | ||
{ | ||
desc: "AD-x509-Fails_with_invalid_certificate", | ||
databaseSpec: types.DatabaseSpecV3{ | ||
Protocol: defaults.ProtocolSQLServer, | ||
URI: "sqlserver:1443", | ||
AD: types.AD{ | ||
LDAPCert: "BEGIN CERTIFICATE", | ||
KDCHostName: "kdc.example.com", | ||
Krb5File: krbConfPath, | ||
}, | ||
}, | ||
// When using a non-Azure database, the connector should attempt to get a kinit client | ||
errAssertion: func(t require.TestingT, err error, _ ...interface{}) { | ||
require.Error(t, err) | ||
// we can't get a new TGT without an actual kerberos implementation, so we are relying on the existing | ||
// credentials cache being expired | ||
require.ErrorIs(t, err, errBadCertificate) | ||
}, | ||
}, | ||
} { | ||
t.Run(tt.desc, func(t *testing.T) { | ||
database, err := types.NewDatabaseV3(types.Metadata{ | ||
Name: fmt.Sprintf("db-%v", i), | ||
}, tt.databaseSpec) | ||
require.NoError(t, err) | ||
|
||
databaseUser := "alice" | ||
|
||
session := &common.Session{ | ||
Database: database, | ||
DatabaseUser: databaseUser, | ||
DatabaseName: database.GetName(), | ||
} | ||
|
||
client, err := provider.GetKerberosClient(ctx, session) | ||
if client == nil { | ||
tt.errAssertion(t, err) | ||
} else { | ||
err = client.Login() | ||
tt.errAssertion(t, err) | ||
} | ||
}) | ||
} | ||
} |
File renamed without changes.
File renamed without changes.
File renamed without changes.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We had some offline discussion on the cache dir. we can look into that afterwards. Here are just some notes but they should not block this PR:
kinit
does refresh the output every connection, but there could be a race that two connections trying to output to the same file then read it.On the other hand, it seems
credentials.LoadCCache
finishes reading that file immediately and has no use of it afterwards. We could experiment if we could just use tmp dir instead of data-dir.Another consideration is if we want to add real caching to avoid kinit on every connection.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another possibility is to reimplement the the protocol internally, without shelling out to
kinit
. Not only we would drop the external dependency and the need to juggle various files. As @gabrielcorado points out, we would also benefit from much better control over the whole process and much better diagnostic information.