From 62f1dbebfbd2ef3e03d8dcbb5c1b824e4aa085bd Mon Sep 17 00:00:00 2001 From: James Rasell Date: Wed, 23 Jul 2025 15:32:26 +0200 Subject: [PATCH] server: Add RPC and HTTP functionality for node intro token gen. (#26320) The node introduction workflow will utilise JWT's that can be used as authentication tokens on initial client registration. This change implements the basic builder for this JWT claim type and the RPC and HTTP handler functionality that will expose this to the operator. --- command/agent/acl_endpoint.go | 28 ++++++ command/agent/acl_endpoint_test.go | 137 ++++++++++++++++++++++++++ command/agent/http.go | 1 + nomad/acl_endpoint.go | 54 +++++++++++ nomad/acl_endpoint_test.go | 149 +++++++++++++++++++++++++++++ nomad/structs/acl.go | 73 ++++++++++++++ nomad/structs/acl_test.go | 86 +++++++++++++++++ nomad/structs/identity.go | 82 +++++++++++++++- nomad/structs/identity_test.go | 104 +++++++++++++++++++- nomad/structs/node.go | 36 +++++++ nomad/structs/node_test.go | 17 ++++ 11 files changed, 759 insertions(+), 8 deletions(-) diff --git a/command/agent/acl_endpoint.go b/command/agent/acl_endpoint.go index baa793612..43ed5b67b 100644 --- a/command/agent/acl_endpoint.go +++ b/command/agent/acl_endpoint.go @@ -945,3 +945,31 @@ func (s *HTTPServer) ACLLoginRequest(resp http.ResponseWriter, req *http.Request setIndex(resp, out.Index) return out.ACLToken, nil } + +func (s *HTTPServer) ACLCreateClientIntroductionTokenRequest( + _ http.ResponseWriter, + req *http.Request) (any, error) { + + // The endpoint only supports PUT or POST requests. + if req.Method != http.MethodPost && req.Method != http.MethodPut { + return nil, CodedError(http.StatusMethodNotAllowed, ErrInvalidMethod) + } + + args := structs.ACLCreateClientIntroductionTokenRequest{} + + // Perform the parsing of the write request. The parameters can be passed + // using just headers, or within the request body. + s.parseWriteRequest(req, &args.WriteRequest) + + if req.Body != nil { + if err := decodeBody(req, &args); err != nil { + return nil, CodedError(http.StatusBadRequest, err.Error()) + } + } + + var out structs.ACLCreateClientIntroductionTokenResponse + if err := s.agent.RPC(structs.ACLCreateClientIntroductionTokenRPCMethod, &args, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/command/agent/acl_endpoint_test.go b/command/agent/acl_endpoint_test.go index 4e4f764ff..750690191 100644 --- a/command/agent/acl_endpoint_test.go +++ b/command/agent/acl_endpoint_test.go @@ -2126,3 +2126,140 @@ func requestAuthState(t *testing.T, server *HTTPServer, authMethod *structs.ACLA must.NoError(t, err) return u.Query().Get("state") } + +func TestHTTPServer_ACLClientIntroductionTokenRequest(t *testing.T) { + ci.Parallel(t) + + testCases := []struct { + name string + testFn func(srv *TestAgent) + }{ + { + name: "incorrect method", + testFn: func(testAgent *TestAgent) { + + // Build the HTTP request. + req, err := http.NewRequest( + http.MethodConnect, + "/v1/acl/identity/client-introduction-token", + nil, + ) + must.NoError(t, err) + respW := httptest.NewRecorder() + + // Send the HTTP request. + obj, err := testAgent.Server.ACLCreateClientIntroductionTokenRequest(respW, req) + must.Error(t, err) + must.ErrorContains(t, err, ErrInvalidMethod) + must.Nil(t, obj) + }, + }, + { + name: "incorrect permissions", + testFn: func(testAgent *TestAgent) { + + // Build the HTTP request. + req, err := http.NewRequest( + http.MethodPost, + "/v1/acl/identity/client-introduction-token", + nil, + ) + must.NoError(t, err) + + respW := httptest.NewRecorder() + + // Send the HTTP request. + obj, err := testAgent.Server.ACLCreateClientIntroductionTokenRequest(respW, req) + must.Error(t, err) + must.ErrorContains(t, err, "Permission denied") + must.Nil(t, obj) + }, + }, + { + name: "valid request with body", + testFn: func(testAgent *TestAgent) { + + nodeWriteToken := mock.CreatePolicyAndToken( + t, + testAgent.Agent.Server().State(), + 10, + fmt.Sprintf("policy-%s-%s", t.Name(), uuid.Generate()), + `node{policy = "write"}`, + ) + + requestBody := structs.ACLCreateClientIntroductionTokenRequest{ + WriteRequest: structs.WriteRequest{ + Region: testAgent.config().Region, + AuthToken: nodeWriteToken.SecretID, + }, + } + + // Build the HTTP request. + req, err := http.NewRequest( + http.MethodPost, + "/v1/acl/identity/client-introduction-token", + encodeReq(&requestBody), + ) + must.NoError(t, err) + + respW := httptest.NewRecorder() + + // Send the HTTP request. + obj, err := testAgent.Server.ACLCreateClientIntroductionTokenRequest(respW, req) + must.NoError(t, err) + must.NotNil(t, obj) + + // We do not have access to the encrypter, so we cannot verify + // the JWT content. Tests in the RPC layer cover this. + nodeIntroTokenResp, ok := obj.(*structs.ACLCreateClientIntroductionTokenResponse) + must.True(t, ok) + must.NotNil(t, nodeIntroTokenResp) + must.NotEq(t, "", nodeIntroTokenResp.JWT) + }, + }, + { + name: "valid request with headers", + testFn: func(testAgent *TestAgent) { + + nodeWriteToken := mock.CreatePolicyAndToken( + t, + testAgent.Agent.Server().State(), + 10, + fmt.Sprintf("policy-%s-%s", t.Name(), uuid.Generate()), + `node{policy = "write"}`, + ) + + // Build the HTTP request. + req, err := http.NewRequest( + http.MethodPost, + "/v1/acl/identity/client-introduction-token", + nil, + ) + must.NoError(t, err) + + req.Header.Add("X-Nomad-Token", nodeWriteToken.SecretID) + req.Header.Add("X-Nomad-Region", testAgent.config().Region) + + respW := httptest.NewRecorder() + + // Send the HTTP request. + obj, err := testAgent.Server.ACLCreateClientIntroductionTokenRequest(respW, req) + must.NoError(t, err) + must.NotNil(t, obj) + + // We do not have access to the encrypter, so we cannot verify + // the JWT content. Tests in the RPC layer cover this. + nodeIntroTokenResp, ok := obj.(*structs.ACLCreateClientIntroductionTokenResponse) + must.True(t, ok) + must.NotNil(t, nodeIntroTokenResp) + must.NotEq(t, "", nodeIntroTokenResp.JWT) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + httpACLTest(t, nil, tc.testFn) + }) + } +} diff --git a/command/agent/http.go b/command/agent/http.go index 67e88fa06..a8ef27963 100644 --- a/command/agent/http.go +++ b/command/agent/http.go @@ -444,6 +444,7 @@ func (s *HTTPServer) registerHandlers(enableDebug bool) { s.mux.HandleFunc("/v1/acl/oidc/auth-url", s.wrap(s.ACLOIDCAuthURLRequest)) s.mux.HandleFunc("/v1/acl/oidc/complete-auth", s.wrap(s.ACLOIDCCompleteAuthRequest)) s.mux.HandleFunc("/v1/acl/login", s.wrap(s.ACLLoginRequest)) + s.mux.HandleFunc("/v1/acl/identity/client-introduction-token", s.wrap(s.ACLCreateClientIntroductionTokenRequest)) s.mux.Handle("/v1/client/fs/", wrapCORS(s.wrap(s.FsRequest))) s.mux.HandleFunc("/v1/client/gc", s.wrap(s.ClientGCRequest)) diff --git a/nomad/acl_endpoint.go b/nomad/acl_endpoint.go index af5478834..5298acdc9 100644 --- a/nomad/acl_endpoint.go +++ b/nomad/acl_endpoint.go @@ -3122,3 +3122,57 @@ func (a *ACL) oidcClientAssertion(config *structs.ACLAuthMethodConfig) (*cass.JW } return j, nil } + +func (a *ACL) CreateClientIntroductionToken( + args *structs.ACLCreateClientIntroductionTokenRequest, + reply *structs.ACLCreateClientIntroductionTokenResponse) error { + + authErr := a.srv.Authenticate(a.ctx, args) + + if done, err := a.srv.forward(structs.ACLCreateClientIntroductionTokenRPCMethod, args, args, reply); done { + return err + } + a.srv.MeasureRPCRate("acl", structs.RateMetricWrite, args) + if authErr != nil { + return structs.ErrPermissionDenied + } + defer metrics.MeasureSince([]string{ + "nomad", "acl", "create_node_introduction_identity"}, time.Now()) + + // Unlike the other ACL RPCs, this accepts node write permissions rather + // than management. This allows cluster administrators to delegate node + // introduction identity operations to other users who can bring their own + // nodes to join the cluster. + if aclObj, err := a.srv.ResolveACL(args); err != nil { + return err + } else if !aclObj.AllowNodeWrite() { + return structs.ErrPermissionDenied + } + + // Ensure the request is canonicalized, so we have a consistent experience + // on requests that use the CLI or HTTP API directly. + args.Canonicalize() + + // Generate the node introduction identity TTL based on the server config + // and any possible user provided TTL. + identityTTL := args.IdentityTTL( + a.logger, + a.srv.config.NodeIntroductionConfig.DefaultIdentityTTL, + a.srv.config.NodeIntroductionConfig.MaxIdentityTTL, + ) + + introIdentity := structs.GenerateNodeIntroductionIdentityClaims( + args.NodeName, + args.NodePool, + args.Region, + identityTTL, + ) + + signedIdentity, _, err := a.srv.encrypter.SignClaims(introIdentity) + if err != nil { + return fmt.Errorf("failed to sign node introduction identity claims: %w", err) + } + + reply.JWT = signedIdentity + return nil +} diff --git a/nomad/acl_endpoint_test.go b/nomad/acl_endpoint_test.go index 13a5b5896..941e4187c 100644 --- a/nomad/acl_endpoint_test.go +++ b/nomad/acl_endpoint_test.go @@ -4220,3 +4220,152 @@ func cacheOIDCRequest(t *testing.T, cache *oidc.RequestCache, req structs.ACLOID cache.LoadAndDelete(req.ClientNonce) must.NoError(t, cache.Store(oidcReq)) } + +func TestACL_ClientIntroductionToken(t *testing.T) { + ci.Parallel(t) + + // Set up a test ACL server with a keyring and encrypter that are ready for + // use. + testACLServer, _, testACLServerCleanupFn := TestACLServer(t, nil) + t.Cleanup(testACLServerCleanupFn) + testutil.WaitForKeyring(t, testACLServer.RPC, testACLServer.Region()) + + aclCodec := rpcClient(t, testACLServer) + + // Perform a test without setting an auth token, so that the RPC uses the + // anonymous token. This should fail with a permission denied error. + t.Run("acl_server_anonymous", func(t *testing.T) { + anonymousReq := structs.ACLCreateClientIntroductionTokenRequest{ + WriteRequest: structs.WriteRequest{ + Region: testACLServer.Region(), + }, + } + + must.EqError(t, msgpackrpc.CallWithCodec( + aclCodec, + structs.ACLCreateClientIntroductionTokenRPCMethod, + &anonymousReq, + &structs.ACLCreateClientIntroductionTokenResponse{}, + ), structs.ErrPermissionDenied.Error()) + + }) + + // Perform a test with token that only has node read permissions. This + // should fail with a permission denied error. + t.Run("acl_server_node_read", func(t *testing.T) { + nodeReadToken := mock.CreatePolicyAndToken( + t, + testACLServer.fsm.State(), + testACLServer.raft.LastIndex(), + fmt.Sprintf("policy-%s-%s", t.Name(), uuid.Generate()), + `node{policy = "read"}`, + ) + + nodeReadReq := structs.ACLCreateClientIntroductionTokenRequest{ + WriteRequest: structs.WriteRequest{ + AuthToken: nodeReadToken.SecretID, + Region: testACLServer.Region(), + }, + } + + must.EqError(t, msgpackrpc.CallWithCodec( + aclCodec, + structs.ACLCreateClientIntroductionTokenRPCMethod, + &nodeReadReq, + &structs.ACLCreateClientIntroductionTokenResponse{}, + ), structs.ErrPermissionDenied.Error()) + }) + + // Perform a test with token that has node write permissions. This should + // succeed and return a valid JWT that matches the requested claims. + t.Run("acl_server_node_write", func(t *testing.T) { + nodeWriteToken := mock.CreatePolicyAndToken( + t, + testACLServer.fsm.State(), + testACLServer.raft.LastIndex(), + fmt.Sprintf("policy-%s-%s", t.Name(), uuid.Generate()), + `node{policy = "write"}`, + ) + + nodeWriteReq := structs.ACLCreateClientIntroductionTokenRequest{ + NodeName: "test-node", + NodePool: "test-pool", + TTL: 15 * time.Minute, + WriteRequest: structs.WriteRequest{ + AuthToken: nodeWriteToken.SecretID, + Region: testACLServer.Region(), + }, + } + + timeNow := time.Now() + nodeWriteResp := structs.ACLCreateClientIntroductionTokenResponse{} + + must.NoError(t, msgpackrpc.CallWithCodec( + aclCodec, + structs.ACLCreateClientIntroductionTokenRPCMethod, + &nodeWriteReq, + &nodeWriteResp, + )) + must.NotEq(t, "", nodeWriteResp.JWT) + + nodeWriteClaims, err := testACLServer.encrypter.VerifyClaim(nodeWriteResp.JWT) + must.NoError(t, err) + must.True(t, nodeWriteClaims.IsNodeIntroduction()) + must.Eq(t, nodeWriteReq.NodeName, nodeWriteClaims.NodeIntroductionIdentityClaims.NodeName) + must.Eq(t, nodeWriteReq.NodePool, nodeWriteClaims.NodeIntroductionIdentityClaims.NodePool) + must.Eq(t, nodeWriteReq.Region, nodeWriteClaims.NodeIntroductionIdentityClaims.NodeRegion) + + // The JWT creation happens asynchronously in the RPC handler, so we + // need to verify the TTL is set using a bound check. + nodeWriteExpiry := nodeWriteClaims.Expiry.Time() + must.True(t, nodeWriteExpiry.Before(timeNow.Add(nodeWriteReq.TTL))) + must.True(t, nodeWriteExpiry.After(timeNow.Add(nodeWriteReq.TTL).Add(-10*time.Second))) + }) + + // Set up a test server without ACLs with a keyring and encrypter that are + // ready for use. + testServer, testServerCleanupFn := TestServer(t, nil) + t.Cleanup(testServerCleanupFn) + testutil.WaitForKeyring(t, testServer.RPC, testServer.Region()) + + codec := rpcClient(t, testServer) + + // Perform a test without setting an auth token on a server not running + // ACLs. This should succeed and return a valid JWT that matches the + // requested claims. + t.Run("non_acl_server", func(t *testing.T) { + + req := structs.ACLCreateClientIntroductionTokenRequest{ + NodeName: "test-node", + NodePool: "test-pool", + TTL: 15 * time.Minute, + WriteRequest: structs.WriteRequest{ + Region: testServer.Region(), + }, + } + + timeNow := time.Now() + resp := structs.ACLCreateClientIntroductionTokenResponse{} + + must.NoError(t, msgpackrpc.CallWithCodec( + codec, + structs.ACLCreateClientIntroductionTokenRPCMethod, + &req, + &resp, + )) + must.NotEq(t, "", resp.JWT) + + nodeWriteClaims, err := testServer.encrypter.VerifyClaim(resp.JWT) + must.NoError(t, err) + must.True(t, nodeWriteClaims.IsNodeIntroduction()) + must.Eq(t, req.NodeName, nodeWriteClaims.NodeIntroductionIdentityClaims.NodeName) + must.Eq(t, req.NodePool, nodeWriteClaims.NodeIntroductionIdentityClaims.NodePool) + must.Eq(t, req.Region, nodeWriteClaims.NodeIntroductionIdentityClaims.NodeRegion) + + // The JWT creation happens asynchronously in the RPC handler, so we + // need to verify the TTL is set using a bound check. + nodeWriteExpiry := nodeWriteClaims.Expiry.Time() + must.True(t, nodeWriteExpiry.Before(timeNow.Add(req.TTL))) + must.True(t, nodeWriteExpiry.After(timeNow.Add(req.TTL).Add(-10*time.Second))) + }) +} diff --git a/nomad/structs/acl.go b/nomad/structs/acl.go index 4c603d791..f62e7e116 100644 --- a/nomad/structs/acl.go +++ b/nomad/structs/acl.go @@ -16,6 +16,7 @@ import ( "time" "github.com/hashicorp/go-bexpr" + "github.com/hashicorp/go-hclog" "github.com/hashicorp/go-multierror" "github.com/hashicorp/go-set/v3" lru "github.com/hashicorp/golang-lru/v2" @@ -182,6 +183,15 @@ const ( // Args: ACLLoginRequest // Reply: ACLLoginResponse ACLLoginRPCMethod = "ACL.Login" + + // ACLCreateClientIntroductionTokenRPCMethod is the RPC method for + // generating a client introduction token. This token is used by Nomad + // clients as an authentication token when first registering with the + // cluster. + // + // Args: ACLCreateClientIntroductionTokenRequest + // Reply: ACLCreateClientIntroductionTokenResponse + ACLCreateClientIntroductionTokenRPCMethod = "ACL.CreateClientIntroductionToken" ) const ( @@ -1980,3 +1990,66 @@ func (a *ACLLoginRequest) Validate() error { } return mErr.ErrorOrNil() } + +// ACLCreateClientIntroductionTokenRequest is the request object used within the ACL +// client introduction RPC handler. This is used to generate a JWT token that +// can be used to register a new client node into the cluster. +type ACLCreateClientIntroductionTokenRequest struct { + + // TTL is the requested TTL for the identity token. This is an optional + // parameter and if not set, defaults to the server defined default TTL. + TTL time.Duration + + // NodeName is the name of the node that is being introduced. This is added + // to the token as a claim when present, but is optional. + NodeName string + + // NodePool is the name of the node pool that this node belongs to. This is + // an optional parameter, and if not set, defaults to "default". + NodePool string + + WriteRequest +} + +// Canonicalize performs basic canonicalization on the ACL client introduction +// request object. This should be called within the RPC handler, to ensure a +// consistent experience for the user across CLI and HTTP API calls. +func (a *ACLCreateClientIntroductionTokenRequest) Canonicalize() { + if a.NodePool == "" { + a.NodePool = NodePoolDefault + } +} + +// IdentityTTL returns the TTL that should be used for the identity token based +// on the request and server defaults. +func (a *ACLCreateClientIntroductionTokenRequest) IdentityTTL( + logger hclog.Logger, + serverDefault, serverMax time.Duration) time.Duration { + + // If the user has not provided a TTL, we use the server default. + if a.TTL == 0 { + return serverDefault + } + + // If the user has requested a TTL that is greater than the server defined + // maximum, we log a warning and use the server maximum instead. It is + // possible to return an error here, but providing a ceiling provides a + // smoother UX. + if a.TTL > serverMax { + logger.Warn( + "node introduction identity TTL request exceeds server maximum, using server maximum", + "requested_ttl", a.TTL, "server_max_ttl", serverMax, + ) + return serverMax + } + return a.TTL +} + +// ACLCreateClientIntroductionTokenResponse is the response object used within the ACL +// client introduction RPC handler. +type ACLCreateClientIntroductionTokenResponse struct { + + // JWT is the signed identity token that can be used as an introduction + // token for a new client node to register with the Nomad cluster. + JWT string +} diff --git a/nomad/structs/acl_test.go b/nomad/structs/acl_test.go index 179f2ad3c..b8ec5ca21 100644 --- a/nomad/structs/acl_test.go +++ b/nomad/structs/acl_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/hashicorp/go-hclog" "github.com/hashicorp/go-multierror" "github.com/hashicorp/nomad/ci" "github.com/hashicorp/nomad/helper/pointer" @@ -2155,3 +2156,88 @@ func validClientAssertion() *OIDCClientAssertion { ClientSecret: "test-client-secret", } } + +func TestACLClientIntroductionTokenRequest_Canonicalize(t *testing.T) { + ci.Parallel(t) + + testCases := []struct { + name string + inputClientIntroductionTokenReq *ACLCreateClientIntroductionTokenRequest + expectedResult *ACLCreateClientIntroductionTokenRequest + }{ + { + name: "empty node pool", + inputClientIntroductionTokenReq: &ACLCreateClientIntroductionTokenRequest{ + NodePool: "", + }, + expectedResult: &ACLCreateClientIntroductionTokenRequest{ + NodePool: "default", + }, + }, + { + name: "node pool set", + inputClientIntroductionTokenReq: &ACLCreateClientIntroductionTokenRequest{ + NodePool: "custom-pool", + }, + expectedResult: &ACLCreateClientIntroductionTokenRequest{ + NodePool: "custom-pool", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tc.inputClientIntroductionTokenReq.Canonicalize() + must.Eq(t, tc.expectedResult, tc.inputClientIntroductionTokenReq) + }) + } +} + +func TestACLClientIntroductionTokenRequest_IdentityTTL(t *testing.T) { + ci.Parallel(t) + + testCases := []struct { + name string + inputClientIntroductionTokenReq *ACLCreateClientIntroductionTokenRequest + inputDefault time.Duration + inputMax time.Duration + expectedOutput time.Duration + }{ + { + name: "no ttl set", + inputClientIntroductionTokenReq: &ACLCreateClientIntroductionTokenRequest{}, + inputDefault: 5 * time.Minute, + inputMax: 30 * time.Minute, + expectedOutput: 5 * time.Minute, + }, + { + name: "ttl set in bounds", + inputClientIntroductionTokenReq: &ACLCreateClientIntroductionTokenRequest{ + TTL: 25 * time.Minute, + }, + inputDefault: 5 * time.Minute, + inputMax: 30 * time.Minute, + expectedOutput: 25 * time.Minute, + }, + { + name: "ttl set exceeds bounds", + inputClientIntroductionTokenReq: &ACLCreateClientIntroductionTokenRequest{ + TTL: 35 * time.Minute, + }, + inputDefault: 5 * time.Minute, + inputMax: 30 * time.Minute, + expectedOutput: 30 * time.Minute, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actualOutput := tc.inputClientIntroductionTokenReq.IdentityTTL( + hclog.NewNullLogger(), + tc.inputDefault, + tc.inputMax, + ) + must.Eq(t, tc.expectedOutput, actualOutput) + }) + } +} diff --git a/nomad/structs/identity.go b/nomad/structs/identity.go index 4c17a9b03..6722ad360 100644 --- a/nomad/structs/identity.go +++ b/nomad/structs/identity.go @@ -4,6 +4,7 @@ package structs import ( + "encoding/json" "strings" "time" @@ -15,14 +16,19 @@ import ( const IdentityDefaultAud = "nomadproject.io" // IdentityClaims is an envelope for a Nomad identity JWT that can be either a -// node identity or a workload identity. It contains the specific claims for the -// identity type, as well as the common JWT claims. +// node identity, node introduction identity, or a workload identity. It +// contains the specific claims for the identity type, as well as the common JWT +// claims. type IdentityClaims struct { - // *NodeIdentityClaims contains the claims specific to a node identity. + // NodeIdentityClaims contains the claims specific to a node identity. *NodeIdentityClaims - // *WorkloadIdentityClaims contains the claims specific to a workload as + // NodeIntroductionIdentityClaims contains the claims specific to a node + // introduction identity. + *NodeIntroductionIdentityClaims + + // WorkloadIdentityClaims contains the claims specific to a workload as // defined by an allocation running on a client. *WorkloadIdentityClaims @@ -31,9 +37,60 @@ type IdentityClaims struct { jwt.Claims } +// MarshalJSON is a custom JSON marshaler that specifically handles the node +// pool field which exists within the node identity and node introduction +// embedded objects. +func (i *IdentityClaims) MarshalJSON() ([]byte, error) { + type Alias IdentityClaims + exported := &struct { + NomadNodePool string `json:"nomad_node_pool,omitempty"` + *Alias + }{ + Alias: (*Alias)(i), + } + if i.IsNodeIntroduction() { + exported.NomadNodePool = i.NodeIntroductionIdentityClaims.NodePool + } else if i.IsNode() { + exported.NomadNodePool = i.NodeIdentityClaims.NodePool + } + return json.Marshal(exported) +} + +// UnmarshalJSON is a custom JSON unmarshaler that specifically handles the node +// pool field which exists within the node identity and node introduction +// embedded objects. +func (i *IdentityClaims) UnmarshalJSON(data []byte) (err error) { + type Alias IdentityClaims + aux := &struct { + NomadNodePool string `json:"nomad_node_pool,omitempty"` + *Alias + }{ + Alias: (*Alias)(i), + } + if err = json.Unmarshal(data, &aux); err != nil { + return err + } + + if i.IsNodeIntroduction() { + i.NodeIntroductionIdentityClaims.NodePool = aux.NomadNodePool + aux.NomadNodePool = "" + } else if i.IsNode() { + i.NodeIdentityClaims.NodePool = aux.NomadNodePool + aux.NomadNodePool = "" + } + + return nil +} + // IsNode checks if the identity JWT is a node identity. func (i *IdentityClaims) IsNode() bool { return i != nil && i.NodeIdentityClaims != nil } +// IsNodeIntroduction checks if the identity JWT is a node introduction +// identity. +func (i *IdentityClaims) IsNodeIntroduction() bool { + return i != nil && i.NodeIntroductionIdentityClaims != nil +} + // IsWorkload checks if the identity JWT is a workload identity. func (i *IdentityClaims) IsWorkload() bool { return i != nil && i.WorkloadIdentityClaims != nil } @@ -93,6 +150,23 @@ func (i *IdentityClaims) setNodeSubject(node *Node, region string) { }, ":") } +// setNodeSubject sets the "subject" or "sub" claim for the node introduction +// identity JWT. It follows the format +// "node-introduction::::default", where "default" +// indicates identity name. While this is currently hardcoded, it could be +// configurable in the future as we expand the node identity offering and allow +// greater control of node access.If the operator does not provide a node name, +// this is omitted from the subject. +func (i *IdentityClaims) setNodeIntroductionSubject(name, pool, region string) { + i.Subject = strings.Join([]string{ + "node-introduction", + region, + pool, + name, + "default", + }, ":") +} + // setWorkloadSubject sets the "subject" or "sub" claim for the workload // identity JWT. It follows the format // ":::::". The diff --git a/nomad/structs/identity_test.go b/nomad/structs/identity_test.go index 8e09a7061..5de1e62c5 100644 --- a/nomad/structs/identity_test.go +++ b/nomad/structs/identity_test.go @@ -44,6 +44,13 @@ func TestIdentityClaims_IsNode(t *testing.T) { }, expectedOutput: true, }, + { + name: "node introduction identity claims", + inputIdentityClaims: &IdentityClaims{ + NodeIntroductionIdentityClaims: &NodeIntroductionIdentityClaims{}, + }, + expectedOutput: false, + }, } for _, tc := range testCases { @@ -54,6 +61,55 @@ func TestIdentityClaims_IsNode(t *testing.T) { } } +func TestIdentityClaims_IsNodeIntroduction(t *testing.T) { + ci.Parallel(t) + + testCases := []struct { + name string + inputIdentityClaims *IdentityClaims + expectedOutput bool + }{ + { + name: "nil identity claims", + inputIdentityClaims: nil, + expectedOutput: false, + }, + { + name: "no identity claims", + inputIdentityClaims: &IdentityClaims{}, + expectedOutput: false, + }, + { + name: "workload identity claims", + inputIdentityClaims: &IdentityClaims{ + WorkloadIdentityClaims: &WorkloadIdentityClaims{}, + }, + expectedOutput: false, + }, + { + name: "node identity claims", + inputIdentityClaims: &IdentityClaims{ + NodeIdentityClaims: &NodeIdentityClaims{}, + }, + expectedOutput: false, + }, + { + name: "node introduction identity claims", + inputIdentityClaims: &IdentityClaims{ + NodeIntroductionIdentityClaims: &NodeIntroductionIdentityClaims{}, + }, + expectedOutput: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actualOutput := tc.inputIdentityClaims.IsNodeIntroduction() + must.Eq(t, tc.expectedOutput, actualOutput) + }) + } +} + func TestIdentityClaims_IsWorkload(t *testing.T) { ci.Parallel(t) @@ -86,6 +142,13 @@ func TestIdentityClaims_IsWorkload(t *testing.T) { }, expectedOutput: true, }, + { + name: "node introduction identity claims", + inputIdentityClaims: &IdentityClaims{ + NodeIntroductionIdentityClaims: &NodeIntroductionIdentityClaims{}, + }, + expectedOutput: false, + }, } for _, tc := range testCases { @@ -225,7 +288,7 @@ func TestIdentityClaims_IsExpiringWithTTL(t *testing.T) { } } -func TestIdentityClaimsNg_setExpiry(t *testing.T) { +func TestIdentityClaims_setExpiry(t *testing.T) { ci.Parallel(t) timeNow := time.Now().UTC() @@ -242,7 +305,7 @@ func TestIdentityClaimsNg_setExpiry(t *testing.T) { claims.Expiry.Time().UTC().Round(time.Minute)) } -func TestIdentityClaimsNg_setNodeSubject(t *testing.T) { +func TestIdentityClaims_setNodeSubject(t *testing.T) { ci.Parallel(t) testCases := []struct { @@ -273,11 +336,44 @@ func TestIdentityClaimsNg_setNodeSubject(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - ci.Parallel(t) - claims := IdentityClaims{} claims.setNodeSubject(tc.inputNode, tc.inputRegion) must.Eq(t, tc.expectedSubject, claims.Subject) }) } } + +func TestIdentityClaims_setNodeIntroductionSubject(t *testing.T) { + ci.Parallel(t) + + testCases := []struct { + name string + inputName string + inputPool string + inputRegion string + expectedSubject string + }{ + { + name: "eu1 region with node name", + inputName: "node-id-1", + inputPool: "nlp", + inputRegion: "eu1", + expectedSubject: "node-introduction:eu1:nlp:node-id-1:default", + }, + { + name: "eu1 region without node name", + inputName: "", + inputPool: "nlp", + inputRegion: "eu1", + expectedSubject: "node-introduction:eu1:nlp::default", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + claims := IdentityClaims{} + claims.setNodeIntroductionSubject(tc.inputName, tc.inputPool, tc.inputRegion) + must.Eq(t, tc.expectedSubject, claims.Subject) + }) + } +} diff --git a/nomad/structs/node.go b/nomad/structs/node.go index c46126650..a4f0156e0 100644 --- a/nomad/structs/node.go +++ b/nomad/structs/node.go @@ -832,3 +832,39 @@ func (n *NodeIntroductionConfig) Validate() error { return mErr.ErrorOrNil() } + +// NodeIntroductionIdentityClaims contains the claims for node introduction. +type NodeIntroductionIdentityClaims struct { + NodeRegion string `json:"nomad_region"` + NodePool string `json:"nomad_node_pool"` + NodeName string `json:"nomad_node_name"` +} + +// GenerateNodeIntroductionIdentityClaims generates a new identity JWT for node +// introduction. +// +// The caller is responsible for ensuring that the passed arguments are valid. +func GenerateNodeIntroductionIdentityClaims(name, pool, region string, ttl time.Duration) *IdentityClaims { + + timeNow := time.Now().UTC() + timeJWTNow := jwt.NewNumericDate(timeNow) + + claims := &IdentityClaims{ + NodeIntroductionIdentityClaims: &NodeIntroductionIdentityClaims{ + NodeRegion: region, + NodePool: pool, + NodeName: name, + }, + Claims: jwt.Claims{ + ID: uuid.Generate(), + IssuedAt: timeJWTNow, + NotBefore: timeJWTNow, + }, + } + + claims.setAudience([]string{IdentityDefaultAud}) + claims.setExpiry(timeNow, ttl) + claims.setNodeIntroductionSubject(name, pool, region) + + return claims +} diff --git a/nomad/structs/node_test.go b/nomad/structs/node_test.go index 936c5dce9..010005d74 100644 --- a/nomad/structs/node_test.go +++ b/nomad/structs/node_test.go @@ -750,3 +750,20 @@ func TestNodeIntroductionConfig_Validate(t *testing.T) { }) } } + +func TestGenerateNodeIntroductionIdentityClaims(t *testing.T) { + ci.Parallel(t) + + claims := GenerateNodeIntroductionIdentityClaims( + "node-name-1", "custom-pool", "euw", 10*time.Minute) + + must.Eq(t, "node-name-1", claims.NodeIntroductionIdentityClaims.NodeName) + must.Eq(t, "custom-pool", claims.NodeIntroductionIdentityClaims.NodePool) + must.Eq(t, "euw", claims.NodeIntroductionIdentityClaims.NodeRegion) + must.StrEqFold(t, "node-introduction:euw:custom-pool:node-name-1:default", claims.Subject) + must.Eq(t, []string{IdentityDefaultAud}, claims.Audience) + must.NotNil(t, claims.ID) + must.NotNil(t, claims.IssuedAt) + must.NotNil(t, claims.NotBefore) + must.NotNil(t, claims.Expiry) +}