Skip to content
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

Adding X.509 SPIFFE auth #14084

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions go/cmd/vttestserver/vttestserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"fmt"
"io"
"math/rand"
"net/url"
"os"
"os/exec"
"path"
Expand Down Expand Up @@ -307,6 +308,96 @@ func TestMtlsAuthUnauthorizedFails(t *testing.T) {
assert.Contains(t, err.Error(), "code = Unauthenticated desc = client certificate not authorized")
}

func TestSPIFFEAuth(t *testing.T) {
args := os.Args
conf := config
defer resetFlags(args, conf)

// Our test root.
root := t.TempDir()

// Create the certs and configs.
tlstest.CreateCA(root)
caCert := path.Join(root, "ca-cert.pem")

tlstest.CreateSignedCert(root, tlstest.CA, "01", "vtctld", "vtctld.example.com")
cert := path.Join(root, "vtctld-cert.pem")
key := path.Join(root, "vtctld-key.pem")

clientSPIFFEId := &url.URL{
Scheme: "spiffe",
Host: "auth.example.com",
Path: "/client",
}
tlstest.CreateSignedSvid(root, tlstest.CA, "02", "client", "ClientApp", clientSPIFFEId)
clientCert := path.Join(root, "client-cert.pem")
clientKey := path.Join(root, "client-key.pem")

// When cluster starts it will apply SQL and VSchema migrations in the configured schema_dir folder
// With SPIFFE authorization enabled, the certificate/SVID's SPIFFE ID must be in one of the allowed trust domains
cluster, err := startCluster(
"--grpc_auth_mode=spiffe",
fmt.Sprintf("--grpc_key=%s", key),
fmt.Sprintf("--grpc_cert=%s", cert),
fmt.Sprintf("--grpc_ca=%s", caCert),
fmt.Sprintf("--vtctld_grpc_key=%s", clientKey),
fmt.Sprintf("--vtctld_grpc_cert=%s", clientCert),
fmt.Sprintf("--vtctld_grpc_ca=%s", caCert),
fmt.Sprintf("--grpc_auth_spiffe_allowed_trust_domains=%s", "auth.example.com"))
assert.NoError(t, err)
defer func() {
cluster.PersistentMode = false // Cleanup the tmpdir as we're done
cluster.TearDown()
}()

// startCluster will apply vschema migrations using vtctl grpc and the clientCert.
assertColumnVindex(t, cluster, columnVindex{keyspace: "test_keyspace", table: "test_table", vindex: "my_vdx", vindexType: "hash", column: "id"})
assertColumnVindex(t, cluster, columnVindex{keyspace: "app_customer", table: "customers", vindex: "hash", vindexType: "hash", column: "id"})
}

func TestSPIFFEAuthUnauthorizedFails(t *testing.T) {
args := os.Args
conf := config
defer resetFlags(args, conf)

// Our test root.
root := t.TempDir()

// Create the certs and configs.
tlstest.CreateCA(root)
caCert := path.Join(root, "ca-cert.pem")

tlstest.CreateSignedCert(root, tlstest.CA, "01", "vtctld", "vtctld.example.com")
cert := path.Join(root, "vtctld-cert.pem")
key := path.Join(root, "vtctld-key.pem")

clientSPIFFEId := &url.URL{
Scheme: "spiffe",
Host: "some.otherdomain.com",
Path: "/anotherclient",
}
tlstest.CreateSignedSvid(root, tlstest.CA, "02", "client", "AnotherApp", clientSPIFFEId)
clientCert := path.Join(root, "client-cert.pem")
clientKey := path.Join(root, "client-key.pem")

// When cluster starts it will apply SQL and VSchema migrations in the configured schema_dir folder
// Force SPIFFE authorization failure by providing a client certificate with SPIFFE ID for a domain
// that is not in the list of allowed trust domains
cluster, err := startCluster(
"--grpc_auth_mode=spiffe",
fmt.Sprintf("--grpc_key=%s", key),
fmt.Sprintf("--grpc_cert=%s", cert),
fmt.Sprintf("--grpc_ca=%s", caCert),
fmt.Sprintf("--vtctld_grpc_key=%s", clientKey),
fmt.Sprintf("--vtctld_grpc_cert=%s", clientCert),
fmt.Sprintf("--vtctld_grpc_ca=%s", caCert),
fmt.Sprintf("--grpc_auth_spiffe_allowed_trust_domains=%s", "auth.example.com"))
defer cluster.TearDown()

assert.Error(t, err)
assert.Contains(t, err.Error(), "code = Unauthenticated desc = client certificate not authorized")
}

