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) +}