Skip to content

Commit

Permalink
Encode Join Attributes in Bot Certificates (#49426)
Browse files Browse the repository at this point in the history
* Persist Join Attributes in X509 Cert

* Use proto names when encoding

* Fix kube tests

* Fix other kube tests

* Fix interface nilness issue

* Add some tests to the TLSCA package and issuer

* Add more E2E style test that covers join attributes and workload id

* Explain test better

* Add JoinAttrs test for bots

* Remove methods no longer necesarry

* Fix imports

* Fix deprecation version

* Add comment explaining why we return even on failure

* Add GoDoc

* Fix logger

* Use auth server logger

* Remove unneccessary import
  • Loading branch information
strideynet authored Jan 8, 2025
1 parent d5b6acc commit 84e9f20
Show file tree
Hide file tree
Showing 26 changed files with 931 additions and 252 deletions.
3 changes: 3 additions & 0 deletions api/gen/proto/go/teleport/machineid/v1/bot_instance.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions api/proto/teleport/machineid/v1/bot_instance.proto
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,16 @@ message BotInstanceStatusAuthentication {
// Server.
google.protobuf.Timestamp authenticated_at = 1;
// The join method used for this join or renewal.
// Deprecated: prefer using join_attrs.meta.join_method
string join_method = 2;
// The join token used for this join or renewal. This is only populated for
// delegated join methods as the value for `token` join methods is sensitive.
// Deprecated: prefer using join_attrs.meta.join_token_name
string join_token = 3;
// The metadata sourced from the join method.
// Deprecated: prefer using join_attrs.
google.protobuf.Struct metadata = 4;

// On each renewal, this generation is incremented. For delegated join
// methods, this counter is not checked during renewal. For the `token` join
// method, this counter is checked during renewal and the Bot is locked out if
Expand Down
7 changes: 6 additions & 1 deletion lib/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import (
headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1"
notificationsv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/notifications/v1"
workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
"github.com/gravitational/teleport/api/internalutils/stream"
"github.com/gravitational/teleport/api/metadata"
"github.com/gravitational/teleport/api/types"
Expand Down Expand Up @@ -2290,6 +2291,9 @@ type certRequest struct {
// botInstanceID is the unique identifier of the bot instance associated
// with this cert, if any
botInstanceID string
// joinAttributes holds attributes derived from attested metadata from the
// join process, should any exist.
joinAttributes *workloadidentityv1pb.JoinAttrs
}

// check verifies the cert request is valid.
Expand Down Expand Up @@ -3370,7 +3374,8 @@ func generateCert(ctx context.Context, a *Server, req certRequest, caType types.
AssetTag: req.deviceExtensions.AssetTag,
CredentialID: req.deviceExtensions.CredentialID,
},
UserType: req.user.GetUserType(),
UserType: req.user.GetUserType(),
JoinAttributes: req.joinAttributes,
}

var signedTLSCert []byte
Expand Down
3 changes: 3 additions & 0 deletions lib/auth/auth_with_roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -3453,6 +3453,9 @@ func (a *ServerWithRoles) generateUserCerts(ctx context.Context, req proto.UserC
// `updateBotInstance()` is called below, and this (empty) value will be
// overridden.
botInstanceID: a.context.Identity.GetIdentity().BotInstanceID,
// Propagate any join attributes from the current identity to the new
// identity.
joinAttributes: a.context.Identity.GetIdentity().JoinAttributes,
}

if user.GetName() != a.context.User.GetName() {
Expand Down
25 changes: 14 additions & 11 deletions lib/auth/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"github.com/gravitational/teleport/api/client/proto"
headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1"
workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
"github.com/gravitational/teleport/api/types"
apievents "github.com/gravitational/teleport/api/types/events"
apiutils "github.com/gravitational/teleport/api/utils"
Expand Down Expand Up @@ -315,7 +316,7 @@ func (a *Server) updateBotInstance(
if templateAuthRecord != nil {
authRecord.JoinToken = templateAuthRecord.JoinToken
authRecord.JoinMethod = templateAuthRecord.JoinMethod
authRecord.Metadata = templateAuthRecord.Metadata
authRecord.JoinAttrs = templateAuthRecord.JoinAttrs
}

// An empty bot instance most likely means a bot is rejoining after an
Expand Down Expand Up @@ -493,6 +494,7 @@ func (a *Server) generateInitialBotCerts(
expires time.Time, renewable bool,
initialAuth *machineidv1pb.BotInstanceStatusAuthentication,
existingInstanceID string, currentIdentityGeneration int32,
joinAttrs *workloadidentityv1pb.JoinAttrs,
) (*proto.Certs, string, error) {
var err error

Expand Down Expand Up @@ -535,16 +537,17 @@ func (a *Server) generateInitialBotCerts(

// Generate certificate
certReq := certRequest{
user: userState,
ttl: expires.Sub(a.GetClock().Now()),
sshPublicKey: sshPubKey,
tlsPublicKey: tlsPubKey,
checker: checker,
traits: accessInfo.Traits,
renewable: renewable,
includeHostCA: true,
loginIP: loginIP,
botName: botName,
user: userState,
ttl: expires.Sub(a.GetClock().Now()),
sshPublicKey: sshPubKey,
tlsPublicKey: tlsPubKey,
checker: checker,
traits: accessInfo.Traits,
renewable: renewable,
includeHostCA: true,
loginIP: loginIP,
botName: botName,
joinAttributes: joinAttrs,
}

if existingInstanceID == "" {
Expand Down
144 changes: 143 additions & 1 deletion lib/auth/bot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,20 @@ import (
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
"google.golang.org/grpc"
"google.golang.org/protobuf/testing/protocmp"

"github.com/gravitational/teleport"
apiclient "github.com/gravitational/teleport/api/client"
"github.com/gravitational/teleport/api/client/proto"
"github.com/gravitational/teleport/api/client/webclient"
headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1"
workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
"github.com/gravitational/teleport/api/metadata"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/api/utils/keys"
"github.com/gravitational/teleport/integrations/lib/testing/fakejoin"
"github.com/gravitational/teleport/lib/auth/authclient"
"github.com/gravitational/teleport/lib/auth/join"
"github.com/gravitational/teleport/lib/auth/machineid/machineidv1"
Expand Down Expand Up @@ -216,6 +219,146 @@ func TestRegisterBotCertificateGenerationCheck(t *testing.T) {
}
}

// TestBotJoinAttrs_Kubernetes validates that a bot can join using the
// Kubernetes join method and that the correct join attributes are encoded in
// the resulting bot cert, and, that when this cert is used to produce role
// certificates, the correct attributes are encoded in the role cert.
//
// Whilst this specifically tests the Kubernetes join method, it tests by proxy
// the implementation for most of the join methods.
func TestBotJoinAttrs_Kubernetes(t *testing.T) {
t.Parallel()

srv := newTestTLSServer(t)
ctx := context.Background()

role, err := CreateRole(ctx, srv.Auth(), "example", types.RoleSpecV6{})
require.NoError(t, err)

// Create a new bot.
client, err := srv.NewClient(TestAdmin())
require.NoError(t, err)
bot, err := client.BotServiceClient().CreateBot(ctx, &machineidv1pb.CreateBotRequest{
Bot: &machineidv1pb.Bot{
Metadata: &headerv1.Metadata{
Name: "test",
},
Spec: &machineidv1pb.BotSpec{
Roles: []string{"example"},
},
},
})
require.NoError(t, err)

k8s, err := fakejoin.NewKubernetesSigner(srv.Clock())
require.NoError(t, err)
jwks, err := k8s.GetMarshaledJWKS()
require.NoError(t, err)
fakePSAT, err := k8s.SignServiceAccountJWT(
"my-pod",
"my-namespace",
"my-service-account",
srv.ClusterName(),
)
require.NoError(t, err)

tok, err := types.NewProvisionTokenFromSpec(
"my-k8s-token",
time.Time{},
types.ProvisionTokenSpecV2{
Roles: types.SystemRoles{types.RoleBot},
JoinMethod: types.JoinMethodKubernetes,
BotName: bot.Metadata.Name,
Kubernetes: &types.ProvisionTokenSpecV2Kubernetes{
Type: types.KubernetesJoinTypeStaticJWKS,
StaticJWKS: &types.ProvisionTokenSpecV2Kubernetes_StaticJWKSConfig{
JWKS: jwks,
},
Allow: []*types.ProvisionTokenSpecV2Kubernetes_Rule{
{
ServiceAccount: "my-namespace:my-service-account",
},
},
},
},
)
require.NoError(t, err)
require.NoError(t, client.CreateToken(ctx, tok))

result, err := join.Register(ctx, join.RegisterParams{
Token: tok.GetName(),
JoinMethod: types.JoinMethodKubernetes,
ID: state.IdentityID{
Role: types.RoleBot,
},
AuthServers: []utils.NetAddr{*utils.MustParseAddr(srv.Addr().String())},
KubernetesReadFileFunc: func(name string) ([]byte, error) {
return []byte(fakePSAT), nil
},
})
require.NoError(t, err)

// Validate correct join attributes are encoded.
cert, err := tlsca.ParseCertificatePEM(result.Certs.TLS)
require.NoError(t, err)
ident, err := tlsca.FromSubject(cert.Subject, cert.NotAfter)
require.NoError(t, err)
wantAttrs := &workloadidentityv1pb.JoinAttrs{
Meta: &workloadidentityv1pb.JoinAttrsMeta{
JoinTokenName: tok.GetName(),
JoinMethod: string(types.JoinMethodKubernetes),
},
Kubernetes: &workloadidentityv1pb.JoinAttrsKubernetes{
ServiceAccount: &workloadidentityv1pb.JoinAttrsKubernetesServiceAccount{
Namespace: "my-namespace",
Name: "my-service-account",
},
Pod: &workloadidentityv1pb.JoinAttrsKubernetesPod{
Name: "my-pod",
},
Subject: "system:serviceaccount:my-namespace:my-service-account",
},
}
require.Empty(t, cmp.Diff(
ident.JoinAttributes,
wantAttrs,
protocmp.Transform(),
))

// Now, try to produce a role certificate using the bot cert, to ensure
// that the join attributes are correctly propagated.
privateKeyPEM, err := keys.MarshalPrivateKey(result.PrivateKey)
require.NoError(t, err)
tlsCert, err := tls.X509KeyPair(result.Certs.TLS, privateKeyPEM)
require.NoError(t, err)
sshPub, err := ssh.NewPublicKey(result.PrivateKey.Public())
require.NoError(t, err)
tlsPub, err := keys.MarshalPublicKey(result.PrivateKey.Public())
require.NoError(t, err)
botClient := srv.NewClientWithCert(tlsCert)
roleCerts, err := botClient.GenerateUserCerts(ctx, proto.UserCertsRequest{
SSHPublicKey: ssh.MarshalAuthorizedKey(sshPub),
TLSPublicKey: tlsPub,
Username: bot.Status.UserName,
RoleRequests: []string{
role.GetName(),
},
UseRoleRequests: true,
Expires: srv.Clock().Now().Add(time.Hour),
})
require.NoError(t, err)

roleCert, err := tlsca.ParseCertificatePEM(roleCerts.TLS)
require.NoError(t, err)
roleIdent, err := tlsca.FromSubject(roleCert.Subject, roleCert.NotAfter)
require.NoError(t, err)
require.Empty(t, cmp.Diff(
roleIdent.JoinAttributes,
wantAttrs,
protocmp.Transform(),
))
}

// TestRegisterBotInstance tests that bot instances are created properly on join
func TestRegisterBotInstance(t *testing.T) {
t.Parallel()
Expand Down Expand Up @@ -282,7 +425,6 @@ func TestRegisterBotInstance(t *testing.T) {
require.Equal(t, int32(1), ia.Generation)
require.Equal(t, string(types.JoinMethodToken), ia.JoinMethod)
require.Equal(t, token.GetSafeName(), ia.JoinToken)

// The latest authentications field should contain the same record (and
// only that record.)
require.Len(t, botInstance.GetStatus().LatestAuthentications, 1)
Expand Down
Loading

0 comments on commit 84e9f20

Please sign in to comment.