From deb91f3d3c0e988242272bb0249a5a88d1fcc907 Mon Sep 17 00:00:00 2001 From: Matt Keeler Date: Fri, 6 Dec 2019 09:25:26 -0500 Subject: [PATCH] =?UTF-8?q?[Feature]=20API:=20Add=20a=20internal=20endpoin?= =?UTF-8?q?t=20to=20query=20for=20ACL=20authori=E2=80=A6=20(#6888)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement endpoint to query whether the given token is authorized for a set of operations * Updates to allow for remote ACL authorization via RPC This is only used when making an authorization request to a different datacenter. --- acl/authorizer.go | 116 +++++++ acl/authorizer_oss.go | 4 + acl/authorizer_test.go | 621 +++++++++++++++++++++++++++++++++++ agent/acl_endpoint.go | 82 ++++- agent/acl_endpoint_test.go | 444 +++++++++++++++++++++++++ agent/consul/acl_endpoint.go | 25 ++ agent/http_register.go | 1 + agent/structs/acl.go | 40 +++ agent/testagent.go | 16 +- testrpc/wait.go | 18 + 10 files changed, 1359 insertions(+), 8 deletions(-) create mode 100644 acl/authorizer_test.go diff --git a/acl/authorizer.go b/acl/authorizer.go index a4d28be75c..9591aa1f1a 100644 --- a/acl/authorizer.go +++ b/acl/authorizer.go @@ -1,5 +1,10 @@ package acl +import ( + "fmt" + "strings" +) + type EnforcementDecision int const ( @@ -29,6 +34,22 @@ func (d EnforcementDecision) String() string { } } +type Resource string + +const ( + ResourceACL Resource = "acl" + ResourceAgent Resource = "agent" + ResourceEvent Resource = "event" + ResourceIntention Resource = "intention" + ResourceKey Resource = "key" + ResourceKeyring Resource = "keyring" + ResourceNode Resource = "node" + ResourceOperator Resource = "operator" + ResourceQuery Resource = "query" + ResourceService Resource = "service" + ResourceSession Resource = "session" +) + // Authorizer is the interface for policy enforcement. type Authorizer interface { // ACLRead checks for permission to list all the ACLs @@ -126,3 +147,98 @@ type Authorizer interface { // Embedded Interface for Consul Enterprise specific ACL enforcement EnterpriseAuthorizer } + +func Enforce(authz Authorizer, rsc Resource, segment string, access string, ctx *EnterpriseAuthorizerContext) (EnforcementDecision, error) { + lowerAccess := strings.ToLower(access) + + switch rsc { + case ResourceACL: + switch lowerAccess { + case "read": + return authz.ACLRead(ctx), nil + case "write": + return authz.ACLWrite(ctx), nil + } + case ResourceAgent: + switch lowerAccess { + case "read": + return authz.AgentRead(segment, ctx), nil + case "write": + return authz.AgentWrite(segment, ctx), nil + } + case ResourceEvent: + switch lowerAccess { + case "read": + return authz.EventRead(segment, ctx), nil + case "write": + return authz.EventWrite(segment, ctx), nil + } + case ResourceIntention: + switch lowerAccess { + case "read": + return authz.IntentionRead(segment, ctx), nil + case "write": + return authz.IntentionWrite(segment, ctx), nil + } + case ResourceKey: + switch lowerAccess { + case "read": + return authz.KeyRead(segment, ctx), nil + case "list": + return authz.KeyList(segment, ctx), nil + case "write": + return authz.KeyWrite(segment, ctx), nil + case "write-prefix": + return authz.KeyWritePrefix(segment, ctx), nil + } + case ResourceKeyring: + switch lowerAccess { + case "read": + return authz.KeyringRead(ctx), nil + case "write": + return authz.KeyringWrite(ctx), nil + } + case ResourceNode: + switch lowerAccess { + case "read": + return authz.NodeRead(segment, ctx), nil + case "write": + return authz.NodeWrite(segment, ctx), nil + } + case ResourceOperator: + switch lowerAccess { + case "read": + return authz.OperatorRead(ctx), nil + case "write": + return authz.OperatorWrite(ctx), nil + } + case ResourceQuery: + switch lowerAccess { + case "read": + return authz.PreparedQueryRead(segment, ctx), nil + case "write": + return authz.PreparedQueryWrite(segment, ctx), nil + } + case ResourceService: + switch lowerAccess { + case "read": + return authz.ServiceRead(segment, ctx), nil + case "write": + return authz.ServiceWrite(segment, ctx), nil + } + case ResourceSession: + switch lowerAccess { + case "read": + return authz.SessionRead(segment, ctx), nil + case "write": + return authz.SessionWrite(segment, ctx), nil + } + default: + if processed, decision, err := EnforceEnterprise(authz, rsc, segment, lowerAccess, ctx); processed { + return decision, err + } + return Deny, fmt.Errorf("Invalid ACL resource requested: %q", rsc) + } + + return Deny, fmt.Errorf("Invalid access level for %s resource: %s", rsc, access) +} diff --git a/acl/authorizer_oss.go b/acl/authorizer_oss.go index 3731f9eae5..499f797f13 100644 --- a/acl/authorizer_oss.go +++ b/acl/authorizer_oss.go @@ -7,3 +7,7 @@ type EnterpriseAuthorizerContext struct{} // EnterpriseAuthorizer stub interface type EnterpriseAuthorizer interface{} + +func EnforceEnterprise(_ Authorizer, _ Resource, _ string, _ string, _ *EnterpriseAuthorizerContext) (bool, EnforcementDecision, error) { + return false, Deny, nil +} diff --git a/acl/authorizer_test.go b/acl/authorizer_test.go new file mode 100644 index 0000000000..1a01e4dbb1 --- /dev/null +++ b/acl/authorizer_test.go @@ -0,0 +1,621 @@ +package acl + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type mockAuthorizer struct { + mock.Mock +} + +// ACLRead checks for permission to list all the ACLs +func (m *mockAuthorizer) ACLRead(ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(ctx) + return ret.Get(0).(EnforcementDecision) +} + +// ACLWrite checks for permission to manipulate ACLs +func (m *mockAuthorizer) ACLWrite(ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(ctx) + return ret.Get(0).(EnforcementDecision) +} + +// AgentRead checks for permission to read from agent endpoints for a +// given node. +func (m *mockAuthorizer) AgentRead(segment string, ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(segment, ctx) + return ret.Get(0).(EnforcementDecision) +} + +// AgentWrite checks for permission to make changes via agent endpoints +// for a given node. +func (m *mockAuthorizer) AgentWrite(segment string, ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(segment, ctx) + return ret.Get(0).(EnforcementDecision) +} + +// EventRead determines if a specific event can be queried. +func (m *mockAuthorizer) EventRead(segment string, ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(segment, ctx) + return ret.Get(0).(EnforcementDecision) +} + +// EventWrite determines if a specific event may be fired. +func (m *mockAuthorizer) EventWrite(segment string, ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(segment, ctx) + return ret.Get(0).(EnforcementDecision) +} + +// IntentionDefaultAllow determines the default authorized behavior +// when no intentions match a Connect request. +func (m *mockAuthorizer) IntentionDefaultAllow(ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(ctx) + return ret.Get(0).(EnforcementDecision) +} + +// IntentionRead determines if a specific intention can be read. +func (m *mockAuthorizer) IntentionRead(segment string, ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(segment, ctx) + return ret.Get(0).(EnforcementDecision) +} + +// IntentionWrite determines if a specific intention can be +// created, modified, or deleted. +func (m *mockAuthorizer) IntentionWrite(segment string, ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(segment, ctx) + return ret.Get(0).(EnforcementDecision) +} + +// KeyList checks for permission to list keys under a prefix +func (m *mockAuthorizer) KeyList(segment string, ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(segment, ctx) + return ret.Get(0).(EnforcementDecision) +} + +// KeyRead checks for permission to read a given key +func (m *mockAuthorizer) KeyRead(segment string, ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(segment, ctx) + return ret.Get(0).(EnforcementDecision) +} + +// KeyWrite checks for permission to write a given key +func (m *mockAuthorizer) KeyWrite(segment string, ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(segment, ctx) + return ret.Get(0).(EnforcementDecision) +} + +// KeyWritePrefix checks for permission to write to an +// entire key prefix. This means there must be no sub-policies +// that deny a write. +func (m *mockAuthorizer) KeyWritePrefix(segment string, ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(segment, ctx) + return ret.Get(0).(EnforcementDecision) +} + +// KeyringRead determines if the encryption keyring used in +// the gossip layer can be read. +func (m *mockAuthorizer) KeyringRead(ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(ctx) + return ret.Get(0).(EnforcementDecision) +} + +// KeyringWrite determines if the keyring can be manipulated +func (m *mockAuthorizer) KeyringWrite(ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(ctx) + return ret.Get(0).(EnforcementDecision) +} + +// NodeRead checks for permission to read (discover) a given node. +func (m *mockAuthorizer) NodeRead(segment string, ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(segment, ctx) + return ret.Get(0).(EnforcementDecision) +} + +// NodeWrite checks for permission to create or update (register) a +// given node. +func (m *mockAuthorizer) NodeWrite(segment string, ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(segment, ctx) + return ret.Get(0).(EnforcementDecision) +} + +// OperatorRead determines if the read-only Consul operator functions +// can be used. ret := m.Called(segment, ctx) +func (m *mockAuthorizer) OperatorRead(ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(ctx) + return ret.Get(0).(EnforcementDecision) +} + +// OperatorWrite determines if the state-changing Consul operator +// functions can be used. +func (m *mockAuthorizer) OperatorWrite(ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(ctx) + return ret.Get(0).(EnforcementDecision) +} + +// PreparedQueryRead determines if a specific prepared query can be read +// to show its contents (this is not used for execution). +func (m *mockAuthorizer) PreparedQueryRead(segment string, ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(segment, ctx) + return ret.Get(0).(EnforcementDecision) +} + +// PreparedQueryWrite determines if a specific prepared query can be +// created, modified, or deleted. +func (m *mockAuthorizer) PreparedQueryWrite(segment string, ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(segment, ctx) + return ret.Get(0).(EnforcementDecision) +} + +// ServiceRead checks for permission to read a given service +func (m *mockAuthorizer) ServiceRead(segment string, ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(segment, ctx) + return ret.Get(0).(EnforcementDecision) +} + +// ServiceWrite checks for permission to create or update a given +// service +func (m *mockAuthorizer) ServiceWrite(segment string, ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(segment, ctx) + return ret.Get(0).(EnforcementDecision) +} + +// SessionRead checks for permission to read sessions for a given node. +func (m *mockAuthorizer) SessionRead(segment string, ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(segment, ctx) + return ret.Get(0).(EnforcementDecision) +} + +// SessionWrite checks for permission to create sessions for a given +// node. +func (m *mockAuthorizer) SessionWrite(segment string, ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(segment, ctx) + return ret.Get(0).(EnforcementDecision) +} + +// Snapshot checks for permission to take and restore snapshots. +func (m *mockAuthorizer) Snapshot(ctx *EnterpriseAuthorizerContext) EnforcementDecision { + ret := m.Called(ctx) + return ret.Get(0).(EnforcementDecision) +} + +func TestACL_Enforce(t *testing.T) { + t.Parallel() + + type testCase struct { + method string + resource Resource + segment string + access string + ret EnforcementDecision + err string + } + + testName := func(t testCase) string { + if t.segment != "" { + return fmt.Sprintf("%s/%s/%s/%s", t.resource, t.segment, t.access, t.ret.String()) + } + return fmt.Sprintf("%s/%s/%s", t.resource, t.access, t.ret.String()) + } + + cases := []testCase{ + testCase{ + method: "ACLRead", + resource: ResourceACL, + access: "read", + ret: Deny, + }, + testCase{ + method: "ACLRead", + resource: ResourceACL, + access: "read", + ret: Allow, + }, + testCase{ + method: "ACLWrite", + resource: ResourceACL, + access: "write", + ret: Deny, + }, + testCase{ + method: "ACLWrite", + resource: ResourceACL, + access: "write", + ret: Allow, + }, + testCase{ + resource: ResourceACL, + access: "list", + ret: Deny, + err: "Invalid access level", + }, + testCase{ + method: "OperatorRead", + resource: ResourceOperator, + access: "read", + ret: Deny, + }, + testCase{ + method: "OperatorRead", + resource: ResourceOperator, + access: "read", + ret: Allow, + }, + testCase{ + method: "OperatorWrite", + resource: ResourceOperator, + access: "write", + ret: Deny, + }, + testCase{ + method: "OperatorWrite", + resource: ResourceOperator, + access: "write", + ret: Allow, + }, + testCase{ + resource: ResourceOperator, + access: "list", + ret: Deny, + err: "Invalid access level", + }, + testCase{ + method: "KeyringRead", + resource: ResourceKeyring, + access: "read", + ret: Deny, + }, + testCase{ + method: "KeyringRead", + resource: ResourceKeyring, + access: "read", + ret: Allow, + }, + testCase{ + method: "KeyringWrite", + resource: ResourceKeyring, + access: "write", + ret: Deny, + }, + testCase{ + method: "KeyringWrite", + resource: ResourceKeyring, + access: "write", + ret: Allow, + }, + testCase{ + resource: ResourceKeyring, + access: "list", + ret: Deny, + err: "Invalid access level", + }, + testCase{ + method: "AgentRead", + resource: ResourceAgent, + segment: "foo", + access: "read", + ret: Deny, + }, + testCase{ + method: "AgentRead", + resource: ResourceAgent, + segment: "foo", + access: "read", + ret: Allow, + }, + testCase{ + method: "AgentWrite", + resource: ResourceAgent, + segment: "foo", + access: "write", + ret: Deny, + }, + testCase{ + method: "AgentWrite", + resource: ResourceAgent, + segment: "foo", + access: "write", + ret: Allow, + }, + testCase{ + resource: ResourceAgent, + segment: "foo", + access: "list", + ret: Deny, + err: "Invalid access level", + }, + testCase{ + method: "EventRead", + resource: ResourceEvent, + segment: "foo", + access: "read", + ret: Deny, + }, + testCase{ + method: "EventRead", + resource: ResourceEvent, + segment: "foo", + access: "read", + ret: Allow, + }, + testCase{ + method: "EventWrite", + resource: ResourceEvent, + segment: "foo", + access: "write", + ret: Deny, + }, + testCase{ + method: "EventWrite", + resource: ResourceEvent, + segment: "foo", + access: "write", + ret: Allow, + }, + testCase{ + resource: ResourceEvent, + segment: "foo", + access: "list", + ret: Deny, + err: "Invalid access level", + }, + testCase{ + method: "IntentionRead", + resource: ResourceIntention, + segment: "foo", + access: "read", + ret: Deny, + }, + testCase{ + method: "IntentionRead", + resource: ResourceIntention, + segment: "foo", + access: "read", + ret: Allow, + }, + testCase{ + method: "IntentionWrite", + resource: ResourceIntention, + segment: "foo", + access: "write", + ret: Deny, + }, + testCase{ + method: "IntentionWrite", + resource: ResourceIntention, + segment: "foo", + access: "write", + ret: Allow, + }, + testCase{ + resource: ResourceIntention, + segment: "foo", + access: "list", + ret: Deny, + err: "Invalid access level", + }, + testCase{ + method: "NodeRead", + resource: ResourceNode, + segment: "foo", + access: "read", + ret: Deny, + }, + testCase{ + method: "NodeRead", + resource: ResourceNode, + segment: "foo", + access: "read", + ret: Allow, + }, + testCase{ + method: "NodeWrite", + resource: ResourceNode, + segment: "foo", + access: "write", + ret: Deny, + }, + testCase{ + method: "NodeWrite", + resource: ResourceNode, + segment: "foo", + access: "write", + ret: Allow, + }, + testCase{ + resource: ResourceNode, + segment: "foo", + access: "list", + ret: Deny, + err: "Invalid access level", + }, + testCase{ + method: "PreparedQueryRead", + resource: ResourceQuery, + segment: "foo", + access: "read", + ret: Deny, + }, + testCase{ + method: "PreparedQueryRead", + resource: ResourceQuery, + segment: "foo", + access: "read", + ret: Allow, + }, + testCase{ + method: "PreparedQueryWrite", + resource: ResourceQuery, + segment: "foo", + access: "write", + ret: Deny, + }, + testCase{ + method: "PreparedQueryWrite", + resource: ResourceQuery, + segment: "foo", + access: "write", + ret: Allow, + }, + testCase{ + resource: ResourceQuery, + segment: "foo", + access: "list", + ret: Deny, + err: "Invalid access level", + }, + testCase{ + method: "ServiceRead", + resource: ResourceService, + segment: "foo", + access: "read", + ret: Deny, + }, + testCase{ + method: "ServiceRead", + resource: ResourceService, + segment: "foo", + access: "read", + ret: Allow, + }, + testCase{ + method: "ServiceWrite", + resource: ResourceService, + segment: "foo", + access: "write", + ret: Deny, + }, + testCase{ + method: "ServiceWrite", + resource: ResourceService, + segment: "foo", + access: "write", + ret: Allow, + }, + testCase{ + resource: ResourceSession, + segment: "foo", + access: "list", + ret: Deny, + err: "Invalid access level", + }, + testCase{ + method: "SessionRead", + resource: ResourceSession, + segment: "foo", + access: "read", + ret: Deny, + }, + testCase{ + method: "SessionRead", + resource: ResourceSession, + segment: "foo", + access: "read", + ret: Allow, + }, + testCase{ + method: "SessionWrite", + resource: ResourceSession, + segment: "foo", + access: "write", + ret: Deny, + }, + testCase{ + method: "SessionWrite", + resource: ResourceSession, + segment: "foo", + access: "write", + ret: Allow, + }, + testCase{ + resource: ResourceSession, + segment: "foo", + access: "list", + ret: Deny, + err: "Invalid access level", + }, + testCase{ + method: "KeyRead", + resource: ResourceKey, + segment: "foo", + access: "read", + ret: Deny, + }, + testCase{ + method: "KeyRead", + resource: ResourceKey, + segment: "foo", + access: "read", + ret: Allow, + }, + testCase{ + method: "KeyWrite", + resource: ResourceKey, + segment: "foo", + access: "write", + ret: Deny, + }, + testCase{ + method: "KeyWrite", + resource: ResourceKey, + segment: "foo", + access: "write", + ret: Allow, + }, + testCase{ + method: "KeyList", + resource: ResourceKey, + segment: "foo", + access: "list", + ret: Deny, + }, + testCase{ + method: "KeyList", + resource: ResourceKey, + segment: "foo", + access: "list", + ret: Allow, + }, + testCase{ + resource: ResourceKey, + segment: "foo", + access: "deny", + ret: Deny, + err: "Invalid access level", + }, + testCase{ + resource: "not-a-real-resource", + access: "read", + ret: Deny, + err: "Invalid ACL resource requested:", + }, + } + + for _, tcase := range cases { + t.Run(testName(tcase), func(t *testing.T) { + m := &mockAuthorizer{} + + if tcase.err == "" { + var nilCtx *EnterpriseAuthorizerContext + if tcase.segment != "" { + m.On(tcase.method, tcase.segment, nilCtx).Return(tcase.ret) + } else { + m.On(tcase.method, nilCtx).Return(tcase.ret) + } + } + + ret, err := Enforce(m, tcase.resource, tcase.segment, tcase.access, nil) + if tcase.err == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), tcase.err) + } + require.Equal(t, tcase.ret, ret) + m.AssertExpectations(t) + }) + } +} diff --git a/agent/acl_endpoint.go b/agent/acl_endpoint.go index adb3d4cd1a..41bc8be5d9 100644 --- a/agent/acl_endpoint.go +++ b/agent/acl_endpoint.go @@ -981,7 +981,7 @@ func (s *HTTPServer) ACLLogin(resp http.ResponseWriter, req *http.Request) (inte s.parseEntMeta(req, &args.Auth.EnterpriseMeta) if err := decodeBody(req.Body, &args.Auth); err != nil { - return nil, BadRequestError{Reason: fmt.Sprintf("Failed to decode request body:: %v", err)} + return nil, BadRequestError{Reason: fmt.Sprintf("Failed to decode request body: %v", err)} } var out structs.ACLToken @@ -1027,3 +1027,83 @@ func fixupAuthMethodConfig(method *structs.ACLAuthMethod) { } } } + +func (s *HTTPServer) ACLAuthorize(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + // At first glance it may appear like this endpoint is going to leak security relevant information. + // There are a number of reason why this is okay. + // + // 1. The authorizations performed here are the same as what would be done if other HTTP APIs + // were used. This is just a way to see if it would be allowed. In the future when we have + // audit logging, these authorization checks will be logged along with those from the real + // endpoints. In that respect, you can figure out if you have access just as easily by + // attempting to perform the requested operation. + // 2. In order to use this API you must have a valid ACL token secret. + // 3. Along with #2 you can use the ACL.GetPolicy RPC endpoint which will return a rolled up + // set of policy rules showing your tokens effective policy. This RPC endpoint exposes + // more information than this one and has been around since before v1.0.0. With that other + // endpoint you get to see all things possible rather than having to have a list of things + // you may want to do and to request authorizations for each one. + // 4. In addition to the legacy ACL.GetPolicy RPC endpoint we have an ACL.PolicyResolve and + // ACL.RoleResolve endpoints. These RPC endpoints allow reading roles and policies so long + // as the token used for the request is linked with them. This is needed to allow client + // agents to pull the policy and roles for a token that they are resolving. The only + // alternative to this style of access would be to make every agent use a token + // with acl:read privileges for all policy and role resolution requests. Once you have + // all the associated policies and roles it would be easy enough to recreate the effective + // policy. + const maxRequests = 64 + + if s.checkACLDisabled(resp, req) { + return nil, nil + } + + request := structs.RemoteACLAuthorizationRequest{ + Datacenter: s.agent.config.Datacenter, + QueryOptions: structs.QueryOptions{ + AllowStale: true, + RequireConsistent: false, + }, + } + var responses []structs.ACLAuthorizationResponse + + s.parseToken(req, &request.Token) + s.parseDC(req, &request.Datacenter) + + if err := decodeBody(req.Body, &request.Requests); err != nil { + return nil, BadRequestError{Reason: fmt.Sprintf("Failed to decode request body: %v", err)} + } + + if len(request.Requests) > maxRequests { + return nil, BadRequestError{Reason: fmt.Sprintf("Refusing to process more than %d authorizations at once", maxRequests)} + } + + if len(request.Requests) == 0 { + return make([]structs.ACLAuthorizationResponse, 0), nil + } + + if request.Datacenter != "" && request.Datacenter != s.agent.config.Datacenter { + // when we are targeting a datacenter other than our own then we must issue an RPC + // to perform the resolution as it may involve a local token + if err := s.agent.RPC("ACL.Authorize", &request, &responses); err != nil { + return nil, err + } + } else { + authz, err := s.agent.resolveToken(request.Token) + if err != nil { + return nil, err + } else if authz == nil { + return nil, fmt.Errorf("Failed to initialize authorizer") + } + + responses, err = structs.CreateACLAuthorizationResponses(authz, request.Requests) + if err != nil { + return nil, BadRequestError{Reason: err.Error()} + } + } + + if responses == nil { + responses = make([]structs.ACLAuthorizationResponse, 0) + } + + return responses, nil +} diff --git a/agent/acl_endpoint_test.go b/agent/acl_endpoint_test.go index d38d52ed97..f29ec136ac 100644 --- a/agent/acl_endpoint_test.go +++ b/agent/acl_endpoint_test.go @@ -3,6 +3,7 @@ package agent import ( "bytes" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -53,6 +54,7 @@ func TestACL_Disabled_Response(t *testing.T) { {"ACLAuthMethodCRUD", a.srv.ACLAuthMethodCRUD}, {"ACLLogin", a.srv.ACLLogin}, {"ACLLogout", a.srv.ACLLogout}, + {"ACLAuthorize", a.srv.ACLAuthorize}, } testrpc.WaitForLeader(t, a.RPC, "dc1") for _, tt := range tests { @@ -1576,3 +1578,445 @@ func TestACL_LoginProcedure_HTTP(t *testing.T) { }) }) } + +func TestACL_Authorize(t *testing.T) { + t.Parallel() + a1 := NewTestAgent(t, t.Name(), TestACLConfigWithParams(nil)) + defer a1.Shutdown() + + testrpc.WaitForTestAgent(t, a1.RPC, "dc1", testrpc.WithToken(TestDefaultMasterToken)) + + policyReq := structs.ACLPolicySetRequest{ + Policy: structs.ACLPolicy{ + Name: "test", + Rules: `acl = "read" operator = "write" service_prefix "" { policy = "read"} node_prefix "" { policy= "write" } key_prefix "/foo" { policy = "write" } `, + }, + Datacenter: "dc1", + WriteRequest: structs.WriteRequest{Token: TestDefaultMasterToken}, + } + var policy structs.ACLPolicy + require.NoError(t, a1.RPC("ACL.PolicySet", &policyReq, &policy)) + + tokenReq := structs.ACLTokenSetRequest{ + ACLToken: structs.ACLToken{ + Policies: []structs.ACLTokenPolicyLink{ + structs.ACLTokenPolicyLink{ + ID: policy.ID, + }, + }, + }, + Datacenter: "dc1", + WriteRequest: structs.WriteRequest{Token: TestDefaultMasterToken}, + } + + var token structs.ACLToken + require.NoError(t, a1.RPC("ACL.TokenSet", &tokenReq, &token)) + + // secondary also needs to setup a replication token to pull tokens and policies + secondaryParams := DefaulTestACLConfigParams() + secondaryParams.ReplicationToken = secondaryParams.MasterToken + secondaryParams.EnableTokenReplication = true + + a2 := NewTestAgent(t, t.Name(), `datacenter = "dc2" `+TestACLConfigWithParams(secondaryParams)) + defer a2.Shutdown() + + addr := fmt.Sprintf("127.0.0.1:%d", a1.Config.SerfPortWAN) + _, err := a2.JoinWAN([]string{addr}) + require.NoError(t, err) + + testrpc.WaitForTestAgent(t, a2.RPC, "dc2", testrpc.WithToken(TestDefaultMasterToken)) + // this actually ensures a few things. First the dcs got connect okay, secondly that the policy we + // are about ready to use in our local token creation exists in the secondary DC + testrpc.WaitForACLReplication(t, a2.RPC, "dc2", structs.ACLReplicateTokens, policy.CreateIndex, 1, 0) + + localTokenReq := structs.ACLTokenSetRequest{ + ACLToken: structs.ACLToken{ + Policies: []structs.ACLTokenPolicyLink{ + structs.ACLTokenPolicyLink{ + ID: policy.ID, + }, + }, + Local: true, + }, + Datacenter: "dc2", + WriteRequest: structs.WriteRequest{Token: TestDefaultMasterToken}, + } + + var localToken structs.ACLToken + require.NoError(t, a2.RPC("ACL.TokenSet", &localTokenReq, &localToken)) + + t.Run("master-token", func(t *testing.T) { + request := []structs.ACLAuthorizationRequest{ + structs.ACLAuthorizationRequest{ + Resource: "acl", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "acl", + Access: "write", + }, + structs.ACLAuthorizationRequest{ + Resource: "agent", + Segment: "foo", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "agent", + Segment: "foo", + Access: "write", + }, + structs.ACLAuthorizationRequest{ + Resource: "event", + Segment: "foo", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "event", + Segment: "foo", + Access: "write", + }, + structs.ACLAuthorizationRequest{ + Resource: "intention", + Segment: "foo", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "intention", + Segment: "foo", + Access: "write", + }, + structs.ACLAuthorizationRequest{ + Resource: "key", + Segment: "foo", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "key", + Segment: "foo", + Access: "list", + }, + structs.ACLAuthorizationRequest{ + Resource: "key", + Segment: "foo", + Access: "write", + }, + structs.ACLAuthorizationRequest{ + Resource: "keyring", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "keyring", + Access: "write", + }, + structs.ACLAuthorizationRequest{ + Resource: "node", + Segment: "foo", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "node", + Segment: "foo", + Access: "write", + }, + structs.ACLAuthorizationRequest{ + Resource: "operator", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "operator", + Access: "write", + }, + structs.ACLAuthorizationRequest{ + Resource: "query", + Segment: "foo", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "query", + Segment: "foo", + Access: "write", + }, + structs.ACLAuthorizationRequest{ + Resource: "service", + Segment: "foo", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "service", + Segment: "foo", + Access: "write", + }, + structs.ACLAuthorizationRequest{ + Resource: "session", + Segment: "foo", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "session", + Segment: "foo", + Access: "write", + }, + } + + for _, dc := range []string{"dc1", "dc2"} { + t.Run(dc, func(t *testing.T) { + req, _ := http.NewRequest("POST", "/v1/internal/acl/authorize?dc="+dc, jsonBody(request)) + req.Header.Add("X-Consul-Token", TestDefaultMasterToken) + recorder := httptest.NewRecorder() + raw, err := a1.srv.ACLAuthorize(recorder, req) + require.NoError(t, err) + responses, ok := raw.([]structs.ACLAuthorizationResponse) + require.True(t, ok) + require.Len(t, responses, len(request)) + + for idx, req := range request { + resp := responses[idx] + + require.Equal(t, req, resp.ACLAuthorizationRequest) + require.True(t, resp.Allow, "should have allowed all access for master token") + } + }) + } + + }) + + customAuthorizationRequests := []structs.ACLAuthorizationRequest{ + structs.ACLAuthorizationRequest{ + Resource: "acl", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "acl", + Access: "write", + }, + structs.ACLAuthorizationRequest{ + Resource: "agent", + Segment: "foo", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "agent", + Segment: "foo", + Access: "write", + }, + structs.ACLAuthorizationRequest{ + Resource: "event", + Segment: "foo", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "event", + Segment: "foo", + Access: "write", + }, + structs.ACLAuthorizationRequest{ + Resource: "intention", + Segment: "foo", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "intention", + Segment: "foo", + Access: "write", + }, + structs.ACLAuthorizationRequest{ + Resource: "key", + Segment: "foo", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "key", + Segment: "foo", + Access: "list", + }, + structs.ACLAuthorizationRequest{ + Resource: "key", + Segment: "foo", + Access: "write", + }, + structs.ACLAuthorizationRequest{ + Resource: "keyring", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "keyring", + Access: "write", + }, + structs.ACLAuthorizationRequest{ + Resource: "node", + Segment: "foo", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "node", + Segment: "foo", + Access: "write", + }, + structs.ACLAuthorizationRequest{ + Resource: "operator", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "operator", + Access: "write", + }, + structs.ACLAuthorizationRequest{ + Resource: "query", + Segment: "foo", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "query", + Segment: "foo", + Access: "write", + }, + structs.ACLAuthorizationRequest{ + Resource: "service", + Segment: "foo", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "service", + Segment: "foo", + Access: "write", + }, + structs.ACLAuthorizationRequest{ + Resource: "session", + Segment: "foo", + Access: "read", + }, + structs.ACLAuthorizationRequest{ + Resource: "session", + Segment: "foo", + Access: "write", + }, + } + + expectedCustomAuthorizationResponses := []bool{ + true, // acl:read + false, // acl:write + false, // agent:read + false, // agent:write + false, // event:read + false, // event:write + true, // intention:read + false, // intention:write + false, // key:read + false, // key:list + false, // key:write + false, // keyring:read + false, // keyring:write + true, // node:read + true, // node:write + true, // operator:read + true, // operator:write + false, // query:read + false, // query:write + true, // service:read + false, // service:write + false, // session:read + false, // session:write + } + + t.Run("custom-token", func(t *testing.T) { + for _, dc := range []string{"dc1", "dc2"} { + t.Run(dc, func(t *testing.T) { + req, _ := http.NewRequest("POST", "/v1/internal/acl/authorize", jsonBody(customAuthorizationRequests)) + req.Header.Add("X-Consul-Token", token.SecretID) + recorder := httptest.NewRecorder() + raw, err := a1.srv.ACLAuthorize(recorder, req) + require.NoError(t, err) + responses, ok := raw.([]structs.ACLAuthorizationResponse) + require.True(t, ok) + require.Len(t, responses, len(customAuthorizationRequests)) + require.Len(t, responses, len(expectedCustomAuthorizationResponses)) + + for idx, req := range customAuthorizationRequests { + resp := responses[idx] + + require.Equal(t, req, resp.ACLAuthorizationRequest) + require.Equal(t, expectedCustomAuthorizationResponses[idx], resp.Allow, "request %d - %+v returned unexpected response", idx, resp.ACLAuthorizationRequest) + } + }) + } + }) + + t.Run("too-many-requests", func(t *testing.T) { + var request []structs.ACLAuthorizationRequest + + for i := 0; i < 100; i++ { + request = append(request, structs.ACLAuthorizationRequest{Resource: "acl", Access: "read"}) + } + + req, _ := http.NewRequest("POST", "/v1/internal/acl/authorize", jsonBody(request)) + req.Header.Add("X-Consul-Token", token.SecretID) + recorder := httptest.NewRecorder() + raw, err := a1.srv.ACLAuthorize(recorder, req) + require.Error(t, err) + require.Contains(t, err.Error(), "Refusing to process more than 64 authorizations at once") + require.Nil(t, raw) + }) + + t.Run("decode-failure", func(t *testing.T) { + req, _ := http.NewRequest("POST", "/v1/internal/acl/authorize", jsonBody(structs.ACLAuthorizationRequest{Resource: "acl", Access: "read"})) + req.Header.Add("X-Consul-Token", token.SecretID) + recorder := httptest.NewRecorder() + raw, err := a1.srv.ACLAuthorize(recorder, req) + require.Error(t, err) + require.Contains(t, err.Error(), "Failed to decode request body") + require.Nil(t, raw) + }) + + t.Run("acl-not-found", func(t *testing.T) { + request := []structs.ACLAuthorizationRequest{ + structs.ACLAuthorizationRequest{ + Resource: "acl", + Access: "read", + }, + } + + req, _ := http.NewRequest("POST", "/v1/internal/acl/authorize", jsonBody(request)) + req.Header.Add("X-Consul-Token", "d908c0be-22e1-433e-84db-8718e1a019de") + recorder := httptest.NewRecorder() + raw, err := a1.srv.ACLAuthorize(recorder, req) + require.Error(t, err) + require.Equal(t, acl.ErrNotFound, err) + require.Nil(t, raw) + }) + + t.Run("local-token-in-secondary-dc", func(t *testing.T) { + req, _ := http.NewRequest("POST", "/v1/internal/acl/authorize?dc=dc2", jsonBody(customAuthorizationRequests)) + req.Header.Add("X-Consul-Token", localToken.SecretID) + recorder := httptest.NewRecorder() + raw, err := a1.srv.ACLAuthorize(recorder, req) + require.NoError(t, err) + responses, ok := raw.([]structs.ACLAuthorizationResponse) + require.True(t, ok) + require.Len(t, responses, len(customAuthorizationRequests)) + require.Len(t, responses, len(expectedCustomAuthorizationResponses)) + + for idx, req := range customAuthorizationRequests { + resp := responses[idx] + + require.Equal(t, req, resp.ACLAuthorizationRequest) + require.Equal(t, expectedCustomAuthorizationResponses[idx], resp.Allow, "request %d - %+v returned unexpected response", idx, resp.ACLAuthorizationRequest) + } + }) + + t.Run("local-token-wrong-dc", func(t *testing.T) { + request := []structs.ACLAuthorizationRequest{ + structs.ACLAuthorizationRequest{ + Resource: "acl", + Access: "read", + }, + } + + req, _ := http.NewRequest("POST", "/v1/internal/acl/authorize", jsonBody(request)) + req.Header.Add("X-Consul-Token", localToken.SecretID) + recorder := httptest.NewRecorder() + raw, err := a1.srv.ACLAuthorize(recorder, req) + require.Error(t, err) + require.Equal(t, acl.ErrNotFound, err) + require.Nil(t, raw) + }) +} diff --git a/agent/consul/acl_endpoint.go b/agent/consul/acl_endpoint.go index 4b46a65e95..1ca6cf7282 100644 --- a/agent/consul/acl_endpoint.go +++ b/agent/consul/acl_endpoint.go @@ -2372,3 +2372,28 @@ func (a *ACL) Logout(args *structs.ACLLogoutRequest, reply *bool) error { return nil } + +func (a *ACL) Authorize(args *structs.RemoteACLAuthorizationRequest, reply *[]structs.ACLAuthorizationResponse) error { + if err := a.aclPreCheck(); err != nil { + return err + } + + if done, err := a.srv.forward("ACL.Authorize", args, args, reply); done { + return err + } + + authz, err := a.srv.ResolveToken(args.Token) + if err != nil { + return err + } else if authz == nil { + return fmt.Errorf("Failed to initialize authorizer") + } + + responses, err := structs.CreateACLAuthorizationResponses(authz, args.Requests) + if err != nil { + return err + } + + *reply = responses + return nil +} diff --git a/agent/http_register.go b/agent/http_register.go index 87c4c716bf..096e32cf69 100644 --- a/agent/http_register.go +++ b/agent/http_register.go @@ -89,6 +89,7 @@ func init() { registerEndpoint("/v1/internal/ui/nodes", []string{"GET"}, (*HTTPServer).UINodes) registerEndpoint("/v1/internal/ui/node/", []string{"GET"}, (*HTTPServer).UINodeInfo) registerEndpoint("/v1/internal/ui/services", []string{"GET"}, (*HTTPServer).UIServices) + registerEndpoint("/v1/internal/acl/authorize", []string{"POST"}, (*HTTPServer).ACLAuthorize) registerEndpoint("/v1/kv/", []string{"GET", "PUT", "DELETE"}, (*HTTPServer).KVSEndpoint) registerEndpoint("/v1/operator/raft/configuration", []string{"GET"}, (*HTTPServer).OperatorRaftConfiguration) registerEndpoint("/v1/operator/raft/peer", []string{"DELETE"}, (*HTTPServer).OperatorRaftPeer) diff --git a/agent/structs/acl.go b/agent/structs/acl.go index 60ae09a0c8..8a93d112bb 100644 --- a/agent/structs/acl.go +++ b/agent/structs/acl.go @@ -1562,3 +1562,43 @@ type ACLLogoutRequest struct { func (r *ACLLogoutRequest) RequestDatacenter() string { return r.Datacenter } + +type RemoteACLAuthorizationRequest struct { + Datacenter string + Requests []ACLAuthorizationRequest + QueryOptions +} + +type ACLAuthorizationRequest struct { + Resource acl.Resource + Segment string `json:",omitempty"` + Access string + EnterpriseMeta +} + +type ACLAuthorizationResponse struct { + ACLAuthorizationRequest + Allow bool +} + +func (r *RemoteACLAuthorizationRequest) RequestDatacenter() string { + return r.Datacenter +} + +func CreateACLAuthorizationResponses(authz acl.Authorizer, requests []ACLAuthorizationRequest) ([]ACLAuthorizationResponse, error) { + responses := make([]ACLAuthorizationResponse, len(requests)) + var ctx acl.EnterpriseAuthorizerContext + + for idx, req := range requests { + req.FillAuthzContext(&ctx) + decision, err := acl.Enforce(authz, req.Resource, req.Segment, req.Access, &ctx) + if err != nil { + return nil, err + } + + responses[idx].ACLAuthorizationRequest = req + responses[idx].Allow = decision == acl.Allow + } + + return responses, nil +} diff --git a/agent/testagent.go b/agent/testagent.go index 46ed721f5c..a67ffa29c2 100644 --- a/agent/testagent.go +++ b/agent/testagent.go @@ -480,13 +480,14 @@ const ( ) type TestACLConfigParams struct { - PrimaryDatacenter string - DefaultPolicy string - MasterToken string - AgentToken string - DefaultToken string - AgentMasterToken string - ReplicationToken string + PrimaryDatacenter string + DefaultPolicy string + MasterToken string + AgentToken string + DefaultToken string + AgentMasterToken string + ReplicationToken string + EnableTokenReplication bool } func DefaulTestACLConfigParams() *TestACLConfigParams { @@ -526,6 +527,7 @@ var aclConfigTpl = template.Must(template.New("ACL Config").Parse(` {{if ne .DefaultPolicy ""}} default_policy = "{{ .DefaultPolicy }}" {{end}} + enable_token_replication = {{printf "%t" .EnableTokenReplication }} {{if .HasConfiguredTokens }} tokens { {{if ne .MasterToken ""}} diff --git a/testrpc/wait.go b/testrpc/wait.go index 8e3e616122..503b84f027 100644 --- a/testrpc/wait.go +++ b/testrpc/wait.go @@ -5,6 +5,7 @@ import ( "github.com/hashicorp/consul/agent/structs" "github.com/hashicorp/consul/sdk/testutil/retry" + "github.com/stretchr/testify/require" ) type rpcFn func(string, interface{}, interface{}) error @@ -142,3 +143,20 @@ func WaitForActiveCARoot(t *testing.T, rpc rpcFn, dc string, expect *structs.CAR } }) } + +func WaitForACLReplication(t *testing.T, rpc rpcFn, dc string, expectedReplicationType structs.ACLReplicationType, minPolicyIndex, minTokenIndex, minRoleIndex uint64) { + retry.Run(t, func(r *retry.R) { + args := structs.DCSpecificRequest{ + Datacenter: dc, + } + var reply structs.ACLReplicationStatus + + require.NoError(r, rpc("ACL.ReplicationStatus", &args, &reply)) + + require.Equal(r, expectedReplicationType, reply.ReplicationType) + require.True(r, reply.Running, "Server not running new replicator yet") + require.True(r, reply.ReplicatedIndex >= minPolicyIndex, "Server hasn't replicated enough policies") + require.True(r, reply.ReplicatedTokenIndex >= minTokenIndex, "Server hasn't replicated enough tokens") + require.True(r, reply.ReplicatedRoleIndex >= minRoleIndex, "Server hasn't replicated enough roles") + }) +}