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.
This commit is contained in:
James Rasell
2025-07-23 15:32:26 +02:00
committed by GitHub
parent 7466dd71b2
commit 62f1dbebfb
11 changed files with 759 additions and 8 deletions

View File

@@ -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
}

View File

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

View File

@@ -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))