From 95c9ffa505624efdfd73a6ee296f93bc09fd0504 Mon Sep 17 00:00:00 2001 From: James Rasell Date: Thu, 15 Dec 2022 09:18:55 +0100 Subject: [PATCH] ACL: add ACL binding rule RPC and HTTP API handlers. (#15529) This change add the RPC ACL binding rule handlers. These handlers are responsible for the creation, updating, reading, and deletion of binding rules. The write handlers are feature gated so that they can only be used when all federated servers are running the required version. The HTTP API handlers and API SDK have also been added where required. This allows the endpoints to be called from the API by users and clients. --- api/acl.go | 151 ++++++++++- api/acl_test.go | 83 +++++- command/agent/acl_endpoint.go | 153 +++++++++++ command/agent/acl_endpoint_test.go | 325 +++++++++++++++++++++++ command/agent/http.go | 5 + nomad/acl_endpoint.go | 324 +++++++++++++++++++++++ nomad/acl_endpoint_test.go | 409 +++++++++++++++++++++++++++++ nomad/leader.go | 8 + 8 files changed, 1456 insertions(+), 2 deletions(-) diff --git a/api/acl.go b/api/acl.go index a8e8a909f..fb80d7d24 100644 --- a/api/acl.go +++ b/api/acl.go @@ -211,6 +211,10 @@ var ( // errMissingACLAuthMethodName is the generic error to use when a call is // missing the required ACL auth-method name parameter. errMissingACLAuthMethodName = errors.New("missing ACL auth-method name") + + // errMissingACLBindingRuleID is the generic error to use when a call is + // missing the required ACL binding rule ID parameter. + errMissingACLBindingRuleID = errors.New("missing ACL binding rule ID") ) // ACLRoles is used to query the ACL Role endpoints. @@ -369,6 +373,75 @@ func (a *ACLAuthMethods) Get(authMethodName string, q *QueryOptions) (*ACLAuthMe return &resp, qm, nil } +// ACLBindingRules is used to query the ACL auth-methods endpoints. +type ACLBindingRules struct { + client *Client +} + +// ACLBindingRules returns a new handle on the ACL auth-methods API client. +func (c *Client) ACLBindingRules() *ACLBindingRules { + return &ACLBindingRules{client: c} +} + +// List is used to detail all the ACL binding rules currently stored within +// state. +func (a *ACLBindingRules) List(q *QueryOptions) ([]*ACLBindingRuleListStub, *QueryMeta, error) { + var resp []*ACLBindingRuleListStub + qm, err := a.client.query("/v1/acl/binding-rules", &resp, q) + if err != nil { + return nil, nil, err + } + return resp, qm, nil +} + +// Create is used to create an ACL binding rule. +func (a *ACLBindingRules) Create(bindingRule *ACLBindingRule, w *WriteOptions) (*ACLBindingRule, *WriteMeta, error) { + var resp ACLBindingRule + wm, err := a.client.write("/v1/acl/binding-rule", bindingRule, &resp, w) + if err != nil { + return nil, nil, err + } + return &resp, wm, nil +} + +// Update is used to update an existing ACL binding rule. +func (a *ACLBindingRules) Update(bindingRule *ACLBindingRule, w *WriteOptions) (*ACLBindingRule, *WriteMeta, error) { + if bindingRule.ID == "" { + return nil, nil, errMissingACLBindingRuleID + } + var resp ACLBindingRule + wm, err := a.client.write("/v1/acl/binding-rule/"+bindingRule.ID, bindingRule, &resp, w) + if err != nil { + return nil, nil, err + } + return &resp, wm, nil +} + +// Delete is used to delete an ACL binding rule. +func (a *ACLBindingRules) Delete(bindingRuleID string, w *WriteOptions) (*WriteMeta, error) { + if bindingRuleID == "" { + return nil, errMissingACLBindingRuleID + } + wm, err := a.client.delete("/v1/acl/binding-rule/"+bindingRuleID, nil, nil, w) + if err != nil { + return nil, err + } + return wm, nil +} + +// Get is used to look up an ACL binding rule. +func (a *ACLBindingRules) Get(bindingRuleID string, q *QueryOptions) (*ACLBindingRule, *QueryMeta, error) { + if bindingRuleID == "" { + return nil, nil, errMissingACLBindingRuleID + } + var resp ACLBindingRule + qm, err := a.client.query("/v1/acl/binding-rule/"+bindingRuleID, &resp, q) + if err != nil { + return nil, nil, err + } + return &resp, qm, nil +} + // ACLPolicyListStub is used to for listing ACL policies type ACLPolicyListStub struct { Name string @@ -647,7 +720,6 @@ type ACLAuthMethodListStub struct { Name string Type string Default bool - Hash []byte CreateIndex uint64 ModifyIndex uint64 @@ -667,3 +739,80 @@ const ( // auth-method which uses the OIDC protocol. ACLAuthMethodTypeOIDC = "OIDC" ) + +// ACLBindingRule contains a direct relation to an ACLAuthMethod and represents +// a rule to apply when logging in via the named AuthMethod. This allows the +// transformation of OIDC provider claims, to Nomad based ACL concepts such as +// ACL Roles and Policies. +type ACLBindingRule struct { + + // ID is an internally generated UUID for this rule and is controlled by + // Nomad. + ID string + + // Description is a human-readable, operator set description that can + // provide additional context about the binding rule. This is an + // operational field. + Description string + + // AuthMethod is the name of the auth method for which this rule applies + // to. This is required and the method must exist within state before the + // cluster administrator can create the rule. + AuthMethod string + + // Selector is an expression that matches against verified identity + // attributes returned from the auth method during login. This is optional + // and when not set, provides a catch-all rule. + Selector string + + // BindType adjusts how this binding rule is applied at login time. The + // valid values are ACLBindingRuleBindTypeRole and + // ACLBindingRuleBindTypePolicy. + BindType string + + // BindName is the target of the binding. Can be lightly templated using + // HIL ${foo} syntax from available field names. How it is used depends + // upon the BindType. + BindName string + + CreateTime time.Time + ModifyTime time.Time + CreateIndex uint64 + ModifyIndex uint64 +} + +const ( + // ACLBindingRuleBindTypeRole is the ACL binding rule bind type that only + // allows the binding rule to function if a role exists at login-time. The + // role will be specified within the ACLBindingRule.BindName parameter, and + // will identify whether this is an ID or Name. + ACLBindingRuleBindTypeRole = "role" + + // ACLBindingRuleBindTypePolicy is the ACL binding rule bind type that + // assigns a policy to the generate ACL token. The role will be specified + // within the ACLBindingRule.BindName parameter, and will be the policy + // name. + ACLBindingRuleBindTypePolicy = "policy" +) + +// ACLBindingRuleListStub is the stub object returned when performing a listing +// of ACL binding rules. +type ACLBindingRuleListStub struct { + + // ID is an internally generated UUID for this role and is controlled by + // Nomad. + ID string + + // Description is a human-readable, operator set description that can + // provide additional context about the binding role. This is an + // operational field. + Description string + + // AuthMethod is the name of the auth method for which this rule applies + // to. This is required and the method must exist within state before the + // cluster administrator can create the rule. + AuthMethod string + + CreateIndex uint64 + ModifyIndex uint64 +} diff --git a/api/acl_test.go b/api/acl_test.go index 0a6e6c973..f0b434f7b 100644 --- a/api/acl_test.go +++ b/api/acl_test.go @@ -618,7 +618,6 @@ func TestACLAuthMethods(t *testing.T) { must.Len(t, 1, aclAuthMethodsListResp) must.Eq(t, authMethod.Name, aclAuthMethodsListResp[0].Name) must.True(t, aclAuthMethodsListResp[0].Default) - must.SliceNotEmpty(t, aclAuthMethodsListResp[0].Hash) assertQueryMeta(t, queryMeta) // Read the auth-method. @@ -655,3 +654,85 @@ func TestACLAuthMethods(t *testing.T) { must.Len(t, 0, aclAuthMethodsListResp) assertQueryMeta(t, queryMeta) } + +func TestACLBindingRules(t *testing.T) { + testutil.Parallel(t) + + testClient, testServer, _ := makeACLClient(t, nil, nil) + defer testServer.Stop() + + // + aclAuthMethod := ACLAuthMethod{ + Name: "auth0", + Type: ACLAuthMethodTypeOIDC, + TokenLocality: ACLAuthMethodTokenLocalityGlobal, + MaxTokenTTL: 10 * time.Hour, + Default: true, + } + _, _, err := testClient.ACLAuthMethods().Create(&aclAuthMethod, nil) + must.NoError(t, err) + + // An initial listing shouldn't return any results. + aclBindingRulesListResp, queryMeta, err := testClient.ACLBindingRules().List(nil) + must.NoError(t, err) + must.Len(t, 0, aclBindingRulesListResp) + assertQueryMeta(t, queryMeta) + + // Create an ACL auth-method. + bindingRule := ACLBindingRule{ + Description: "my-binding-rule", + AuthMethod: "auth0", + Selector: "nomad-engineering-team in list.groups", + BindType: "role", + BindName: "cluster-admin", + } + _, writeMeta, err := testClient.ACLBindingRules().Create(&bindingRule, nil) + must.NoError(t, err) + assertWriteMeta(t, writeMeta) + + // Another listing should return one result. + aclBindingRulesListResp, queryMeta, err = testClient.ACLBindingRules().List(nil) + must.NoError(t, err) + must.Len(t, 1, aclBindingRulesListResp) + must.NotEq(t, "", aclBindingRulesListResp[0].ID) + must.Eq(t, "auth0", aclBindingRulesListResp[0].AuthMethod) + assertQueryMeta(t, queryMeta) + + bindingRuleID := aclBindingRulesListResp[0].ID + + // Read the binding rule. + aclBindingRuleReadResp, queryMeta, err := testClient.ACLBindingRules().Get(bindingRuleID, nil) + must.NoError(t, err) + assertQueryMeta(t, queryMeta) + must.NotNil(t, aclBindingRuleReadResp) + must.Eq(t, bindingRuleID, aclBindingRuleReadResp.ID) + must.Eq(t, bindingRule.Description, aclBindingRuleReadResp.Description) + must.Eq(t, bindingRule.AuthMethod, aclBindingRuleReadResp.AuthMethod) + must.Eq(t, bindingRule.Selector, aclBindingRuleReadResp.Selector) + must.Eq(t, bindingRule.BindType, aclBindingRuleReadResp.BindType) + must.Eq(t, bindingRule.BindName, aclBindingRuleReadResp.BindName) + + // Update the binding rule description. + bindingRule.ID = bindingRuleID + bindingRule.Description = "my-binding-rule-updated" + aclBindingRuleUpdateResp, writeMeta, err := testClient.ACLBindingRules().Update(&bindingRule, nil) + must.NoError(t, err) + assertWriteMeta(t, writeMeta) + must.Eq(t, bindingRuleID, aclBindingRuleUpdateResp.ID) + must.Eq(t, bindingRule.Description, aclBindingRuleUpdateResp.Description) + must.Eq(t, bindingRule.AuthMethod, aclBindingRuleUpdateResp.AuthMethod) + must.Eq(t, bindingRule.Selector, aclBindingRuleUpdateResp.Selector) + must.Eq(t, bindingRule.BindType, aclBindingRuleUpdateResp.BindType) + must.Eq(t, bindingRule.BindName, aclBindingRuleUpdateResp.BindName) + + // Delete the role. + writeMeta, err = testClient.ACLBindingRules().Delete(bindingRuleID, nil) + must.NoError(t, err) + assertWriteMeta(t, writeMeta) + + // Make sure there are no ACL auth-methods now present. + aclBindingRulesListResp, queryMeta, err = testClient.ACLBindingRules().List(nil) + must.NoError(t, err) + must.Len(t, 0, aclBindingRulesListResp) + assertQueryMeta(t, queryMeta) +} diff --git a/command/agent/acl_endpoint.go b/command/agent/acl_endpoint.go index abdb471f0..04c6c2742 100644 --- a/command/agent/acl_endpoint.go +++ b/command/agent/acl_endpoint.go @@ -676,3 +676,156 @@ func (s *HTTPServer) aclAuthMethodUpsertRequest( } return nil, nil } + +// ACLBindingRuleListRequest performs a listing of ACL binding rules and is +// callable via the /v1/acl/binding-rules HTTP API. +func (s *HTTPServer) ACLBindingRuleListRequest(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.ACLBindingRulesListRequest{} + + if s.parse(resp, req, &args.Region, &args.QueryOptions) { + return nil, nil + } + + // Perform the RPC request. + var reply structs.ACLBindingRulesListResponse + if err := s.agent.RPC(structs.ACLListBindingRulesRPCMethod, &args, &reply); err != nil { + return nil, err + } + + setMeta(resp, &reply.QueryMeta) + + if reply.ACLBindingRules == nil { + reply.ACLBindingRules = make([]*structs.ACLBindingRuleListStub, 0) + } + return reply.ACLBindingRules, nil +} + +// ACLBindingRuleRequest creates a new ACL binding rule and is callable via the +// /v1/acl/binding-rule HTTP API. +func (s *HTTPServer) ACLBindingRuleRequest(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.aclBindingRuleUpsertRequest(resp, req, "") +} + +// ACLBindingRuleSpecificRequest is callable via the /v1/acl/binding-rule/ HTTP +// API and handles read via both the ID, updates, and deletions. +func (s *HTTPServer) ACLBindingRuleSpecificRequest(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/binding-rule/") + + // Ensure the binding rule ID is not an empty string which is possible if + // the caller requested "/v1/acl/role/binding-rule/". + if reqSuffix == "" { + return nil, CodedError(http.StatusBadRequest, "missing ACL binding rule ID") + } + + // Identify the HTTP method which indicates which downstream function + // should be called. + switch req.Method { + case http.MethodGet: + return s.aclBindingRuleGetRequest(resp, req, reqSuffix) + case http.MethodDelete: + return s.aclBindingRuleDeleteRequest(resp, req, reqSuffix) + case http.MethodPost, http.MethodPut: + return s.aclBindingRuleUpsertRequest(resp, req, reqSuffix) + default: + return nil, CodedError(http.StatusMethodNotAllowed, ErrInvalidMethod) + } +} + +func (s *HTTPServer) aclBindingRuleGetRequest( + resp http.ResponseWriter, req *http.Request, ruleID string) (interface{}, error) { + + args := structs.ACLBindingRuleRequest{ + ACLBindingRuleID: ruleID, + } + if s.parse(resp, req, &args.Region, &args.QueryOptions) { + return nil, nil + } + + var reply structs.ACLBindingRuleResponse + if err := s.agent.RPC(structs.ACLGetBindingRuleRPCMethod, &args, &reply); err != nil { + return nil, err + } + setMeta(resp, &reply.QueryMeta) + + if reply.ACLBindingRule == nil { + return nil, CodedError(http.StatusNotFound, "ACL binding rule not found") + } + return reply.ACLBindingRule, nil +} + +func (s *HTTPServer) aclBindingRuleDeleteRequest( + resp http.ResponseWriter, req *http.Request, ruleID string) (interface{}, error) { + + args := structs.ACLBindingRulesDeleteRequest{ + ACLBindingRuleIDs: []string{ruleID}, + } + s.parseWriteRequest(req, &args.WriteRequest) + + var reply structs.ACLBindingRulesDeleteResponse + if err := s.agent.RPC(structs.ACLDeleteBindingRulesRPCMethod, &args, &reply); err != nil { + return nil, err + } + setIndex(resp, reply.Index) + return nil, nil +} + +// aclBindingRuleUpsertRequest handles upserting an ACL binding rule to the +// Nomad servers. It can handle both new creations, and updates to existing +// rules. +func (s *HTTPServer) aclBindingRuleUpsertRequest( + resp http.ResponseWriter, req *http.Request, ruleID string) (interface{}, error) { + + // Decode the ACL binding rule. + var aclBindingRule structs.ACLBindingRule + if err := decodeBody(req, &aclBindingRule); err != nil { + return nil, CodedError(http.StatusBadRequest, err.Error()) + } + + // If the request path includes an ID, ensure the payload has an ID if it + // has been left empty. + if ruleID != "" && aclBindingRule.ID == "" { + aclBindingRule.ID = ruleID + } + + // Ensure the request path ID matches the ACL binding rule 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 binding + // rule request path. + if ruleID != "" && ruleID != aclBindingRule.ID { + return nil, CodedError(http.StatusBadRequest, "ACL binding rule ID does not match request path") + } + + args := structs.ACLBindingRulesUpsertRequest{ + ACLBindingRules: []*structs.ACLBindingRule{&aclBindingRule}, + } + s.parseWriteRequest(req, &args.WriteRequest) + + var out structs.ACLBindingRulesUpsertResponse + if err := s.agent.RPC(structs.ACLUpsertBindingRulesRPCMethod, &args, &out); err != nil { + return nil, err + } + setIndex(resp, out.Index) + + if len(out.ACLBindingRules) > 0 { + return out.ACLBindingRules[0], nil + } + return nil, nil +} diff --git a/command/agent/acl_endpoint_test.go b/command/agent/acl_endpoint_test.go index 70278599d..59ae857d8 100644 --- a/command/agent/acl_endpoint_test.go +++ b/command/agent/acl_endpoint_test.go @@ -1297,3 +1297,328 @@ func TestHTTPServer_ACLAuthMethodSpecificRequest(t *testing.T) { }) } } + +func TestHTTPServer_ACLBindingRuleListRequest(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/binding-rules", nil) + must.NoError(t, err) + respW := httptest.NewRecorder() + + // Send the HTTP request. + obj, err := srv.Server.ACLBindingRuleListRequest(respW, req) + must.EqError(t, err, "Permission denied") + must.Nil(t, obj) + }, + }, + { + name: "invalid method", + testFn: func(srv *TestAgent) { + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodConnect, "/v1/acl/binding-rules", nil) + must.NoError(t, err) + respW := httptest.NewRecorder() + + // Ensure we have a token set. + setToken(req, srv.RootToken) + + // Send the HTTP request. + obj, err := srv.Server.ACLBindingRuleListRequest(respW, req) + must.EqError(t, err, "Invalid method") + must.Nil(t, obj) + }, + }, + { + name: "no binding rules in state", + testFn: func(srv *TestAgent) { + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodGet, "/v1/acl/binding-rules", nil) + must.NoError(t, err) + respW := httptest.NewRecorder() + + // Ensure we have a token set. + setToken(req, srv.RootToken) + + // Send the HTTP request. + obj, err := srv.Server.ACLBindingRuleListRequest(respW, req) + must.NoError(t, err) + must.Len(t, 0, obj.([]*structs.ACLBindingRuleListStub)) + }, + }, + { + name: "binding rules in state", + testFn: func(srv *TestAgent) { + + // Upsert two binding rules into state. + must.NoError(t, srv.server.State().UpsertACLBindingRules( + 10, []*structs.ACLBindingRule{mock.ACLBindingRule(), mock.ACLBindingRule()}, true)) + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodGet, "/v1/acl/binding-rules", nil) + must.NoError(t, err) + respW := httptest.NewRecorder() + + // Ensure we have a token set. + setToken(req, srv.RootToken) + + // Send the HTTP request. + obj, err := srv.Server.ACLBindingRuleListRequest(respW, req) + must.NoError(t, err) + must.Len(t, 2, obj.([]*structs.ACLBindingRuleListStub)) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + httpACLTest(t, nil, tc.testFn) + }) + } +} + +func TestHTTPServer_ACLBindingRuleRequest(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 binding rule to use in the request body. + mockACLBindingRule := mock.ACLBindingRule() + mockACLBindingRule.ID = "" + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodPut, "/v1/acl/binding-rule", encodeReq(mockACLBindingRule)) + must.NoError(t, err) + respW := httptest.NewRecorder() + + // Send the HTTP request. + obj, err := srv.Server.ACLBindingRuleRequest(respW, req) + must.Error(t, err) + must.StrContains(t, err.Error(), "Permission denied") + must.Nil(t, obj) + }, + }, + { + name: "invalid method", + testFn: func(srv *TestAgent) { + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodConnect, "/v1/acl/binding-rule", nil) + must.NoError(t, err) + respW := httptest.NewRecorder() + + // Ensure we have a token set. + setToken(req, srv.RootToken) + + // Send the HTTP request. + obj, err := srv.Server.ACLBindingRuleRequest(respW, req) + must.Error(t, err) + must.StrContains(t, err.Error(), "Invalid method") + must.Nil(t, obj) + }, + }, + { + name: "successful upsert", + testFn: func(srv *TestAgent) { + + // Upsert the auth method that the binding rule will associate + // with. + mockACLAuthMethod := mock.ACLAuthMethod() + must.NoError(t, srv.server.State().UpsertACLAuthMethods( + 10, []*structs.ACLAuthMethod{mockACLAuthMethod})) + + // Create a mock binding rule to use in the request body. + mockACLBindingRule := mock.ACLBindingRule() + mockACLBindingRule.AuthMethod = mockACLAuthMethod.Name + mockACLBindingRule.ID = "" + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodPut, "/v1/acl/binding-rule", encodeReq(mockACLBindingRule)) + must.NoError(t, err) + respW := httptest.NewRecorder() + + // Ensure we have a token set. + setToken(req, srv.RootToken) + + // Send the HTTP request. + obj, err := srv.Server.ACLBindingRuleRequest(respW, req) + must.NoError(t, err) + + result := obj.(*structs.ACLBindingRule) + must.Eq(t, mockACLBindingRule.Selector, result.Selector) + must.Eq(t, mockACLBindingRule.AuthMethod, result.AuthMethod) + must.Eq(t, mockACLBindingRule.Description, result.Description) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + httpACLTest(t, nil, tc.testFn) + }) + } +} + +func TestHTTPServer_ACLBindingRuleSpecificRequest(t *testing.T) { + ci.Parallel(t) + + testCases := []struct { + name string + testFn func(srv *TestAgent) + }{ + { + name: "missing binding rule ID", + testFn: func(srv *TestAgent) { + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodGet, "/v1/acl/binding-rule/", nil) + must.NoError(t, err) + respW := httptest.NewRecorder() + + // Send the HTTP request. + obj, err := srv.Server.ACLBindingRuleSpecificRequest(respW, req) + must.EqError(t, err, "missing ACL binding rule ID") + must.Nil(t, obj) + }, + }, + { + name: "incorrect method", + testFn: func(srv *TestAgent) { + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodConnect, "/v1/acl/binding-rule/foobar", nil) + must.NoError(t, err) + respW := httptest.NewRecorder() + + // Send the HTTP request. + obj, err := srv.Server.ACLBindingRuleSpecificRequest(respW, req) + must.EqError(t, err, "Invalid method") + must.Nil(t, obj) + }, + }, + { + name: "get binding rule", + testFn: func(srv *TestAgent) { + + // Upsert a binding rule into state. + aclBindingRules := []*structs.ACLBindingRule{mock.ACLBindingRule()} + must.NoError(t, srv.server.State().UpsertACLBindingRules(10, aclBindingRules, true)) + + url := "/v1/acl/binding-rule/" + aclBindingRules[0].ID + + // Build the HTTP request. + req, err := http.NewRequest(http.MethodGet, url, nil) + must.NoError(t, err) + respW := httptest.NewRecorder() + + // Ensure we have a token set. + setToken(req, srv.RootToken) + + // Send the HTTP request. + obj, err := srv.Server.ACLBindingRuleSpecificRequest(respW, req) + must.NoError(t, err) + + result := obj.(*structs.ACLBindingRule) + must.Eq(t, aclBindingRules[0].ID, result.ID) + must.Eq(t, aclBindingRules[0].Selector, result.Selector) + must.Eq(t, aclBindingRules[0].AuthMethod, result.AuthMethod) + must.Eq(t, aclBindingRules[0].Description, result.Description) + }, + }, + { + name: "get, update, and delete binding rule", + testFn: func(srv *TestAgent) { + + // Upsert the auth method that the binding rule will associate + // with. + mockACLAuthMethod := mock.ACLAuthMethod() + must.NoError(t, srv.server.State().UpsertACLAuthMethods( + 10, []*structs.ACLAuthMethod{mockACLAuthMethod})) + + // Upsert a binding rule into state. + mockedAclBindingRule := mock.ACLBindingRule() + mockedAclBindingRule.AuthMethod = mockACLAuthMethod.Name + + must.NoError(t, srv.server.State().UpsertACLBindingRules( + 10, []*structs.ACLBindingRule{mockedAclBindingRule}, true)) + + url := "/v1/acl/binding-rule/" + mockedAclBindingRule.ID + + // Build the HTTP request to read the binding rule. + req, err := http.NewRequest(http.MethodGet, url, nil) + must.NoError(t, err) + respW := httptest.NewRecorder() + + // Ensure we have a token set. + setToken(req, srv.RootToken) + + // Send the HTTP request. + obj, err := srv.Server.ACLBindingRuleSpecificRequest(respW, req) + must.NoError(t, err) + + result := obj.(*structs.ACLBindingRule) + must.Eq(t, mockedAclBindingRule.ID, result.ID) + must.Eq(t, mockedAclBindingRule.Selector, result.Selector) + must.Eq(t, mockedAclBindingRule.AuthMethod, result.AuthMethod) + must.Eq(t, mockedAclBindingRule.Description, result.Description) + + // Update the binding rule and make the request via the HTTP + // API. + result.Description = "new description" + + req, err = http.NewRequest(http.MethodPost, url, encodeReq(result)) + must.NoError(t, err) + respW = httptest.NewRecorder() + + // Ensure we have a token set. + setToken(req, srv.RootToken) + + // Send the HTTP request. + _, err = srv.Server.ACLBindingRuleSpecificRequest(respW, req) + must.NoError(t, err) + + // Delete the ACL binding rule. + req, err = http.NewRequest(http.MethodDelete, url, nil) + must.NoError(t, err) + respW = httptest.NewRecorder() + + // Ensure we have a token set. + setToken(req, srv.RootToken) + + // Send the HTTP request. + obj, err = srv.Server.ACLBindingRuleSpecificRequest(respW, req) + must.NoError(t, err) + must.Nil(t, obj) + + // Ensure the ACL binding rule is no longer stored within + // state. + aclBindingRule, err := srv.server.State().GetACLBindingRule(nil, mockedAclBindingRule.ID) + must.NoError(t, err) + must.Nil(t, aclBindingRule) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cb := func(c *Config) { c.NomadConfig.ACLTokenMaxExpirationTTL = 3600 * time.Hour } + httpACLTest(t, cb, tc.testFn) + }) + } +} diff --git a/command/agent/http.go b/command/agent/http.go index f6ecc8f61..76a754e49 100644 --- a/command/agent/http.go +++ b/command/agent/http.go @@ -392,6 +392,11 @@ func (s *HTTPServer) registerHandlers(enableDebug bool) { s.mux.HandleFunc("/v1/acl/auth-method", s.wrap(s.ACLAuthMethodRequest)) s.mux.HandleFunc("/v1/acl/auth-method/", s.wrap(s.ACLAuthMethodSpecificRequest)) + // Register our ACL binding rule handlers. + s.mux.HandleFunc("/v1/acl/binding-rules", s.wrap(s.ACLBindingRuleListRequest)) + s.mux.HandleFunc("/v1/acl/binding-rule", s.wrap(s.ACLBindingRuleRequest)) + s.mux.HandleFunc("/v1/acl/binding-rule/", s.wrap(s.ACLBindingRuleSpecificRequest)) + 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))) diff --git a/nomad/acl_endpoint.go b/nomad/acl_endpoint.go index a9469ca23..ef6ed6e0e 100644 --- a/nomad/acl_endpoint.go +++ b/nomad/acl_endpoint.go @@ -2005,3 +2005,327 @@ func (a *ACL) WhoAmI(args *structs.GenericRequest, reply *structs.ACLWhoAmIRespo reply.Identity = args.GetIdentity() return nil } + +// UpsertBindingRules creates or updates ACL binding rules held within Nomad. +func (a *ACL) UpsertBindingRules( + args *structs.ACLBindingRulesUpsertRequest, reply *structs.ACLBindingRulesUpsertResponse) error { + + // Ensure ACLs are enabled, and always flow modification requests to the + // authoritative region. + if !a.srv.config.ACLEnabled { + return aclDisabled + } + args.Region = a.srv.config.AuthoritativeRegion + + if done, err := a.srv.forward(structs.ACLUpsertBindingRulesRPCMethod, args, args, reply); done { + return err + } + defer metrics.MeasureSince([]string{"nomad", "acl", "upsert_binding_rules"}, time.Now()) + + // ACL binding rules can only be used once all servers in all federated + // regions have been upgraded to 1.5.0 or greater. + if !ServersMeetMinimumVersion(a.srv.Members(), AllRegions, minACLBindingRuleVersion, false) { + return fmt.Errorf("all servers should be running version %v or later to use ACL binding rules", + minACLBindingRuleVersion) + } + + // Check management level permissions + if acl, err := a.srv.ResolveToken(args.AuthToken); err != nil { + return err + } else if acl == nil || !acl.IsManagement() { + return structs.ErrPermissionDenied + } + + // Validate non-zero set of binding rules. This must be done outside the + // validate function as that uses a loop, which will be skipped if the + // length is zero. + if len(args.ACLBindingRules) == 0 { + return structs.NewErrRPCCoded(http.StatusBadRequest, "must specify as least one binding rule") + } + + stateSnapshot, err := a.srv.State().Snapshot() + if err != nil { + return err + } + + // Validate each binding rules and compute the hash. + for idx, bindingRule := range args.ACLBindingRules { + + if err := bindingRule.Validate(); err != nil { + return structs.NewErrRPCCodedf(http.StatusBadRequest, "binding rule %d invalid: %v", idx, err) + } + + // If the caller has passed a rule ID, this call is considered an + // update to an existing rule. We should therefore ensure it is found + // within state. + if bindingRule.ID != "" { + existingBindingRule, err := stateSnapshot.GetACLBindingRule(nil, bindingRule.ID) + if err != nil { + return structs.NewErrRPCCodedf( + http.StatusInternalServerError, "binding rule lookup failed: %v", err) + } + if existingBindingRule == nil { + return structs.NewErrRPCCodedf( + http.StatusBadRequest, "cannot find binding rule %s", bindingRule.ID) + } + } + + // Ensure the auth method linked to exists within state. + method, err := stateSnapshot.GetACLAuthMethodByName(nil, bindingRule.AuthMethod) + if err != nil { + return err + } + if method == nil { + return structs.NewErrRPCCodedf( + http.StatusBadRequest, "ACL auth method %s not found", bindingRule.AuthMethod) + } + + // All the validation has passed, we can now canonicalize the object + // with the final internal data and set the hash. + bindingRule.Canonicalize() + bindingRule.SetHash() + } + + // Update via Raft. + out, index, err := a.srv.raftApply(structs.ACLBindingRulesUpsertRequestType, args) + if err != nil { + return err + } + + // Check if the FSM response, which is an interface, contains an error. + if err, ok := out.(error); ok && err != nil { + return err + } + + // Populate the response. We do a lookup against the state to pick up the + // proper create / modify indexes. + stateSnapshot, err = a.srv.State().Snapshot() + if err != nil { + return err + } + for _, bindingRule := range args.ACLBindingRules { + lookupBindingRule, err := stateSnapshot.GetACLBindingRule(nil, bindingRule.ID) + if err != nil { + return structs.NewErrRPCCodedf(http.StatusInternalServerError, + "ACL binding rule lookup failed: %v", err) + } + if lookupBindingRule == nil { + return structs.NewErrRPCCoded(http.StatusInternalServerError, + "ACL binding rule lookup failed: no entry found") + } + reply.ACLBindingRules = append(reply.ACLBindingRules, lookupBindingRule) + } + + // Update the index + reply.Index = index + return nil +} + +// DeleteBindingRules batch deletes ACL binding rules from Nomad state. +func (a *ACL) DeleteBindingRules( + args *structs.ACLBindingRulesDeleteRequest, reply *structs.ACLBindingRulesDeleteResponse) error { + + // Ensure ACLs are enabled, and always flow modification requests to the + // authoritative region. + if !a.srv.config.ACLEnabled { + return aclDisabled + } + args.Region = a.srv.config.AuthoritativeRegion + + if done, err := a.srv.forward(structs.ACLDeleteBindingRulesRPCMethod, args, args, reply); done { + return err + } + defer metrics.MeasureSince([]string{"nomad", "acl", "delete_binding_rules"}, time.Now()) + + // ACL binding rules can only be used once all servers in all federated + // regions have been upgraded to 1.5.0 or greater. + if !ServersMeetMinimumVersion(a.srv.Members(), AllRegions, minACLBindingRuleVersion, false) { + return fmt.Errorf("all servers should be running version %v or later to use ACL binding rules", + minACLBindingRuleVersion) + } + + // Check management level permissions. + if acl, err := a.srv.ResolveToken(args.AuthToken); err != nil { + return err + } else if acl == nil || !acl.IsManagement() { + return structs.ErrPermissionDenied + } + + // Validate non-zero set of binding rule IDs. + if len(args.ACLBindingRuleIDs) == 0 { + return structs.NewErrRPCCoded(http.StatusBadRequest, "must specify as least one binding rule") + } + + // Update via Raft. + out, index, err := a.srv.raftApply(structs.ACLBindingRulesDeleteRequestType, args) + if err != nil { + return err + } + + // Check if the FSM response, which is an interface, contains an error. + if err, ok := out.(error); ok && err != nil { + return err + } + + // Update the index + reply.Index = index + return nil +} + +// ListBindingRules returns a stub list of ACL binding rules. +func (a *ACL) ListBindingRules( + args *structs.ACLBindingRulesListRequest, reply *structs.ACLBindingRulesListResponse) error { + + // Only allow operators to list ACL binding rules when ACLs are enabled. + if !a.srv.config.ACLEnabled { + return aclDisabled + } + + if done, err := a.srv.forward(structs.ACLListBindingRulesRPCMethod, args, args, reply); done { + return err + } + defer metrics.MeasureSince([]string{"nomad", "acl", "list_binding_rules"}, time.Now()) + + // Check management level permissions. + if acl, err := a.srv.ResolveToken(args.AuthToken); err != nil { + return err + } else if acl == nil || !acl.IsManagement() { + return structs.ErrPermissionDenied + } + + // Set up and return the blocking query. + return a.srv.blockingRPC(&blockingOptions{ + queryOpts: &args.QueryOptions, + queryMeta: &reply.QueryMeta, + run: func(ws memdb.WatchSet, stateStore *state.StateStore) error { + + // The iteration below appends directly to the reply object, so in + // order for blocking queries to work properly we must ensure the + // ACLBindingRules are reset. This allows the blocking query run + // function to work as expected. + reply.ACLBindingRules = nil + + iter, err := stateStore.GetACLBindingRules(ws) + if err != nil { + return err + } + + // Iterate all the results and add these to our reply object. + for raw := iter.Next(); raw != nil; raw = iter.Next() { + reply.ACLBindingRules = append(reply.ACLBindingRules, raw.(*structs.ACLBindingRule).Stub()) + } + + // Use the index table to populate the query meta as we have no way + // of tracking the max index on deletes. + return a.srv.setReplyQueryMeta(stateStore, state.TableACLBindingRules, &reply.QueryMeta) + }, + }) +} + +// GetBindingRules is used to query for a set of ACL binding rules. This +// endpoint is used for replication purposes and is not exposed via the HTTP +// API. +func (a *ACL) GetBindingRules( + args *structs.ACLBindingRulesRequest, reply *structs.ACLBindingRulesResponse) error { + + // This endpoint is only used by the replication process which is only + // running on ACL enabled clusters, so this check should never be + // triggered. + if !a.srv.config.ACLEnabled { + return aclDisabled + } + + if done, err := a.srv.forward(structs.ACLGetBindingRulesRPCMethod, args, args, reply); done { + return err + } + defer metrics.MeasureSince([]string{"nomad", "acl", "get_rules"}, time.Now()) + + // Check management level permissions. + if acl, err := a.srv.ResolveToken(args.AuthToken); err != nil { + return err + } else if acl == nil || !acl.IsManagement() { + return structs.ErrPermissionDenied + } + + // Set up and return the blocking query + return a.srv.blockingRPC(&blockingOptions{ + queryOpts: &args.QueryOptions, + queryMeta: &reply.QueryMeta, + run: func(ws memdb.WatchSet, stateStore *state.StateStore) error { + + // Instantiate the output map to the correct maximum length. + reply.ACLBindingRules = make(map[string]*structs.ACLBindingRule, len(args.ACLBindingRuleIDs)) + + // Look for the ACL role and add this to our mapping if we have + // found it. + for _, bindingRuleID := range args.ACLBindingRuleIDs { + out, err := stateStore.GetACLBindingRule(ws, bindingRuleID) + if err != nil { + return err + } + if out != nil { + reply.ACLBindingRules[out.ID] = out + } + } + + // Use the index table to populate the query meta as we have no way + // of tracking the max index on deletes. + return a.srv.setReplyQueryMeta(stateStore, state.TableACLBindingRules, &reply.QueryMeta) + }, + }) +} + +// GetBindingRule is used to retrieve a single ACL binding rule as defined by +// its ID. +func (a *ACL) GetBindingRule( + args *structs.ACLBindingRuleRequest, reply *structs.ACLBindingRuleResponse) error { + + // Only allow operators to read an ACL binding rule when ACLs are enabled. + if !a.srv.config.ACLEnabled { + return aclDisabled + } + + if done, err := a.srv.forward(structs.ACLGetBindingRuleRPCMethod, args, args, reply); done { + return err + } + defer metrics.MeasureSince([]string{"nomad", "acl", "get_binding_rule"}, time.Now()) + + // Check management level permissions. + if acl, err := a.srv.ResolveToken(args.AuthToken); err != nil { + return err + } else if acl == nil || !acl.IsManagement() { + return structs.ErrPermissionDenied + } + + // Set up and return the blocking query. + return a.srv.blockingRPC(&blockingOptions{ + queryOpts: &args.QueryOptions, + queryMeta: &reply.QueryMeta, + run: func(ws memdb.WatchSet, stateStore *state.StateStore) error { + + // Perform a lookup for the ACL role. + out, err := stateStore.GetACLBindingRule(ws, args.ACLBindingRuleID) + if err != nil { + return err + } + + // Set the index correctly depending on whether the ACL binding + // rule was found. + switch out { + case nil: + index, err := stateStore.Index(state.TableACLBindingRules) + if err != nil { + return err + } + reply.Index = index + default: + reply.Index = out.ModifyIndex + } + + // We didn't encounter an error looking up the index; set the ACL + // binding rule on the reply and exit successfully. + reply.ACLBindingRule = out + return nil + }, + }) +} diff --git a/nomad/acl_endpoint_test.go b/nomad/acl_endpoint_test.go index aa200deaa..c578fc25d 100644 --- a/nomad/acl_endpoint_test.go +++ b/nomad/acl_endpoint_test.go @@ -3057,3 +3057,412 @@ func TestACLEndpoint_UpsertACLAuthMethods(t *testing.T) { // We expect this to err since there's already a default method of the same type must.Error(t, msgpackrpc.CallWithCodec(codec, structs.ACLUpsertAuthMethodsRPCMethod, req, &resp)) } + +func TestACL_UpsertBindingRules(t *testing.T) { + ci.Parallel(t) + + testServer, aclRootToken, testServerCleanupFn := TestACLServer(t, nil) + defer testServerCleanupFn() + codec := rpcClient(t, testServer) + testutil.WaitForLeader(t, testServer.RPC) + + // Create a mock ACL binding rule and remove the ID so this looks like a + // creation. + aclBindingRule1 := mock.ACLBindingRule() + aclBindingRule1.ID = "" + + // Attempt to upsert this binding rule without setting an ACL token. This + // should fail. + aclBindingRuleReq1 := &structs.ACLBindingRulesUpsertRequest{ + ACLBindingRules: []*structs.ACLBindingRule{aclBindingRule1}, + WriteRequest: structs.WriteRequest{ + Region: "global", + }, + } + var aclBindingRuleResp1 structs.ACLBindingRulesUpsertResponse + err := msgpackrpc.CallWithCodec(codec, structs.ACLUpsertBindingRulesRPCMethod, aclBindingRuleReq1, &aclBindingRuleResp1) + must.EqError(t, err, "Permission denied") + + // Attempt to upsert this binding rule that references a auth method that + // does not exist in state. + aclBindingRuleReq2 := &structs.ACLBindingRulesUpsertRequest{ + ACLBindingRules: []*structs.ACLBindingRule{aclBindingRule1}, + WriteRequest: structs.WriteRequest{ + Region: "global", + AuthToken: aclRootToken.SecretID, + }, + } + var aclBindingRuleResp2 structs.ACLBindingRulesUpsertResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLUpsertBindingRulesRPCMethod, aclBindingRuleReq2, &aclBindingRuleResp2) + must.EqError(t, err, "RPC Error:: 400,ACL auth method auth0 not found") + + // Create the policies our ACL roles wants to link to. + authMethod := mock.ACLAuthMethod() + authMethod.Name = aclBindingRule1.AuthMethod + + must.NoError(t, testServer.fsm.State().UpsertACLAuthMethods(10, []*structs.ACLAuthMethod{authMethod})) + + // Try the upsert a third time, which should succeed. + aclBindingRuleReq3 := &structs.ACLBindingRulesUpsertRequest{ + ACLBindingRules: []*structs.ACLBindingRule{aclBindingRule1}, + WriteRequest: structs.WriteRequest{ + Region: "global", + AuthToken: aclRootToken.SecretID, + }, + } + var aclBindingRuleResp3 structs.ACLBindingRulesUpsertResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLUpsertBindingRulesRPCMethod, aclBindingRuleReq3, &aclBindingRuleResp3) + must.NoError(t, err) + must.Len(t, 1, aclBindingRuleResp3.ACLBindingRules) + + // Perform an update of the ACL binding rule by updating the description. + aclBindingRule1Copy := aclBindingRule1.Copy() + aclBindingRule1Copy.Description = "updated-description" + + aclBindingRuleReq4 := &structs.ACLBindingRulesUpsertRequest{ + ACLBindingRules: []*structs.ACLBindingRule{aclBindingRule1}, + WriteRequest: structs.WriteRequest{ + Region: "global", + AuthToken: aclRootToken.SecretID, + }, + } + var aclBindingRuleResp4 structs.ACLBindingRulesUpsertResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLUpsertBindingRulesRPCMethod, aclBindingRuleReq4, &aclBindingRuleResp4) + must.NoError(t, err) + must.Len(t, 1, aclBindingRuleResp4.ACLBindingRules) + must.Greater(t, aclBindingRuleResp3.ACLBindingRules[0].ModifyIndex, aclBindingRuleResp4.ACLBindingRules[0].ModifyIndex) + + // Create another ACL binding rule that will fail validation. Attempting to + // upsert this ensures the handler is triggering the validation function. + aclBindingRule2 := mock.ACLBindingRule() + aclBindingRule2.BindType = "" + + aclBindingRuleReq5 := &structs.ACLBindingRulesUpsertRequest{ + ACLBindingRules: []*structs.ACLBindingRule{aclBindingRule2}, + WriteRequest: structs.WriteRequest{ + Region: DefaultRegion, + AuthToken: aclRootToken.SecretID, + }, + } + var aclBindingRuleResp5 structs.ACLBindingRulesUpsertResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLUpsertBindingRulesRPCMethod, aclBindingRuleReq5, &aclBindingRuleResp5) + must.Error(t, err) + must.StrContains(t, err.Error(), "bind type is missing") +} + +func TestACL_DeleteBindingRules(t *testing.T) { + ci.Parallel(t) + + testServer, aclRootToken, testServerCleanupFn := TestACLServer(t, nil) + defer testServerCleanupFn() + codec := rpcClient(t, testServer) + testutil.WaitForLeader(t, testServer.RPC) + + // Create two ACL binding rules and put these directly into state. + aclBindingRules := []*structs.ACLBindingRule{mock.ACLBindingRule(), mock.ACLBindingRule()} + must.NoError(t, testServer.State().UpsertACLBindingRules(10, aclBindingRules, true)) + + // Attempt to delete an ACL binding rule without setting an auth token. + // This should fail. + aclBindingRuleReq1 := &structs.ACLBindingRulesDeleteRequest{ + ACLBindingRuleIDs: []string{aclBindingRules[0].ID}, + WriteRequest: structs.WriteRequest{ + Region: DefaultRegion, + }, + } + var aclBindingRuleResp1 structs.ACLBindingRulesDeleteResponse + err := msgpackrpc.CallWithCodec(codec, structs.ACLDeleteBindingRulesRPCMethod, aclBindingRuleReq1, &aclBindingRuleResp1) + must.EqError(t, err, "Permission denied") + + // Attempt to delete an ACL binding rule now using a valid management token + // which should succeed. + aclBindingRuleReq2 := &structs.ACLBindingRulesDeleteRequest{ + ACLBindingRuleIDs: []string{aclBindingRules[0].ID}, + WriteRequest: structs.WriteRequest{ + Region: DefaultRegion, + AuthToken: aclRootToken.SecretID, + }, + } + var aclBindingRuleResp2 structs.ACLBindingRulesDeleteResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLDeleteBindingRulesRPCMethod, aclBindingRuleReq2, &aclBindingRuleResp2) + must.NoError(t, err) + + // Ensure the deleted binding rule is not found within state and that the + // other is. + ws := memdb.NewWatchSet() + iter, err := testServer.State().GetACLBindingRules(ws) + must.NoError(t, err) + + var aclBindingRulesLookup []*structs.ACLBindingRule + for raw := iter.Next(); raw != nil; raw = iter.Next() { + aclBindingRulesLookup = append(aclBindingRulesLookup, raw.(*structs.ACLBindingRule)) + } + + must.Len(t, 1, aclBindingRulesLookup) + must.Eq(t, aclBindingRulesLookup[0], aclBindingRules[1]) + + // Try to delete the previously deleted ACL binding rule, this should fail. + aclBindingRuleReq3 := &structs.ACLBindingRulesDeleteRequest{ + ACLBindingRuleIDs: []string{aclBindingRules[0].ID}, + WriteRequest: structs.WriteRequest{ + Region: DefaultRegion, + AuthToken: aclRootToken.SecretID, + }, + } + var aclBindingRuleResp3 structs.ACLBindingRulesDeleteResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLDeleteBindingRulesRPCMethod, aclBindingRuleReq3, &aclBindingRuleResp3) + must.EqError(t, err, "ACL binding rule not found") +} + +func TestACL_ListBindingRules(t *testing.T) { + ci.Parallel(t) + + testServer, aclRootToken, testServerCleanupFn := TestACLServer(t, nil) + defer testServerCleanupFn() + codec := rpcClient(t, testServer) + testutil.WaitForLeader(t, testServer.RPC) + + // Create two ACL binding rules and put these directly into state. + aclBindingRules := []*structs.ACLBindingRule{mock.ACLBindingRule(), mock.ACLBindingRule()} + must.NoError(t, testServer.State().UpsertACLBindingRules(10, aclBindingRules, true)) + + // Try listing binding rules without a valid ACL token. + aclBindingRuleReq1 := &structs.ACLBindingRulesListRequest{ + QueryOptions: structs.QueryOptions{ + Region: DefaultRegion, + }, + } + var aclBindingRuleResp1 structs.ACLBindingRulesListResponse + err := msgpackrpc.CallWithCodec(codec, structs.ACLListBindingRulesRPCMethod, aclBindingRuleReq1, &aclBindingRuleResp1) + must.EqError(t, err, "Permission denied") + + // Try listing roles with a valid ACL token. + aclBindingRuleReq2 := &structs.ACLBindingRulesListRequest{ + QueryOptions: structs.QueryOptions{ + Region: DefaultRegion, + AuthToken: aclRootToken.SecretID, + }, + } + var aclBindingRuleResp2 structs.ACLBindingRulesListResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLListBindingRulesRPCMethod, aclBindingRuleReq2, &aclBindingRuleResp2) + must.NoError(t, err) + must.Len(t, 2, aclBindingRuleResp2.ACLBindingRules) + + // Now test a blocking query, where we wait for an update to the list which + // is triggered by a deletion. + type res struct { + err error + reply *structs.ACLBindingRulesListResponse + } + resultCh := make(chan *res) + + go func(resultCh chan *res) { + aclBindingRuleReq3 := &structs.ACLBindingRulesListRequest{ + QueryOptions: structs.QueryOptions{ + Region: DefaultRegion, + AuthToken: aclRootToken.SecretID, + MinQueryIndex: aclBindingRuleResp2.Index, + MaxQueryTime: 10 * time.Second, + }, + } + var aclBindingRuleResp3 structs.ACLBindingRulesListResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLListBindingRulesRPCMethod, aclBindingRuleReq3, &aclBindingRuleResp3) + resultCh <- &res{err: err, reply: &aclBindingRuleResp3} + }(resultCh) + + // Delete an ACL binding rule from state which should return the blocking + // query. + must.NoError(t, testServer.fsm.State().DeleteACLBindingRules( + aclBindingRuleResp2.Index+10, []string{aclBindingRules[0].ID})) + + // Wait until the test within the routine is complete. + result := <-resultCh + must.NoError(t, result.err) + must.Len(t, 1, result.reply.ACLBindingRules) + must.NotEq(t, result.reply.ACLBindingRules[0].ID, aclBindingRules[0].ID) + must.Greater(t, aclBindingRuleResp2.Index, result.reply.Index) +} + +func TestACL_GetBindingRules(t *testing.T) { + ci.Parallel(t) + + testServer, aclRootToken, testServerCleanupFn := TestACLServer(t, nil) + defer testServerCleanupFn() + codec := rpcClient(t, testServer) + testutil.WaitForLeader(t, testServer.RPC) + + // Try reading a binding rule without setting a correct auth token. + aclBindingRuleReq1 := &structs.ACLBindingRulesRequest{ + ACLBindingRuleIDs: []string{"nope"}, + QueryOptions: structs.QueryOptions{ + Region: DefaultRegion, + }, + } + var aclBindingRuleResp1 structs.ACLBindingRulesResponse + err := msgpackrpc.CallWithCodec(codec, structs.ACLGetBindingRulesRPCMethod, aclBindingRuleReq1, &aclBindingRuleResp1) + must.EqError(t, err, "Permission denied") + must.MapEmpty(t, aclBindingRuleResp1.ACLBindingRules) + + // Try reading a binding rule that doesn't exist. + aclBindingRuleReq2 := &structs.ACLBindingRulesRequest{ + ACLBindingRuleIDs: []string{"nope"}, + QueryOptions: structs.QueryOptions{ + Region: DefaultRegion, + AuthToken: aclRootToken.SecretID, + }, + } + var aclBindingRuleResp2 structs.ACLBindingRulesResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLGetBindingRulesRPCMethod, aclBindingRuleReq2, &aclBindingRuleResp2) + must.NoError(t, err) + must.MapEmpty(t, aclBindingRuleResp1.ACLBindingRules) + + // Create two ACL binding rules and put these directly into state. + aclBindingRules := []*structs.ACLBindingRule{mock.ACLBindingRule(), mock.ACLBindingRule()} + must.NoError(t, testServer.State().UpsertACLBindingRules(10, aclBindingRules, true)) + + // Try reading both binding rules that are within state. + aclBindingRuleReq3 := &structs.ACLBindingRulesRequest{ + ACLBindingRuleIDs: []string{aclBindingRules[0].ID, aclBindingRules[1].ID}, + QueryOptions: structs.QueryOptions{ + Region: DefaultRegion, + AuthToken: aclRootToken.SecretID, + }, + } + var aclBindingRuleResp3 structs.ACLBindingRulesResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLGetBindingRulesRPCMethod, aclBindingRuleReq3, &aclBindingRuleResp3) + must.NoError(t, err) + must.MapLen(t, 2, aclBindingRuleResp3.ACLBindingRules) + must.MapContainsKeys(t, aclBindingRuleResp3.ACLBindingRules, []string{aclBindingRules[0].ID, aclBindingRules[1].ID}) + + // Now test a blocking query, where we wait for an update to the set which + // is triggered by a deletion. + type res struct { + err error + reply *structs.ACLBindingRulesResponse + } + resultCh := make(chan *res) + + go func(resultCh chan *res) { + aclBindingRuleReq4 := &structs.ACLBindingRulesRequest{ + ACLBindingRuleIDs: []string{aclBindingRules[0].ID, aclBindingRules[1].ID}, + QueryOptions: structs.QueryOptions{ + Region: DefaultRegion, + AuthToken: aclRootToken.SecretID, + MinQueryIndex: aclBindingRuleResp3.Index, + MaxQueryTime: 10 * time.Second, + }, + } + var aclBindingRuleResp4 structs.ACLBindingRulesResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLGetBindingRulesRPCMethod, aclBindingRuleReq4, &aclBindingRuleResp4) + resultCh <- &res{err: err, reply: &aclBindingRuleResp4} + }(resultCh) + + // Delete an ACL role from state which should return the blocking query. + must.NoError(t, testServer.fsm.State().DeleteACLBindingRules( + aclBindingRuleResp3.Index+10, []string{aclBindingRules[0].ID})) + + // Wait for the result and then test it. + result := <-resultCh + must.NoError(t, result.err) + must.MapLen(t, 1, result.reply.ACLBindingRules) + must.MapContainsKeys(t, result.reply.ACLBindingRules, []string{aclBindingRules[1].ID}) + must.Greater(t, aclBindingRuleResp3.Index, result.reply.Index) +} + +func TestACL_GetBindingRule(t *testing.T) { + ci.Parallel(t) + + testServer, aclRootToken, testServerCleanupFn := TestACLServer(t, nil) + defer testServerCleanupFn() + codec := rpcClient(t, testServer) + testutil.WaitForLeader(t, testServer.RPC) + + // Create two ACL binding rules and put these directly into state. + aclBindingRules := []*structs.ACLBindingRule{mock.ACLBindingRule(), mock.ACLBindingRule()} + must.NoError(t, testServer.State().UpsertACLBindingRules(10, aclBindingRules, true)) + + // Try reading a role without setting a correct auth token. + aclBindingRuleReq1 := &structs.ACLBindingRuleRequest{ + QueryOptions: structs.QueryOptions{ + Region: DefaultRegion, + }, + } + var aclBindingRuleResp1 structs.ACLBindingRuleResponse + err := msgpackrpc.CallWithCodec(codec, structs.ACLGetBindingRuleRPCMethod, aclBindingRuleReq1, &aclBindingRuleResp1) + must.EqError(t, err, "Permission denied") + + // Try reading a role that doesn't exist. + aclBindingRuleReq2 := &structs.ACLBindingRuleRequest{ + ACLBindingRuleID: "nope", + QueryOptions: structs.QueryOptions{ + Region: DefaultRegion, + AuthToken: aclRootToken.SecretID, + }, + } + var aclBindingRuleResp2 structs.ACLBindingRuleResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLGetBindingRuleRPCMethod, aclBindingRuleReq2, &aclBindingRuleResp2) + must.NoError(t, err) + must.Nil(t, aclBindingRuleResp2.ACLBindingRule) + + // Read both our available ACL roles using a valid auth token. + aclBindingRuleReq3 := &structs.ACLBindingRuleRequest{ + ACLBindingRuleID: aclBindingRules[0].ID, + QueryOptions: structs.QueryOptions{ + Region: DefaultRegion, + AuthToken: aclRootToken.SecretID, + }, + } + var aclBindingRuleResp3 structs.ACLBindingRuleResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLGetBindingRuleRPCMethod, aclBindingRuleReq3, &aclBindingRuleResp3) + must.NoError(t, err) + must.Eq(t, aclBindingRules[0].ID, aclBindingRuleResp3.ACLBindingRule.ID) + + aclBindingRuleReq4 := &structs.ACLBindingRuleRequest{ + ACLBindingRuleID: aclBindingRules[1].ID, + QueryOptions: structs.QueryOptions{ + Region: DefaultRegion, + AuthToken: aclRootToken.SecretID, + }, + } + var aclBindingRuleResp4 structs.ACLBindingRuleResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLGetBindingRuleRPCMethod, aclBindingRuleReq4, &aclBindingRuleResp4) + must.NoError(t, err) + must.Eq(t, aclBindingRules[1].ID, aclBindingRuleResp4.ACLBindingRule.ID) + + // Now test a blocking query, where we wait for an update to the set which + // is triggered by an upsert. + type res struct { + err error + reply *structs.ACLBindingRuleResponse + } + resultCh := make(chan *res) + + go func(resultCh chan *res) { + aclBindingRuleReq5 := &structs.ACLBindingRuleRequest{ + ACLBindingRuleID: aclBindingRules[0].ID, + QueryOptions: structs.QueryOptions{ + Region: DefaultRegion, + AuthToken: aclRootToken.SecretID, + MinQueryIndex: aclBindingRuleResp4.Index, + MaxQueryTime: 10 * time.Second, + }, + } + var aclBindingRuleResp5 structs.ACLBindingRuleResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLGetBindingRuleRPCMethod, aclBindingRuleReq5, &aclBindingRuleResp5) + resultCh <- &res{err: err, reply: &aclBindingRuleResp5} + }(resultCh) + + // Delete an ACL role from state which should return the blocking query. + aclBindingRule1Copy := aclBindingRules[0].Copy() + aclBindingRule1Copy.Description = "updated-description" + aclBindingRule1Copy.SetHash() + + must.NoError(t, testServer.fsm.State().UpsertACLBindingRules( + aclBindingRuleResp4.Index+10, []*structs.ACLBindingRule{aclBindingRule1Copy}, true)) + + // Wait for the result and then test it. + result := <-resultCh + must.NoError(t, result.err) + must.Eq(t, aclBindingRules[0].ID, result.reply.ACLBindingRule.ID) + must.Greater(t, aclBindingRuleResp4.Index, result.reply.Index) +} diff --git a/nomad/leader.go b/nomad/leader.go index d82c453d1..c521f8cda 100644 --- a/nomad/leader.go +++ b/nomad/leader.go @@ -62,6 +62,14 @@ var minACLRoleVersion = version.Must(version.NewVersion("1.4.0")) // 1.5, otherwise it's hard to test the functionality var minACLAuthMethodVersion = version.Must(version.NewVersion("1.4.3-dev")) +// minACLBindingRuleVersion is the Nomad version at which the ACL binding rules +// table was introduced. It forms the minimum version all federated servers +// must meet before the feature can be used. +// +// TODO: version constraint will be updated for every beta or rc until we reach +// 1.5, otherwise it's hard to test the functionality +var minACLBindingRuleVersion = version.Must(version.NewVersion("1.4.3-dev")) + // minNomadServiceRegistrationVersion is the Nomad version at which the service // registrations table was introduced. It forms the minimum version all local // servers must meet before the feature can be used.