diff --git a/nomad/search_endpoint.go b/nomad/search_endpoint.go index 1404a4f41..e0ad2dee4 100644 --- a/nomad/search_endpoint.go +++ b/nomad/search_endpoint.go @@ -4,6 +4,7 @@ import ( "strings" memdb "github.com/hashicorp/go-memdb" + "github.com/hashicorp/nomad/acl" "github.com/hashicorp/nomad/nomad/state" "github.com/hashicorp/nomad/nomad/structs" ) @@ -107,8 +108,36 @@ func roundUUIDDownIfOdd(prefix string, context structs.Context) string { // PrefixSearch is used to list matches for a given prefix, and returns // matching jobs, evaluations, allocations, and/or nodes. -func (s *Search) PrefixSearch(args *structs.SearchRequest, - reply *structs.SearchResponse) error { +func (s *Search) PrefixSearch(args *structs.SearchRequest, reply *structs.SearchResponse) error { + aclObj, err := s.srv.ResolveToken(args.SecretID) + if err != nil { + return err + } + + // Require either node:read or namespace:read-job + nodeRead := true + jobRead := true + if aclObj != nil { + nodeRead = aclObj.AllowNodeRead() + jobRead = aclObj.AllowNsOp(args.RequestNamespace(), acl.NamespaceCapabilityReadJob) + if !nodeRead && !jobRead { + return structs.ErrPermissionDenied + } + + // Reject requests that explicitly specify a disallowed context. This + // should give the user better feedback then simply filtering out all + // results and returning an empty list. + if !nodeRead && args.Context == structs.Nodes { + return structs.ErrPermissionDenied + } + if !jobRead { + switch args.Context { + case structs.Allocs, structs.Deployments, structs.Evals, structs.Jobs: + return structs.ErrPermissionDenied + } + } + } + reply.Matches = make(map[structs.Context][]string) reply.Truncations = make(map[structs.Context]bool) @@ -126,6 +155,19 @@ func (s *Search) PrefixSearch(args *structs.SearchRequest, } for _, ctx := range contexts { + if ctx == structs.Nodes && !nodeRead { + // Not allowed to search nodes + continue + } + + if !jobRead { + switch ctx { + case structs.Allocs, structs.Deployments, structs.Evals, structs.Jobs: + // Not allowed to read jobs + continue + } + } + iter, err := getResourceIter(ctx, args.RequestNamespace(), roundUUIDDownIfOdd(args.Prefix, args.Context), ws, state) if err != nil { diff --git a/nomad/search_endpoint_test.go b/nomad/search_endpoint_test.go index 5846bab43..84b6a050f 100644 --- a/nomad/search_endpoint_test.go +++ b/nomad/search_endpoint_test.go @@ -6,6 +6,7 @@ import ( "testing" msgpackrpc "github.com/hashicorp/net-rpc-msgpackrpc" + "github.com/hashicorp/nomad/acl" "github.com/hashicorp/nomad/nomad/mock" "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/testutil" @@ -59,6 +60,117 @@ func TestSearch_PrefixSearch_Job(t *testing.T) { assert.Equal(uint64(jobIndex), resp.Index) } +func TestSearch_PrefixSearch_ACL(t *testing.T) { + assert := assert.New(t) + jobID := "aaaaaaaa-e8f7-fd38-c855-ab94ceb8970" + + t.Parallel() + s, root := testACLServer(t, func(c *Config) { + c.NumSchedulers = 0 + }) + + defer s.Shutdown() + codec := rpcClient(t, s) + testutil.WaitForLeader(t, s.RPC) + state := s.fsm.State() + + job := registerAndVerifyJob(s, t, jobID, 0) + assert.Nil(state.UpsertNode(1001, mock.Node())) + + req := &structs.SearchRequest{ + Prefix: "", + Context: structs.Jobs, + QueryOptions: structs.QueryOptions{ + Region: "global", + Namespace: job.Namespace, + }, + } + + // Try without a token and expect failure + { + var resp structs.SearchResponse + err := msgpackrpc.CallWithCodec(codec, "Search.PrefixSearch", req, &resp) + assert.NotNil(err) + assert.Equal(err.Error(), structs.ErrPermissionDenied.Error()) + } + + // Try with an invalid token and expect failure + { + invalidToken := mock.CreatePolicyAndToken(t, state, 1003, "test-invalid", + mock.NamespacePolicy(structs.DefaultNamespace, "", []string{acl.NamespaceCapabilityListJobs})) + req.SecretID = invalidToken.SecretID + var resp structs.SearchResponse + err := msgpackrpc.CallWithCodec(codec, "Search.PrefixSearch", req, &resp) + assert.NotNil(err) + assert.Equal(err.Error(), structs.ErrPermissionDenied.Error()) + } + + // Try with a node:read token and expect failure due to Jobs being the context + { + validToken := mock.CreatePolicyAndToken(t, state, 1005, "test-invalid2", mock.NodePolicy(acl.PolicyRead)) + req.SecretID = validToken.SecretID + var resp structs.SearchResponse + err := msgpackrpc.CallWithCodec(codec, "Search.PrefixSearch", req, &resp) + assert.NotNil(err) + assert.Equal(err.Error(), structs.ErrPermissionDenied.Error()) + } + + // Try with a node:read token and expect success due to All context + { + validToken := mock.CreatePolicyAndToken(t, state, 1007, "test-valid", mock.NodePolicy(acl.PolicyRead)) + req.Context = structs.All + req.SecretID = validToken.SecretID + var resp structs.SearchResponse + assert.Nil(msgpackrpc.CallWithCodec(codec, "Search.PrefixSearch", req, &resp)) + assert.Equal(uint64(1001), resp.Index) + assert.Len(resp.Matches[structs.Nodes], 1) + + // Jobs filtered out since token only has access to node:read + assert.Len(resp.Matches[structs.Jobs], 0) + } + + // Try with a valid token for namespace:read-job + { + validToken := mock.CreatePolicyAndToken(t, state, 1009, "test-valid2", + mock.NamespacePolicy(structs.DefaultNamespace, "", []string{acl.NamespaceCapabilityReadJob})) + req.SecretID = validToken.SecretID + var resp structs.SearchResponse + assert.Nil(msgpackrpc.CallWithCodec(codec, "Search.PrefixSearch", req, &resp)) + assert.Equal(uint64(1001), resp.Index) + assert.Len(resp.Matches[structs.Jobs], 1) + assert.Equal(job.ID, resp.Matches[structs.Jobs][0]) + + // Nodes filtered out since token only has access to namespace:read-job + assert.Len(resp.Matches[structs.Nodes], 0) + } + + // Try with a valid token for node:read and namespace:read-job + { + validToken := mock.CreatePolicyAndToken(t, state, 1011, "test-valid3", strings.Join([]string{ + mock.NamespacePolicy(structs.DefaultNamespace, "", []string{acl.NamespaceCapabilityReadJob}), + mock.NodePolicy(acl.PolicyRead), + }, "\n")) + req.SecretID = validToken.SecretID + var resp structs.SearchResponse + assert.Nil(msgpackrpc.CallWithCodec(codec, "Search.PrefixSearch", req, &resp)) + assert.Equal(uint64(1001), resp.Index) + assert.Len(resp.Matches[structs.Jobs], 1) + assert.Equal(job.ID, resp.Matches[structs.Jobs][0]) + assert.Len(resp.Matches[structs.Nodes], 1) + } + + // Try with a management token + { + req.SecretID = root.SecretID + var resp structs.SearchResponse + assert.Nil(msgpackrpc.CallWithCodec(codec, "Search.PrefixSearch", req, &resp)) + assert.Equal(uint64(1001), resp.Index) + assert.Len(resp.Matches[structs.Jobs], 1) + assert.Equal(job.ID, resp.Matches[structs.Jobs][0]) + assert.Len(resp.Matches[structs.Nodes], 1) + } +} + func TestSearch_PrefixSearch_All_JobWithHyphen(t *testing.T) { assert := assert.New(t) prefix := "example-test-------" // Assert that a job with more than 4 hyphens works diff --git a/website/source/api/search.html.md b/website/source/api/search.html.md index ec07f4cf0..52b413bfb 100644 --- a/website/source/api/search.html.md +++ b/website/source/api/search.html.md @@ -20,9 +20,14 @@ The table below shows this endpoint's support for [blocking queries](/api/index.html#blocking-queries) and [required ACLs](/api/index.html#acls). -| Blocking Queries | ACL Required | -| ---------------- | ------------ | -| `NO` | `none` | +| Blocking Queries | ACL Required | +| ---------------- | -------------------------------- | +| `NO` | `node:read, namespace:read-jobs` | + +When ACLs are enabled, requests must have a token valid for `node:read` or +`namespace:read-jobs` roles. If the token is only valid for `node:read`, then +job related results will not be returned. If the token is only valid for +`namespace:read-jobs`, then node results will not be returned. ### Parameters