func startPersistentCluster(dir string, flags ...string) (vttest.LocalCluster, error) {
flags = append(flags, []string{
"--persistent_mode",
Expand Down
1 change: 1 addition & 0 deletions go/flags/endtoend/mysqlctld.txt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Usage of mysqlctld:
--dba_pool_size int Size of the connection pool for dba connections (default 20)
--grpc_auth_mode string Which auth plugin implementation to use (eg: static)
--grpc_auth_mtls_allowed_substrings string List of substrings of at least one of the client certificate names (separated by colon).
--grpc_auth_spiffe_allowed_trust_domains string List of allowed SPIFFE Trust Domains for client SVIDs (separated by comma).
--grpc_auth_static_client_creds string When using grpc_static_auth in the server, this file provides the credentials to use to authenticate with server.
--grpc_auth_static_password_file string JSON File to read the users/passwords from.
--grpc_ca string server CA to use for gRPC connections, requires TLS, and enforces client certificate check
Expand Down
1 change: 1 addition & 0 deletions go/flags/endtoend/vtctld.txt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Flags:
--gcs_backup_storage_root string Root prefix for all backup-related object names.
--grpc_auth_mode string Which auth plugin implementation to use (eg: static)
--grpc_auth_mtls_allowed_substrings string List of substrings of at least one of the client certificate names (separated by colon).
--grpc_auth_spiffe_allowed_trust_domains string List of allowed SPIFFE Trust Domains for client SVIDs (separated by comma).
--grpc_auth_static_client_creds string When using grpc_static_auth in the server, this file provides the credentials to use to authenticate with server.
--grpc_auth_static_password_file string JSON File to read the users/passwords from.
--grpc_ca string server CA to use for gRPC connections, requires TLS, and enforces client certificate check
Expand Down
1 change: 1 addition & 0 deletions go/flags/endtoend/vtgate.txt
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ Flags:
--grpc-use-static-authentication-callerid If set, will set the immediate caller id to the username authenticated by the static auth plugin.
--grpc_auth_mode string Which auth plugin implementation to use (eg: static)
--grpc_auth_mtls_allowed_substrings string List of substrings of at least one of the client certificate names (separated by colon).
--grpc_auth_spiffe_allowed_trust_domains string List of allowed SPIFFE Trust Domains for client SVIDs (separated by comma).
--grpc_auth_static_client_creds string When using grpc_static_auth in the server, this file provides the credentials to use to authenticate with server.
--grpc_auth_static_password_file string JSON File to read the users/passwords from.
--grpc_ca string server CA to use for gRPC connections, requires TLS, and enforces client certificate check
Expand Down
1 change: 1 addition & 0 deletions go/flags/endtoend/vttablet.txt
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ Flags:
--gh-ost-path string override default gh-ost binary full path
--grpc_auth_mode string Which auth plugin implementation to use (eg: static)
--grpc_auth_mtls_allowed_substrings string List of substrings of at least one of the client certificate names (separated by colon).
--grpc_auth_spiffe_allowed_trust_domains string List of allowed SPIFFE Trust Domains for client SVIDs (separated by comma).
--grpc_auth_static_client_creds string When using grpc_static_auth in the server, this file provides the credentials to use to authenticate with server.
--grpc_auth_static_password_file string JSON File to read the users/passwords from.
--grpc_ca string server CA to use for gRPC connections, requires TLS, and enforces client certificate check
Expand Down
1 change: 1 addition & 0 deletions go/flags/endtoend/vttestserver.txt
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Usage of vttestserver:
--foreign_key_mode string This is to provide how to handle foreign key constraint in create/alter table. Valid values are: allow, disallow (default "allow")
--grpc_auth_mode string Which auth plugin implementation to use (eg: static)
--grpc_auth_mtls_allowed_substrings string List of substrings of at least one of the client certificate names (separated by colon).
--grpc_auth_spiffe_allowed_trust_domains string List of allowed SPIFFE Trust Domains for client SVIDs (separated by comma).
--grpc_auth_static_client_creds string When using grpc_static_auth in the server, this file provides the credentials to use to authenticate with server.
--grpc_auth_static_password_file string JSON File to read the users/passwords from.
--grpc_ca string server CA to use for gRPC connections, requires TLS, and enforces client certificate check
Expand Down
4 changes: 2 additions & 2 deletions go/vt/servenv/grpc_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ import (
// Note servenv.GRPCServer can only be used in servenv.OnRun,
// and not before, as it is initialized right before calling OnRun.
var (
// gRPCAuth specifies which auth plugin to use. Currently only "static" and
// "mtls" are supported.
// gRPCAuth specifies which auth plugin to use. Currently only "static",
// "mtls", and "spiffe" are supported.
//
// To expose this flag, call RegisterGRPCAuthServerFlags before ParseFlags.
gRPCAuth string
Expand Down
160 changes: 160 additions & 0 deletions go/vt/servenv/grpc_server_auth_spiffe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/*
Copyright 2023 The Vitess Authors.

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 servenv

import (
"context"
"crypto/x509"
"net/url"
"strings"

"github.com/spf13/pflag"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"

"vitess.io/vitess/go/vt/log"
)

var (
// spiffeTrustDomains list of allowed SPIFFE Trust Domains for client SVIDs during authorization
spiffeTrustDomains string
// SPIFFEAuthPlugin implements AuthPlugin interface
_ Authenticator = (*SPIFFEAuthPlugin)(nil)
)

// The datatype for spiffe auth Context keys
type spiffeIdKey int

const (
// Internal Context key for the authenticated SPIFFE ID
spiffeId spiffeIdKey = 0
)

func registerGRPCServerAuthSPIFFEFlags(fs *pflag.FlagSet) {
fs.StringVar(&spiffeTrustDomains, "grpc_auth_spiffe_allowed_trust_domains", spiffeTrustDomains, "List of allowed SPIFFE Trust Domains for client SVIDs (separated by comma).")
}

// SPIFFEAuthPlugin implements X.509-based SVID for SPIFFE authentication for grpc. It contains an array of trust domains
// that will be authorized to connect to the grpc server.
type SPIFFEAuthPlugin struct {
spiffeTrustDomains []string
}

// Authenticate implements Authenticator interface. This method will be used inside a middleware in grpc_server to authenticate
// incoming requests.
func (spa *SPIFFEAuthPlugin) Authenticate(ctx context.Context, fullMethod string) (context.Context, error) {
p, ok := peer.FromContext(ctx)
if !ok {
return nil, status.Errorf(codes.Unauthenticated, "no peer connection info")
}
tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo)
if !ok {
return nil, status.Errorf(codes.Unauthenticated, "not connected via TLS")
}

cert := tlsInfo.State.PeerCertificates[0] // Only check the leaf certificate
spiffeIdUrl, ok := validateSVIDCert(cert, spa.spiffeTrustDomains)
if !ok {
return nil, status.Errorf(codes.Unauthenticated, "client certificate not authorized")
}

log.Infof("SPIFFE auth plugin has authenticated client with SPIFFE ID %v", spiffeId)
return newSPIFFEAuthContext(ctx, spiffeIdUrl), nil
}

// Validates the given certificate as a valid SVID leaf certificate based on
// https://github.com/spiffe/spiffe/blob/main/standards/X509-SVID.md#4-constraints-and-usage
// and https://github.com/spiffe/spiffe/blob/main/standards/X509-SVID.md#5-validation
func validateSVIDCert(cert *x509.Certificate, trustedDomains []string) (*url.URL, bool) {
issuedForTrustedDomain := false

// Leaf SVIDs should have exactly one URI SAN
if len(cert.URIs) != 1 {
return nil, false
}

if cert.URIs[0].Scheme != "spiffe" {
return nil, false
}

for _, trustDomain := range trustedDomains {
if cert.URIs[0].Hostname() == trustDomain {
Copy link
Contributor

@mattrobenolt mattrobenolt Sep 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to clarify, this is explicitly only validating the hostnames obviously, but realistically, would you not want to also validate against the path as well?

Typically, this also can be paired with a (excuse me since I don't fully know the terms here), some SPIRE server to phone home to to ask "is this URI allowed to do this", similar to OAuth.

Without that full implementation, is there any merit into only validating the hostname rather than the full path?

I can see a case of:

spiffe://example.com/client1, and spiffe://example.com/client2 and wanting to allow client1, but not allow client2.

I'm not entirely sure of all the use cases, but I believe in a lot of contexts, this translates to a per-user issuing a certificate for auth, or per-app, and you'd want to restrict to some apps/users, and not the others.

I think explicitly declaring we only validate the hostname, but I would assume there'd be a desire to do more and the implementation here would make that a bit harder to shoehorn in.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I'm not an expert on this subject at all, but I tend to refer to Envoy for a golden standard with this configuration, and they seem to cover a lot more options as well as a SPIRE agent. https://spiffe.io/docs/latest/microservices/envoy-x509/readme/

If we're choosing to implement a subset of the feature set, I think that's personally fine, I just worry if we implement a subset, now others might want more.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mattrobenolt these are great questions/points. For SPIFFE, the entire URI forms the SPIFFE ID, which uniquely identifies a particular user/workload, so things like ACLs could be based on that ID. However, a collection of SPIFFE IDs can belong to a single trust domain (which usually corresponds to a CA, but doesn't necessarily have to). Given this, I think, for accepting connections at all, the thing we should validate is the trust domain, much like we're validating the CA that signed a client cert. This is mostly because we'd accept connections from many individual workloads and users all issued for the same trust domain.

The SPIFFE ID makes more sense to use in ACLs, where we might decide that certain SPIFFE IDs (perhaps some applications or users) should be considered writers, while other IDs are readers.

SPIRE is one implementation of the SPIFFE Workload API which makes it an issuer of SVIDs. It can attest to the identity of workloads and then provide them the tools to access protected resources and validate the IDs of other workloads. You could think of it as something like cert-manager + let's encrypt. SPIRE agents are used to create and automatically rotate workload certs and provide the CA bundle for verifying both sides of an mTLS connection. SPIRE is not used as an API to query information about other workloads. Further, SPIRE is not required to use SPIFFE as long as you have some issuer for SVIDs.

issuedForTrustedDomain = true
break
}
}

if !issuedForTrustedDomain {
return nil, false
}

// Leaf SVIDs should not be CA certs
if cert.IsCA {
return nil, false
}

// Leaf SVIDs should not have CA usage
if cert.KeyUsage&x509.KeyUsageCertSign != 0 {
return nil, false
}

// Leaf SVIDs should not have CRL signing usage
if cert.KeyUsage&x509.KeyUsageCRLSign != 0 {
return nil, false
}

// Leaf SVIDs must have Digital Signature usage
if cert.KeyUsage&x509.KeyUsageDigitalSignature == 0 {
return nil, false
}

return cert.URIs[0], true
}

func newSPIFFEAuthContext(ctx context.Context, foundSPIFFEId *url.URL) context.Context {
return context.WithValue(ctx, spiffeId, foundSPIFFEId)
}

func spiffeAuthPluginInitializer() (Authenticator, error) {
spiffeAuthPlugin := &SPIFFEAuthPlugin{
spiffeTrustDomains: strings.Split(spiffeTrustDomains, ","),
}
log.Infof("SPIFFE auth plugin has initialized successfully with allowed trust domains of %v", spiffeTrustDomains)
return spiffeAuthPlugin, nil
}

// SPIFFEIdFromContext returns the SPIFFE ID authenticated by the spiffe auth plugin and stored in the Context, if any
func SPIFFEIdFromContext(ctx context.Context) *url.URL {
spiffeId, ok := ctx.Value(spiffeId).(*url.URL)
if ok {
return spiffeId
}
return nil
}

// SPIFFETrustDomains returns the value of the
// `--grpc_auth_spiffe_allowed_trust_domains` flag.
func SPIFFETrustDomains() string {
return spiffeTrustDomains
}

func init() {
RegisterAuthPlugin("spiffe", spiffeAuthPluginInitializer)
grpcAuthServerFlagHooks = append(grpcAuthServerFlagHooks, registerGRPCServerAuthSPIFFEFlags)
}
Loading
Loading