diff --git a/command/agent/acl_endpoint.go b/command/agent/acl_endpoint.go index df3f56851..ceaecb0b8 100644 --- a/command/agent/acl_endpoint.go +++ b/command/agent/acl_endpoint.go @@ -322,3 +322,206 @@ func (s *HTTPServer) ExchangeOneTimeToken(resp http.ResponseWriter, req *http.Re setIndex(resp, out.Index) return out, nil } + +// ACLRoleListRequest performs a listing of ACL roles and is callable via the +// /v1/acl/roles HTTP API. +func (s *HTTPServer) ACLRoleListRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + + // The endpoint only supports GET requests. + if req.Method != http.MethodGet { + return nil, CodedError(http.StatusMethodNotAllowed, ErrInvalidMethod) + } + + // Set up the request args and parse this to ensure the query options are + // set. + args := structs.ACLRolesListRequest{} + + if s.parse(resp, req, &args.Region, &args.QueryOptions) { + return nil, nil + } + + // Perform the RPC request. + var reply structs.ACLRolesListResponse + if err := s.agent.RPC(structs.ACLListRolesRPCMethod, &args, &reply); err != nil { + return nil, err + } + + setMeta(resp, &reply.QueryMeta) + + if reply.ACLRoles == nil { + reply.ACLRoles = make([]*structs.ACLRole, 0) + } + return reply.ACLRoles, nil +} + +// ACLRoleRequest creates a new ACL role and is callable via the +// /v1/acl/role HTTP API. +func (s *HTTPServer) ACLRoleRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + + // // The endpoint only supports PUT or POST requests. + if !(req.Method == http.MethodPut || req.Method == http.MethodPost) { + return nil, CodedError(http.StatusMethodNotAllowed, ErrInvalidMethod) + } + + // Use the generic upsert function without setting an ID as this will be + // handled by the Nomad leader. + return s.aclRoleUpsertRequest(resp, req, "") +} + +// ACLRoleSpecificRequest is callable via the /v1/acl/role/ HTTP API and +// handles read via both the role name and ID, updates, and deletions. +func (s *HTTPServer) ACLRoleSpecificRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + + // Grab the suffix of the request, so we can further understand it. + reqSuffix := strings.TrimPrefix(req.URL.Path, "/v1/acl/role/") + + // Split the request suffix in order to identify whether this is a lookup + // of a service, or whether this includes a service and service identifier. + suffixParts := strings.Split(reqSuffix, "/") + + switch len(suffixParts) { + case 1: + // Ensure the role ID is not an empty string which is possible if the + // caller requested "/v1/acl/role/" + if suffixParts[0] == "" { + return nil, CodedError(http.StatusBadRequest, "missing ACL role ID") + } + return s.aclRoleRequest(resp, req, suffixParts[0]) + case 2: + // This endpoint only supports GET. + if req.Method != http.MethodGet { + return nil, CodedError(http.StatusMethodNotAllowed, ErrInvalidMethod) + } + + // Ensure that the path is correct, otherwise the call could use + // "/v1/acl/role/foobar/role-name" and successfully pass through here. + if suffixParts[0] != "name" { + return nil, CodedError(http.StatusBadRequest, "invalid URI") + } + + // Ensure the role name is not an empty string which is possible if the + // caller requested "/v1/acl/role/name/" + if suffixParts[1] == "" { + return nil, CodedError(http.StatusBadRequest, "missing ACL role name") + } + + return s.aclRoleGetByNameRequest(resp, req, suffixParts[1]) + + default: + return nil, CodedError(http.StatusBadRequest, "invalid URI") + } +} + +func (s *HTTPServer) aclRoleRequest( + resp http.ResponseWriter, req *http.Request, roleID string) (interface{}, error) { + + // Identify the method which indicates which downstream function should be + // called. + switch req.Method { + case http.MethodGet: + return s.aclRoleGetByIDRequest(resp, req, roleID) + case http.MethodDelete: + return s.aclRoleDeleteRequest(resp, req, roleID) + case http.MethodPost, http.MethodPut: + return s.aclRoleUpsertRequest(resp, req, roleID) + default: + return nil, CodedError(http.StatusMethodNotAllowed, ErrInvalidMethod) + } +} + +func (s *HTTPServer) aclRoleGetByIDRequest( + resp http.ResponseWriter, req *http.Request, roleID string) (interface{}, error) { + + args := structs.ACLRoleByIDRequest{ + RoleID: roleID, + } + if s.parse(resp, req, &args.Region, &args.QueryOptions) { + return nil, nil + } + + var reply structs.ACLRoleByIDResponse + if err := s.agent.RPC(structs.ACLGetRoleByIDRPCMethod, &args, &reply); err != nil { + return nil, err + } + setMeta(resp, &reply.QueryMeta) + + if reply.ACLRole == nil { + return nil, CodedError(http.StatusNotFound, "ACL role not found") + } + return reply.ACLRole, nil +} + +func (s *HTTPServer) aclRoleDeleteRequest( + resp http.ResponseWriter, req *http.Request, roleID string) (interface{}, error) { + + args := structs.ACLRolesDeleteByIDRequest{ + ACLRoleIDs: []string{roleID}, + } + s.parseWriteRequest(req, &args.WriteRequest) + + var reply structs.ACLRolesDeleteByIDResponse + if err := s.agent.RPC(structs.ACLDeleteRolesByIDRPCMethod, &args, &reply); err != nil { + return nil, err + } + setIndex(resp, reply.Index) + return nil, nil + +} + +// aclRoleUpsertRequest handles upserting an ACL to the Nomad servers. It can +// handle both new creations, and updates to existing roles. +func (s *HTTPServer) aclRoleUpsertRequest( + resp http.ResponseWriter, req *http.Request, roleID string) (interface{}, error) { + + // Decode the ACL role. + var aclRole structs.ACLRole + if err := decodeBody(req, &aclRole); err != nil { + return nil, CodedError(http.StatusInternalServerError, err.Error()) + } + + // Ensure the request path ID matches the ACL role ID that was decoded. + // Only perform this check on updates as a generic error on creation might + // be confusing to operators as there is no specific role request path. + if roleID != "" && roleID != aclRole.ID { + return nil, CodedError(http.StatusBadRequest, "ACL role ID does not match request path") + } + + args := structs.ACLRolesUpsertRequest{ + ACLRoles: []*structs.ACLRole{&aclRole}, + } + s.parseWriteRequest(req, &args.WriteRequest) + + var out structs.ACLRolesUpsertResponse + if err := s.agent.RPC(structs.ACLUpsertRolesRPCMethod, &args, &out); err != nil { + return nil, err + } + setIndex(resp, out.Index) + + if len(out.ACLRoles) > 0 { + return out.ACLRoles[0], nil + } + return nil, nil + +} + +func (s *HTTPServer) aclRoleGetByNameRequest( + resp http.ResponseWriter, req *http.Request, roleName string) (interface{}, error) { + + args := structs.ACLRoleByNameRequest{ + RoleName: roleName, + } + if s.parse(resp, req, &args.Region, &args.QueryOptions) { + return nil, nil + } + + var reply structs.ACLRoleByNameResponse + if err := s.agent.RPC(structs.ACLGetRoleByNameRPCMethod, &args, &reply); err != nil { + return nil, err + } + setMeta(resp, &reply.QueryMeta) + + if reply.ACLRole == nil { + return nil, CodedError(http.StatusNotFound, "ACL role not found") + } + return reply.ACLRole, nil +} diff --git a/command/agent/acl_endpoint_test.go b/command/agent/acl_endpoint_test.go index 0e52a0899..25350fb0f 100644 --- a/command/agent/acl_endpoint_test.go +++ b/command/agent/acl_endpoint_test.go @@ -1,11 +1,13 @@ package agent import ( + "fmt" "net/http" "net/http/httptest" "testing" "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/helper/uuid" "github.com/hashicorp/nomad/nomad/mock" "github.com/hashicorp/nomad/nomad/structs" "github.com/stretchr/testify/assert" @@ -558,3 +560,438 @@ func TestHTTP_OneTimeToken(t *testing.T) { require.EqualError(t, err, structs.ErrPermissionDenied.Error()) }) } + +func TestHTTPServer_ACLRoleListRequest(t *testing.T) { + ci.Parallel(t) + + testCases := []struct { + name string + testFn func(srv *TestAgent) + }{ + { + name: "no auth token set", + testFn: func(srv *TestAgent) { + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodGet, "/v1/acl/roles", nil) + require.NoError(t, err) + respW := httptest.NewRecorder() + + // Send the HTTP request. + obj, err := srv.Server.ACLRoleListRequest(respW, req) + require.Error(t, err) + require.ErrorContains(t, err, "Permission denied") + require.Nil(t, obj) + }, + }, + { + name: "invalid method", + testFn: func(srv *TestAgent) { + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodConnect, "/v1/acl/roles", nil) + require.NoError(t, err) + respW := httptest.NewRecorder() + + // Ensure we have a token set. + setToken(req, srv.RootToken) + + // Send the HTTP request. + obj, err := srv.Server.ACLRoleListRequest(respW, req) + require.Error(t, err) + require.ErrorContains(t, err, "Invalid method") + require.Nil(t, obj) + }, + }, + { + name: "no roles in state", + testFn: func(srv *TestAgent) { + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodGet, "/v1/acl/roles", nil) + require.NoError(t, err) + respW := httptest.NewRecorder() + + // Ensure we have a token set. + setToken(req, srv.RootToken) + + // Send the HTTP request. + obj, err := srv.Server.ACLRoleListRequest(respW, req) + require.NoError(t, err) + require.Empty(t, obj.([]*structs.ACLRole)) + }, + }, + { + name: "roles in state", + testFn: func(srv *TestAgent) { + + // Create the policies our ACL roles wants to link to. + policy1 := mock.ACLPolicy() + policy1.Name = "mocked-test-policy-1" + policy2 := mock.ACLPolicy() + policy2.Name = "mocked-test-policy-2" + + require.NoError(t, srv.server.State().UpsertACLPolicies( + structs.MsgTypeTestSetup, 10, []*structs.ACLPolicy{policy1, policy2})) + + // Create two ACL roles and put these directly into state. + aclRoles := []*structs.ACLRole{mock.ACLRole(), mock.ACLRole()} + require.NoError(t, srv.server.State().UpsertACLRoles(structs.MsgTypeTestSetup, 20, aclRoles)) + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodGet, "/v1/acl/roles", nil) + require.NoError(t, err) + respW := httptest.NewRecorder() + + // Ensure we have a token set. + setToken(req, srv.RootToken) + + // Send the HTTP request. + obj, err := srv.Server.ACLRoleListRequest(respW, req) + require.NoError(t, err) + require.Len(t, obj.([]*structs.ACLRole), 2) + }, + }, + { + name: "roles in state using prefix", + testFn: func(srv *TestAgent) { + + // Create the policies our ACL roles wants to link to. + policy1 := mock.ACLPolicy() + policy1.Name = "mocked-test-policy-1" + policy2 := mock.ACLPolicy() + policy2.Name = "mocked-test-policy-2" + + require.NoError(t, srv.server.State().UpsertACLPolicies( + structs.MsgTypeTestSetup, 10, []*structs.ACLPolicy{policy1, policy2})) + + // Create two ACL roles and put these directly into state, one + // using a custom prefix. + aclRoles := []*structs.ACLRole{mock.ACLRole(), mock.ACLRole()} + aclRoles[1].ID = "badger-badger-badger-" + uuid.Generate() + require.NoError(t, srv.server.State().UpsertACLRoles(structs.MsgTypeTestSetup, 20, aclRoles)) + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodGet, "/v1/acl/roles?prefix=badger-badger-badger", nil) + require.NoError(t, err) + respW := httptest.NewRecorder() + + // Ensure we have a token set. + setToken(req, srv.RootToken) + + // Send the HTTP request. + obj, err := srv.Server.ACLRoleListRequest(respW, req) + require.NoError(t, err) + require.Len(t, obj.([]*structs.ACLRole), 1) + require.Contains(t, obj.([]*structs.ACLRole)[0].ID, "badger-badger-badger") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + httpACLTest(t, nil, tc.testFn) + }) + } +} + +func TestHTTPServer_ACLRoleRequest(t *testing.T) { + ci.Parallel(t) + + testCases := []struct { + name string + testFn func(srv *TestAgent) + }{ + { + name: "no auth token set", + testFn: func(srv *TestAgent) { + + // Create a mock role to use in the request body. + mockACLRole := mock.ACLRole() + mockACLRole.ID = "" + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodPut, "/v1/acl/role", encodeReq(mockACLRole)) + require.NoError(t, err) + respW := httptest.NewRecorder() + + // Send the HTTP request. + obj, err := srv.Server.ACLRoleRequest(respW, req) + require.Error(t, err) + require.ErrorContains(t, err, "Permission denied") + require.Nil(t, obj) + }, + }, + { + name: "invalid method", + testFn: func(srv *TestAgent) { + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodConnect, "/v1/acl/role", nil) + require.NoError(t, err) + respW := httptest.NewRecorder() + + // Ensure we have a token set. + setToken(req, srv.RootToken) + + // Send the HTTP request. + obj, err := srv.Server.ACLRoleRequest(respW, req) + require.Error(t, err) + require.ErrorContains(t, err, "Invalid method") + require.Nil(t, obj) + }, + }, + { + name: "successful upsert", + testFn: func(srv *TestAgent) { + + // Create the policies our ACL roles wants to link to. + policy1 := mock.ACLPolicy() + policy1.Name = "mocked-test-policy-1" + policy2 := mock.ACLPolicy() + policy2.Name = "mocked-test-policy-2" + + require.NoError(t, srv.server.State().UpsertACLPolicies( + structs.MsgTypeTestSetup, 10, []*structs.ACLPolicy{policy1, policy2})) + + // Create a mock role to use in the request body. + mockACLRole := mock.ACLRole() + mockACLRole.ID = "" + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodPut, "/v1/acl/role", encodeReq(mockACLRole)) + require.NoError(t, err) + respW := httptest.NewRecorder() + + // Ensure we have a token set. + setToken(req, srv.RootToken) + + // Send the HTTP request. + obj, err := srv.Server.ACLRoleRequest(respW, req) + require.NoError(t, err) + require.NotNil(t, obj) + require.Equal(t, obj.(*structs.ACLRole).Hash, mockACLRole.Hash) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + httpACLTest(t, nil, tc.testFn) + }) + } +} + +func TestHTTPServer_ACLRoleSpecificRequest(t *testing.T) { + ci.Parallel(t) + + testCases := []struct { + name string + testFn func(srv *TestAgent) + }{ + { + name: "invalid URI", + testFn: func(srv *TestAgent) { + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodGet, "/v1/acl/role/name/this/is/will/not/work", nil) + require.NoError(t, err) + respW := httptest.NewRecorder() + + // Send the HTTP request. + obj, err := srv.Server.ACLRoleSpecificRequest(respW, req) + require.Error(t, err) + require.ErrorContains(t, err, "invalid URI") + require.Nil(t, obj) + }, + }, + { + name: "invalid role name lookalike URI", + testFn: func(srv *TestAgent) { + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodGet, "/v1/acl/role/foobar/rolename", nil) + require.NoError(t, err) + respW := httptest.NewRecorder() + + // Send the HTTP request. + obj, err := srv.Server.ACLRoleSpecificRequest(respW, req) + require.Error(t, err) + require.ErrorContains(t, err, "invalid URI") + require.Nil(t, obj) + }, + }, + { + name: "missing role name", + testFn: func(srv *TestAgent) { + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodGet, "/v1/acl/role/name/", nil) + require.NoError(t, err) + respW := httptest.NewRecorder() + + // Send the HTTP request. + obj, err := srv.Server.ACLRoleSpecificRequest(respW, req) + require.Error(t, err) + require.ErrorContains(t, err, "missing ACL role name") + require.Nil(t, obj) + }, + }, + { + name: "missing role ID", + testFn: func(srv *TestAgent) { + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodGet, "/v1/acl/role/", nil) + require.NoError(t, err) + respW := httptest.NewRecorder() + + // Send the HTTP request. + obj, err := srv.Server.ACLRoleSpecificRequest(respW, req) + require.Error(t, err) + require.ErrorContains(t, err, "missing ACL role ID") + require.Nil(t, obj) + }, + }, + { + name: "role name incorrect method", + testFn: func(srv *TestAgent) { + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodConnect, "/v1/acl/role/name/foobar", nil) + require.NoError(t, err) + respW := httptest.NewRecorder() + + // Send the HTTP request. + obj, err := srv.Server.ACLRoleSpecificRequest(respW, req) + require.Error(t, err) + require.ErrorContains(t, err, "Invalid method") + require.Nil(t, obj) + }, + }, + { + name: "role ID incorrect method", + testFn: func(srv *TestAgent) { + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodConnect, "/v1/acl/role/foobar", nil) + require.NoError(t, err) + respW := httptest.NewRecorder() + + // Send the HTTP request. + obj, err := srv.Server.ACLRoleSpecificRequest(respW, req) + require.Error(t, err) + require.ErrorContains(t, err, "Invalid method") + require.Nil(t, obj) + }, + }, + { + name: "get role by name", + testFn: func(srv *TestAgent) { + + // Create the policies our ACL roles wants to link to. + policy1 := mock.ACLPolicy() + policy1.Name = "mocked-test-policy-1" + policy2 := mock.ACLPolicy() + policy2.Name = "mocked-test-policy-2" + + require.NoError(t, srv.server.State().UpsertACLPolicies( + structs.MsgTypeTestSetup, 10, []*structs.ACLPolicy{policy1, policy2})) + + // Create a mock role and put directly into state. + mockACLRole := mock.ACLRole() + require.NoError(t, srv.server.State().UpsertACLRoles( + structs.MsgTypeTestSetup, 20, []*structs.ACLRole{mockACLRole})) + + url := fmt.Sprintf("/v1/acl/role/name/%s", mockACLRole.Name) + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodGet, url, nil) + require.NoError(t, err) + respW := httptest.NewRecorder() + + // Ensure we have a token set. + setToken(req, srv.RootToken) + + // Send the HTTP request. + obj, err := srv.Server.ACLRoleSpecificRequest(respW, req) + require.NoError(t, err) + require.Equal(t, obj.(*structs.ACLRole).Hash, mockACLRole.Hash) + }, + }, + { + name: "get, update, and delete role by ID", + testFn: func(srv *TestAgent) { + + // Create the policies our ACL roles wants to link to. + policy1 := mock.ACLPolicy() + policy1.Name = "mocked-test-policy-1" + policy2 := mock.ACLPolicy() + policy2.Name = "mocked-test-policy-2" + + require.NoError(t, srv.server.State().UpsertACLPolicies( + structs.MsgTypeTestSetup, 10, []*structs.ACLPolicy{policy1, policy2})) + + // Create a mock role and put directly into state. + mockACLRole := mock.ACLRole() + require.NoError(t, srv.server.State().UpsertACLRoles( + structs.MsgTypeTestSetup, 20, []*structs.ACLRole{mockACLRole})) + + url := fmt.Sprintf("/v1/acl/role/%s", mockACLRole.ID) + + // Build the HTTP request to read the role using its ID. + req, err := http.NewRequest(http.MethodGet, url, nil) + require.NoError(t, err) + respW := httptest.NewRecorder() + + // Ensure we have a token set. + setToken(req, srv.RootToken) + + // Send the HTTP request. + obj, err := srv.Server.ACLRoleSpecificRequest(respW, req) + require.NoError(t, err) + require.Equal(t, obj.(*structs.ACLRole).Hash, mockACLRole.Hash) + + // Update the role policy list and make the request via the + // HTTP API. + mockACLRole.Policies = []*structs.ACLRolePolicyLink{{Name: "mocked-test-policy-1"}} + + req, err = http.NewRequest(http.MethodPost, url, encodeReq(mockACLRole)) + require.NoError(t, err) + respW = httptest.NewRecorder() + + // Ensure we have a token set. + setToken(req, srv.RootToken) + + // Send the HTTP request. + obj, err = srv.Server.ACLRoleSpecificRequest(respW, req) + require.NoError(t, err) + require.Equal(t, obj.(*structs.ACLRole).Policies, mockACLRole.Policies) + + // Delete the ACL role using its ID. + req, err = http.NewRequest(http.MethodDelete, url, nil) + require.NoError(t, err) + respW = httptest.NewRecorder() + + // Ensure we have a token set. + setToken(req, srv.RootToken) + + // Send the HTTP request. + obj, err = srv.Server.ACLRoleSpecificRequest(respW, req) + require.NoError(t, err) + require.Nil(t, obj) + + // Ensure the ACL role is no longer stored within state. + aclRole, err := srv.server.State().GetACLRoleByID(nil, mockACLRole.ID) + require.NoError(t, err) + require.Nil(t, aclRole) + }, + }, + } + + 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 b0676ec82..8a73e786a 100644 --- a/command/agent/http.go +++ b/command/agent/http.go @@ -381,6 +381,11 @@ func (s HTTPServer) registerHandlers(enableDebug bool) { s.mux.HandleFunc("/v1/acl/token", s.wrap(s.ACLTokenSpecificRequest)) s.mux.HandleFunc("/v1/acl/token/", s.wrap(s.ACLTokenSpecificRequest)) + // Register our ACL role handlers. + s.mux.HandleFunc("/v1/acl/roles", s.wrap(s.ACLRoleListRequest)) + s.mux.HandleFunc("/v1/acl/role", s.wrap(s.ACLRoleRequest)) + s.mux.HandleFunc("/v1/acl/role/", s.wrap(s.ACLRoleSpecificRequest)) + s.mux.Handle("/v1/client/fs/", wrapCORS(s.wrap(s.FsRequest))) s.mux.HandleFunc("/v1/client/gc", s.wrap(s.ClientGCRequest)) s.mux.Handle("/v1/client/stats", wrapCORS(s.wrap(s.ClientStatsRequest)))