Allow wildcard for Evaluations API (#13530)

* Failing test and TODO for wildcard

* Alias the namespace query parameter for Evals

* eval: fix list when using ACLs and * namespace

Apply the same verification process as in job, allocs and scaling
policy list endpoints to handle the eval list when using an ACL token
with limited namespace support but querying using the `*` wildcard
namespace.

* changelog: add entry for #13530

* ui: set namespace when querying eval

Evals have a unique UUID as ID, but when querying them the Nomad API
still expects a namespace query param, otherwise it assumes `default`.

Co-authored-by: Luiz Aoqui <luiz@hashicorp.com>
This commit is contained in:
Phil Renaud 2022-07-11 16:42:17 -04:00 committed by GitHub
parent 674c0ae08b
commit e9219a1ae0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 178 additions and 88 deletions

7
.changelog/13530.txt Normal file
View File

@ -0,0 +1,7 @@
```release-note:bug
api: Fix listing evaluations with the wildcard namespace and an ACL token
```
```release-note:bug
ui: Fix a bug that prevented viewing the details of an evaluation in a non-default namespace
```

View File

@ -571,11 +571,14 @@ func (e *Eval) List(args *structs.EvalListRequest, reply *structs.EvalListRespon
namespace := args.RequestNamespace() namespace := args.RequestNamespace()
// Check for read-job permissions // Check for read-job permissions
if aclObj, err := e.srv.ResolveToken(args.AuthToken); err != nil { aclObj, err := e.srv.ResolveToken(args.AuthToken)
if err != nil {
return err return err
} else if aclObj != nil && !aclObj.AllowNsOp(namespace, acl.NamespaceCapabilityReadJob) { }
if !aclObj.AllowNsOp(namespace, acl.NamespaceCapabilityReadJob) {
return structs.ErrPermissionDenied return structs.ErrPermissionDenied
} }
allow := aclObj.AllowNsOpFunc(acl.NamespaceCapabilityReadJob)
if args.Filter != "" { if args.Filter != "" {
// Check for incompatible filtering. // Check for incompatible filtering.
@ -596,57 +599,72 @@ func (e *Eval) List(args *structs.EvalListRequest, reply *structs.EvalListRespon
var iter memdb.ResultIterator var iter memdb.ResultIterator
var opts paginator.StructsTokenizerOptions var opts paginator.StructsTokenizerOptions
if prefix := args.QueryOptions.Prefix; prefix != "" { // Get the namespaces the user is allowed to access.
iter, err = store.EvalsByIDPrefix(ws, namespace, prefix, sort) allowableNamespaces, err := allowedNSes(aclObj, store, allow)
opts = paginator.StructsTokenizerOptions{ if err == structs.ErrPermissionDenied {
WithID: true, // return empty evals if token isn't authorized for any
} // namespace, matching other endpoints
} else if namespace != structs.AllNamespacesSentinel { reply.Evaluations = make([]*structs.Evaluation, 0)
iter, err = store.EvalsByNamespaceOrdered(ws, namespace, sort) } else if err != nil {
opts = paginator.StructsTokenizerOptions{
WithCreateIndex: true,
WithID: true,
}
} else {
iter, err = store.Evals(ws, sort)
opts = paginator.StructsTokenizerOptions{
WithCreateIndex: true,
WithID: true,
}
}
if err != nil {
return err return err
} } else {
if prefix := args.QueryOptions.Prefix; prefix != "" {
iter = memdb.NewFilterIterator(iter, func(raw interface{}) bool { iter, err = store.EvalsByIDPrefix(ws, namespace, prefix, sort)
if eval := raw.(*structs.Evaluation); eval != nil { opts = paginator.StructsTokenizerOptions{
return args.ShouldBeFiltered(eval) WithID: true,
}
} else if namespace != structs.AllNamespacesSentinel {
iter, err = store.EvalsByNamespaceOrdered(ws, namespace, sort)
opts = paginator.StructsTokenizerOptions{
WithCreateIndex: true,
WithID: true,
}
} else {
iter, err = store.Evals(ws, sort)
opts = paginator.StructsTokenizerOptions{
WithCreateIndex: true,
WithID: true,
}
}
if err != nil {
return err
} }
return false
})
tokenizer := paginator.NewStructsTokenizer(iter, opts) iter = memdb.NewFilterIterator(iter, func(raw interface{}) bool {
if eval := raw.(*structs.Evaluation); eval != nil {
var evals []*structs.Evaluation return args.ShouldBeFiltered(eval)
paginator, err := paginator.NewPaginator(iter, tokenizer, nil, args.QueryOptions, }
func(raw interface{}) error { return false
eval := raw.(*structs.Evaluation)
evals = append(evals, eval)
return nil
}) })
if err != nil {
return structs.NewErrRPCCodedf(
http.StatusBadRequest, "failed to create result paginator: %v", err)
}
nextToken, err := paginator.Page() tokenizer := paginator.NewStructsTokenizer(iter, opts)
if err != nil { filters := []paginator.Filter{
return structs.NewErrRPCCodedf( paginator.NamespaceFilter{
http.StatusBadRequest, "failed to read result page: %v", err) AllowableNamespaces: allowableNamespaces,
} },
}
reply.QueryMeta.NextToken = nextToken var evals []*structs.Evaluation
reply.Evaluations = evals paginator, err := paginator.NewPaginator(iter, tokenizer, filters, args.QueryOptions,
func(raw interface{}) error {
eval := raw.(*structs.Evaluation)
evals = append(evals, eval)
return nil
})
if err != nil {
return structs.NewErrRPCCodedf(
http.StatusBadRequest, "failed to create result paginator: %v", err)
}
nextToken, err := paginator.Page()
if err != nil {
return structs.NewErrRPCCodedf(
http.StatusBadRequest, "failed to read result page: %v", err)
}
reply.QueryMeta.NextToken = nextToken
reply.Evaluations = evals
}
// Use the last index that affected the jobs table // Use the last index that affected the jobs table
index, err := store.Index("evals") index, err := store.Index("evals")

View File

@ -1244,62 +1244,104 @@ func TestEvalEndpoint_List_ACL(t *testing.T) {
defer cleanupS1() defer cleanupS1()
codec := rpcClient(t, s1) codec := rpcClient(t, s1)
testutil.WaitForLeader(t, s1.RPC) testutil.WaitForLeader(t, s1.RPC)
assert := assert.New(t)
// Create dev namespace
devNS := mock.Namespace()
devNS.Name = "dev"
err := s1.fsm.State().UpsertNamespaces(999, []*structs.Namespace{devNS})
require.NoError(t, err)
// Create the register request // Create the register request
eval1 := mock.Eval() eval1 := mock.Eval()
eval1.ID = "aaaaaaaa-3350-4b4b-d185-0e1992ed43e9" eval1.ID = "aaaaaaaa-3350-4b4b-d185-0e1992ed43e9"
eval2 := mock.Eval() eval2 := mock.Eval()
eval2.ID = "aaaabbbb-3350-4b4b-d185-0e1992ed43e9" eval2.ID = "aaaabbbb-3350-4b4b-d185-0e1992ed43e9"
eval3 := mock.Eval()
eval3.ID = "aaaacccc-3350-4b4b-d185-0e1992ed43e9"
eval3.Namespace = devNS.Name
state := s1.fsm.State() state := s1.fsm.State()
assert.Nil(state.UpsertEvals(structs.MsgTypeTestSetup, 1000, []*structs.Evaluation{eval1, eval2})) err = state.UpsertEvals(structs.MsgTypeTestSetup, 1000, []*structs.Evaluation{eval1, eval2, eval3})
require.NoError(t, err)
// Create ACL tokens // Create ACL tokens
validToken := mock.CreatePolicyAndToken(t, state, 1003, "test-valid", validToken := mock.CreatePolicyAndToken(t, state, 1003, "test-valid",
mock.NamespacePolicy(structs.DefaultNamespace, "", []string{acl.NamespaceCapabilityReadJob})) mock.NamespacePolicy(structs.DefaultNamespace, "", []string{acl.NamespaceCapabilityReadJob}))
invalidToken := mock.CreatePolicyAndToken(t, state, 1001, "test-invalid", invalidToken := mock.CreatePolicyAndToken(t, state, 1001, "test-invalid",
mock.NamespacePolicy(structs.DefaultNamespace, "", []string{acl.NamespaceCapabilityListJobs})) mock.NamespacePolicy(structs.DefaultNamespace, "", []string{acl.NamespaceCapabilityListJobs}))
devToken := mock.CreatePolicyAndToken(t, state, 1005, "test-dev",
mock.NamespacePolicy("dev", "", []string{acl.NamespaceCapabilityReadJob}))
get := &structs.EvalListRequest{ testCases := []struct {
QueryOptions: structs.QueryOptions{ name string
Region: "global", namespace string
Namespace: structs.DefaultNamespace, token string
expectedEvals []string
expectedError string
}{
{
name: "no token",
token: "",
namespace: structs.DefaultNamespace,
expectedError: structs.ErrPermissionDenied.Error(),
},
{
name: "invalid token",
token: invalidToken.SecretID,
namespace: structs.DefaultNamespace,
expectedError: structs.ErrPermissionDenied.Error(),
},
{
name: "valid token",
token: validToken.SecretID,
namespace: structs.DefaultNamespace,
expectedEvals: []string{eval1.ID, eval2.ID},
},
{
name: "root token default namespace",
token: root.SecretID,
namespace: structs.DefaultNamespace,
expectedEvals: []string{eval1.ID, eval2.ID},
},
{
name: "root token all namespaces",
token: root.SecretID,
namespace: structs.AllNamespacesSentinel,
expectedEvals: []string{eval1.ID, eval2.ID, eval3.ID},
},
{
name: "dev token all namespaces",
token: devToken.SecretID,
namespace: structs.AllNamespacesSentinel,
expectedEvals: []string{eval3.ID},
}, },
} }
// Try without a token and expect permission denied for _, tc := range testCases {
{ t.Run(tc.name, func(t *testing.T) {
var resp structs.EvalListResponse get := &structs.EvalListRequest{
err := msgpackrpc.CallWithCodec(codec, "Eval.List", get, &resp) QueryOptions: structs.QueryOptions{
assert.NotNil(err) AuthToken: tc.token,
assert.Contains(err.Error(), structs.ErrPermissionDenied.Error()) Region: "global",
} Namespace: tc.namespace,
},
}
// Try with an invalid token and expect permission denied var resp structs.EvalListResponse
{ err := msgpackrpc.CallWithCodec(codec, "Eval.List", get, &resp)
get.AuthToken = invalidToken.SecretID
var resp structs.EvalListResponse
err := msgpackrpc.CallWithCodec(codec, "Eval.List", get, &resp)
assert.NotNil(err)
assert.Contains(err.Error(), structs.ErrPermissionDenied.Error())
}
// List evals with a valid token if tc.expectedError != "" {
{ require.Contains(t, err.Error(), tc.expectedError)
get.AuthToken = validToken.SecretID } else {
var resp structs.EvalListResponse require.NoError(t, err)
assert.Nil(msgpackrpc.CallWithCodec(codec, "Eval.List", get, &resp)) require.Equal(t, uint64(1000), resp.Index, "Bad index: %d %d", resp.Index, 1000)
assert.Equal(uint64(1000), resp.Index, "Bad index: %d %d", resp.Index, 1000)
assert.Lenf(resp.Evaluations, 2, "bad: %#v", resp.Evaluations)
}
// List evals with a root token got := make([]string, len(resp.Evaluations))
{ for i, eval := range resp.Evaluations {
get.AuthToken = root.SecretID got[i] = eval.ID
var resp structs.EvalListResponse }
assert.Nil(msgpackrpc.CallWithCodec(codec, "Eval.List", get, &resp)) require.ElementsMatch(t, got, tc.expectedEvals)
assert.Equal(uint64(1000), resp.Index, "Bad index: %d %d", resp.Index, 1000) }
assert.Lenf(resp.Evaluations, 2, "bad: %#v", resp.Evaluations) })
} }
} }
@ -1377,6 +1419,12 @@ func TestEvalEndpoint_List_PaginationFiltering(t *testing.T) {
codec := rpcClient(t, s1) codec := rpcClient(t, s1)
testutil.WaitForLeader(t, s1.RPC) testutil.WaitForLeader(t, s1.RPC)
// Create non-default namespace
nondefaultNS := mock.Namespace()
nondefaultNS.Name = "non-default"
err := s1.fsm.State().UpsertNamespaces(999, []*structs.Namespace{nondefaultNS})
require.NoError(t, err)
// create a set of evals and field values to filter on. these are // create a set of evals and field values to filter on. these are
// in the order that the state store will return them from the // in the order that the state store will return them from the
// iterator (sorted by create index), for ease of writing tests // iterator (sorted by create index), for ease of writing tests
@ -1388,7 +1436,7 @@ func TestEvalEndpoint_List_PaginationFiltering(t *testing.T) {
}{ }{
{ids: []string{"aaaa1111-3350-4b4b-d185-0e1992ed43e9"}, jobID: "example"}, // 0 {ids: []string{"aaaa1111-3350-4b4b-d185-0e1992ed43e9"}, jobID: "example"}, // 0
{ids: []string{"aaaaaa22-3350-4b4b-d185-0e1992ed43e9"}, jobID: "example"}, // 1 {ids: []string{"aaaaaa22-3350-4b4b-d185-0e1992ed43e9"}, jobID: "example"}, // 1
{ids: []string{"aaaaaa33-3350-4b4b-d185-0e1992ed43e9"}, namespace: "non-default"}, // 2 {ids: []string{"aaaaaa33-3350-4b4b-d185-0e1992ed43e9"}, namespace: nondefaultNS.Name}, // 2
{ids: []string{"aaaaaaaa-3350-4b4b-d185-0e1992ed43e9"}, jobID: "example", status: "blocked"}, // 3 {ids: []string{"aaaaaaaa-3350-4b4b-d185-0e1992ed43e9"}, jobID: "example", status: "blocked"}, // 3
{ids: []string{"aaaaaabb-3350-4b4b-d185-0e1992ed43e9"}}, // 4 {ids: []string{"aaaaaabb-3350-4b4b-d185-0e1992ed43e9"}}, // 4
{ids: []string{"aaaaaacc-3350-4b4b-d185-0e1992ed43e9"}}, // 5 {ids: []string{"aaaaaacc-3350-4b4b-d185-0e1992ed43e9"}}, // 5

View File

@ -10956,6 +10956,14 @@ func (e *Evaluation) GetID() string {
return e.ID return e.ID
} }
// GetNamespace implements the NamespaceGetter interface, required for pagination.
func (e *Evaluation) GetNamespace() string {
if e == nil {
return ""
}
return e.Namespace
}
// GetCreateIndex implements the CreateIndexGetter interface, required for // GetCreateIndex implements the CreateIndexGetter interface, required for
// pagination. // pagination.
func (e *Evaluation) GetCreateIndex() uint64 { func (e *Evaluation) GetCreateIndex() uint64 {

View File

@ -10,10 +10,12 @@ export default class EvaluationAdapter extends ApplicationAdapter {
} }
urlForFindRecord(_id, _modelName, snapshot) { urlForFindRecord(_id, _modelName, snapshot) {
const url = super.urlForFindRecord(...arguments); const namespace = snapshot.attr('namespace') || 'default';
const baseURL = super.urlForFindRecord(...arguments);
const url = `${baseURL}?namespace=${namespace}`;
if (snapshot.adapterOptions?.related) { if (snapshot.adapterOptions?.related) {
return `${url}?related=true`; return `${url}&related=true`;
} }
return url; return url;
} }

View File

@ -35,7 +35,7 @@ export default class EvaluationsController extends Controller {
'currentEval', 'currentEval',
'pageSize', 'pageSize',
'status', 'status',
'qpNamespace', { qpNamespace: 'namespace' },
'type', 'type',
'searchTerm', 'searchTerm',
]; ];

View File

@ -676,7 +676,14 @@ module('Acceptance | evaluations list', function (hooks) {
module('evaluation detail', function () { module('evaluation detail', function () {
test('clicking an evaluation opens the detail view', async function (assert) { test('clicking an evaluation opens the detail view', async function (assert) {
server.get('/evaluations', getStandardRes); server.get('/evaluations', getStandardRes);
server.get('/evaluation/:id', function (_, { params }) { server.get('/evaluation/:id', function (_, { queryParams, params }) {
const expectedNamespaces = ['default', 'ted-lasso'];
assert.notEqual(
expectedNamespaces.indexOf(queryParams.namespace),
-1,
'Eval details request has namespace query param'
);
return { ...generateAcceptanceTestEvalMock(params.id), ID: params.id }; return { ...generateAcceptanceTestEvalMock(params.id), ID: params.id };
}); });