diff --git a/.changelog/12935.txt b/.changelog/12935.txt new file mode 100644 index 0000000000..ae892d891c --- /dev/null +++ b/.changelog/12935.txt @@ -0,0 +1,3 @@ +```release-note:feature +acl: It is now possible to login and logout using the gRPC API +``` diff --git a/acl/validation.go b/acl/validation.go new file mode 100644 index 0000000000..816ec0cae1 --- /dev/null +++ b/acl/validation.go @@ -0,0 +1,56 @@ +package acl + +import "regexp" + +const ( + ServiceIdentityNameMaxLength = 256 + NodeIdentityNameMaxLength = 256 +) + +var ( + validServiceIdentityName = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-_]*[a-z0-9])?$`) + validNodeIdentityName = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-_]*[a-z0-9])?$`) + validPolicyName = regexp.MustCompile(`^[A-Za-z0-9\-_]{1,128}$`) + validRoleName = regexp.MustCompile(`^[A-Za-z0-9\-_]{1,256}$`) + validAuthMethodName = regexp.MustCompile(`^[A-Za-z0-9\-_]{1,128}$`) +) + +// IsValidServiceIdentityName returns true if the provided name can be used as +// an ACLServiceIdentity ServiceName. This is more restrictive than standard +// catalog registration, which basically takes the view that "everything is +// valid". +func IsValidServiceIdentityName(name string) bool { + if len(name) < 1 || len(name) > ServiceIdentityNameMaxLength { + return false + } + return validServiceIdentityName.MatchString(name) +} + +// IsValidNodeIdentityName returns true if the provided name can be used as +// an ACLNodeIdentity NodeName. This is more restrictive than standard +// catalog registration, which basically takes the view that "everything is +// valid". +func IsValidNodeIdentityName(name string) bool { + if len(name) < 1 || len(name) > NodeIdentityNameMaxLength { + return false + } + return validNodeIdentityName.MatchString(name) +} + +// IsValidPolicyName returns true if the provided name can be used as an +// ACLPolicy Name. +func IsValidPolicyName(name string) bool { + return validPolicyName.MatchString(name) +} + +// IsValidRoleName returns true if the provided name can be used as an +// ACLRole Name. +func IsValidRoleName(name string) bool { + return validRoleName.MatchString(name) +} + +// IsValidRoleName returns true if the provided name can be used as an +// ACLAuthMethod Name. +func IsValidAuthMethodName(name string) bool { + return validAuthMethodName.MatchString(name) +} diff --git a/agent/consul/acl.go b/agent/consul/acl.go index cf190ea713..8543b7f516 100644 --- a/agent/consul/acl.go +++ b/agent/consul/acl.go @@ -344,8 +344,6 @@ func (r *ACLResolver) Close() { } func (r *ACLResolver) fetchAndCacheIdentityFromToken(token string, cached *structs.IdentityCacheEntry) (structs.ACLIdentity, error) { - cacheID := tokenSecretCacheID(token) - req := structs.ACLTokenGetRequest{ Datacenter: r.backend.ACLDatacenter(), TokenID: token, @@ -360,20 +358,20 @@ func (r *ACLResolver) fetchAndCacheIdentityFromToken(token string, cached *struc err := r.backend.RPC("ACL.TokenRead", &req, &resp) if err == nil { if resp.Token == nil { - r.cache.PutIdentity(cacheID, nil, nil) + r.cache.RemoveIdentityWithSecretToken(token) return nil, acl.ErrNotFound } else if resp.Token.Local && r.config.Datacenter != resp.SourceDatacenter { - r.cache.PutIdentity(cacheID, nil, nil) + r.cache.RemoveIdentityWithSecretToken(token) return nil, acl.PermissionDeniedError{Cause: fmt.Sprintf("This is a local token in datacenter %q", resp.SourceDatacenter)} } else { - r.cache.PutIdentity(cacheID, resp.Token, nil) + r.cache.PutIdentityWithSecretToken(token, resp.Token) return resp.Token, nil } } if acl.IsErrNotFound(err) { // Make sure to remove from the cache if it was deleted - r.cache.PutIdentity(cacheID, nil, err) + r.cache.RemoveIdentityWithSecretToken(token) return nil, acl.ErrNotFound } @@ -381,11 +379,11 @@ func (r *ACLResolver) fetchAndCacheIdentityFromToken(token string, cached *struc // some other RPC error if cached != nil && (r.config.ACLDownPolicy == "extend-cache" || r.config.ACLDownPolicy == "async-cache") { // extend the cache - r.cache.PutIdentity(cacheID, cached.Identity, err) + r.cache.PutIdentityWithSecretToken(token, cached.Identity) return cached.Identity, nil } - r.cache.PutIdentity(cacheID, nil, err) + r.cache.RemoveIdentityWithSecretToken(token) return nil, err } @@ -399,13 +397,10 @@ func (r *ACLResolver) resolveIdentityFromToken(token string) (structs.ACLIdentit } // Check the cache before making any RPC requests - cacheEntry := r.cache.GetIdentity(tokenSecretCacheID(token)) + cacheEntry := r.cache.GetIdentityWithSecretToken(token) if cacheEntry != nil && cacheEntry.Age() <= r.config.ACLTokenTTL { metrics.IncrCounter([]string{"acl", "token", "cache_hit"}, 1) - if cacheEntry.Error != nil && !acl.IsErrNotFound(cacheEntry.Error) { - return cacheEntry.Identity, ACLRemoteError{Err: cacheEntry.Error} - } - return cacheEntry.Identity, cacheEntry.Error + return cacheEntry.Identity, nil } metrics.IncrCounter([]string{"acl", "token", "cache_miss"}, 1) @@ -419,10 +414,7 @@ func (r *ACLResolver) resolveIdentityFromToken(token string) (structs.ACLIdentit waitForResult := cacheEntry == nil || r.config.ACLDownPolicy != "async-cache" if !waitForResult { // waitForResult being false requires the cacheEntry to not be nil - if cacheEntry.Error != nil && !acl.IsErrNotFound(cacheEntry.Error) { - return cacheEntry.Identity, ACLRemoteError{Err: cacheEntry.Error} - } - return cacheEntry.Identity, cacheEntry.Error + return cacheEntry.Identity, nil } // block on the read here, this is why we don't need chan buffering @@ -555,7 +547,7 @@ func (r *ACLResolver) maybeHandleIdentityErrorDuringFetch(identity structs.ACLId if acl.IsErrNotFound(err) { // make sure to indicate that this identity is no longer valid within // the cache - r.cache.PutIdentity(tokenSecretCacheID(identity.SecretToken()), nil, err) + r.cache.RemoveIdentityWithSecretToken(identity.SecretToken()) // Do not touch the cache. Getting a top level ACL not found error // only indicates that the secret token used in the request @@ -566,7 +558,7 @@ func (r *ACLResolver) maybeHandleIdentityErrorDuringFetch(identity structs.ACLId if acl.IsErrPermissionDenied(err) { // invalidate our ID cache so that identity resolution will take place // again in the future - r.cache.RemoveIdentity(tokenSecretCacheID(identity.SecretToken())) + r.cache.RemoveIdentityWithSecretToken(identity.SecretToken()) // Do not remove from the cache for permission denied // what this does indicate is that our view of the token is out of date @@ -599,8 +591,8 @@ func (r *ACLResolver) resolvePoliciesForIdentity(identity structs.ACLIdentity) ( var ( policyIDs = identity.PolicyIDs() roleIDs = identity.RoleIDs() - serviceIdentities = identity.ServiceIdentityList() - nodeIdentities = identity.NodeIdentityList() + serviceIdentities = structs.ACLServiceIdentities(identity.ServiceIdentityList()) + nodeIdentities = structs.ACLNodeIdentities(identity.NodeIdentityList()) ) if len(policyIDs) == 0 && len(serviceIdentities) == 0 && len(roleIDs) == 0 && len(nodeIdentities) == 0 { @@ -625,8 +617,8 @@ func (r *ACLResolver) resolvePoliciesForIdentity(identity structs.ACLIdentity) ( // Now deduplicate any policies or service identities that occur more than once. policyIDs = dedupeStringSlice(policyIDs) - serviceIdentities = dedupeServiceIdentities(serviceIdentities) - nodeIdentities = dedupeNodeIdentities(nodeIdentities) + serviceIdentities = serviceIdentities.Deduplicate() + nodeIdentities = nodeIdentities.Deduplicate() // Generate synthetic policies for all service identities in effect. syntheticPolicies := r.synthesizePoliciesForServiceIdentities(serviceIdentities, identity.EnterpriseMetadata()) @@ -690,72 +682,6 @@ func (r plainACLResolver) ResolveTokenAndDefaultMeta( return r.resolver.ResolveTokenAndDefaultMeta(token, entMeta, authzContext) } -func dedupeServiceIdentities(in []*structs.ACLServiceIdentity) []*structs.ACLServiceIdentity { - // From: https://github.com/golang/go/wiki/SliceTricks#in-place-deduplicate-comparable - - if len(in) <= 1 { - return in - } - - sort.Slice(in, func(i, j int) bool { - return in[i].ServiceName < in[j].ServiceName - }) - - j := 0 - for i := 1; i < len(in); i++ { - if in[j].ServiceName == in[i].ServiceName { - // Prefer increasing scope. - if len(in[j].Datacenters) == 0 || len(in[i].Datacenters) == 0 { - in[j].Datacenters = nil - } else { - in[j].Datacenters = mergeStringSlice(in[j].Datacenters, in[i].Datacenters) - } - continue - } - j++ - in[j] = in[i] - } - - // Discard the skipped items. - for i := j + 1; i < len(in); i++ { - in[i] = nil - } - - return in[:j+1] -} - -func dedupeNodeIdentities(in []*structs.ACLNodeIdentity) []*structs.ACLNodeIdentity { - // From: https://github.com/golang/go/wiki/SliceTricks#in-place-deduplicate-comparable - - if len(in) <= 1 { - return in - } - - sort.Slice(in, func(i, j int) bool { - if in[i].NodeName < in[j].NodeName { - return true - } - - return in[i].Datacenter < in[j].Datacenter - }) - - j := 0 - for i := 1; i < len(in); i++ { - if in[j].NodeName == in[i].NodeName && in[j].Datacenter == in[i].Datacenter { - continue - } - j++ - in[j] = in[i] - } - - // Discard the skipped items. - for i := j + 1; i < len(in); i++ { - in[i] = nil - } - - return in[:j+1] -} - func mergeStringSlice(a, b []string) []string { out := make([]string, 0, len(a)+len(b)) out = append(out, a...) diff --git a/agent/consul/acl_authmethod.go b/agent/consul/acl_authmethod.go index 34035e159d..f7826c7bd3 100644 --- a/agent/consul/acl_authmethod.go +++ b/agent/consul/acl_authmethod.go @@ -3,9 +3,6 @@ package consul import ( "fmt" - "github.com/hashicorp/go-bexpr" - - "github.com/hashicorp/consul/acl" "github.com/hashicorp/consul/agent/consul/authmethod" "github.com/hashicorp/consul/agent/structs" @@ -38,100 +35,3 @@ func (s *Server) loadAuthMethodValidator(idx uint64, method *structs.ACLAuthMeth return v, nil } - -type aclBindings struct { - roles []structs.ACLTokenRoleLink - serviceIdentities []*structs.ACLServiceIdentity - nodeIdentities []*structs.ACLNodeIdentity -} - -// evaluateRoleBindings evaluates all current binding rules associated with the -// given auth method against the verified data returned from the authentication -// process. -// -// A list of role links and service identities are returned. -func (s *Server) evaluateRoleBindings( - validator authmethod.Validator, - verifiedIdentity *authmethod.Identity, - methodMeta *acl.EnterpriseMeta, - targetMeta *acl.EnterpriseMeta, -) (*aclBindings, error) { - // Only fetch rules that are relevant for this method. - _, rules, err := s.fsm.State().ACLBindingRuleList(nil, validator.Name(), methodMeta) - if err != nil { - return nil, err - } else if len(rules) == 0 { - return nil, nil - } - - // Find all binding rules that match the provided fields. - var matchingRules []*structs.ACLBindingRule - for _, rule := range rules { - if doesSelectorMatch(rule.Selector, verifiedIdentity.SelectableFields) { - matchingRules = append(matchingRules, rule) - } - } - if len(matchingRules) == 0 { - return nil, nil - } - - // For all matching rules compute the attributes of a token. - var bindings aclBindings - for _, rule := range matchingRules { - bindName, valid, err := computeBindingRuleBindName(rule.BindType, rule.BindName, verifiedIdentity.ProjectedVars) - if err != nil { - return nil, fmt.Errorf("cannot compute %q bind name for bind target: %v", rule.BindType, err) - } else if !valid { - return nil, fmt.Errorf("computed %q bind name for bind target is invalid: %q", rule.BindType, bindName) - } - - switch rule.BindType { - case structs.BindingRuleBindTypeService: - bindings.serviceIdentities = append(bindings.serviceIdentities, &structs.ACLServiceIdentity{ - ServiceName: bindName, - }) - - case structs.BindingRuleBindTypeNode: - bindings.nodeIdentities = append(bindings.nodeIdentities, &structs.ACLNodeIdentity{ - NodeName: bindName, - Datacenter: s.config.Datacenter, - }) - - case structs.BindingRuleBindTypeRole: - _, role, err := s.fsm.State().ACLRoleGetByName(nil, bindName, targetMeta) - if err != nil { - return nil, err - } - - if role != nil { - bindings.roles = append(bindings.roles, structs.ACLTokenRoleLink{ - ID: role.ID, - }) - } - - default: - // skip unknown bind type; don't grant privileges - } - } - - return &bindings, nil -} - -// doesSelectorMatch checks that a single selector matches the provided vars. -func doesSelectorMatch(selector string, selectableVars interface{}) bool { - if selector == "" { - return true // catch-all - } - - eval, err := bexpr.CreateEvaluatorForType(selector, nil, selectableVars) - if err != nil { - return false // fails to match if selector is invalid - } - - result, err := eval.Evaluate(selectableVars) - if err != nil { - return false // fails to match if evaluation fails - } - - return result -} diff --git a/agent/consul/acl_authmethod_test.go b/agent/consul/acl_authmethod_test.go deleted file mode 100644 index 61fedf33eb..0000000000 --- a/agent/consul/acl_authmethod_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package consul - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestDoesSelectorMatch(t *testing.T) { - type matchable struct { - A string `bexpr:"a"` - C string `bexpr:"c"` - } - - for _, test := range []struct { - name string - selector string - details interface{} - ok bool - }{ - {"no fields", - "a==b", nil, false}, - {"1 term ok", - "a==b", &matchable{A: "b"}, true}, - {"1 term no field", - "a==b", &matchable{C: "d"}, false}, - {"1 term wrong value", - "a==b", &matchable{A: "z"}, false}, - {"2 terms ok", - "a==b and c==d", &matchable{A: "b", C: "d"}, true}, - {"2 terms one missing field", - "a==b and c==d", &matchable{A: "b"}, false}, - {"2 terms one wrong value", - "a==b and c==d", &matchable{A: "z", C: "d"}, false}, - /////////////////////////////// - {"no fields (no selectors)", - "", nil, true}, - {"1 term ok (no selectors)", - "", &matchable{A: "b"}, true}, - } { - t.Run(test.name, func(t *testing.T) { - ok := doesSelectorMatch(test.selector, test.details) - require.Equal(t, test.ok, ok) - }) - } -} diff --git a/agent/consul/acl_endpoint.go b/agent/consul/acl_endpoint.go index 3b9b00cf8f..69908c055e 100644 --- a/agent/consul/acl_endpoint.go +++ b/agent/consul/acl_endpoint.go @@ -2,13 +2,11 @@ package consul import ( "context" - "encoding/json" "errors" "fmt" "io/ioutil" "os" "path/filepath" - "regexp" "time" "github.com/armon/go-metrics" @@ -19,11 +17,11 @@ import ( uuid "github.com/hashicorp/go-uuid" "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/agent/consul/auth" "github.com/hashicorp/consul/agent/consul/authmethod" "github.com/hashicorp/consul/agent/consul/state" "github.com/hashicorp/consul/agent/structs" "github.com/hashicorp/consul/lib" - "github.com/hashicorp/consul/lib/template" ) const ( @@ -99,17 +97,6 @@ var ACLEndpointSummaries = []prometheus.SummaryDefinition{ }, } -// Regex for matching -var ( - validPolicyName = regexp.MustCompile(`^[A-Za-z0-9\-_]{1,128}$`) - validServiceIdentityName = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-_]*[a-z0-9])?$`) - serviceIdentityNameMaxLength = 256 - validNodeIdentityName = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-_]*[a-z0-9])?$`) - nodeIdentityNameMaxLength = 256 - validRoleName = regexp.MustCompile(`^[A-Za-z0-9\-_]{1,256}$`) - validAuthMethod = regexp.MustCompile(`^[A-Za-z0-9\-_]{1,128}$`) -) - // ACL endpoint is used to manipulate ACLs type ACL struct { srv *Server @@ -472,26 +459,26 @@ func (a *ACL) TokenClone(args *structs.ACLTokenSetRequest, reply *structs.ACLTok return fmt.Errorf("Cannot clone a legacy ACL with this endpoint") } - cloneReq := structs.ACLTokenSetRequest{ - Datacenter: args.Datacenter, - ACLToken: structs.ACLToken{ - Policies: token.Policies, - Roles: token.Roles, - ServiceIdentities: token.ServiceIdentities, - NodeIdentities: token.NodeIdentities, - Local: token.Local, - Description: token.Description, - ExpirationTime: token.ExpirationTime, - EnterpriseMeta: args.ACLToken.EnterpriseMeta, - }, - WriteRequest: args.WriteRequest, + clone := &structs.ACLToken{ + Policies: token.Policies, + Roles: token.Roles, + ServiceIdentities: token.ServiceIdentities, + NodeIdentities: token.NodeIdentities, + Local: token.Local, + Description: token.Description, + ExpirationTime: token.ExpirationTime, + EnterpriseMeta: args.ACLToken.EnterpriseMeta, } if args.ACLToken.Description != "" { - cloneReq.ACLToken.Description = args.ACLToken.Description + clone.Description = args.ACLToken.Description } - return a.tokenSetInternal(&cloneReq, reply, false) + updated, err := a.srv.aclTokenWriter().Create(clone, false) + if err == nil { + *reply = *updated + } + return err } func (a *ACL) TokenSet(args *structs.ACLTokenSetRequest, reply *structs.ACLToken) error { @@ -524,382 +511,21 @@ func (a *ACL) TokenSet(args *structs.ACLTokenSetRequest, reply *structs.ACLToken return err } - return a.tokenSetInternal(args, reply, false) -} - -func (a *ACL) tokenSetInternal(args *structs.ACLTokenSetRequest, reply *structs.ACLToken, fromLogin bool) error { - token := &args.ACLToken - - if !a.srv.LocalTokensEnabled() { - // local token operations - return fmt.Errorf("Cannot upsert tokens within this datacenter") - } else if !a.srv.InPrimaryDatacenter() && !token.Local { - return fmt.Errorf("Cannot upsert global tokens within this datacenter") - } - - state := a.srv.fsm.State() - - var accessorMatch *structs.ACLToken - var secretMatch *structs.ACLToken - var err error - - if token.AccessorID != "" { - _, accessorMatch, err = state.ACLTokenGetByAccessor(nil, token.AccessorID, nil) - if err != nil { - return fmt.Errorf("Failed acl token lookup by accessor: %v", err) - } - } - if token.SecretID != "" { - _, secretMatch, err = state.ACLTokenGetBySecret(nil, token.SecretID, nil) - if err != nil { - return fmt.Errorf("Failed acl token lookup by secret: %v", err) - } - } - - if token.AccessorID == "" || args.Create { - // Token Create - - // Generate the AccessorID if not specified - if token.AccessorID == "" { - token.AccessorID, err = lib.GenerateUUID(a.srv.checkTokenUUID) - if err != nil { - return err - } - } else if _, err := uuid.ParseUUID(token.AccessorID); err != nil { - return fmt.Errorf("Invalid Token: AccessorID is not a valid UUID") - } else if accessorMatch != nil { - return fmt.Errorf("Invalid Token: AccessorID is already in use") - } else if _, match, err := state.ACLTokenGetBySecret(nil, token.AccessorID, nil); err != nil || match != nil { - if err != nil { - return fmt.Errorf("Failed to lookup the acl token: %v", err) - } - return fmt.Errorf("Invalid Token: AccessorID is already in use") - } else if structs.ACLIDReserved(token.AccessorID) { - return fmt.Errorf("Invalid Token: UUIDs with the prefix %q are reserved", structs.ACLReservedPrefix) - } - - // Generate the SecretID if not specified - if token.SecretID == "" { - token.SecretID, err = lib.GenerateUUID(a.srv.checkTokenUUID) - if err != nil { - return err - } - } else if _, err := uuid.ParseUUID(token.SecretID); err != nil { - return fmt.Errorf("Invalid Token: SecretID is not a valid UUID") - } else if secretMatch != nil { - return fmt.Errorf("Invalid Token: SecretID is already in use") - } else if _, match, err := state.ACLTokenGetByAccessor(nil, token.SecretID, nil); err != nil || match != nil { - if err != nil { - return fmt.Errorf("Failed to lookup the acl token: %v", err) - } - return fmt.Errorf("Invalid Token: SecretID is already in use") - } else if structs.ACLIDReserved(token.SecretID) { - return fmt.Errorf("Invalid Token: UUIDs with the prefix %q are reserved", structs.ACLReservedPrefix) - } - - token.CreateTime = time.Now() - - if fromLogin { - if token.AuthMethod == "" { - return fmt.Errorf("AuthMethod field is required during Login") - } - } else { - if token.AuthMethod != "" { - return fmt.Errorf("AuthMethod field is disallowed outside of Login") - } - } - - // Ensure an ExpirationTTL is valid if provided. - if token.ExpirationTTL != 0 { - if token.ExpirationTTL < 0 { - return fmt.Errorf("Token Expiration TTL '%s' should be > 0", token.ExpirationTTL) - } - if token.HasExpirationTime() { - return fmt.Errorf("Token Expiration TTL and Expiration Time cannot both be set") - } - - token.ExpirationTime = timePointer(token.CreateTime.Add(token.ExpirationTTL)) - token.ExpirationTTL = 0 - } - - if token.HasExpirationTime() { - if token.CreateTime.After(*token.ExpirationTime) { - return fmt.Errorf("ExpirationTime cannot be before CreateTime") - } - - expiresIn := token.ExpirationTime.Sub(token.CreateTime) - if expiresIn > a.srv.config.ACLTokenMaxExpirationTTL { - return fmt.Errorf("ExpirationTime cannot be more than %s in the future (was %s)", - a.srv.config.ACLTokenMaxExpirationTTL, expiresIn) - } else if expiresIn < a.srv.config.ACLTokenMinExpirationTTL { - return fmt.Errorf("ExpirationTime cannot be less than %s in the future (was %s)", - a.srv.config.ACLTokenMinExpirationTTL, expiresIn) - } - } + var ( + updated *structs.ACLToken + err error + ) + writer := a.srv.aclTokenWriter() + if args.ACLToken.AccessorID == "" || args.Create { + updated, err = writer.Create(&args.ACLToken, false) } else { - // Token Update - if _, err := uuid.ParseUUID(token.AccessorID); err != nil { - return fmt.Errorf("AccessorID is not a valid UUID") - } - - // DEPRECATED (ACL-Legacy-Compat) - maybe get rid of this in the future - // and instead do a ParseUUID check. New tokens will not have - // secrets generated by users but rather they will always be UUIDs. - // However if users just continue the upgrade cycle they may still - // have tokens using secrets that are not UUIDS - // The RootAuthorizer checks that the SecretID is not "allow", "deny" - // or "manage" as a precaution against something accidentally using - // one of these root policies by setting the secret to it. - if acl.RootAuthorizer(token.SecretID) != nil { - return acl.PermissionDeniedError{Cause: "Cannot modify root ACL"} - } - - // Verify the token exists - if accessorMatch == nil || accessorMatch.IsExpired(time.Now()) { - return fmt.Errorf("Cannot find token %q", token.AccessorID) - } - if token.SecretID == "" { - token.SecretID = accessorMatch.SecretID - } else if accessorMatch.SecretID != token.SecretID { - return fmt.Errorf("Changing a tokens SecretID is not permitted") - } - - // Cannot toggle the "Global" mode - if token.Local != accessorMatch.Local { - return fmt.Errorf("cannot toggle local mode of %s", token.AccessorID) - } - - if token.AuthMethod == "" { - token.AuthMethod = accessorMatch.AuthMethod - } else if token.AuthMethod != accessorMatch.AuthMethod { - return fmt.Errorf("Cannot change AuthMethod of %s", token.AccessorID) - } - - if token.ExpirationTTL != 0 { - return fmt.Errorf("Cannot change expiration time of %s", token.AccessorID) - } - - if !token.HasExpirationTime() { - token.ExpirationTime = accessorMatch.ExpirationTime - } else if !accessorMatch.HasExpirationTime() { - return fmt.Errorf("Cannot change expiration time of %s", token.AccessorID) - } else if !token.ExpirationTime.Equal(*accessorMatch.ExpirationTime) { - return fmt.Errorf("Cannot change expiration time of %s", token.AccessorID) - } - - token.CreateTime = accessorMatch.CreateTime + updated, err = writer.Update(&args.ACLToken) } - policyIDs := make(map[string]struct{}) - var policies []structs.ACLTokenPolicyLink - - // Validate all the policy names and convert them to policy IDs - for _, link := range token.Policies { - if link.ID == "" { - _, policy, err := state.ACLPolicyGetByName(nil, link.Name, &token.EnterpriseMeta) - if err != nil { - return fmt.Errorf("Error looking up policy for name %q: %v", link.Name, err) - } - if policy == nil { - return fmt.Errorf("No such ACL policy with name %q", link.Name) - } - link.ID = policy.ID - } else { - _, policy, err := state.ACLPolicyGetByID(nil, link.ID, &token.EnterpriseMeta) - if err != nil { - return fmt.Errorf("Error looking up policy for id %q: %v", link.ID, err) - } - - if policy == nil { - return fmt.Errorf("No such ACL policy with ID %q", link.ID) - } - } - - // Do not store the policy name within raft/memdb as the policy could be renamed in the future. - link.Name = "" - - // dedup policy links by id - if _, ok := policyIDs[link.ID]; !ok { - policies = append(policies, link) - policyIDs[link.ID] = struct{}{} - } + if err == nil { + *reply = *updated } - token.Policies = policies - - roleIDs := make(map[string]struct{}) - var roles []structs.ACLTokenRoleLink - - // Validate all the role names and convert them to role IDs. - for _, link := range token.Roles { - if link.ID == "" { - _, role, err := state.ACLRoleGetByName(nil, link.Name, &token.EnterpriseMeta) - if err != nil { - return fmt.Errorf("Error looking up role for name %q: %v", link.Name, err) - } - if role == nil { - return fmt.Errorf("No such ACL role with name %q", link.Name) - } - link.ID = role.ID - } else { - _, role, err := state.ACLRoleGetByID(nil, link.ID, &token.EnterpriseMeta) - if err != nil { - return fmt.Errorf("Error looking up role for id %q: %v", link.ID, err) - } - - if role == nil { - return fmt.Errorf("No such ACL role with ID %q", link.ID) - } - } - - // Do not store the role name within raft/memdb as the role could be renamed in the future. - link.Name = "" - - // dedup role links by id - if _, ok := roleIDs[link.ID]; !ok { - roles = append(roles, link) - roleIDs[link.ID] = struct{}{} - } - } - token.Roles = roles - - for _, svcid := range token.ServiceIdentities { - if svcid.ServiceName == "" { - return fmt.Errorf("Service identity is missing the service name field on this token") - } - if token.Local && len(svcid.Datacenters) > 0 { - return fmt.Errorf("Service identity %q cannot specify a list of datacenters on a local token", svcid.ServiceName) - } - if !isValidServiceIdentityName(svcid.ServiceName) { - return fmt.Errorf("Service identity %q has an invalid name. Only lowercase alphanumeric characters, '-' and '_' are allowed", svcid.ServiceName) - } - } - token.ServiceIdentities = dedupeServiceIdentities(token.ServiceIdentities) - - for _, nodeid := range token.NodeIdentities { - if nodeid.NodeName == "" { - return fmt.Errorf("Node identity is missing the node name field on this token") - } - if nodeid.Datacenter == "" { - return fmt.Errorf("Node identity is missing the datacenter field on this token") - } - if !isValidNodeIdentityName(nodeid.NodeName) { - return fmt.Errorf("Node identity has an invalid name. Only lowercase alphanumeric characters, '-' and '_' are allowed") - } - } - token.NodeIdentities = dedupeNodeIdentities(token.NodeIdentities) - - if token.Rules != "" { - return fmt.Errorf("Rules cannot be specified for this token") - } - - if token.Type != "" { - return fmt.Errorf("Type cannot be specified for this token") - } - - token.SetHash(true) - - // validate the enterprise specific fields - if err = a.tokenUpsertValidateEnterprise(token, accessorMatch); err != nil { - return err - } - - req := &structs.ACLTokenBatchSetRequest{ - Tokens: structs.ACLTokens{token}, - CAS: false, - } - - if fromLogin { - // Logins may attempt to link to roles that do not exist. These - // may be persisted, but don't allow tokens to be created that - // have no privileges (i.e. role links that point nowhere). - req.AllowMissingLinks = true - req.ProhibitUnprivileged = true - } - - _, err = a.srv.raftApply(structs.ACLTokenSetRequestType, req) - if err != nil { - return fmt.Errorf("Failed to apply token write request: %v", err) - } - - // Purge the identity from the cache to prevent using the previous definition of the identity - a.srv.ACLResolver.cache.RemoveIdentity(tokenSecretCacheID(token.SecretID)) - - // Don't check expiration times here as it doesn't really matter. - if _, updatedToken, err := a.srv.fsm.State().ACLTokenGetByAccessor(nil, token.AccessorID, nil); err == nil && updatedToken != nil { - *reply = *updatedToken - } else { - return fmt.Errorf("Failed to retrieve the token after insertion") - } - - return nil -} - -func validateBindingRuleBindName(bindType, bindName string, availableFields []string) (bool, error) { - if bindType == "" || bindName == "" { - return false, nil - } - - fakeVarMap := make(map[string]string) - for _, v := range availableFields { - fakeVarMap[v] = "fake" - } - - _, valid, err := computeBindingRuleBindName(bindType, bindName, fakeVarMap) - if err != nil { - return false, err - } - return valid, nil -} - -// computeBindingRuleBindName processes the HIL for the provided bind type+name -// using the projected variables. -// -// - If the HIL is invalid ("", false, AN_ERROR) is returned. -// - If the computed name is not valid for the type ("INVALID_NAME", false, nil) is returned. -// - If the computed name is valid for the type ("VALID_NAME", true, nil) is returned. -func computeBindingRuleBindName(bindType, bindName string, projectedVars map[string]string) (string, bool, error) { - bindName, err := template.InterpolateHIL(bindName, projectedVars, true) - if err != nil { - return "", false, err - } - - valid := false - - switch bindType { - case structs.BindingRuleBindTypeService: - valid = isValidServiceIdentityName(bindName) - case structs.BindingRuleBindTypeNode: - valid = isValidNodeIdentityName(bindName) - case structs.BindingRuleBindTypeRole: - valid = validRoleName.MatchString(bindName) - - default: - return "", false, fmt.Errorf("unknown binding rule bind type: %s", bindType) - } - - return bindName, valid, nil -} - -// isValidServiceIdentityName returns true if the provided name can be used as -// an ACLServiceIdentity ServiceName. This is more restrictive than standard -// catalog registration, which basically takes the view that "everything is -// valid". -func isValidServiceIdentityName(name string) bool { - if len(name) < 1 || len(name) > serviceIdentityNameMaxLength { - return false - } - return validServiceIdentityName.MatchString(name) -} - -// isValidNodeIdentityName returns true if the provided name can be used as -// an ACLNodeIdentity NodeName. This is more restrictive than standard -// catalog registration, which basically takes the view that "everything is -// valid". -func isValidNodeIdentityName(name string) bool { - if len(name) < 1 || len(name) > nodeIdentityNameMaxLength { - return false - } - return validNodeIdentityName.MatchString(name) + return err } func (a *ACL) TokenDelete(args *structs.ACLTokenDeleteRequest, reply *string) error { @@ -974,7 +600,7 @@ func (a *ACL) TokenDelete(args *structs.ACLTokenDeleteRequest, reply *string) er } // Purge the identity from the cache to prevent using the previous definition of the identity - a.srv.ACLResolver.cache.RemoveIdentity(tokenSecretCacheID(token.SecretID)) + a.srv.ACLResolver.cache.RemoveIdentityWithSecretToken(token.SecretID) if reply != nil { *reply = token.AccessorID @@ -1218,7 +844,7 @@ func (a *ACL) PolicySet(args *structs.ACLPolicySetRequest, reply *structs.ACLPol return fmt.Errorf("Invalid Policy: no Name is set") } - if !validPolicyName.MatchString(policy.Name) { + if !acl.IsValidPolicyName(policy.Name) { return fmt.Errorf("Invalid Policy: invalid Name. Only alphanumeric characters, '-' and '_' are allowed") } @@ -1604,7 +1230,7 @@ func (a *ACL) RoleSet(args *structs.ACLRoleSetRequest, reply *structs.ACLRole) e return fmt.Errorf("Invalid Role: no Name is set") } - if !validRoleName.MatchString(role.Name) { + if !acl.IsValidRoleName(role.Name) { return fmt.Errorf("Invalid Role: invalid Name. Only alphanumeric characters, '-' and '_' are allowed") } @@ -1681,11 +1307,11 @@ func (a *ACL) RoleSet(args *structs.ACLRoleSetRequest, reply *structs.ACLRole) e if svcid.ServiceName == "" { return fmt.Errorf("Service identity is missing the service name field on this role") } - if !isValidServiceIdentityName(svcid.ServiceName) { + if !acl.IsValidServiceIdentityName(svcid.ServiceName) { return fmt.Errorf("Service identity %q has an invalid name. Only lowercase alphanumeric characters, '-' and '_' are allowed", svcid.ServiceName) } } - role.ServiceIdentities = dedupeServiceIdentities(role.ServiceIdentities) + role.ServiceIdentities = role.ServiceIdentities.Deduplicate() for _, nodeid := range role.NodeIdentities { if nodeid.NodeName == "" { @@ -1694,11 +1320,11 @@ func (a *ACL) RoleSet(args *structs.ACLRoleSetRequest, reply *structs.ACLRole) e if nodeid.Datacenter == "" { return fmt.Errorf("Node identity is missing the datacenter field on this role") } - if !isValidNodeIdentityName(nodeid.NodeName) { + if !acl.IsValidNodeIdentityName(nodeid.NodeName) { return fmt.Errorf("Node identity has an invalid name. Only lowercase alphanumeric characters, '-' and '_' are allowed") } } - role.NodeIdentities = dedupeNodeIdentities(role.NodeIdentities) + role.NodeIdentities = role.NodeIdentities.Deduplicate() // calculate the hash for this role role.SetHash(true) @@ -2018,7 +1644,7 @@ func (a *ACL) BindingRuleSet(args *structs.ACLBindingRuleSetRequest, reply *stru return fmt.Errorf("Invalid Binding Rule: unknown BindType %q", rule.BindType) } - if valid, err := validateBindingRuleBindName(rule.BindType, rule.BindName, blankID.ProjectedVarNames()); err != nil { + if valid, err := auth.IsValidBindName(rule.BindType, rule.BindName, blankID.ProjectedVarNames()); err != nil { return fmt.Errorf("Invalid Binding Rule: invalid BindName: %v", err) } else if !valid { return fmt.Errorf("Invalid Binding Rule: invalid BindName") @@ -2167,7 +1793,7 @@ func (a *ACL) AuthMethodRead(args *structs.ACLAuthMethodGetRequest, reply *struc return errNotFound } - _ = a.enterpriseAuthMethodTypeValidation(method.Type) + _ = a.srv.enterpriseAuthMethodTypeValidation(method.Type) return nil }) } @@ -2207,11 +1833,11 @@ func (a *ACL) AuthMethodSet(args *structs.ACLAuthMethodSetRequest, reply *struct if method.Name == "" { return fmt.Errorf("Invalid Auth Method: no Name is set") } - if !validAuthMethod.MatchString(method.Name) { + if !acl.IsValidAuthMethodName(method.Name) { return fmt.Errorf("Invalid Auth Method: invalid Name. Only alphanumeric characters, '-' and '_' are allowed") } - if err := a.enterpriseAuthMethodTypeValidation(method.Type); err != nil { + if err := a.srv.enterpriseAuthMethodTypeValidation(method.Type); err != nil { return err } @@ -2321,7 +1947,7 @@ func (a *ACL) AuthMethodDelete(args *structs.ACLAuthMethodDeleteRequest, reply * return nil } - if err := a.enterpriseAuthMethodTypeValidation(method.Type); err != nil { + if err := a.srv.enterpriseAuthMethodTypeValidation(method.Type); err != nil { return err } @@ -2377,7 +2003,7 @@ func (a *ACL) AuthMethodList(args *structs.ACLAuthMethodListRequest, reply *stru var stubs structs.ACLAuthMethodListStubs for _, method := range methods { - _ = a.enterpriseAuthMethodTypeValidation(method.Type) + _ = a.srv.enterpriseAuthMethodTypeValidation(method.Type) stubs = append(stubs, method.Stub()) } @@ -2413,132 +2039,28 @@ func (a *ACL) Login(args *structs.ACLLoginRequest, reply *structs.ACLToken) erro defer metrics.MeasureSince([]string{"acl", "login"}, time.Now()) - auth := args.Auth - - // 1. take args.Data.AuthMethod to get an AuthMethod Validator - idx, method, err := a.srv.fsm.State().ACLAuthMethodGetByName(nil, auth.AuthMethod, &auth.EnterpriseMeta) - if err != nil { - return err - } else if method == nil { - return fmt.Errorf("%w: auth method %q not found", acl.ErrNotFound, auth.AuthMethod) - } - - if err := a.enterpriseAuthMethodTypeValidation(method.Type); err != nil { - return err - } - - validator, err := a.srv.loadAuthMethodValidator(idx, method) + authMethod, validator, err := a.srv.loadAuthMethod(args.Auth.AuthMethod, &args.Auth.EnterpriseMeta) if err != nil { return err } - // 2. Send args.Data.BearerToken to method validator and get back a fields map - verifiedIdentity, err := validator.ValidateLogin(context.Background(), auth.BearerToken) + verifiedIdentity, err := validator.ValidateLogin(context.Background(), args.Auth.BearerToken) if err != nil { return err } - return a.tokenSetFromAuthMethod( - method, - &auth.EnterpriseMeta, - "token created via login", - auth.Meta, - validator, - verifiedIdentity, - &structs.ACLTokenSetRequest{ - Datacenter: args.Datacenter, - WriteRequest: args.WriteRequest, - }, - reply, - ) -} - -func (a *ACL) tokenSetFromAuthMethod( - method *structs.ACLAuthMethod, - entMeta *acl.EnterpriseMeta, - tokenDescriptionPrefix string, - tokenMetadata map[string]string, - validator authmethod.Validator, - verifiedIdentity *authmethod.Identity, - createReq *structs.ACLTokenSetRequest, // this should be prepopulated with datacenter+writerequest - reply *structs.ACLToken, -) error { - // This always will return a valid pointer - targetMeta, err := computeTargetEnterpriseMeta(method, verifiedIdentity) + description, err := auth.BuildTokenDescription("token created via login", args.Auth.Meta) if err != nil { return err } - // 3. send map through role bindings - bindings, err := a.srv.evaluateRoleBindings(validator, verifiedIdentity, entMeta, targetMeta) - if err != nil { - return err + token, err := a.srv.aclLogin().TokenForVerifiedIdentity(verifiedIdentity, authMethod, description) + if err == nil { + *reply = *token } - - // We try to prevent the creation of a useless token without taking a trip - // through the state store if we can. - if bindings == nil || (len(bindings.serviceIdentities) == 0 && len(bindings.nodeIdentities) == 0 && len(bindings.roles) == 0) { - return acl.ErrPermissionDenied - } - - // TODO(sso): add a CapturedField to ACLAuthMethod that would pluck fields from the returned identity and stuff into `auth.Meta`. - - description := tokenDescriptionPrefix - loginMeta, err := encodeLoginMeta(tokenMetadata) - if err != nil { - return err - } - if loginMeta != "" { - description += ": " + loginMeta - } - - // 4. create token - createReq.ACLToken = structs.ACLToken{ - Description: description, - Local: true, - AuthMethod: method.Name, - ServiceIdentities: bindings.serviceIdentities, - NodeIdentities: bindings.nodeIdentities, - Roles: bindings.roles, - ExpirationTTL: method.MaxTokenTTL, - EnterpriseMeta: *targetMeta, - } - - if method.TokenLocality == "global" { - if !a.srv.InPrimaryDatacenter() { - return errors.New("creating global tokens via auth methods is only permitted in the primary datacenter") - } - createReq.ACLToken.Local = false - } - - createReq.ACLToken.ACLAuthMethodEnterpriseMeta.FillWithEnterpriseMeta(entMeta) - - // 5. return token information like a TokenCreate would - err = a.tokenSetInternal(createReq, reply, true) - - // If we were in a slight race with a role delete operation then we may - // still end up failing to insert an unprivileged token in the state - // machine instead. Return the same error as earlier so it doesn't - // actually matter which one prevents the insertion. - if err != nil && err.Error() == state.ErrTokenHasNoPrivileges.Error() { - return acl.ErrPermissionDenied - } - return err } -func encodeLoginMeta(meta map[string]string) (string, error) { - if len(meta) == 0 { - return "", nil - } - - d, err := json.Marshal(meta) - if err != nil { - return "", err - } - return string(d), nil -} - func (a *ACL) Logout(args *structs.ACLLogoutRequest, reply *bool) error { if err := a.aclPreCheck(); err != nil { return err @@ -2558,39 +2080,18 @@ func (a *ACL) Logout(args *structs.ACLLogoutRequest, reply *bool) error { defer metrics.MeasureSince([]string{"acl", "logout"}, time.Now()) - _, token, err := a.srv.fsm.State().ACLTokenGetBySecret(nil, args.Token, nil) - if err != nil { - return err - - } else if token == nil { - return acl.ErrNotFound - - } else if token.AuthMethod == "" { - // Can't "logout" of a token that wasn't a result of login. - return acl.ErrPermissionDenied - - } else if !a.srv.InPrimaryDatacenter() && !token.Local { - // global token writes must be forwarded to the primary DC + // No need to check expiration time because it's being deleted. + err := a.srv.aclTokenWriter().Delete(args.Token, true) + switch { + case errors.Is(err, auth.ErrCannotWriteGlobalToken): + // Writes to global tokens must be forwarded to the primary DC. args.Datacenter = a.srv.config.PrimaryDatacenter return a.srv.forwardDC("ACL.Logout", a.srv.config.PrimaryDatacenter, args, reply) + case err != nil: + return err } - // No need to check expiration time because it's being deleted. - - req := &structs.ACLTokenBatchDeleteRequest{ - TokenIDs: []string{token.AccessorID}, - } - - _, err = a.srv.raftApply(structs.ACLTokenDeleteRequestType, req) - if err != nil { - return fmt.Errorf("Failed to apply token delete request: %v", err) - } - - // Purge the identity from the cache to prevent using the previous definition of the identity - a.srv.ACLResolver.cache.RemoveIdentity(tokenSecretCacheID(token.SecretID)) - *reply = true - return nil } diff --git a/agent/consul/acl_endpoint_oss.go b/agent/consul/acl_endpoint_oss.go index e218826a6d..da2890fde0 100644 --- a/agent/consul/acl_endpoint_oss.go +++ b/agent/consul/acl_endpoint_oss.go @@ -27,21 +27,10 @@ func (a *ACL) roleUpsertValidateEnterprise(role *structs.ACLRole, existing *stru return state.ACLRoleUpsertValidateEnterprise(role, existing) } -func (a *ACL) enterpriseAuthMethodTypeValidation(authMethodType string) error { - return nil -} - func enterpriseAuthMethodValidation(method *structs.ACLAuthMethod, validator authmethod.Validator) error { return nil } -func computeTargetEnterpriseMeta( - method *structs.ACLAuthMethod, - verifiedIdentity *authmethod.Identity, -) (*acl.EnterpriseMeta, error) { - return &acl.EnterpriseMeta{}, nil -} - func getTokenNamespaceDefaults(ws memdb.WatchSet, state *state.Store, entMeta *acl.EnterpriseMeta) ([]string, []string, error) { return nil, nil, nil } diff --git a/agent/consul/acl_endpoint_test.go b/agent/consul/acl_endpoint_test.go index 1ceb3a0a2d..09a752f434 100644 --- a/agent/consul/acl_endpoint_test.go +++ b/agent/consul/acl_endpoint_test.go @@ -478,7 +478,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { }, false) waitForLeaderEstablishment(t, srv) - acl := ACL{srv: srv} + a := ACL{srv: srv} var tokenID string @@ -501,7 +501,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err := acl.TokenSet(&req, &resp) + err := a.TokenSet(&req, &resp) require.NoError(t, err) // Get the token directly to validate that it exists @@ -532,7 +532,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err := acl.TokenSet(&req, &resp) + err := a.TokenSet(&req, &resp) require.NoError(t, err) // Get the token directly to validate that it exists @@ -572,7 +572,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err = acl.TokenSet(&req, &resp) + err = a.TokenSet(&req, &resp) require.NoError(t, err) // Delete both policies to ensure that we skip resolving ID->Name @@ -618,7 +618,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err = acl.TokenSet(&req, &resp) + err = a.TokenSet(&req, &resp) require.NoError(t, err) // Delete both roles to ensure that we skip resolving ID->Name @@ -651,8 +651,8 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err := acl.TokenSet(&req, &resp) - testutil.RequireErrorContains(t, err, "AuthMethod field is disallowed outside of Login") + err := a.TokenSet(&req, &resp) + testutil.RequireErrorContains(t, err, "AuthMethod field is disallowed outside of login") }) t.Run("Update auth method linked token and try to change auth method", func(t *testing.T) { @@ -767,12 +767,12 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err := acl.TokenSet(&req, &resp) + err := a.TokenSet(&req, &resp) testutil.RequireErrorContains(t, err, "Service identity is missing the service name field") }) t.Run("Create it with invalid service identity (too large)", func(t *testing.T) { - long := strings.Repeat("x", serviceIdentityNameMaxLength+1) + long := strings.Repeat("x", acl.ServiceIdentityNameMaxLength+1) req := structs.ACLTokenSetRequest{ Datacenter: "dc1", ACLToken: structs.ACLToken{ @@ -788,7 +788,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err := acl.TokenSet(&req, &resp) + err := a.TokenSet(&req, &resp) require.NotNil(t, err) }) @@ -834,7 +834,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err := acl.TokenSet(&req, &resp) + err := a.TokenSet(&req, &resp) if test.ok { require.NoError(t, err) @@ -867,7 +867,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err := acl.TokenSet(&req, &resp) + err := a.TokenSet(&req, &resp) require.NoError(t, err) // Get the token directly to validate that it exists @@ -901,7 +901,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err := acl.TokenSet(&req, &resp) + err := a.TokenSet(&req, &resp) require.NoError(t, err) // Get the token directly to validate that it exists @@ -931,7 +931,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err := acl.TokenSet(&req, &resp) + err := a.TokenSet(&req, &resp) testutil.RequireErrorContains(t, err, "cannot specify a list of datacenters on a local token") }) @@ -959,7 +959,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err := acl.TokenSet(&req, &resp) + err := a.TokenSet(&req, &resp) if test.errString != "" { testutil.RequireErrorContains(t, err, test.errString) } else { @@ -981,7 +981,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err := acl.TokenSet(&req, &resp) + err := a.TokenSet(&req, &resp) if test.errString != "" { testutil.RequireErrorContains(t, err, test.errStringTTL) } else { @@ -1005,7 +1005,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err := acl.TokenSet(&req, &resp) + err := a.TokenSet(&req, &resp) testutil.RequireErrorContains(t, err, "Expiration TTL and Expiration Time cannot both be set") }) @@ -1023,7 +1023,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err := acl.TokenSet(&req, &resp) + err := a.TokenSet(&req, &resp) require.NoError(t, err) // Get the token directly to validate that it exists @@ -1058,7 +1058,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err := acl.TokenSet(&req, &resp) + err := a.TokenSet(&req, &resp) require.NoError(t, err) // Get the token directly to validate that it exists @@ -1090,7 +1090,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err := acl.TokenSet(&req, &resp) + err := a.TokenSet(&req, &resp) testutil.RequireErrorContains(t, err, "Cannot change expiration time") }) @@ -1108,7 +1108,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err := acl.TokenSet(&req, &resp) + err := a.TokenSet(&req, &resp) require.NoError(t, err) // Get the token directly to validate that it exists @@ -1136,7 +1136,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err := acl.TokenSet(&req, &resp) + err := a.TokenSet(&req, &resp) require.NoError(t, err) // Get the token directly to validate that it exists @@ -1172,7 +1172,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err = acl.TokenSet(&req, &resp) + err = a.TokenSet(&req, &resp) testutil.RequireErrorContains(t, err, "Cannot find token") }) @@ -1191,7 +1191,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err := acl.TokenSet(&req, &resp) + err := a.TokenSet(&req, &resp) testutil.RequireErrorContains(t, err, "Node identity is missing the node name field on this token") }) @@ -1211,7 +1211,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err := acl.TokenSet(&req, &resp) + err := a.TokenSet(&req, &resp) testutil.RequireErrorContains(t, err, "Node identity has an invalid name.") }) t.Run("invalid node identity - no datacenter", func(t *testing.T) { @@ -1229,7 +1229,7 @@ func TestACLEndpoint_TokenSet(t *testing.T) { resp := structs.ACLToken{} - err := acl.TokenSet(&req, &resp) + err := a.TokenSet(&req, &resp) testutil.RequireErrorContains(t, err, "Node identity is missing the datacenter field on this token") }) } @@ -2389,7 +2389,7 @@ func TestACLEndpoint_RoleSet(t *testing.T) { _, srv, codec := testACLServerWithConfig(t, nil, false) waitForLeaderEstablishment(t, srv) - acl := ACL{srv: srv} + a := ACL{srv: srv} var roleID string testPolicy1, err := upsertTestPolicy(codec, TestDefaultInitialManagementToken, "dc1") @@ -2419,7 +2419,7 @@ func TestACLEndpoint_RoleSet(t *testing.T) { } resp := structs.ACLRole{} - err := acl.RoleSet(&req, &resp) + err := a.RoleSet(&req, &resp) require.NoError(t, err) require.NotNil(t, resp.ID) @@ -2457,7 +2457,7 @@ func TestACLEndpoint_RoleSet(t *testing.T) { } resp := structs.ACLRole{} - err := acl.RoleSet(&req, &resp) + err := a.RoleSet(&req, &resp) require.NoError(t, err) require.NotNil(t, resp.ID) @@ -2498,7 +2498,7 @@ func TestACLEndpoint_RoleSet(t *testing.T) { } resp := structs.ACLRole{} - err = acl.RoleSet(&req, &resp) + err = a.RoleSet(&req, &resp) require.NoError(t, err) require.NotNil(t, resp.ID) @@ -2540,12 +2540,12 @@ func TestACLEndpoint_RoleSet(t *testing.T) { } resp := structs.ACLRole{} - err := acl.RoleSet(&req, &resp) + err := a.RoleSet(&req, &resp) testutil.RequireErrorContains(t, err, "Service identity is missing the service name field") }) t.Run("Create it with invalid service identity (too large)", func(t *testing.T) { - long := strings.Repeat("x", serviceIdentityNameMaxLength+1) + long := strings.Repeat("x", acl.ServiceIdentityNameMaxLength+1) req := structs.ACLRoleSetRequest{ Datacenter: "dc1", Role: structs.ACLRole{ @@ -2559,7 +2559,7 @@ func TestACLEndpoint_RoleSet(t *testing.T) { } resp := structs.ACLRole{} - err := acl.RoleSet(&req, &resp) + err := a.RoleSet(&req, &resp) require.NotNil(t, err) }) @@ -2604,7 +2604,7 @@ func TestACLEndpoint_RoleSet(t *testing.T) { resp := structs.ACLRole{} - err := acl.RoleSet(&req, &resp) + err := a.RoleSet(&req, &resp) if test.ok { require.NoError(t, err) @@ -2635,7 +2635,7 @@ func TestACLEndpoint_RoleSet(t *testing.T) { resp := structs.ACLRole{} - err := acl.RoleSet(&req, &resp) + err := a.RoleSet(&req, &resp) require.NoError(t, err) // Get the role directly to validate that it exists @@ -2667,7 +2667,7 @@ func TestACLEndpoint_RoleSet(t *testing.T) { resp := structs.ACLRole{} - err := acl.RoleSet(&req, &resp) + err := a.RoleSet(&req, &resp) require.NoError(t, err) // Get the role directly to validate that it exists @@ -2696,7 +2696,7 @@ func TestACLEndpoint_RoleSet(t *testing.T) { resp := structs.ACLRole{} - err := acl.RoleSet(&req, &resp) + err := a.RoleSet(&req, &resp) testutil.RequireErrorContains(t, err, "Node identity is missing the node name field on this role") }) @@ -2717,7 +2717,7 @@ func TestACLEndpoint_RoleSet(t *testing.T) { resp := structs.ACLRole{} - err := acl.RoleSet(&req, &resp) + err := a.RoleSet(&req, &resp) testutil.RequireErrorContains(t, err, "Node identity has an invalid name.") }) t.Run("invalid node identity - no datacenter", func(t *testing.T) { @@ -2736,7 +2736,7 @@ func TestACLEndpoint_RoleSet(t *testing.T) { resp := structs.ACLRole{} - err := acl.RoleSet(&req, &resp) + err := a.RoleSet(&req, &resp) testutil.RequireErrorContains(t, err, "Node identity is missing the datacenter field on this role") }) } @@ -5314,106 +5314,6 @@ func gatherIDs(t *testing.T, v interface{}) []string { return out } -func TestValidateBindingRuleBindName(t *testing.T) { - t.Parallel() - - type testcase struct { - name string - bindType string - bindName string - fields string - valid bool // valid HIL, invalid contents - err bool // invalid HIL - } - - for _, test := range []testcase{ - {"no bind type", - "", "", "", false, false}, - {"bad bind type", - "invalid", "blah", "", false, true}, - // valid HIL, invalid name - {"empty", - "both", "", "", false, false}, - {"just end", - "both", "}", "", false, false}, - {"var without start", - "both", " item }", "item", false, false}, - {"two vars missing second start", - "both", "before-${ item }after--more }", "item,more", false, false}, - // names for the two types are validated differently - {"@ is disallowed", - "both", "bad@name", "", false, false}, - {"leading dash", - "role", "-name", "", true, false}, - {"leading dash", - "service", "-name", "", false, false}, - {"trailing dash", - "role", "name-", "", true, false}, - {"trailing dash", - "service", "name-", "", false, false}, - {"inner dash", - "both", "name-end", "", true, false}, - {"upper case", - "role", "NAME", "", true, false}, - {"upper case", - "service", "NAME", "", false, false}, - // valid HIL, valid name - {"no vars", - "both", "nothing", "", true, false}, - {"just var", - "both", "${item}", "item", true, false}, - {"var in middle", - "both", "before-${item}after", "item", true, false}, - {"two vars", - "both", "before-${item}after-${more}", "item,more", true, false}, - // bad - {"no bind name", - "both", "", "", false, false}, - {"just start", - "both", "${", "", false, true}, - {"backwards", - "both", "}${", "", false, true}, - {"no varname", - "both", "${}", "", false, true}, - {"missing map key", - "both", "${item}", "", false, true}, - {"var without end", - "both", "${ item ", "item", false, true}, - {"two vars missing first end", - "both", "before-${ item after-${ more }", "item,more", false, true}, - } { - var cases []testcase - if test.bindType == "both" { - test1 := test - test1.bindType = "role" - test2 := test - test2.bindType = "service" - cases = []testcase{test1, test2} - } else { - cases = []testcase{test} - } - - for _, test := range cases { - test := test - t.Run(test.bindType+"--"+test.name, func(t *testing.T) { - t.Parallel() - valid, err := validateBindingRuleBindName( - test.bindType, - test.bindName, - strings.Split(test.fields, ","), - ) - if test.err { - require.NotNil(t, err) - require.False(t, valid) - } else { - require.NoError(t, err) - require.Equal(t, test.valid, valid) - } - }) - } - } -} - // upsertTestToken creates a token for testing purposes func upsertTestTokenInEntMeta(codec rpc.ClientCodec, initialManagementToken string, datacenter string, tokenModificationFn func(token *structs.ACLToken), entMeta *acl.EnterpriseMeta) (*structs.ACLToken, error) { diff --git a/agent/consul/acl_server.go b/agent/consul/acl_server.go index ba44b5606c..b6047752f8 100644 --- a/agent/consul/acl_server.go +++ b/agent/consul/acl_server.go @@ -1,9 +1,12 @@ package consul import ( + "fmt" "time" "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/agent/consul/auth" + "github.com/hashicorp/consul/agent/consul/authmethod" "github.com/hashicorp/consul/agent/structs" ) @@ -172,3 +175,44 @@ func (s *Server) filterACL(token string, subj interface{}) error { func (s *Server) filterACLWithAuthorizer(authorizer acl.Authorizer, subj interface{}) { filterACLWithAuthorizer(s.ACLResolver.logger, authorizer, subj) } + +func (s *Server) aclLogin() *auth.Login { + return auth.NewLogin(s.aclBinder(), s.aclTokenWriter()) +} + +func (s *Server) aclBinder() *auth.Binder { + return auth.NewBinder(s.fsm.State(), s.config.Datacenter) +} + +func (s *Server) aclTokenWriter() *auth.TokenWriter { + return auth.NewTokenWriter(auth.TokenWriterConfig{ + RaftApply: s.raftApply, + ACLCache: s.ACLResolver.cache, + Store: s.fsm.State(), + CheckUUID: s.checkTokenUUID, + MaxExpirationTTL: s.config.ACLTokenMaxExpirationTTL, + MinExpirationTTL: s.config.ACLTokenMinExpirationTTL, + PrimaryDatacenter: s.config.PrimaryDatacenter, + InPrimaryDatacenter: s.InPrimaryDatacenter(), + LocalTokensEnabled: s.LocalTokensEnabled(), + }) +} + +func (s *Server) loadAuthMethod(methodName string, entMeta *acl.EnterpriseMeta) (*structs.ACLAuthMethod, authmethod.Validator, error) { + idx, method, err := s.fsm.State().ACLAuthMethodGetByName(nil, methodName, entMeta) + if err != nil { + return nil, nil, err + } else if method == nil { + return nil, nil, fmt.Errorf("%w: auth method %q not found", acl.ErrNotFound, methodName) + } + + if err := s.enterpriseAuthMethodTypeValidation(method.Type); err != nil { + return nil, nil, err + } + + validator, err := s.loadAuthMethodValidator(idx, method) + if err != nil { + return nil, nil, err + } + return method, validator, nil +} diff --git a/agent/consul/acl_server_oss.go b/agent/consul/acl_server_oss.go index a3ed18aea1..6d281c2255 100644 --- a/agent/consul/acl_server_oss.go +++ b/agent/consul/acl_server_oss.go @@ -19,3 +19,7 @@ func (s *Server) validateEnterpriseToken(identity structs.ACLIdentity) error { func (s *Server) aclBootstrapAllowed() error { return nil } + +func (*Server) enterpriseAuthMethodTypeValidation(authMethodType string) error { + return nil +} diff --git a/agent/consul/acl_test.go b/agent/consul/acl_test.go index 86e5ad478f..9fa551dcbb 100644 --- a/agent/consul/acl_test.go +++ b/agent/consul/acl_test.go @@ -768,15 +768,15 @@ func TestACLResolver_ResolveRootACL(t *testing.T) { } func TestACLResolver_DownPolicy(t *testing.T) { - requireIdentityCached := func(t *testing.T, r *ACLResolver, id string, present bool, msg string) { + requireIdentityCached := func(t *testing.T, r *ACLResolver, secretID string, present bool, msg string) { t.Helper() - cacheVal := r.cache.GetIdentity(id) - require.NotNil(t, cacheVal) + cacheVal := r.cache.GetIdentityWithSecretToken(secretID) if present { + require.NotNil(t, cacheVal, msg) require.NotNil(t, cacheVal.Identity, msg) } else { - require.Nil(t, cacheVal.Identity, msg) + require.Nil(t, cacheVal, msg) } } requirePolicyCached := func(t *testing.T, r *ACLResolver, policyID string, present bool, msg string) { @@ -816,7 +816,7 @@ func TestACLResolver_DownPolicy(t *testing.T) { } require.Equal(t, expected, authz) - requireIdentityCached(t, r, tokenSecretCacheID("foo"), false, "not present") + requireIdentityCached(t, r, "foo", false, "not present") }) t.Run("Allow", func(t *testing.T) { @@ -844,7 +844,7 @@ func TestACLResolver_DownPolicy(t *testing.T) { } require.Equal(t, expected, authz) - requireIdentityCached(t, r, tokenSecretCacheID("foo"), false, "not present") + requireIdentityCached(t, r, "foo", false, "not present") }) t.Run("Expired-Policy", func(t *testing.T) { @@ -935,7 +935,7 @@ func TestACLResolver_DownPolicy(t *testing.T) { require.NotNil(t, authz) require.Equal(t, acl.Allow, authz.NodeWrite("foo", nil)) - requireIdentityCached(t, r, tokenSecretCacheID("found"), true, "cached") + requireIdentityCached(t, r, "found", true, "cached") authz2, err := r.ResolveToken("found") require.NoError(t, err) @@ -986,7 +986,7 @@ func TestACLResolver_DownPolicy(t *testing.T) { require.NotNil(t, authz) require.Equal(t, acl.Allow, authz.NodeWrite("foo", nil)) - requireIdentityCached(t, r, tokenSecretCacheID("found-role"), true, "still cached") + requireIdentityCached(t, r, "found-role", true, "still cached") authz2, err := r.ResolveToken("found-role") require.NoError(t, err) @@ -1245,7 +1245,7 @@ func TestACLResolver_DownPolicy(t *testing.T) { require.NotNil(t, authz) require.Equal(t, acl.Allow, authz.NodeWrite("foo", nil)) - requireIdentityCached(t, r, tokenSecretCacheID("found"), true, "cached") + requireIdentityCached(t, r, "found", true, "cached") // The identity should have been cached so this should still be valid authz2, err := r.ResolveToken("found") @@ -1261,45 +1261,7 @@ func TestACLResolver_DownPolicy(t *testing.T) { assert.True(t, acl.IsErrNotFound(err)) }) - requireIdentityCached(t, r, tokenSecretCacheID("found"), false, "no longer cached") - }) - - t.Run("Cache-Error", func(t *testing.T) { - _, rawToken, _ := testIdentityForToken("found") - foundToken := rawToken.(*structs.ACLToken) - secretID := foundToken.SecretID - - remoteErr := fmt.Errorf("network error") - delegate := &ACLResolverTestDelegate{ - enabled: true, - datacenter: "dc1", - legacy: false, - localTokens: false, - localPolicies: false, - tokenReadFn: func(_ *structs.ACLTokenGetRequest, reply *structs.ACLTokenResponse) error { - return remoteErr - }, - } - r := newTestACLResolver(t, delegate, func(config *ACLResolverConfig) { - config.Config.ACLDownPolicy = "deny" - }) - - // Attempt to resolve the token - this should set up the cache entry with a nil - // identity and permission denied error. - authz, err := r.ResolveToken(secretID) - require.NoError(t, err) - require.NotNil(t, authz) - entry := r.cache.GetIdentity(tokenSecretCacheID(secretID)) - require.Nil(t, entry.Identity) - require.Equal(t, remoteErr, entry.Error) - - // Attempt to resolve again to pull from the cache. - authz, err = r.ResolveToken(secretID) - require.NoError(t, err) - require.NotNil(t, authz) - entry = r.cache.GetIdentity(tokenSecretCacheID(secretID)) - require.Nil(t, entry.Identity) - require.Equal(t, remoteErr, entry.Error) + requireIdentityCached(t, r, "found", false, "no longer cached") }) t.Run("PolicyResolve-TokenNotFound", func(t *testing.T) { @@ -1351,7 +1313,7 @@ func TestACLResolver_DownPolicy(t *testing.T) { require.Equal(t, acl.Allow, authz.NodeWrite("foo", nil)) // Verify that the caches are setup properly. - requireIdentityCached(t, r, tokenSecretCacheID(secretID), true, "cached") + requireIdentityCached(t, r, secretID, true, "cached") requirePolicyCached(t, r, "node-wr", true, "cached") // from "found" token requirePolicyCached(t, r, "dc2-key-wr", true, "cached") // from "found" token @@ -1362,7 +1324,7 @@ func TestACLResolver_DownPolicy(t *testing.T) { _, err = r.ResolveToken(secretID) require.True(t, acl.IsErrNotFound(err)) - requireIdentityCached(t, r, tokenSecretCacheID(secretID), false, "identity not found cached") + requireIdentityCached(t, r, secretID, false, "identity not found cached") requirePolicyCached(t, r, "node-wr", true, "still cached") require.Nil(t, r.cache.GetPolicy("dc2-key-wr"), "not stored at all") }) @@ -1411,7 +1373,7 @@ func TestACLResolver_DownPolicy(t *testing.T) { require.Equal(t, acl.Allow, authz.NodeWrite("foo", nil)) // Verify that the caches are setup properly. - requireIdentityCached(t, r, tokenSecretCacheID(secretID), true, "cached") + requireIdentityCached(t, r, secretID, true, "cached") requirePolicyCached(t, r, "node-wr", true, "cached") // from "found" token requirePolicyCached(t, r, "dc2-key-wr", true, "cached") // from "found" token @@ -1422,7 +1384,7 @@ func TestACLResolver_DownPolicy(t *testing.T) { _, err = r.ResolveToken(secretID) require.True(t, acl.IsErrPermissionDenied(err)) - require.Nil(t, r.cache.GetIdentity(tokenSecretCacheID(secretID)), "identity not stored at all") + require.Nil(t, r.cache.GetIdentityWithSecretToken(secretID), "identity not stored at all") requirePolicyCached(t, r, "node-wr", true, "still cached") require.Nil(t, r.cache.GetPolicy("dc2-key-wr"), "not stored at all") }) @@ -3815,94 +3777,6 @@ func TestACL_unhandledFilterType(t *testing.T) { srv.filterACL(token, &structs.HealthCheck{}) } -func TestDedupeServiceIdentities(t *testing.T) { - srvid := func(name string, datacenters ...string) *structs.ACLServiceIdentity { - return &structs.ACLServiceIdentity{ - ServiceName: name, - Datacenters: datacenters, - } - } - - tests := []struct { - name string - in []*structs.ACLServiceIdentity - expect []*structs.ACLServiceIdentity - }{ - { - name: "empty", - in: nil, - expect: nil, - }, - { - name: "one", - in: []*structs.ACLServiceIdentity{ - srvid("foo"), - }, - expect: []*structs.ACLServiceIdentity{ - srvid("foo"), - }, - }, - { - name: "just names", - in: []*structs.ACLServiceIdentity{ - srvid("fooZ"), - srvid("fooA"), - srvid("fooY"), - srvid("fooB"), - }, - expect: []*structs.ACLServiceIdentity{ - srvid("fooA"), - srvid("fooB"), - srvid("fooY"), - srvid("fooZ"), - }, - }, - { - name: "just names with dupes", - in: []*structs.ACLServiceIdentity{ - srvid("fooZ"), - srvid("fooA"), - srvid("fooY"), - srvid("fooB"), - srvid("fooA"), - srvid("fooB"), - srvid("fooY"), - srvid("fooZ"), - }, - expect: []*structs.ACLServiceIdentity{ - srvid("fooA"), - srvid("fooB"), - srvid("fooY"), - srvid("fooZ"), - }, - }, - { - name: "names with dupes and datacenters", - in: []*structs.ACLServiceIdentity{ - srvid("fooZ", "dc2", "dc4"), - srvid("fooA"), - srvid("fooY", "dc1"), - srvid("fooB"), - srvid("fooA", "dc9", "dc8"), - srvid("fooB"), - srvid("fooY", "dc1"), - srvid("fooZ", "dc3", "dc4"), - }, - expect: []*structs.ACLServiceIdentity{ - srvid("fooA"), - srvid("fooB"), - srvid("fooY", "dc1"), - srvid("fooZ", "dc2", "dc3", "dc4"), - }, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - got := dedupeServiceIdentities(test.in) - require.ElementsMatch(t, test.expect, got) - }) - } -} func TestACL_LocalToken(t *testing.T) { t.Run("local token in same dc", func(t *testing.T) { d := &ACLResolverTestDelegate{ diff --git a/agent/consul/acl_token_exp.go b/agent/consul/acl_token_exp.go index fac2017e7e..e373e0cdd6 100644 --- a/agent/consul/acl_token_exp.go +++ b/agent/consul/acl_token_exp.go @@ -108,7 +108,7 @@ func (s *Server) reapExpiredACLTokens(local, global bool) (int, error) { // Purge the identities from the cache for _, secretID := range secretIDs { - s.ACLResolver.cache.RemoveIdentity(tokenSecretCacheID(secretID)) + s.ACLResolver.cache.RemoveIdentityWithSecretToken(secretID) } return len(req.TokenIDs), nil diff --git a/agent/consul/auth/binder.go b/agent/consul/auth/binder.go new file mode 100644 index 0000000000..f1eec01410 --- /dev/null +++ b/agent/consul/auth/binder.go @@ -0,0 +1,189 @@ +package auth + +import ( + "fmt" + + "github.com/hashicorp/go-bexpr" + "github.com/hashicorp/go-memdb" + + "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/agent/consul/authmethod" + "github.com/hashicorp/consul/agent/structs" + "github.com/hashicorp/consul/lib/template" +) + +// Binder is responsible for collecting the ACL roles, service identities, node +// identities, and enterprise metadata to be assigned to a token generated as a +// result of "logging in" via an auth method. +// +// It does so by applying the auth method's configured binding rules and in the +// case of enterprise, namespace rules. +type Binder struct { + store BinderStateStore + datacenter string +} + +// NewBinder creates a Binder with the given state store and datacenter. +func NewBinder(store BinderStateStore, datacenter string) *Binder { + return &Binder{store, datacenter} +} + +// BinderStateStore is the subset of state store methods used by the binder. +type BinderStateStore interface { + ACLBindingRuleList(ws memdb.WatchSet, methodName string, entMeta *acl.EnterpriseMeta) (uint64, structs.ACLBindingRules, error) + ACLRoleGetByName(ws memdb.WatchSet, roleName string, entMeta *acl.EnterpriseMeta) (uint64, *structs.ACLRole, error) +} + +// Bindings contains the ACL roles, service identities, node identities and +// enterprise meta to be assigned to the created token. +type Bindings struct { + Roles []structs.ACLTokenRoleLink + ServiceIdentities []*structs.ACLServiceIdentity + NodeIdentities []*structs.ACLNodeIdentity + EnterpriseMeta acl.EnterpriseMeta +} + +// None indicates that the resulting bindings would not give the created token +// access to any resources. +func (b *Bindings) None() bool { + if b == nil { + return true + } + + return len(b.ServiceIdentities) == 0 && + len(b.NodeIdentities) == 0 && + len(b.Roles) == 0 +} + +// Bind collects the ACL roles, service identities, etc. to be assigned to the +// created token. +func (b *Binder) Bind(authMethod *structs.ACLAuthMethod, verifiedIdentity *authmethod.Identity) (*Bindings, error) { + var ( + bindings Bindings + err error + ) + if bindings.EnterpriseMeta, err = bindEnterpriseMeta(authMethod, verifiedIdentity); err != nil { + return nil, err + } + + // Load the auth method's binding rules. + _, rules, err := b.store.ACLBindingRuleList(nil, authMethod.Name, &authMethod.EnterpriseMeta) + if err != nil { + return nil, err + } + + // Find the rules with selectors that match the identity's fields. + matchingRules := make(structs.ACLBindingRules, 0, len(rules)) + for _, rule := range rules { + if doesSelectorMatch(rule.Selector, verifiedIdentity.SelectableFields) { + matchingRules = append(matchingRules, rule) + } + } + if len(matchingRules) == 0 { + return &bindings, nil + } + + // Compute role, service identity, or node identity names by interpolating + // the identity's projected variables into the rule BindName templates. + for _, rule := range matchingRules { + bindName, valid, err := computeBindName(rule.BindType, rule.BindName, verifiedIdentity.ProjectedVars) + switch { + case err != nil: + return nil, fmt.Errorf("cannot compute %q bind name for bind target: %w", rule.BindType, err) + case !valid: + return nil, fmt.Errorf("computed %q bind name for bind target is invalid: %q", rule.BindType, bindName) + } + + switch rule.BindType { + case structs.BindingRuleBindTypeService: + bindings.ServiceIdentities = append(bindings.ServiceIdentities, &structs.ACLServiceIdentity{ + ServiceName: bindName, + }) + + case structs.BindingRuleBindTypeNode: + bindings.NodeIdentities = append(bindings.NodeIdentities, &structs.ACLNodeIdentity{ + NodeName: bindName, + Datacenter: b.datacenter, + }) + + case structs.BindingRuleBindTypeRole: + _, role, err := b.store.ACLRoleGetByName(nil, bindName, &bindings.EnterpriseMeta) + if err != nil { + return nil, err + } + + if role != nil { + bindings.Roles = append(bindings.Roles, structs.ACLTokenRoleLink{ + ID: role.ID, + }) + } + } + } + + return &bindings, nil +} + +// IsValidBindName returns whether the given BindName template produces valid +// results when interpolating the auth method's available variables. +func IsValidBindName(bindType, bindName string, availableVariables []string) (bool, error) { + if bindType == "" || bindName == "" { + return false, nil + } + + fakeVarMap := make(map[string]string) + for _, v := range availableVariables { + fakeVarMap[v] = "fake" + } + + _, valid, err := computeBindName(bindType, bindName, fakeVarMap) + if err != nil { + return false, err + } + return valid, nil +} + +// computeBindName processes the HIL for the provided bind type+name using the +// projected variables. +// +// - If the HIL is invalid ("", false, AN_ERROR) is returned. +// - If the computed name is not valid for the type ("INVALID_NAME", false, nil) is returned. +// - If the computed name is valid for the type ("VALID_NAME", true, nil) is returned. +func computeBindName(bindType, bindName string, projectedVars map[string]string) (string, bool, error) { + bindName, err := template.InterpolateHIL(bindName, projectedVars, true) + if err != nil { + return "", false, err + } + + var valid bool + switch bindType { + case structs.BindingRuleBindTypeService: + valid = acl.IsValidServiceIdentityName(bindName) + case structs.BindingRuleBindTypeNode: + valid = acl.IsValidNodeIdentityName(bindName) + case structs.BindingRuleBindTypeRole: + valid = acl.IsValidRoleName(bindName) + default: + return "", false, fmt.Errorf("unknown binding rule bind type: %s", bindType) + } + + return bindName, valid, nil +} + +// doesSelectorMatch checks that a single selector matches the provided vars. +func doesSelectorMatch(selector string, selectableVars interface{}) bool { + if selector == "" { + return true // catch-all + } + + eval, err := bexpr.CreateEvaluatorForType(selector, nil, selectableVars) + if err != nil { + return false // fails to match if selector is invalid + } + + result, err := eval.Evaluate(selectableVars) + if err != nil { + return false // fails to match if evaluation fails + } + + return result +} diff --git a/agent/consul/auth/binder_oss.go b/agent/consul/auth/binder_oss.go new file mode 100644 index 0000000000..bbd34090ee --- /dev/null +++ b/agent/consul/auth/binder_oss.go @@ -0,0 +1,14 @@ +//go:build !consulent +// +build !consulent + +package auth + +import ( + "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/agent/consul/authmethod" + "github.com/hashicorp/consul/agent/structs" +) + +func bindEnterpriseMeta(authMethod *structs.ACLAuthMethod, verifiedIdentity *authmethod.Identity) (acl.EnterpriseMeta, error) { + return acl.EnterpriseMeta{}, nil +} diff --git a/agent/consul/auth/binder_test.go b/agent/consul/auth/binder_test.go new file mode 100644 index 0000000000..240131dfdb --- /dev/null +++ b/agent/consul/auth/binder_test.go @@ -0,0 +1,372 @@ +package auth + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/go-uuid" + + "github.com/hashicorp/consul/agent/consul/authmethod" + "github.com/hashicorp/consul/agent/consul/state" + "github.com/hashicorp/consul/agent/structs" +) + +func TestBindings_None(t *testing.T) { + var b *Bindings + require.True(t, b.None()) + + b = &Bindings{} + require.True(t, b.None()) + + b = &Bindings{Roles: []structs.ACLTokenRoleLink{{ID: generateID(t)}}} + require.False(t, b.None()) + + b = &Bindings{ServiceIdentities: []*structs.ACLServiceIdentity{{ServiceName: "web"}}} + require.False(t, b.None()) + + b = &Bindings{NodeIdentities: []*structs.ACLNodeIdentity{{NodeName: "node-123"}}} + require.False(t, b.None()) +} + +func TestBinder_Roles_Success(t *testing.T) { + store := testStateStore(t) + binder := &Binder{store: store} + + authMethod := &structs.ACLAuthMethod{ + Name: "test-auth-method", + Type: "testing", + } + require.NoError(t, store.ACLAuthMethodSet(0, authMethod)) + + targetRole := &structs.ACLRole{ + ID: generateID(t), + Name: "vim-role", + } + require.NoError(t, store.ACLRoleSet(0, targetRole)) + + otherRole := &structs.ACLRole{ + ID: generateID(t), + Name: "frontend-engineers", + } + require.NoError(t, store.ACLRoleSet(0, otherRole)) + + bindingRules := structs.ACLBindingRules{ + { + ID: generateID(t), + Selector: "role==engineer", + BindType: structs.BindingRuleBindTypeRole, + BindName: "${editor}-role", + AuthMethod: authMethod.Name, + }, + { + ID: generateID(t), + Selector: "role==engineer", + BindType: structs.BindingRuleBindTypeRole, + BindName: "this-role-does-not-exist", + AuthMethod: authMethod.Name, + }, + { + ID: generateID(t), + Selector: "language==js", + BindType: structs.BindingRuleBindTypeRole, + BindName: otherRole.Name, + AuthMethod: authMethod.Name, + }, + } + require.NoError(t, store.ACLBindingRuleBatchSet(0, bindingRules)) + + result, err := binder.Bind(&structs.ACLAuthMethod{}, &authmethod.Identity{ + SelectableFields: map[string]string{ + "role": "engineer", + "language": "go", + }, + ProjectedVars: map[string]string{ + "editor": "vim", + }, + }) + require.NoError(t, err) + require.Equal(t, []structs.ACLTokenRoleLink{ + {ID: targetRole.ID}, + }, result.Roles) +} + +func TestBinder_Roles_NameValidation(t *testing.T) { + store := testStateStore(t) + binder := &Binder{store: store} + + authMethod := &structs.ACLAuthMethod{ + Name: "test-auth-method", + Type: "testing", + } + require.NoError(t, store.ACLAuthMethodSet(0, authMethod)) + + bindingRules := structs.ACLBindingRules{ + { + ID: generateID(t), + Selector: "", + BindType: structs.BindingRuleBindTypeRole, + BindName: "INVALID!", + AuthMethod: authMethod.Name, + }, + } + require.NoError(t, store.ACLBindingRuleBatchSet(0, bindingRules)) + + _, err := binder.Bind(&structs.ACLAuthMethod{}, &authmethod.Identity{}) + require.Error(t, err) + require.Contains(t, err.Error(), "bind name for bind target is invalid") +} + +func TestBinder_ServiceIdentities_Success(t *testing.T) { + store := testStateStore(t) + binder := &Binder{store: store} + + authMethod := &structs.ACLAuthMethod{ + Name: "test-auth-method", + Type: "testing", + } + require.NoError(t, store.ACLAuthMethodSet(0, authMethod)) + + bindingRules := structs.ACLBindingRules{ + { + ID: generateID(t), + Selector: "tier==web", + BindType: structs.BindingRuleBindTypeService, + BindName: "web-service-${name}", + AuthMethod: authMethod.Name, + }, + { + ID: generateID(t), + Selector: "tier==db", + BindType: structs.BindingRuleBindTypeService, + BindName: "database-${name}", + AuthMethod: authMethod.Name, + }, + } + require.NoError(t, store.ACLBindingRuleBatchSet(0, bindingRules)) + + result, err := binder.Bind(&structs.ACLAuthMethod{}, &authmethod.Identity{ + SelectableFields: map[string]string{ + "tier": "web", + }, + ProjectedVars: map[string]string{ + "name": "billing", + }, + }) + require.NoError(t, err) + require.Equal(t, []*structs.ACLServiceIdentity{ + {ServiceName: "web-service-billing"}, + }, result.ServiceIdentities) +} + +func TestBinder_ServiceIdentities_NameValidation(t *testing.T) { + store := testStateStore(t) + binder := &Binder{store: store} + + authMethod := &structs.ACLAuthMethod{ + Name: "test-auth-method", + Type: "testing", + } + require.NoError(t, store.ACLAuthMethodSet(0, authMethod)) + + bindingRules := structs.ACLBindingRules{ + { + ID: generateID(t), + Selector: "", + BindType: structs.BindingRuleBindTypeService, + BindName: "INVALID!", + AuthMethod: authMethod.Name, + }, + } + require.NoError(t, store.ACLBindingRuleBatchSet(0, bindingRules)) + + _, err := binder.Bind(&structs.ACLAuthMethod{}, &authmethod.Identity{}) + require.Error(t, err) + require.Contains(t, err.Error(), "bind name for bind target is invalid") +} + +func TestBinder_NodeIdentities_Success(t *testing.T) { + store := testStateStore(t) + binder := &Binder{store: store, datacenter: "dc1"} + + authMethod := &structs.ACLAuthMethod{ + Name: "test-auth-method", + Type: "testing", + } + require.NoError(t, store.ACLAuthMethodSet(0, authMethod)) + + bindingRules := structs.ACLBindingRules{ + { + ID: generateID(t), + Selector: "provider==gcp", + BindType: structs.BindingRuleBindTypeNode, + BindName: "gcp-${os}", + AuthMethod: authMethod.Name, + }, + { + ID: generateID(t), + Selector: "provider==aws", + BindType: structs.BindingRuleBindTypeNode, + BindName: "aws-${os}", + AuthMethod: authMethod.Name, + }, + } + require.NoError(t, store.ACLBindingRuleBatchSet(0, bindingRules)) + + result, err := binder.Bind(&structs.ACLAuthMethod{}, &authmethod.Identity{ + SelectableFields: map[string]string{ + "provider": "gcp", + }, + ProjectedVars: map[string]string{ + "os": "linux", + }, + }) + require.NoError(t, err) + require.Equal(t, []*structs.ACLNodeIdentity{ + {NodeName: "gcp-linux", Datacenter: "dc1"}, + }, result.NodeIdentities) +} + +func TestBinder_NodeIdentities_NameValidation(t *testing.T) { + store := testStateStore(t) + binder := &Binder{store: store} + + authMethod := &structs.ACLAuthMethod{ + Name: "test-auth-method", + Type: "testing", + } + require.NoError(t, store.ACLAuthMethodSet(0, authMethod)) + + bindingRules := structs.ACLBindingRules{ + { + ID: generateID(t), + Selector: "", + BindType: structs.BindingRuleBindTypeNode, + BindName: "INVALID!", + AuthMethod: authMethod.Name, + }, + } + require.NoError(t, store.ACLBindingRuleBatchSet(0, bindingRules)) + + _, err := binder.Bind(&structs.ACLAuthMethod{}, &authmethod.Identity{}) + require.Error(t, err) + require.Contains(t, err.Error(), "bind name for bind target is invalid") +} + +func Test_IsValidBindName(t *testing.T) { + type testcase struct { + name string + bindType string + bindName string + fields string + valid bool // valid HIL, invalid contents + err bool // invalid HIL + } + + for _, test := range []testcase{ + {"no bind type", + "", "", "", false, false}, + {"bad bind type", + "invalid", "blah", "", false, true}, + // valid HIL, invalid name + {"empty", + "both", "", "", false, false}, + {"just end", + "both", "}", "", false, false}, + {"var without start", + "both", " item }", "item", false, false}, + {"two vars missing second start", + "both", "before-${ item }after--more }", "item,more", false, false}, + // names for the two types are validated differently + {"@ is disallowed", + "both", "bad@name", "", false, false}, + {"leading dash", + "role", "-name", "", true, false}, + {"leading dash", + "service", "-name", "", false, false}, + {"trailing dash", + "role", "name-", "", true, false}, + {"trailing dash", + "service", "name-", "", false, false}, + {"inner dash", + "both", "name-end", "", true, false}, + {"upper case", + "role", "NAME", "", true, false}, + {"upper case", + "service", "NAME", "", false, false}, + // valid HIL, valid name + {"no vars", + "both", "nothing", "", true, false}, + {"just var", + "both", "${item}", "item", true, false}, + {"var in middle", + "both", "before-${item}after", "item", true, false}, + {"two vars", + "both", "before-${item}after-${more}", "item,more", true, false}, + // bad + {"no bind name", + "both", "", "", false, false}, + {"just start", + "both", "${", "", false, true}, + {"backwards", + "both", "}${", "", false, true}, + {"no varname", + "both", "${}", "", false, true}, + {"missing map key", + "both", "${item}", "", false, true}, + {"var without end", + "both", "${ item ", "item", false, true}, + {"two vars missing first end", + "both", "before-${ item after-${ more }", "item,more", false, true}, + } { + var cases []testcase + if test.bindType == "both" { + test1 := test + test1.bindType = "role" + test2 := test + test2.bindType = "service" + cases = []testcase{test1, test2} + } else { + cases = []testcase{test} + } + + for _, test := range cases { + test := test + t.Run(test.bindType+"--"+test.name, func(t *testing.T) { + t.Parallel() + valid, err := IsValidBindName( + test.bindType, + test.bindName, + strings.Split(test.fields, ","), + ) + if test.err { + require.NotNil(t, err) + require.False(t, valid) + } else { + require.NoError(t, err) + require.Equal(t, test.valid, valid) + } + }) + } + } +} + +func generateID(t *testing.T) string { + t.Helper() + + id, err := uuid.GenerateUUID() + require.NoError(t, err) + + return id +} + +func testStateStore(t *testing.T) *state.Store { + t.Helper() + + gc, err := state.NewTombstoneGC(time.Second, time.Millisecond) + require.NoError(t, err) + + return state.NewStateStore(gc) +} diff --git a/agent/consul/auth/login.go b/agent/consul/auth/login.go new file mode 100644 index 0000000000..3848fb8c8c --- /dev/null +++ b/agent/consul/auth/login.go @@ -0,0 +1,77 @@ +package auth + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/agent/consul/authmethod" + "github.com/hashicorp/consul/agent/consul/state" + "github.com/hashicorp/consul/agent/structs" +) + +// Login wraps the process of creating an ACLToken from the identity verified +// by an auth method. +type Login struct { + binder *Binder + writer *TokenWriter +} + +// NewLogin returns a new Login with the given binder and writer. +func NewLogin(binder *Binder, writer *TokenWriter) *Login { + return &Login{binder, writer} +} + +// TokenForVerifiedIdentity creates an ACLToken for the given identity verified +// by an auth method. +func (l *Login) TokenForVerifiedIdentity(identity *authmethod.Identity, authMethod *structs.ACLAuthMethod, description string) (*structs.ACLToken, error) { + bindings, err := l.binder.Bind(authMethod, identity) + switch { + case err != nil: + return nil, err + case bindings.None(): + // We try to prevent the creation of a useless token without taking a trip + // through Raft and the state store if we can. + return nil, acl.ErrPermissionDenied + } + + token := &structs.ACLToken{ + Description: description, + Local: authMethod.TokenLocality != "global", // TokenWriter prevents the creation of global tokens in secondary datacenters. + AuthMethod: authMethod.Name, + ExpirationTTL: authMethod.MaxTokenTTL, + ServiceIdentities: bindings.ServiceIdentities, + NodeIdentities: bindings.NodeIdentities, + Roles: bindings.Roles, + EnterpriseMeta: bindings.EnterpriseMeta, + } + token.ACLAuthMethodEnterpriseMeta.FillWithEnterpriseMeta(&authMethod.EnterpriseMeta) + + updated, err := l.writer.Create(token, true) + switch { + case err != nil && strings.Contains(err.Error(), state.ErrTokenHasNoPrivileges.Error()): + // If we were in a slight race with a role delete operation then we may + // still end up failing to insert an unprivileged token in the state + // machine instead. Return the same error as earlier so it doesn't + // actually matter which one prevents the insertion. + return nil, acl.ErrPermissionDenied + case err != nil: + return nil, err + } + return updated, nil +} + +// BuildTokenDescription builds a description for an ACLToken by encoding the +// given meta as JSON and applying the prefix. +func BuildTokenDescription(prefix string, meta map[string]string) (string, error) { + if len(meta) == 0 { + return prefix, nil + } + + d, err := json.Marshal(meta) + if err != nil { + return "", err + } + return fmt.Sprintf("%s: %s", prefix, d), nil +} diff --git a/agent/consul/auth/mock_ACLCache.go b/agent/consul/auth/mock_ACLCache.go new file mode 100644 index 0000000000..e8e5c68283 --- /dev/null +++ b/agent/consul/auth/mock_ACLCache.go @@ -0,0 +1,29 @@ +// Code generated by mockery v2.12.0. DO NOT EDIT. + +package auth + +import ( + testing "testing" + + mock "github.com/stretchr/testify/mock" +) + +// MockACLCache is an autogenerated mock type for the ACLCache type +type MockACLCache struct { + mock.Mock +} + +// RemoveIdentityWithSecretToken provides a mock function with given fields: secretToken +func (_m *MockACLCache) RemoveIdentityWithSecretToken(secretToken string) { + _m.Called(secretToken) +} + +// NewMockACLCache creates a new instance of MockACLCache. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations. +func NewMockACLCache(t testing.TB) *MockACLCache { + mock := &MockACLCache{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/agent/consul/auth/token_writer.go b/agent/consul/auth/token_writer.go new file mode 100644 index 0000000000..ae59570454 --- /dev/null +++ b/agent/consul/auth/token_writer.go @@ -0,0 +1,449 @@ +package auth + +import ( + "errors" + "fmt" + "time" + + "github.com/hashicorp/go-memdb" + "github.com/hashicorp/go-uuid" + + "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/agent/structs" + "github.com/hashicorp/consul/lib" +) + +// ErrCannotWriteGlobalToken indicates that writing a token failed because +// the token is global and this is a non-primary datacenter. +var ErrCannotWriteGlobalToken = errors.New("Cannot upsert global tokens within this datacenter") + +// NewTokenWriter creates a new token writer. +func NewTokenWriter(cfg TokenWriterConfig) *TokenWriter { + return &TokenWriter{cfg} +} + +// TokenWriter encapsulates the logic of writing ACL tokens to the state store +// including validation, cache purging, etc. +type TokenWriter struct { + TokenWriterConfig +} + +type TokenWriterConfig struct { + RaftApply RaftApplyFn + ACLCache ACLCache + Store TokenWriterStore + CheckUUID lib.UUIDCheckFunc + + MaxExpirationTTL time.Duration + MinExpirationTTL time.Duration + + PrimaryDatacenter string + InPrimaryDatacenter bool + LocalTokensEnabled bool +} + +type RaftApplyFn func(structs.MessageType, interface{}) (interface{}, error) + +//go:generate mockery --name ACLCache --inpackage +type ACLCache interface { + RemoveIdentityWithSecretToken(secretToken string) +} + +type TokenWriterStore interface { + ACLTokenGetByAccessor(ws memdb.WatchSet, accessorID string, entMeta *acl.EnterpriseMeta) (uint64, *structs.ACLToken, error) + ACLTokenGetBySecret(ws memdb.WatchSet, secretID string, entMeta *acl.EnterpriseMeta) (uint64, *structs.ACLToken, error) + ACLRoleGetByID(ws memdb.WatchSet, id string, entMeta *acl.EnterpriseMeta) (uint64, *structs.ACLRole, error) + ACLRoleGetByName(ws memdb.WatchSet, name string, entMeta *acl.EnterpriseMeta) (uint64, *structs.ACLRole, error) + ACLPolicyGetByID(ws memdb.WatchSet, id string, entMeta *acl.EnterpriseMeta) (uint64, *structs.ACLPolicy, error) + ACLPolicyGetByName(ws memdb.WatchSet, name string, entMeta *acl.EnterpriseMeta) (uint64, *structs.ACLPolicy, error) + ACLTokenUpsertValidateEnterprise(token *structs.ACLToken, existing *structs.ACLToken) error +} + +// Create a new token. Setting fromLogin to true changes behavior slightly for +// tokens created by login (as opposed to set manually via the API). +func (w *TokenWriter) Create(token *structs.ACLToken, fromLogin bool) (*structs.ACLToken, error) { + if err := w.checkCanWriteToken(token); err != nil { + return nil, err + } + + if token.AccessorID == "" { + // Caller didn't provide an AccessorID, so generate one. + id, err := lib.GenerateUUID(w.CheckUUID) + if err != nil { + return nil, fmt.Errorf("Failed to generate AccessorID: %w", err) + } + token.AccessorID = id + } else { + // Check the AccessorID is valid and not already in-use. + if err := validateTokenID(token.AccessorID); err != nil { + return nil, fmt.Errorf("Invalid Token: AccessorID - %w", err) + } + if inUse, err := w.tokenIDInUse(token.AccessorID); err != nil { + return nil, fmt.Errorf("Failed to lookup ACL token: %w", err) + } else if inUse { + return nil, errors.New("Invalid Token: AccessorID is already in use") + } + } + + if token.SecretID == "" { + // Caller didn't provide a SecretID, so generate one. + id, err := lib.GenerateUUID(w.CheckUUID) + if err != nil { + return nil, fmt.Errorf("Failed to generate SecretID: %w", err) + } + token.SecretID = id + } else { + // Check the SecretID is valid and not already in-use. + if err := validateTokenID(token.SecretID); err != nil { + return nil, fmt.Errorf("Invalid Token: SecretID - %w", err) + } + if inUse, err := w.tokenIDInUse(token.SecretID); err != nil { + return nil, fmt.Errorf("Failed to lookup ACL token: %w", err) + } else if inUse { + return nil, errors.New("Invalid Token: SecretID is already in use") + } + } + + token.CreateTime = time.Now() + + // Ensure ExpirationTTL is valid if provided. + if token.ExpirationTTL < 0 { + return nil, fmt.Errorf("Token Expiration TTL '%s' should be > 0", token.ExpirationTTL) + } else if token.ExpirationTTL > 0 { + if token.HasExpirationTime() { + return nil, errors.New("Token Expiration TTL and Expiration Time cannot both be set") + } + + expirationTime := token.CreateTime.Add(token.ExpirationTTL) + token.ExpirationTime = &expirationTime + token.ExpirationTTL = 0 + } + + if token.HasExpirationTime() { + if token.ExpirationTime.Before(token.CreateTime) { + return nil, errors.New("ExpirationTime cannot be before CreateTime") + } + + expiresIn := token.ExpirationTime.Sub(token.CreateTime) + + if expiresIn > w.MaxExpirationTTL { + return nil, fmt.Errorf("ExpirationTime cannot be more than %s in the future (was %s)", + w.MaxExpirationTTL, expiresIn) + } + + if expiresIn < w.MinExpirationTTL { + return nil, fmt.Errorf("ExpirationTime cannot be less than %s in the future (was %s)", + w.MinExpirationTTL, expiresIn) + } + } + + if fromLogin { + if token.AuthMethod == "" { + return nil, errors.New("AuthMethod field is required during login") + } + } else { + if token.AuthMethod != "" { + return nil, errors.New("AuthMethod field is disallowed outside of login") + } + } + + return w.write(token, nil, fromLogin) +} + +// Update an existing token. +func (w *TokenWriter) Update(token *structs.ACLToken) (*structs.ACLToken, error) { + if err := w.checkCanWriteToken(token); err != nil { + return nil, err + } + + if _, err := uuid.ParseUUID(token.AccessorID); err != nil { + return nil, errors.New("AccessorID is not a valid UUID") + } + + // DEPRECATED (ACL-Legacy-Compat) - maybe get rid of this in the future + // and instead do a ParseUUID check. New tokens will not have + // secrets generated by users but rather they will always be UUIDs. + // However if users just continue the upgrade cycle they may still + // have tokens using secrets that are not UUIDS + // The RootAuthorizer checks that the SecretID is not "allow", "deny" + // or "manage" as a precaution against something accidentally using + // one of these root policies by setting the secret to it. + if acl.RootAuthorizer(token.SecretID) != nil { + return nil, acl.PermissionDeniedError{Cause: "Cannot modify root ACL"} + } + + _, match, err := w.Store.ACLTokenGetByAccessor(nil, token.AccessorID, nil) + switch { + case err != nil: + return nil, fmt.Errorf("Failed acl token lookup by accessor: %w", err) + case match == nil || match.IsExpired(time.Now()): + return nil, fmt.Errorf("Cannot find token %q", token.AccessorID) + } + + if token.SecretID == "" { + token.SecretID = match.SecretID + } else if match.SecretID != token.SecretID { + return nil, errors.New("Changing a token's SecretID is not permitted") + } + + if token.Local != match.Local { + return nil, fmt.Errorf("Cannot toggle local mode of %s", token.AccessorID) + } + + if token.AuthMethod == "" { + token.AuthMethod = match.AuthMethod + } else if match.AuthMethod != token.AuthMethod { + return nil, fmt.Errorf("Cannot change AuthMethod of %s", token.AccessorID) + } + + if token.ExpirationTTL != 0 { + return nil, fmt.Errorf("Cannot change expiration time of %s", token.AccessorID) + } + + if token.HasExpirationTime() { + if !match.HasExpirationTime() || !match.ExpirationTime.Equal(*token.ExpirationTime) { + return nil, fmt.Errorf("Cannot change expiration time of %s", token.AccessorID) + } + } else { + token.ExpirationTime = match.ExpirationTime + } + + token.CreateTime = match.CreateTime + + return w.write(token, match, false) +} + +// Delete the ACL token with the given SecretID from the state store. +func (w *TokenWriter) Delete(secretID string, fromLogout bool) error { + _, token, err := w.Store.ACLTokenGetBySecret(nil, secretID, nil) + switch { + case err != nil: + return err + case token == nil: + return acl.ErrNotFound + case token.AuthMethod == "" && fromLogout: + return fmt.Errorf("%w: token wasn't created via login", acl.ErrPermissionDenied) + } + + if err := w.checkCanWriteToken(token); err != nil { + return err + } + + if _, err := w.RaftApply(structs.ACLTokenDeleteRequestType, &structs.ACLTokenBatchDeleteRequest{ + TokenIDs: []string{token.AccessorID}, + }); err != nil { + return fmt.Errorf("Failed to apply token delete request: %w", err) + } + + w.ACLCache.RemoveIdentityWithSecretToken(token.SecretID) + return nil +} + +func validateTokenID(id string) error { + if structs.ACLIDReserved(id) { + return fmt.Errorf("UUIDs with the prefix %q are reserved", structs.ACLReservedPrefix) + } + if _, err := uuid.ParseUUID(id); err != nil { + return errors.New("not a valid UUID") + } + return nil +} + +func (w *TokenWriter) checkCanWriteToken(token *structs.ACLToken) error { + if !w.LocalTokensEnabled { + return fmt.Errorf("Cannot upsert tokens within this datacenter") + } + + if !w.InPrimaryDatacenter && !token.Local { + return ErrCannotWriteGlobalToken + } + + return nil +} + +func (w *TokenWriter) tokenIDInUse(id string) (bool, error) { + _, accessorMatch, err := w.Store.ACLTokenGetByAccessor(nil, id, nil) + switch { + case err != nil: + return false, err + case accessorMatch != nil: + return true, nil + } + + _, secretMatch, err := w.Store.ACLTokenGetBySecret(nil, id, nil) + switch { + case err != nil: + return false, err + case secretMatch != nil: + return true, nil + } + + return false, nil +} + +func (w *TokenWriter) write(token, existing *structs.ACLToken, fromLogin bool) (*structs.ACLToken, error) { + roles, err := w.normalizeRoleLinks(token.Roles, &token.EnterpriseMeta) + if err != nil { + return nil, err + } + token.Roles = roles + + policies, err := w.normalizePolicyLinks(token.Policies, &token.EnterpriseMeta) + if err != nil { + return nil, err + } + token.Policies = policies + + serviceIdentities, err := w.normalizeServiceIdentities(token.ServiceIdentities, token.Local) + if err != nil { + return nil, err + } + token.ServiceIdentities = serviceIdentities + + nodeIdentities, err := w.normalizeNodeIdentities(token.NodeIdentities) + if err != nil { + return nil, err + } + token.NodeIdentities = nodeIdentities + + if token.Rules != "" { + return nil, errors.New("Rules cannot be specified for this token") + } + + if token.Type != "" { + return nil, errors.New("Type cannot be specified for this token") + } + + if err := w.enterpriseValidation(token, existing); err != nil { + return nil, err + } + + token.SetHash(true) + + // Persist the token by writing to Raft. + _, err = w.RaftApply(structs.ACLTokenSetRequestType, &structs.ACLTokenBatchSetRequest{ + Tokens: structs.ACLTokens{token}, + // Logins may attempt to link to roles that do not exist. These may be + // persisted, but don't allow tokens to be created that have no privileges + // (i.e. role links that point nowhere). + AllowMissingLinks: fromLogin, + ProhibitUnprivileged: fromLogin, + }) + if err != nil { + return nil, fmt.Errorf("Failed to apply token write request: %w", err) + } + + // Purge the token from the ACL cache. + w.ACLCache.RemoveIdentityWithSecretToken(token.SecretID) + + // Refresh the token from the state store. + _, updatedToken, err := w.Store.ACLTokenGetByAccessor(nil, token.AccessorID, nil) + if err != nil || updatedToken == nil { + return nil, errors.New("Failed to retrieve token after insertion") + } + return updatedToken, nil +} + +func (w *TokenWriter) normalizeRoleLinks(links []structs.ACLTokenRoleLink, entMeta *acl.EnterpriseMeta) ([]structs.ACLTokenRoleLink, error) { + var normalized []structs.ACLTokenRoleLink + uniqueIDs := make(map[string]struct{}) + + for _, link := range links { + if link.ID == "" { + _, role, err := w.Store.ACLRoleGetByName(nil, link.Name, entMeta) + switch { + case err != nil: + return nil, fmt.Errorf("Error looking up role for name: %q: %w", link.Name, err) + case role == nil: + return nil, fmt.Errorf("No such ACL role with name %q", link.Name) + } + link.ID = role.ID + } else { + _, role, err := w.Store.ACLRoleGetByID(nil, link.ID, entMeta) + switch { + case err != nil: + return nil, fmt.Errorf("Error looking up role for ID: %q: %w", link.ID, err) + case role == nil: + return nil, fmt.Errorf("No such ACL role with ID %q", link.ID) + } + } + + // Do not persist the role name as the role could be renamed in the future. + link.Name = "" + + // De-duplicate role links by ID. + if _, ok := uniqueIDs[link.ID]; !ok { + normalized = append(normalized, link) + uniqueIDs[link.ID] = struct{}{} + } + } + + return normalized, nil +} + +func (w *TokenWriter) normalizePolicyLinks(links []structs.ACLTokenPolicyLink, entMeta *acl.EnterpriseMeta) ([]structs.ACLTokenPolicyLink, error) { + var normalized []structs.ACLTokenPolicyLink + uniqueIDs := make(map[string]struct{}) + + for _, link := range links { + if link.ID == "" { + _, role, err := w.Store.ACLPolicyGetByName(nil, link.Name, entMeta) + switch { + case err != nil: + return nil, fmt.Errorf("Error looking up policy for name: %q: %w", link.Name, err) + case role == nil: + return nil, fmt.Errorf("No such ACL policy with name %q", link.Name) + } + link.ID = role.ID + } else { + _, role, err := w.Store.ACLPolicyGetByID(nil, link.ID, entMeta) + switch { + case err != nil: + return nil, fmt.Errorf("Error looking up policy for ID: %q: %w", link.ID, err) + case role == nil: + return nil, fmt.Errorf("No such ACL policy with ID %q", link.ID) + } + } + + // Do not persist the role name as the role could be renamed in the future. + link.Name = "" + + // De-duplicate role links by ID. + if _, ok := uniqueIDs[link.ID]; !ok { + normalized = append(normalized, link) + uniqueIDs[link.ID] = struct{}{} + } + } + + return normalized, nil +} + +func (w *TokenWriter) normalizeServiceIdentities(svcIDs structs.ACLServiceIdentities, tokenLocal bool) (structs.ACLServiceIdentities, error) { + for _, id := range svcIDs { + if id.ServiceName == "" { + return nil, errors.New("Service identity is missing the service name field on this token") + } + if tokenLocal && len(id.Datacenters) > 0 { + return nil, fmt.Errorf("Service identity %q cannot specify a list of datacenters on a local token", id.ServiceName) + } + if !acl.IsValidServiceIdentityName(id.ServiceName) { + return nil, fmt.Errorf("Service identity %q has an invalid name. Only lowercase alphanumeric characters, '-' and '_' are allowed", id.ServiceName) + } + } + return svcIDs.Deduplicate(), nil +} + +func (w *TokenWriter) normalizeNodeIdentities(nodeIDs structs.ACLNodeIdentities) (structs.ACLNodeIdentities, error) { + for _, id := range nodeIDs { + if id.NodeName == "" { + return nil, errors.New("Node identity is missing the node name field on this token") + } + if id.Datacenter == "" { + return nil, errors.New("Node identity is missing the datacenter field on this token") + } + if !acl.IsValidNodeIdentityName(id.NodeName) { + return nil, fmt.Errorf("Node identity has an invalid name. Only lowercase alphanumeric characters, '-' and '_' are allowed") + } + } + return nodeIDs.Deduplicate(), nil +} diff --git a/agent/consul/auth/token_writer_oss.go b/agent/consul/auth/token_writer_oss.go new file mode 100644 index 0000000000..57365610ef --- /dev/null +++ b/agent/consul/auth/token_writer_oss.go @@ -0,0 +1,10 @@ +//go:build !consulent +// +build !consulent + +package auth + +import "github.com/hashicorp/consul/agent/structs" + +func (w *TokenWriter) enterpriseValidation(token, existing *structs.ACLToken) error { + return nil +} diff --git a/agent/consul/auth/token_writer_test.go b/agent/consul/auth/token_writer_test.go new file mode 100644 index 0000000000..b04edef8a9 --- /dev/null +++ b/agent/consul/auth/token_writer_test.go @@ -0,0 +1,703 @@ +package auth + +import ( + "errors" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/agent/consul/state" + "github.com/hashicorp/consul/agent/structs" +) + +func TestTokenWriter_Create_Validation(t *testing.T) { + aclCache := &MockACLCache{} + aclCache.On("RemoveIdentityWithSecretToken", mock.Anything) + + store := testStateStore(t) + + existingToken := &structs.ACLToken{ + AccessorID: generateID(t), + SecretID: generateID(t), + } + require.NoError(t, store.ACLTokenSet(0, existingToken)) + + writer := buildTokenWriter(store, aclCache) + + testCases := map[string]struct { + token structs.ACLToken + fromLogin bool + errorContains string + }{ + "AccessorID not a UUID": { + token: structs.ACLToken{AccessorID: "not-a-uuid"}, + errorContains: "not a valid UUID", + }, + "AccessorID is reserved": { + token: structs.ACLToken{AccessorID: structs.ACLReservedPrefix + generateID(t)}, + errorContains: "reserved", + }, + "AccessorID already in use (as AccessorID)": { + token: structs.ACLToken{AccessorID: existingToken.AccessorID}, + errorContains: "already in use", + }, + "AccessorID already in use (as SecretID)": { + token: structs.ACLToken{AccessorID: existingToken.SecretID}, + errorContains: "already in use", + }, + "SecretID not a UUID": { + token: structs.ACLToken{SecretID: "not-a-uuid"}, + errorContains: "not a valid UUID", + }, + "SecretID is reserved": { + token: structs.ACLToken{SecretID: structs.ACLReservedPrefix + generateID(t)}, + errorContains: "reserved", + }, + "SecretID already in use (as AccessorID)": { + token: structs.ACLToken{SecretID: existingToken.AccessorID}, + errorContains: "already in use", + }, + "SecretID already in use (as SecretID)": { + token: structs.ACLToken{SecretID: existingToken.SecretID}, + errorContains: "already in use", + }, + "ExpirationTTL is negative": { + token: structs.ACLToken{ExpirationTTL: -1}, + errorContains: "should be > 0", + }, + "ExpirationTTL and ExpirationTime both set": { + token: structs.ACLToken{ + ExpirationTTL: 2 * time.Hour, + ExpirationTime: timePointer(time.Now().Add(1 * time.Hour)), + }, + errorContains: "cannot both be set", + }, + "ExpirationTTL > MaxExpirationTTL": { + token: structs.ACLToken{ExpirationTTL: 48 * time.Hour}, + errorContains: "cannot be more than 24h0m0s in the future", + }, + "ExpirationTTL < MinExpirationTTL": { + token: structs.ACLToken{ExpirationTTL: 30 * time.Second}, + errorContains: "cannot be less than 1m0s in the future", + }, + "ExpirationTime before CreateTime": { + token: structs.ACLToken{ExpirationTime: timePointer(time.Now().Add(-5 * time.Minute))}, + errorContains: "ExpirationTime cannot be before CreateTime", + }, + "AuthMethod not set for login": { + token: structs.ACLToken{}, + fromLogin: true, + errorContains: "AuthMethod field is required during login", + }, + "AuthMethod set outside of login": { + token: structs.ACLToken{AuthMethod: "some-auth-method"}, + fromLogin: false, + errorContains: "AuthMethod field is disallowed outside of login", + }, + "Rules set": { + token: structs.ACLToken{Rules: "some rules"}, + errorContains: "Rules cannot be specified for this token", + }, + "Type set": { + token: structs.ACLToken{Type: "some-type"}, + errorContains: "Type cannot be specified for this token", + }, + } + for desc, tc := range testCases { + t.Run(desc, func(t *testing.T) { + _, err := writer.Create(&tc.token, tc.fromLogin) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errorContains) + }) + } +} + +func TestTokenWriter_Create_IDGeneration(t *testing.T) { + aclCache := &MockACLCache{} + aclCache.On("RemoveIdentityWithSecretToken", mock.Anything) + + store := testStateStore(t) + + writer := buildTokenWriter(store, aclCache) + + t.Run("AccessorID", func(t *testing.T) { + token := &structs.ACLToken{ + SecretID: generateID(t), + ServiceIdentities: []*structs.ACLServiceIdentity{ + {ServiceName: "some-service"}, + }, + } + + updated, err := writer.Create(token, false) + require.NoError(t, err) + require.NotEmpty(t, updated.AccessorID) + }) + + t.Run("SecretID", func(t *testing.T) { + token := &structs.ACLToken{ + AccessorID: generateID(t), + ServiceIdentities: []*structs.ACLServiceIdentity{ + {ServiceName: "some-service"}, + }, + } + + updated, err := writer.Create(token, false) + require.NoError(t, err) + require.NotEmpty(t, updated.SecretID) + }) +} + +func TestTokenWriter_Roles(t *testing.T) { + aclCache := &MockACLCache{} + aclCache.On("RemoveIdentityWithSecretToken", mock.Anything) + + store := testStateStore(t) + + role := &structs.ACLRole{ + ID: generateID(t), + Name: generateID(t), + } + require.NoError(t, store.ACLRoleSet(0, role)) + + writer := buildTokenWriter(store, aclCache) + + testCases := map[string]struct { + input []structs.ACLTokenRoleLink + output []structs.ACLTokenRoleLink + errorContains string + }{ + "valid role ID": { + input: []structs.ACLTokenRoleLink{{ID: role.ID}}, + output: []structs.ACLTokenRoleLink{{ID: role.ID, Name: role.Name}}, + }, + "valid role name": { + input: []structs.ACLTokenRoleLink{{Name: role.Name}}, + output: []structs.ACLTokenRoleLink{{ID: role.ID, Name: role.Name}}, + }, + "invalid role ID": { + input: []structs.ACLTokenRoleLink{{ID: generateID(t)}}, + errorContains: "No such ACL role with ID", + }, + "invalid role name": { + input: []structs.ACLTokenRoleLink{{Name: "invalid-role-name"}}, + errorContains: "No such ACL role with name", + }, + "links are de-duplicated": { + input: []structs.ACLTokenRoleLink{{ID: role.ID}, {ID: role.ID}}, + output: []structs.ACLTokenRoleLink{{ID: role.ID, Name: role.Name}}, + }, + } + for desc, tc := range testCases { + t.Run(desc, func(t *testing.T) { + updated, err := writer.Create(&structs.ACLToken{Roles: tc.input}, false) + if tc.errorContains == "" { + require.NoError(t, err) + require.ElementsMatch(t, tc.output, updated.Roles) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), tc.errorContains) + } + }) + } +} + +func TestTokenWriter_Policies(t *testing.T) { + aclCache := &MockACLCache{} + aclCache.On("RemoveIdentityWithSecretToken", mock.Anything) + + store := testStateStore(t) + + policy := &structs.ACLPolicy{ + ID: generateID(t), + Name: generateID(t), + } + require.NoError(t, store.ACLPolicySet(0, policy)) + + writer := buildTokenWriter(store, aclCache) + + testCases := map[string]struct { + input []structs.ACLTokenPolicyLink + output []structs.ACLTokenPolicyLink + errorContains string + }{ + "valid policy ID": { + input: []structs.ACLTokenPolicyLink{{ID: policy.ID}}, + output: []structs.ACLTokenPolicyLink{{ID: policy.ID, Name: policy.Name}}, + }, + "valid policy name": { + input: []structs.ACLTokenPolicyLink{{Name: policy.Name}}, + output: []structs.ACLTokenPolicyLink{{ID: policy.ID, Name: policy.Name}}, + }, + "invalid policy ID": { + input: []structs.ACLTokenPolicyLink{{ID: generateID(t)}}, + errorContains: "No such ACL policy with ID", + }, + "invalid policy name": { + input: []structs.ACLTokenPolicyLink{{Name: "invalid-policy-name"}}, + errorContains: "No such ACL policy with name", + }, + "links are de-duplicated": { + input: []structs.ACLTokenPolicyLink{{ID: policy.ID}, {ID: policy.ID}}, + output: []structs.ACLTokenPolicyLink{{ID: policy.ID, Name: policy.Name}}, + }, + } + for desc, tc := range testCases { + t.Run(desc, func(t *testing.T) { + updated, err := writer.Create(&structs.ACLToken{Policies: tc.input}, false) + if tc.errorContains == "" { + require.NoError(t, err) + require.ElementsMatch(t, tc.output, updated.Policies) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), tc.errorContains) + } + }) + } +} + +func TestTokenWriter_ServiceIdentities(t *testing.T) { + aclCache := &MockACLCache{} + aclCache.On("RemoveIdentityWithSecretToken", mock.Anything) + + store := testStateStore(t) + + writer := buildTokenWriter(store, aclCache) + + testCases := map[string]struct { + input []*structs.ACLServiceIdentity + tokenLocal bool + output []*structs.ACLServiceIdentity + errorContains string + }{ + "empty service name": { + input: []*structs.ACLServiceIdentity{{ServiceName: ""}}, + errorContains: "missing the service name", + }, + "datacenters given on local token": { + input: []*structs.ACLServiceIdentity{{ServiceName: "web", Datacenters: []string{"dc1", "dc2"}}}, + tokenLocal: true, + errorContains: "cannot specify a list of datacenters on a local token", + }, + "invalid service name": { + input: []*structs.ACLServiceIdentity{{ServiceName: "INVALID!"}}, + errorContains: "has an invalid name", + }, + "duplicate identities are merged": { + input: []*structs.ACLServiceIdentity{ + {ServiceName: "web", Datacenters: []string{"dc1"}}, + {ServiceName: "web", Datacenters: []string{"dc2"}}, + }, + output: []*structs.ACLServiceIdentity{{ServiceName: "web", Datacenters: []string{"dc1", "dc2"}}}, + }, + } + for desc, tc := range testCases { + t.Run(desc, func(t *testing.T) { + updated, err := writer.Create(&structs.ACLToken{ + ServiceIdentities: tc.input, + Local: tc.tokenLocal, + }, false) + if tc.errorContains == "" { + require.NoError(t, err) + require.ElementsMatch(t, tc.output, updated.ServiceIdentities) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), tc.errorContains) + } + }) + } +} + +func TestTokenWriter_NodeIdentities(t *testing.T) { + aclCache := &MockACLCache{} + aclCache.On("RemoveIdentityWithSecretToken", mock.Anything) + + store := testStateStore(t) + + writer := buildTokenWriter(store, aclCache) + + testCases := map[string]struct { + input []*structs.ACLNodeIdentity + output []*structs.ACLNodeIdentity + errorContains string + }{ + "empty service name": { + input: []*structs.ACLNodeIdentity{{NodeName: "", Datacenter: "dc1"}}, + errorContains: "missing the node name", + }, + "empty datacenter": { + input: []*structs.ACLNodeIdentity{{NodeName: "web"}}, + errorContains: "missing the datacenter field", + }, + "invalid node name": { + input: []*structs.ACLNodeIdentity{{NodeName: "INVALID!", Datacenter: "dc1"}}, + errorContains: "has an invalid name", + }, + "duplicate identities are removed": { + input: []*structs.ACLNodeIdentity{ + {NodeName: "web", Datacenter: "dc1"}, + {NodeName: "web", Datacenter: "dc2"}, + {NodeName: "web", Datacenter: "dc1"}, + }, + output: []*structs.ACLNodeIdentity{ + {NodeName: "web", Datacenter: "dc1"}, + {NodeName: "web", Datacenter: "dc2"}, + }, + }, + } + for desc, tc := range testCases { + t.Run(desc, func(t *testing.T) { + updated, err := writer.Create(&structs.ACLToken{NodeIdentities: tc.input}, false) + if tc.errorContains == "" { + require.NoError(t, err) + require.ElementsMatch(t, tc.output, updated.NodeIdentities) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), tc.errorContains) + } + }) + } +} + +func TestTokenWriter_Create_Expiration(t *testing.T) { + aclCache := &MockACLCache{} + aclCache.On("RemoveIdentityWithSecretToken", mock.Anything) + + store := testStateStore(t) + + role := &structs.ACLRole{ + ID: generateID(t), + Name: generateID(t), + } + require.NoError(t, store.ACLRoleSet(0, role)) + + writer := buildTokenWriter(store, aclCache) + + t.Run("ExpirationTTL", func(t *testing.T) { + token := &structs.ACLToken{ + AccessorID: generateID(t), + SecretID: generateID(t), + Roles: []structs.ACLTokenRoleLink{ + {ID: role.ID}, + }, + ExpirationTTL: 10 * time.Minute, + } + + updated, err := writer.Create(token, false) + require.NoError(t, err) + require.InEpsilon(t, 10*time.Minute, updated.ExpirationTime.Sub(time.Now()), 0.1) + require.Zero(t, updated.ExpirationTTL) + }) + + t.Run("ExpirationTime", func(t *testing.T) { + expirationTime := time.Now().Add(10 * time.Minute) + + token := &structs.ACLToken{ + AccessorID: generateID(t), + SecretID: generateID(t), + Roles: []structs.ACLTokenRoleLink{ + {ID: role.ID}, + }, + ExpirationTime: &expirationTime, + } + + updated, err := writer.Create(token, false) + require.NoError(t, err) + require.Equal(t, expirationTime, *updated.ExpirationTime) + }) +} + +func TestTokenWriter_Create_Success(t *testing.T) { + store := testStateStore(t) + + role := &structs.ACLRole{ + ID: generateID(t), + Name: "cluster-operators", + } + require.NoError(t, store.ACLRoleSet(0, role)) + + token := &structs.ACLToken{ + AccessorID: generateID(t), + SecretID: generateID(t), + Roles: []structs.ACLTokenRoleLink{ + {ID: role.ID}, + }, + } + + aclCache := &MockACLCache{} + aclCache.On("RemoveIdentityWithSecretToken", token.SecretID) + defer aclCache.AssertExpectations(t) + + writer := buildTokenWriter(store, aclCache) + + updated, err := writer.Create(token, false) + require.NoError(t, err) + require.NotNil(t, updated) +} + +func TestTokenWriter_Update_Validation(t *testing.T) { + aclCache := &MockACLCache{} + aclCache.On("RemoveIdentityWithSecretToken", mock.Anything) + + store := testStateStore(t) + + token := &structs.ACLToken{ + AccessorID: generateID(t), + SecretID: generateID(t), + ExpirationTime: timePointer(time.Now().Add(1 * time.Hour)), + } + expiredToken := &structs.ACLToken{ + AccessorID: generateID(t), + SecretID: generateID(t), + ExpirationTime: timePointer(time.Now().Add(-1 * time.Hour)), + } + require.NoError(t, store.ACLTokenBatchSet(0, []*structs.ACLToken{token, expiredToken}, state.ACLTokenSetOptions{})) + + writer := buildTokenWriter(store, aclCache) + + testCases := map[string]struct { + token structs.ACLToken + errorContains string + }{ + "AccessorID not a UUID": { + token: structs.ACLToken{AccessorID: "not-a-uuid"}, + errorContains: "not a valid UUID", + }, + "SecretID is a legacy root policy name": { + token: structs.ACLToken{AccessorID: token.AccessorID, SecretID: "allow"}, + errorContains: "Cannot modify root ACL", + }, + "AccessorID does not match any token": { + token: structs.ACLToken{AccessorID: generateID(t)}, + errorContains: "Cannot find token", + }, + "AccessorID matches expired token": { + token: structs.ACLToken{AccessorID: expiredToken.AccessorID}, + errorContains: "Cannot find token", + }, + "SecretID changed": { + token: structs.ACLToken{AccessorID: token.AccessorID, SecretID: generateID(t)}, + errorContains: "Changing a token's SecretID is not permitted", + }, + "Local changed": { + token: structs.ACLToken{AccessorID: token.AccessorID, Local: !token.Local}, + errorContains: "Cannot toggle local mode", + }, + "AuthMethod changed": { + token: structs.ACLToken{AccessorID: token.AccessorID, AuthMethod: "some-other-auth-method"}, + errorContains: "Cannot change AuthMethod", + }, + "ExpirationTTL is set": { + token: structs.ACLToken{AccessorID: token.AccessorID, ExpirationTTL: 5 * time.Minute}, + errorContains: "Cannot change expiration time", + }, + "ExpirationTime changed": { + token: structs.ACLToken{AccessorID: token.AccessorID, ExpirationTime: timePointer(token.ExpirationTime.Add(1 * time.Minute))}, + errorContains: "Cannot change expiration time", + }, + "Rules set": { + token: structs.ACLToken{AccessorID: token.AccessorID, Rules: "some rules"}, + errorContains: "Rules cannot be specified for this token", + }, + "Type set": { + token: structs.ACLToken{AccessorID: token.AccessorID, Type: "some-type"}, + errorContains: "Type cannot be specified for this token", + }, + } + for desc, tc := range testCases { + t.Run(desc, func(t *testing.T) { + _, err := writer.Update(&tc.token) + require.Error(t, err) + require.Contains(t, err.Error(), tc.errorContains) + }) + } +} + +func TestTokenWriter_Update_Success(t *testing.T) { + store := testStateStore(t) + + authMethod := &structs.ACLAuthMethod{ + Name: generateID(t), + Type: "jwt", + } + require.NoError(t, store.ACLAuthMethodSet(0, authMethod)) + + token := &structs.ACLToken{ + AccessorID: generateID(t), + SecretID: generateID(t), + ExpirationTime: timePointer(time.Now().Add(1 * time.Hour)), + AuthMethod: authMethod.Name, + } + token.SetHash(true) + require.NoError(t, store.ACLTokenSet(0, token)) + + aclCache := &MockACLCache{} + aclCache.On("RemoveIdentityWithSecretToken", token.SecretID) + defer aclCache.AssertExpectations(t) + + writer := buildTokenWriter(store, aclCache) + updated, err := writer.Update(&structs.ACLToken{ + AccessorID: token.AccessorID, + Description: "New Description", + }) + require.NoError(t, err) + require.Equal(t, "New Description", updated.Description) + + // These should've been left as-is. + require.Equal(t, token.SecretID, updated.SecretID) + require.Equal(t, token.Local, updated.Local) + require.Equal(t, token.AuthMethod, updated.AuthMethod) + require.Equal(t, token.ExpirationTime, updated.ExpirationTime) + require.Equal(t, token.CreateTime, updated.CreateTime) + require.NotEqual(t, token.Hash, updated.Hash) +} + +func TestTokenWriter_Delete(t *testing.T) { + t.Run("success", func(t *testing.T) { + store := testStateStore(t) + + token := &structs.ACLToken{ + AccessorID: generateID(t), + SecretID: generateID(t), + Local: true, + } + require.NoError(t, store.ACLTokenSet(0, token)) + + aclCache := NewMockACLCache(t) + aclCache.On("RemoveIdentityWithSecretToken", token.SecretID).Return() + + var deletedIDs []string + writer := NewTokenWriter(TokenWriterConfig{ + LocalTokensEnabled: true, + ACLCache: aclCache, + Store: store, + RaftApply: func(msgType structs.MessageType, msg interface{}) (interface{}, error) { + if msgType != structs.ACLTokenDeleteRequestType { + return nil, fmt.Errorf("unexpected message type: %v", msgType) + } + + req, ok := msg.(*structs.ACLTokenBatchDeleteRequest) + if !ok { + return nil, fmt.Errorf("unexpected message: %T", msg) + } + deletedIDs = req.TokenIDs + + return nil, nil + }, + }) + err := writer.Delete(token.SecretID, false) + require.NoError(t, err) + require.Equal(t, []string{token.AccessorID}, deletedIDs) + }) + + t.Run("local tokens disabled", func(t *testing.T) { + store := testStateStore(t) + + token := &structs.ACLToken{ + AccessorID: generateID(t), + SecretID: generateID(t), + Local: true, + } + + require.NoError(t, store.ACLTokenSet(0, token)) + writer := NewTokenWriter(TokenWriterConfig{ + LocalTokensEnabled: false, + Store: store, + }) + + err := writer.Delete(token.SecretID, false) + require.Error(t, err) + require.Contains(t, err.Error(), "Cannot upsert tokens within this datacenter") + }) + + t.Run("global token in non-primary datacenter", func(t *testing.T) { + store := testStateStore(t) + + token := &structs.ACLToken{ + AccessorID: generateID(t), + SecretID: generateID(t), + Local: false, + } + require.NoError(t, store.ACLTokenSet(0, token)) + + writer := NewTokenWriter(TokenWriterConfig{ + LocalTokensEnabled: true, + InPrimaryDatacenter: false, + Store: store, + }) + + err := writer.Delete(token.SecretID, false) + require.Error(t, err) + require.Equal(t, ErrCannotWriteGlobalToken, err) + }) + + t.Run("token not found", func(t *testing.T) { + store := testStateStore(t) + + writer := NewTokenWriter(TokenWriterConfig{ + LocalTokensEnabled: true, + Store: store, + }) + err := writer.Delete(generateID(t), false) + require.Error(t, err) + require.True(t, errors.Is(err, acl.ErrNotFound)) + }) + + t.Run("logout requires token to be created by login", func(t *testing.T) { + store := testStateStore(t) + + token := &structs.ACLToken{ + AccessorID: generateID(t), + SecretID: generateID(t), + Local: true, + } + require.NoError(t, store.ACLTokenSet(0, token)) + + writer := NewTokenWriter(TokenWriterConfig{ + LocalTokensEnabled: true, + Store: store, + }) + err := writer.Delete(token.SecretID, true) + require.Error(t, err) + require.True(t, errors.Is(err, acl.ErrPermissionDenied)) + require.Contains(t, err.Error(), "wasn't created via login") + }) +} + +func raftApplyACLTokenSet(store *state.Store) RaftApplyFn { + return func(msgType structs.MessageType, msg interface{}) (interface{}, error) { + if msgType != structs.ACLTokenSetRequestType { + return nil, fmt.Errorf("unexpected message type: %v", msgType) + } + + req, ok := msg.(*structs.ACLTokenBatchSetRequest) + if !ok { + return nil, fmt.Errorf("unexpected message: %T", msg) + } + + err := store.ACLTokenBatchSet(0, req.Tokens, state.ACLTokenSetOptions{ + CAS: req.CAS, + AllowMissingPolicyAndRoleIDs: req.AllowMissingLinks, + ProhibitUnprivileged: req.ProhibitUnprivileged, + }) + return nil, err + } +} + +func timePointer(t time.Time) *time.Time { return &t } + +func buildTokenWriter(store *state.Store, aclCache ACLCache) *TokenWriter { + return NewTokenWriter(TokenWriterConfig{ + RaftApply: raftApplyACLTokenSet(store), + ACLCache: aclCache, + Store: store, + MinExpirationTTL: 1 * time.Minute, + MaxExpirationTTL: 24 * time.Hour, + PrimaryDatacenter: "dc1", + InPrimaryDatacenter: true, + LocalTokensEnabled: true, + }) +} diff --git a/agent/consul/grpc_integration_test.go b/agent/consul/grpc_integration_test.go index d588e324d7..a963832851 100644 --- a/agent/consul/grpc_integration_test.go +++ b/agent/consul/grpc_integration_test.go @@ -8,7 +8,11 @@ import ( "github.com/stretchr/testify/require" "github.com/hashicorp/consul/agent/connect" + "github.com/hashicorp/consul/agent/consul/authmethod/testauth" "github.com/hashicorp/consul/agent/grpc/public" + "github.com/hashicorp/consul/agent/structs" + tokenStore "github.com/hashicorp/consul/agent/token" + "github.com/hashicorp/consul/proto-public/pbacl" "github.com/hashicorp/consul/proto-public/pbconnectca" "github.com/hashicorp/consul/proto-public/pbserverdiscovery" ) @@ -25,12 +29,12 @@ func TestGRPCIntegration_ConnectCA_Sign(t *testing.T) { // * Making a request to a follower's public gRPC port. // * Ensuring that the request is correctly forwarded to the leader. // * Ensuring we get a valid certificate back (so it went through the CAManager). - server1, conn1 := testGRPCIntegrationServer(t, func(c *Config) { + server1, conn1, _ := testGRPCIntegrationServer(t, func(c *Config) { c.Bootstrap = false c.BootstrapExpect = 2 }) - server2, conn2 := testGRPCIntegrationServer(t, func(c *Config) { + server2, conn2, _ := testGRPCIntegrationServer(t, func(c *Config) { c.Bootstrap = false }) @@ -81,7 +85,7 @@ func TestGRPCIntegration_ServerDiscovery_WatchServers(t *testing.T) { // * Adding another server // * Validating another message is sent. - server1, conn := testGRPCIntegrationServer(t, func(c *Config) { + server1, conn, _ := testGRPCIntegrationServer(t, func(c *Config) { c.Bootstrap = true c.BootstrapExpect = 1 }) @@ -115,3 +119,97 @@ func TestGRPCIntegration_ServerDiscovery_WatchServers(t *testing.T) { require.NotNil(t, rsp) require.Len(t, rsp.Servers, 2) } + +func TestGRPCIntegration_ACL_Login_Logout(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + // The gRPC endpoints themselves are well unit tested - this test ensures we're + // correctly wiring everything up and exercises the cross-dc RPC forwarding by: + // + // * Starting two servers in different datacenters. + // * WAN federating them. + // * Configuring ACL token replication in the secondary datacenter. + // * Registering an auth method (configured for global tokens) in the primary + // datacenter. + // * Making a Login request to the secondary DC, with the request's Datacenter + // field set to "primary" (to exercise user requested DC forwarding). + // * Waiting for the token to be replicated to the secondary DC. + // * Making a Logout request to the secondary DC, with the request's Datacenter + // field set to "secondary" — the request will be forwarded to the primary + // datacenter anyway because the token is global. + + // Start the primary DC. + primary, _, primaryCodec := testGRPCIntegrationServer(t, func(c *Config) { + c.Bootstrap = true + c.BootstrapExpect = 1 + c.Datacenter = "primary" + c.PrimaryDatacenter = "primary" + }) + waitForLeaderEstablishment(t, primary) + + // Configured the auth method. + testSessionID := testauth.StartSession() + defer testauth.ResetSession(testSessionID) + testauth.InstallSessionToken(testSessionID, "fake-token", "default", "demo", "abc123") + + authMethod, err := upsertTestCustomizedAuthMethod(primaryCodec, TestDefaultInitialManagementToken, "primary", func(method *structs.ACLAuthMethod) { + method.Config = map[string]interface{}{ + "SessionID": testSessionID, + } + method.TokenLocality = "global" + }) + require.NoError(t, err) + + _, err = upsertTestBindingRule(primaryCodec, TestDefaultInitialManagementToken, "primary", authMethod.Name, "", structs.BindingRuleBindTypeService, "demo") + require.NoError(t, err) + + // Start the secondary DC. + secondary, secondaryConn, _ := testGRPCIntegrationServer(t, func(c *Config) { + c.Bootstrap = true + c.BootstrapExpect = 1 + c.Datacenter = "secondary" + c.PrimaryDatacenter = "primary" + c.ACLTokenReplication = true + }) + secondary.tokens.UpdateReplicationToken(TestDefaultInitialManagementToken, tokenStore.TokenSourceConfig) + waitForLeaderEstablishment(t, secondary) + + // WAN federate the primary and secondary DCs. + joinWAN(t, primary, secondary) + + client := pbacl.NewACLServiceClient(secondaryConn) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + + // Make a Login request to the secondary DC, but request that it is forwarded + // to the primary DC. + rsp, err := client.Login(ctx, &pbacl.LoginRequest{ + AuthMethod: authMethod.Name, + BearerToken: "fake-token", + Datacenter: "primary", + }) + require.NoError(t, err) + require.NotNil(t, rsp.Token) + require.NotEmpty(t, rsp.Token.AccessorId) + require.NotEmpty(t, rsp.Token.SecretId) + + // Check token was created in the primary DC. + tokenIdx, token, err := primary.FSM().State().ACLTokenGetByAccessor(nil, rsp.Token.AccessorId, nil) + require.NoError(t, err) + require.NotNil(t, token) + require.False(t, token.Local, "token should be global") + + // Wait for token to be replicated to the secondary DC. + waitForNewACLReplication(t, secondary, structs.ACLReplicateTokens, 0, tokenIdx, 0) + + // Make a Logout request to the secondary DC, the request should be forwarded + // to the primary DC anyway because the token is global. + _, err = client.Logout(ctx, &pbacl.LogoutRequest{ + Token: rsp.Token.SecretId, + Datacenter: "secondary", + }) + require.NoError(t, err) +} diff --git a/agent/consul/server.go b/agent/consul/server.go index 2f64fa6729..8468ab314a 100644 --- a/agent/consul/server.go +++ b/agent/consul/server.go @@ -17,7 +17,6 @@ import ( "time" "github.com/armon/go-metrics" - "github.com/hashicorp/consul-net-rpc/net/rpc" connlimit "github.com/hashicorp/go-connlimit" "github.com/hashicorp/go-hclog" "github.com/hashicorp/go-memdb" @@ -30,6 +29,8 @@ import ( "golang.org/x/time/rate" "google.golang.org/grpc" + "github.com/hashicorp/consul-net-rpc/net/rpc" + "github.com/hashicorp/consul/acl" "github.com/hashicorp/consul/agent/consul/authmethod" "github.com/hashicorp/consul/agent/consul/authmethod/ssoauth" @@ -40,6 +41,7 @@ import ( "github.com/hashicorp/consul/agent/consul/wanfed" agentgrpc "github.com/hashicorp/consul/agent/grpc/private" "github.com/hashicorp/consul/agent/grpc/private/services/subscribe" + aclgrpc "github.com/hashicorp/consul/agent/grpc/public/services/acl" "github.com/hashicorp/consul/agent/grpc/public/services/connectca" "github.com/hashicorp/consul/agent/grpc/public/services/dataplane" "github.com/hashicorp/consul/agent/grpc/public/services/serverdiscovery" @@ -239,6 +241,11 @@ type Server struct { // is only ever closed. leaveCh chan struct{} + // publicACLServer serves the ACL service exposed on the public gRPC port. + // It is also exposed on the private multiplexed "server" port to enable + // RPC forwarding. + publicACLServer *aclgrpc.Server + // publicConnectCAServer serves the Connect CA service exposed on the public // gRPC port. It is also exposed on the private multiplexed "server" port to // enable RPC forwarding. @@ -667,6 +674,24 @@ func NewServer(config *Config, flat Deps, publicGRPCServer *grpc.Server) (*Serve go s.overviewManager.Run(&lib.StopChannelContext{StopCh: s.shutdownCh}) // Initialize public gRPC server - register services on public gRPC server. + s.publicACLServer = aclgrpc.NewServer(aclgrpc.Config{ + ACLsEnabled: s.config.ACLsEnabled, + ForwardRPC: func(info structs.RPCInfo, fn func(*grpc.ClientConn) error) (bool, error) { + return s.ForwardGRPC(s.grpcConnPool, info, fn) + }, + InPrimaryDatacenter: s.InPrimaryDatacenter(), + LoadAuthMethod: func(methodName string, entMeta *acl.EnterpriseMeta) (*structs.ACLAuthMethod, aclgrpc.Validator, error) { + return s.loadAuthMethod(methodName, entMeta) + }, + LocalTokensEnabled: s.LocalTokensEnabled, + Logger: logger.Named("grpc-api.acl"), + NewLogin: func() aclgrpc.Login { return s.aclLogin() }, + NewTokenWriter: func() aclgrpc.TokenWriter { return s.aclTokenWriter() }, + PrimaryDatacenter: s.config.PrimaryDatacenter, + ValidateEnterpriseRequest: s.validateEnterpriseRequest, + }) + s.publicACLServer.Register(s.publicGRPCServer) + s.publicConnectCAServer = connectca.NewServer(connectca.Config{ Publisher: s.publisher, GetStore: func() connectca.StateStore { return s.FSM().State() }, @@ -748,8 +773,9 @@ func newGRPCHandlerFromConfig(deps Deps, config *Config, s *Server) connHandler pbpeering.RegisterPeeringServiceServer(srv, s.peeringService) s.registerEnterpriseGRPCServices(deps, srv) - // Note: this public gRPC service is also exposed on the private server to + // Note: these public gRPC services are also exposed on the private server to // enable RPC forwarding. + s.publicACLServer.Register(srv) s.publicConnectCAServer.Register(srv) } diff --git a/agent/consul/server_test.go b/agent/consul/server_test.go index c375ff7945..995e03aacd 100644 --- a/agent/consul/server_test.go +++ b/agent/consul/server_test.go @@ -259,8 +259,8 @@ func testACLServerWithConfig(t *testing.T, cb func(*Config), initReplicationToke return dir, srv, codec } -func testGRPCIntegrationServer(t *testing.T, cb func(*Config)) (*Server, *grpc.ClientConn) { - _, srv, _ := testACLServerWithConfig(t, cb, false) +func testGRPCIntegrationServer(t *testing.T, cb func(*Config)) (*Server, *grpc.ClientConn, rpc.ClientCodec) { + _, srv, codec := testACLServerWithConfig(t, cb, false) // Normally the gRPC server listener is created at the agent level and passed down into // the Server creation. For our tests, we need to ensure @@ -276,7 +276,7 @@ func testGRPCIntegrationServer(t *testing.T, cb func(*Config)) (*Server, *grpc.C t.Cleanup(func() { _ = conn.Close() }) - return srv, conn + return srv, conn, codec } func newServer(t *testing.T, c *Config) (*Server, error) { diff --git a/agent/grpc/public/services/acl/login.go b/agent/grpc/public/services/acl/login.go new file mode 100644 index 0000000000..1a68b1eb2f --- /dev/null +++ b/agent/grpc/public/services/acl/login.go @@ -0,0 +1,92 @@ +package acl + +import ( + "context" + "errors" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/agent/consul/auth" + "github.com/hashicorp/consul/agent/grpc/public" + "github.com/hashicorp/consul/proto-public/pbacl" +) + +// Login exchanges the presented bearer token for a Consul ACL token using a +// configured auth method. +func (s *Server) Login(ctx context.Context, req *pbacl.LoginRequest) (*pbacl.LoginResponse, error) { + logger := s.Logger.Named("login").With("request_id", public.TraceID()) + logger.Trace("request received") + + if err := s.requireACLsEnabled(logger); err != nil { + return nil, err + } + + entMeta := acl.NewEnterpriseMetaWithPartition(req.Partition, req.Namespace) + + if err := s.ValidateEnterpriseRequest(&entMeta, true); err != nil { + logger.Error("error during enterprise request validation", "error", err.Error()) + return nil, status.Errorf(codes.Internal, err.Error()) + } + + // Forward request to leader in the correct datacenter. + var rsp *pbacl.LoginResponse + handled, err := s.forwardWriteDC(req.Datacenter, func(conn *grpc.ClientConn) error { + var err error + rsp, err = pbacl.NewACLServiceClient(conn).Login(ctx, req) + return err + }, logger) + if handled || err != nil { + return rsp, err + } + + // This is also validated by the TokenWriter, but doing it early saves any + // work done by the validator (e.g. roundtrip to the Kubernetes API server). + if err := s.requireLocalTokens(logger); err != nil { + return nil, err + } + + authMethod, validator, err := s.LoadAuthMethod(req.AuthMethod, &entMeta) + switch { + case errors.Is(err, acl.ErrNotFound): + return nil, status.Errorf(codes.InvalidArgument, "auth method %q not found", req.AuthMethod) + case err != nil: + logger.Error("failed to load auth method", "error", err.Error()) + return nil, status.Error(codes.Internal, "failed to load auth method") + } + + verifiedIdentity, err := validator.ValidateLogin(ctx, req.BearerToken) + if err != nil { + // TODO(agentless): errors returned from validators aren't standardized so + // it's hard to tell whether validation failed because of an invalid bearer + // token or something internal/transient. We currently return Unauthenticated + // for all errors because it's the most likely, but we should make validators + // return a typed or sentinel error instead. + logger.Error("failed to validate login", "error", err.Error()) + return nil, status.Error(codes.Unauthenticated, err.Error()) + } + + description, err := auth.BuildTokenDescription("token created via login", req.Meta) + if err != nil { + logger.Error("failed to build token description", "error", err.Error()) + return nil, status.Error(codes.Internal, err.Error()) + } + + token, err := s.NewLogin().TokenForVerifiedIdentity(verifiedIdentity, authMethod, description) + switch { + case acl.IsErrPermissionDenied(err): + return nil, status.Error(codes.PermissionDenied, err.Error()) + case err != nil: + logger.Error("failed to create token", "error", err.Error()) + return nil, status.Error(codes.Internal, "failed to create token") + } + + return &pbacl.LoginResponse{ + Token: &pbacl.LoginToken{ + AccessorId: token.AccessorID, + SecretId: token.SecretID, + }, + }, nil +} diff --git a/agent/grpc/public/services/acl/login_test.go b/agent/grpc/public/services/acl/login_test.go new file mode 100644 index 0000000000..84b2693f43 --- /dev/null +++ b/agent/grpc/public/services/acl/login_test.go @@ -0,0 +1,256 @@ +package acl + +import ( + "context" + "errors" + "fmt" + "testing" + + mock "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/hashicorp/go-hclog" + + "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/agent/consul/authmethod" + "github.com/hashicorp/consul/agent/grpc/public/testutils" + structs "github.com/hashicorp/consul/agent/structs" + "github.com/hashicorp/consul/proto-public/pbacl" +) + +const bearerToken = "bearer-token" + +func TestServer_Login_Success(t *testing.T) { + authMethod := &structs.ACLAuthMethod{} + identity := &authmethod.Identity{} + + validator := NewMockValidator(t) + validator.On("ValidateLogin", mock.Anything, bearerToken). + Return(identity, nil) + + token := &structs.ACLToken{ + AccessorID: "accessor-id", + SecretID: "secret-id", + } + + login := NewMockLogin(t) + login.On("TokenForVerifiedIdentity", identity, authMethod, "token created via login"). + Return(token, nil) + + server := NewServer(Config{ + ACLsEnabled: true, + Logger: hclog.NewNullLogger(), + LoadAuthMethod: func(methodName string, entMeta *acl.EnterpriseMeta) (*structs.ACLAuthMethod, Validator, error) { + return authMethod, validator, nil + }, + ForwardRPC: noopForwardRPC, + ValidateEnterpriseRequest: noopValidateEnterpriseRequest, + LocalTokensEnabled: noopLocalTokensEnabled, + NewLogin: func() Login { return login }, + }) + + rsp, err := server.Login(context.Background(), &pbacl.LoginRequest{ + BearerToken: bearerToken, + }) + require.NoError(t, err) + require.Equal(t, token.AccessorID, rsp.Token.AccessorId) + require.Equal(t, token.SecretID, rsp.Token.SecretId) +} + +func TestServer_Login_LoadAuthMethodErrors(t *testing.T) { + testCases := map[string]struct { + error error + code codes.Code + }{ + "auth method not found": { + // Note: we wrap the error here to make sure we correctly unwrap it in the handler. + error: fmt.Errorf("%w auth method not found", acl.ErrNotFound), + code: codes.InvalidArgument, + }, + "unexpected error": { + error: errors.New("BOOM"), + code: codes.Internal, + }, + } + for desc, tc := range testCases { + t.Run(desc, func(t *testing.T) { + server := NewServer(Config{ + ACLsEnabled: true, + Logger: hclog.NewNullLogger(), + LoadAuthMethod: func(methodName string, entMeta *acl.EnterpriseMeta) (*structs.ACLAuthMethod, Validator, error) { + return nil, nil, tc.error + }, + ValidateEnterpriseRequest: noopValidateEnterpriseRequest, + LocalTokensEnabled: noopLocalTokensEnabled, + ForwardRPC: noopForwardRPC, + }) + _, err := server.Login(context.Background(), &pbacl.LoginRequest{ + BearerToken: bearerToken, + }) + require.Error(t, err) + require.Equal(t, tc.code.String(), status.Code(err).String()) + }) + } +} + +func TestServer_Login_ValidateEnterpriseRequest(t *testing.T) { + server := NewServer(Config{ + ACLsEnabled: true, + Logger: hclog.NewNullLogger(), + ValidateEnterpriseRequest: func(*acl.EnterpriseMeta, bool) error { return errors.New("BOOM") }, + ForwardRPC: noopForwardRPC, + }) + + _, err := server.Login(context.Background(), &pbacl.LoginRequest{ + BearerToken: bearerToken, + }) + require.Error(t, err) + require.Equal(t, codes.Internal.String(), status.Code(err).String()) +} + +func TestServer_Login_ACLsDisabled(t *testing.T) { + server := NewServer(Config{ + ACLsEnabled: false, + Logger: hclog.NewNullLogger(), + ValidateEnterpriseRequest: noopValidateEnterpriseRequest, + ForwardRPC: noopForwardRPC, + LocalTokensEnabled: noopLocalTokensEnabled, + }) + + _, err := server.Login(context.Background(), &pbacl.LoginRequest{ + BearerToken: bearerToken, + }) + require.Error(t, err) + require.Equal(t, codes.FailedPrecondition.String(), status.Code(err).String()) +} + +func TestServer_Login_LocalTokensDisabled(t *testing.T) { + server := NewServer(Config{ + ACLsEnabled: true, + Logger: hclog.NewNullLogger(), + ValidateEnterpriseRequest: noopValidateEnterpriseRequest, + ForwardRPC: noopForwardRPC, + LocalTokensEnabled: func() bool { return false }, + }) + + _, err := server.Login(context.Background(), &pbacl.LoginRequest{ + BearerToken: bearerToken, + }) + require.Error(t, err) + require.Equal(t, codes.FailedPrecondition.String(), status.Code(err).String()) +} + +func TestServer_Login_ValidateLoginError(t *testing.T) { + validator := NewMockValidator(t) + validator.On("ValidateLogin", mock.Anything, bearerToken). + Return(nil, errors.New("BOOM")) + + server := NewServer(Config{ + ACLsEnabled: true, + Logger: hclog.NewNullLogger(), + LoadAuthMethod: func(methodName string, entMeta *acl.EnterpriseMeta) (*structs.ACLAuthMethod, Validator, error) { + return &structs.ACLAuthMethod{}, validator, nil + }, + ValidateEnterpriseRequest: noopValidateEnterpriseRequest, + LocalTokensEnabled: noopLocalTokensEnabled, + ForwardRPC: noopForwardRPC, + }) + + _, err := server.Login(context.Background(), &pbacl.LoginRequest{ + BearerToken: bearerToken, + }) + require.Error(t, err) + require.Equal(t, codes.Unauthenticated.String(), status.Code(err).String()) +} + +func TestServer_Login_TokenForVerifiedIdentityErrors(t *testing.T) { + testCases := map[string]struct { + error error + code codes.Code + }{ + "permission denied": { + error: acl.ErrPermissionDenied, + code: codes.PermissionDenied, + }, + "unexpected error": { + error: errors.New("BOOM"), + code: codes.Internal, + }, + } + for desc, tc := range testCases { + t.Run(desc, func(t *testing.T) { + validator := NewMockValidator(t) + validator.On("ValidateLogin", mock.Anything, bearerToken). + Return(&authmethod.Identity{}, nil) + + login := NewMockLogin(t) + login.On("TokenForVerifiedIdentity", mock.Anything, mock.Anything, mock.Anything). + Return(nil, tc.error) + + server := NewServer(Config{ + ACLsEnabled: true, + Logger: hclog.NewNullLogger(), + LoadAuthMethod: func(methodName string, entMeta *acl.EnterpriseMeta) (*structs.ACLAuthMethod, Validator, error) { + return &structs.ACLAuthMethod{}, validator, nil + }, + ValidateEnterpriseRequest: noopValidateEnterpriseRequest, + LocalTokensEnabled: noopLocalTokensEnabled, + ForwardRPC: noopForwardRPC, + NewLogin: func() Login { return login }, + }) + + _, err := server.Login(context.Background(), &pbacl.LoginRequest{ + BearerToken: bearerToken, + }) + require.Error(t, err) + require.Equal(t, tc.code.String(), status.Code(err).String()) + }) + } +} + +func TestServer_Login_RPCForwarding(t *testing.T) { + validator := NewMockValidator(t) + validator.On("ValidateLogin", mock.Anything, mock.Anything). + Return(&authmethod.Identity{}, nil) + + login := NewMockLogin(t) + login.On("TokenForVerifiedIdentity", mock.Anything, mock.Anything, mock.Anything). + Return(&structs.ACLToken{AccessorID: "leader response"}, nil) + + dc2 := NewServer(Config{ + ACLsEnabled: true, + Logger: hclog.NewNullLogger(), + LoadAuthMethod: func(methodName string, entMeta *acl.EnterpriseMeta) (*structs.ACLAuthMethod, Validator, error) { + return &structs.ACLAuthMethod{}, validator, nil + }, + ValidateEnterpriseRequest: noopValidateEnterpriseRequest, + LocalTokensEnabled: noopLocalTokensEnabled, + ForwardRPC: noopForwardRPC, + NewLogin: func() Login { return login }, + }) + + leaderConn, err := grpc.Dial(testutils.RunTestServer(t, dc2).String(), grpc.WithInsecure()) + require.NoError(t, err) + + dc1 := NewServer(Config{ + ACLsEnabled: true, + Logger: hclog.NewNullLogger(), + ForwardRPC: func(info structs.RPCInfo, fn func(*grpc.ClientConn) error) (bool, error) { + if dc := info.RequestDatacenter(); dc != "dc2" { + return false, fmt.Errorf("unexpected target datacenter: %s", dc) + } + return true, fn(leaderConn) + }, + ValidateEnterpriseRequest: noopValidateEnterpriseRequest, + }) + + rsp, err := dc1.Login(context.Background(), &pbacl.LoginRequest{ + BearerToken: bearerToken, + Datacenter: "dc2", + }) + require.NoError(t, err) + require.Equal(t, "leader response", rsp.Token.AccessorId) +} diff --git a/agent/grpc/public/services/acl/logout.go b/agent/grpc/public/services/acl/logout.go new file mode 100644 index 0000000000..db0e68ebce --- /dev/null +++ b/agent/grpc/public/services/acl/logout.go @@ -0,0 +1,69 @@ +package acl + +import ( + "context" + "errors" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/agent/consul/auth" + "github.com/hashicorp/consul/agent/grpc/public" + "github.com/hashicorp/consul/proto-public/pbacl" +) + +// Logout destroys the given ACL token once the caller is done with it. +func (s *Server) Logout(ctx context.Context, req *pbacl.LogoutRequest) (*emptypb.Empty, error) { + logger := s.Logger.Named("logout").With("request_id", public.TraceID()) + logger.Trace("request received") + + if err := s.requireACLsEnabled(logger); err != nil { + return nil, err + } + + if req.Token == "" { + return nil, status.Error(codes.InvalidArgument, "token is required") + } + + // Forward request to leader in the requested datacenter. + var rsp *emptypb.Empty + handled, err := s.forwardWriteDC(req.Datacenter, func(conn *grpc.ClientConn) error { + var err error + rsp, err = pbacl.NewACLServiceClient(conn).Logout(ctx, req) + return err + }, logger) + if handled || err != nil { + return rsp, err + } + + if err := s.requireLocalTokens(logger); err != nil { + return nil, err + } + + err = s.NewTokenWriter().Delete(req.Token, true) + switch { + case errors.Is(err, auth.ErrCannotWriteGlobalToken): + // Writes to global tokens must be forwarded to the primary DC. + req.Datacenter = s.PrimaryDatacenter + + _, err = s.forwardWriteDC(s.PrimaryDatacenter, func(conn *grpc.ClientConn) error { + var err error + rsp, err = pbacl.NewACLServiceClient(conn).Logout(ctx, req) + return err + }, logger) + return rsp, err + case errors.Is(err, acl.ErrNotFound): + // No token? Pretend the delete was successful (for idempotency). + return &emptypb.Empty{}, nil + case errors.Is(err, acl.ErrPermissionDenied): + return nil, status.Error(codes.PermissionDenied, err.Error()) + case err != nil: + logger.Error("failed to delete token", "error", err.Error()) + return nil, status.Error(codes.Internal, "failed to delete token") + } + + return &emptypb.Empty{}, nil +} diff --git a/agent/grpc/public/services/acl/logout_test.go b/agent/grpc/public/services/acl/logout_test.go new file mode 100644 index 0000000000..461b6e249e --- /dev/null +++ b/agent/grpc/public/services/acl/logout_test.go @@ -0,0 +1,224 @@ +package acl + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/hashicorp/go-hclog" + + "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/agent/consul/auth" + "github.com/hashicorp/consul/agent/grpc/public/testutils" + "github.com/hashicorp/consul/agent/structs" + "github.com/hashicorp/consul/proto-public/pbacl" +) + +func TestServer_Logout_Success(t *testing.T) { + secretID := generateID(t) + + tokenWriter := NewMockTokenWriter(t) + tokenWriter.On("Delete", secretID, true).Return(nil) + + server := NewServer(Config{ + ACLsEnabled: true, + InPrimaryDatacenter: true, + ForwardRPC: noopForwardRPC, + LocalTokensEnabled: noopLocalTokensEnabled, + Logger: hclog.NewNullLogger(), + NewTokenWriter: func() TokenWriter { return tokenWriter }, + }) + + _, err := server.Logout(context.Background(), &pbacl.LogoutRequest{ + Token: secretID, + }) + require.NoError(t, err) +} + +func TestServer_Logout_EmptyToken(t *testing.T) { + server := NewServer(Config{ + ACLsEnabled: true, + Logger: hclog.NewNullLogger(), + }) + + _, err := server.Logout(context.Background(), &pbacl.LogoutRequest{ + Token: "", + }) + require.Error(t, err) + require.Equal(t, codes.InvalidArgument.String(), status.Code(err).String()) + require.Contains(t, err.Error(), "token is required") +} + +func TestServer_Logout_ACLsDisabled(t *testing.T) { + server := NewServer(Config{ + ACLsEnabled: false, + Logger: hclog.NewNullLogger(), + ValidateEnterpriseRequest: noopValidateEnterpriseRequest, + ForwardRPC: noopForwardRPC, + LocalTokensEnabled: noopLocalTokensEnabled, + }) + + _, err := server.Logout(context.Background(), &pbacl.LogoutRequest{ + Token: generateID(t), + }) + require.Error(t, err) + require.Equal(t, codes.FailedPrecondition.String(), status.Code(err).String()) +} + +func TestServer_Logout_LocalTokensDisabled(t *testing.T) { + server := NewServer(Config{ + ACLsEnabled: true, + Logger: hclog.NewNullLogger(), + ForwardRPC: noopForwardRPC, + LocalTokensEnabled: func() bool { return false }, + }) + + _, err := server.Logout(context.Background(), &pbacl.LogoutRequest{ + Token: generateID(t), + }) + require.Error(t, err) + require.Equal(t, codes.FailedPrecondition.String(), status.Code(err).String()) + require.Contains(t, err.Error(), "token replication is required") +} + +func TestServer_Logout_NoSuchToken(t *testing.T) { + tokenWriter := NewMockTokenWriter(t) + tokenWriter.On("Delete", mock.Anything, true).Return(acl.ErrNotFound) + + server := NewServer(Config{ + ACLsEnabled: true, + Logger: hclog.NewNullLogger(), + ForwardRPC: noopForwardRPC, + LocalTokensEnabled: noopLocalTokensEnabled, + NewTokenWriter: func() TokenWriter { return tokenWriter }, + }) + + _, err := server.Logout(context.Background(), &pbacl.LogoutRequest{ + Token: generateID(t), + }) + require.NoError(t, err) +} + +func TestServer_Logout_PermissionDenied(t *testing.T) { + tokenWriter := NewMockTokenWriter(t) + tokenWriter.On("Delete", mock.Anything, true).Return(acl.ErrPermissionDenied) + + server := NewServer(Config{ + ACLsEnabled: true, + InPrimaryDatacenter: true, + ForwardRPC: noopForwardRPC, + LocalTokensEnabled: noopLocalTokensEnabled, + Logger: hclog.NewNullLogger(), + NewTokenWriter: func() TokenWriter { return tokenWriter }, + }) + + _, err := server.Logout(context.Background(), &pbacl.LogoutRequest{ + Token: generateID(t), + }) + require.Error(t, err) + require.Equal(t, codes.PermissionDenied.String(), status.Code(err).String()) +} + +func TestServer_Logout_RPCForwarding(t *testing.T) { + tokenWriter := NewMockTokenWriter(t) + tokenWriter.On("Delete", mock.Anything, true).Return(nil) + + dc1 := NewServer(Config{ + ACLsEnabled: true, + Logger: hclog.NewNullLogger(), + NewTokenWriter: func() TokenWriter { return tokenWriter }, + ForwardRPC: noopForwardRPC, + LocalTokensEnabled: func() bool { return true }, + }) + + dc1Conn, err := grpc.Dial( + testutils.RunTestServer(t, dc1).String(), + grpc.WithInsecure(), + ) + require.NoError(t, err) + + dc2 := NewServer(Config{ + ACLsEnabled: true, + Logger: hclog.NewNullLogger(), + ForwardRPC: func(rpcInfo structs.RPCInfo, fn func(*grpc.ClientConn) error) (bool, error) { + return true, fn(dc1Conn) + }, + }) + _, err = dc2.Logout(context.Background(), &pbacl.LogoutRequest{ + Token: generateID(t), + }) + require.NoError(t, err) +} + +func TestServer_Logout_GlobalWritesForwardedToPrimaryDC(t *testing.T) { + tokenWriter := NewMockTokenWriter(t) + tokenWriter.On("Delete", mock.Anything, true).Return(nil) + + // This test checks that requests to delete global tokens are forwared to the + // primary datacenter by: + // + // 1. Setting up 2 servers (1 in the primary DC, 1 in the secondary). + // 2. Making a logout request to the secondary DC. + // 3. Mocking TokenWriter.Delete to return ErrCannotWriteGlobalToken in the + // secondary DC. + // 4. Checking that the primary DC server's TokenWriter receives a call to + // Delete. + // 5. Capturing the forwarded request's Datacenter in the primary DC server's + // ForwardRPC (to check that we overwrote the user-supplied Datacenter + // field to prevent infinite forwarding loops!) + var forwardedRequestDatacenter string + primary := NewServer(Config{ + ACLsEnabled: true, + InPrimaryDatacenter: true, + LocalTokensEnabled: noopLocalTokensEnabled, + Logger: hclog.NewNullLogger(), + NewTokenWriter: func() TokenWriter { return tokenWriter }, + ForwardRPC: func(info structs.RPCInfo, _ func(*grpc.ClientConn) error) (bool, error) { + forwardedRequestDatacenter = info.RequestDatacenter() + return false, nil + }, + }) + + primaryConn, err := grpc.Dial( + testutils.RunTestServer(t, primary).String(), + grpc.WithInsecure(), + ) + require.NoError(t, err) + + secondary := NewServer(Config{ + ACLsEnabled: true, + InPrimaryDatacenter: false, + LocalTokensEnabled: noopLocalTokensEnabled, + Logger: hclog.NewNullLogger(), + PrimaryDatacenter: "primary", + ForwardRPC: func(info structs.RPCInfo, fn func(*grpc.ClientConn) error) (bool, error) { + dc := info.RequestDatacenter() + switch dc { + case "secondary": + return false, nil + case "primary": + return true, fn(primaryConn) + default: + return false, fmt.Errorf("unexpected target datacenter: %s", dc) + } + }, + NewTokenWriter: func() TokenWriter { + tokenWriter := NewMockTokenWriter(t) + tokenWriter.On("Delete", mock.Anything, true).Return(auth.ErrCannotWriteGlobalToken) + return tokenWriter + }, + }) + + _, err = secondary.Logout(context.Background(), &pbacl.LogoutRequest{ + Token: generateID(t), + Datacenter: "secondary", + }) + require.NoError(t, err) + require.Equal(t, "primary", forwardedRequestDatacenter) +} diff --git a/agent/grpc/public/services/acl/mock_Login.go b/agent/grpc/public/services/acl/mock_Login.go new file mode 100644 index 0000000000..3c33169a86 --- /dev/null +++ b/agent/grpc/public/services/acl/mock_Login.go @@ -0,0 +1,50 @@ +// Code generated by mockery v2.12.0. DO NOT EDIT. + +package acl + +import ( + authmethod "github.com/hashicorp/consul/agent/consul/authmethod" + mock "github.com/stretchr/testify/mock" + + structs "github.com/hashicorp/consul/agent/structs" + + testing "testing" +) + +// MockLogin is an autogenerated mock type for the Login type +type MockLogin struct { + mock.Mock +} + +// TokenForVerifiedIdentity provides a mock function with given fields: identity, authMethod, description +func (_m *MockLogin) TokenForVerifiedIdentity(identity *authmethod.Identity, authMethod *structs.ACLAuthMethod, description string) (*structs.ACLToken, error) { + ret := _m.Called(identity, authMethod, description) + + var r0 *structs.ACLToken + if rf, ok := ret.Get(0).(func(*authmethod.Identity, *structs.ACLAuthMethod, string) *structs.ACLToken); ok { + r0 = rf(identity, authMethod, description) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*structs.ACLToken) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*authmethod.Identity, *structs.ACLAuthMethod, string) error); ok { + r1 = rf(identity, authMethod, description) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewMockLogin creates a new instance of MockLogin. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations. +func NewMockLogin(t testing.TB) *MockLogin { + mock := &MockLogin{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/agent/grpc/public/services/acl/mock_TokenWriter.go b/agent/grpc/public/services/acl/mock_TokenWriter.go new file mode 100644 index 0000000000..19408afc88 --- /dev/null +++ b/agent/grpc/public/services/acl/mock_TokenWriter.go @@ -0,0 +1,38 @@ +// Code generated by mockery v2.12.0. DO NOT EDIT. + +package acl + +import ( + testing "testing" + + mock "github.com/stretchr/testify/mock" +) + +// MockTokenWriter is an autogenerated mock type for the TokenWriter type +type MockTokenWriter struct { + mock.Mock +} + +// Delete provides a mock function with given fields: secretID, fromLogout +func (_m *MockTokenWriter) Delete(secretID string, fromLogout bool) error { + ret := _m.Called(secretID, fromLogout) + + var r0 error + if rf, ok := ret.Get(0).(func(string, bool) error); ok { + r0 = rf(secretID, fromLogout) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewMockTokenWriter creates a new instance of MockTokenWriter. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations. +func NewMockTokenWriter(t testing.TB) *MockTokenWriter { + mock := &MockTokenWriter{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/agent/grpc/public/services/acl/mock_Validator.go b/agent/grpc/public/services/acl/mock_Validator.go new file mode 100644 index 0000000000..3c27ec38ba --- /dev/null +++ b/agent/grpc/public/services/acl/mock_Validator.go @@ -0,0 +1,51 @@ +// Code generated by mockery v2.12.0. DO NOT EDIT. + +package acl + +import ( + context "context" + + authmethod "github.com/hashicorp/consul/agent/consul/authmethod" + + mock "github.com/stretchr/testify/mock" + + testing "testing" +) + +// MockValidator is an autogenerated mock type for the Validator type +type MockValidator struct { + mock.Mock +} + +// ValidateLogin provides a mock function with given fields: ctx, loginToken +func (_m *MockValidator) ValidateLogin(ctx context.Context, loginToken string) (*authmethod.Identity, error) { + ret := _m.Called(ctx, loginToken) + + var r0 *authmethod.Identity + if rf, ok := ret.Get(0).(func(context.Context, string) *authmethod.Identity); ok { + r0 = rf(ctx, loginToken) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*authmethod.Identity) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, loginToken) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewMockValidator creates a new instance of MockValidator. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations. +func NewMockValidator(t testing.TB) *MockValidator { + mock := &MockValidator{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/agent/grpc/public/services/acl/server.go b/agent/grpc/public/services/acl/server.go new file mode 100644 index 0000000000..7f0994f040 --- /dev/null +++ b/agent/grpc/public/services/acl/server.go @@ -0,0 +1,88 @@ +package acl + +import ( + "context" + + "github.com/hashicorp/go-hclog" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/agent/consul/authmethod" + "github.com/hashicorp/consul/agent/structs" + "github.com/hashicorp/consul/proto-public/pbacl" +) + +type Config struct { + ACLsEnabled bool + Logger hclog.Logger + LoadAuthMethod func(authMethod string, entMeta *acl.EnterpriseMeta) (*structs.ACLAuthMethod, Validator, error) + NewLogin func() Login + ForwardRPC func(structs.RPCInfo, func(*grpc.ClientConn) error) (bool, error) + ValidateEnterpriseRequest func(*acl.EnterpriseMeta, bool) error + LocalTokensEnabled func() bool + InPrimaryDatacenter bool + PrimaryDatacenter string + NewTokenWriter func() TokenWriter +} + +//go:generate mockery --name Login --inpackage +type Login interface { + TokenForVerifiedIdentity(identity *authmethod.Identity, authMethod *structs.ACLAuthMethod, description string) (*structs.ACLToken, error) +} + +//go:generate mockery --name Validator --inpackage +type Validator interface { + ValidateLogin(ctx context.Context, loginToken string) (*authmethod.Identity, error) +} + +//go:generate mockery --name TokenWriter --inpackage +type TokenWriter interface { + Delete(secretID string, fromLogout bool) error +} + +type Server struct { + Config +} + +func NewServer(cfg Config) *Server { + return &Server{cfg} +} + +func (s *Server) Register(grpcServer *grpc.Server) { + pbacl.RegisterACLServiceServer(grpcServer, s) +} + +func (s *Server) requireACLsEnabled(logger hclog.Logger) error { + if s.ACLsEnabled { + return nil + } + logger.Warn("request blocked ACLs are disabled") + return status.Error(codes.FailedPrecondition, acl.ErrDisabled.Error()) +} + +func (s *Server) requireLocalTokens(logger hclog.Logger) error { + if s.LocalTokensEnabled() { + return nil + } + logger.Warn("request blocked because we're in a non-primary datacenter and token replication is disabled") + return status.Error(codes.FailedPrecondition, "token replication is required for auth methods to function") +} + +func (s *Server) forwardWriteDC(dc string, fn func(*grpc.ClientConn) error, logger hclog.Logger) (bool, error) { + // For private/internal gRPC handlers, protoc-gen-rpc-glue generates the + // requisite methods to satisfy the structs.RPCInfo interface using fields + // from the pbcommon package. This service is public, so we can't use those + // fields in our proto definition. Instead, we construct our RPCInfo manually. + var rpcInfo struct { + structs.WriteRequest // Ensure RPCs are forwarded to the leader. + structs.DCSpecificRequest // Ensure RPCs are forwarded to the correct datacenter. + } + rpcInfo.Datacenter = dc + + return s.ForwardRPC(&rpcInfo, func(conn *grpc.ClientConn) error { + logger.Trace("forwarding RPC", "datacenter", dc) + return fn(conn) + }) +} diff --git a/agent/grpc/public/services/acl/server_test.go b/agent/grpc/public/services/acl/server_test.go new file mode 100644 index 0000000000..d2b381156e --- /dev/null +++ b/agent/grpc/public/services/acl/server_test.go @@ -0,0 +1,37 @@ +package acl + +import ( + "testing" + + "github.com/hashicorp/go-uuid" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/hashicorp/consul/acl" + structs "github.com/hashicorp/consul/agent/structs" +) + +func generateID(t *testing.T) string { + t.Helper() + + id, err := uuid.GenerateUUID() + require.NoError(t, err) + + return id +} + +func noopForwardRPC(structs.RPCInfo, func(*grpc.ClientConn) error) (bool, error) { + return false, nil +} + +func noopValidateEnterpriseRequest(*acl.EnterpriseMeta, bool) error { + return nil +} + +func noopLocalTokensEnabled() bool { + return true +} + +func noopACLsEnabled() bool { + return true +} diff --git a/agent/structs/acl.go b/agent/structs/acl.go index f516e0a6d6..82d19b8aca 100644 --- a/agent/structs/acl.go +++ b/agent/structs/acl.go @@ -169,6 +169,34 @@ func (s *ACLServiceIdentity) SyntheticPolicy(entMeta *acl.EnterpriseMeta) *ACLPo return policy } +type ACLServiceIdentities []*ACLServiceIdentity + +// Deduplicate returns a new list of service identities without duplicates. +// Identities with the same ServiceName but different datacenters will be +// merged into a single identity with all datacenters. +func (ids ACLServiceIdentities) Deduplicate() ACLServiceIdentities { + unique := make(map[string]*ACLServiceIdentity) + + for _, id := range ids { + entry, ok := unique[id.ServiceName] + if ok { + dcs := stringslice.CloneStringSlice(id.Datacenters) + sort.Strings(dcs) + entry.Datacenters = stringslice.MergeSorted(dcs, entry.Datacenters) + } else { + entry = id.Clone() + sort.Strings(entry.Datacenters) + unique[id.ServiceName] = entry + } + } + + results := make(ACLServiceIdentities, 0, len(unique)) + for _, id := range unique { + results = append(results, id) + } + return results +} + // ACLNodeIdentity represents a high-level grant of all privileges // necessary to assume the identity of that node and manage it. type ACLNodeIdentity struct { @@ -213,6 +241,27 @@ func (s *ACLNodeIdentity) SyntheticPolicy(entMeta *acl.EnterpriseMeta) *ACLPolic return policy } +type ACLNodeIdentities []*ACLNodeIdentity + +// Deduplicate returns a new list of node identities without duplicates. +func (ids ACLNodeIdentities) Deduplicate() ACLNodeIdentities { + type mapKey struct { + nodeName, datacenter string + } + seen := make(map[mapKey]struct{}) + + var results ACLNodeIdentities + for _, id := range ids { + key := mapKey{id.NodeName, id.Datacenter} + if _, ok := seen[key]; ok { + continue + } + results = append(results, id.Clone()) + seen[key] = struct{}{} + } + return results +} + type ACLToken struct { // This is the UUID used for tracking and management purposes AccessorID string @@ -234,10 +283,10 @@ type ACLToken struct { Roles []ACLTokenRoleLink `json:",omitempty"` // List of services to generate synthetic policies for. - ServiceIdentities []*ACLServiceIdentity `json:",omitempty"` + ServiceIdentities ACLServiceIdentities `json:",omitempty"` // The node identities that this token should be allowed to manage. - NodeIdentities []*ACLNodeIdentity `json:",omitempty"` + NodeIdentities ACLNodeIdentities `json:",omitempty"` // Type is the V1 Token Type // DEPRECATED (ACL-Legacy-Compat) - remove once we no longer support v1 ACL compat @@ -497,10 +546,10 @@ type ACLTokenListStub struct { AccessorID string SecretID string Description string - Policies []ACLTokenPolicyLink `json:",omitempty"` - Roles []ACLTokenRoleLink `json:",omitempty"` - ServiceIdentities []*ACLServiceIdentity `json:",omitempty"` - NodeIdentities []*ACLNodeIdentity `json:",omitempty"` + Policies []ACLTokenPolicyLink `json:",omitempty"` + Roles []ACLTokenRoleLink `json:",omitempty"` + ServiceIdentities ACLServiceIdentities `json:",omitempty"` + NodeIdentities ACLNodeIdentities `json:",omitempty"` Local bool AuthMethod string `json:",omitempty"` ExpirationTime *time.Time `json:",omitempty"` @@ -808,10 +857,10 @@ type ACLRole struct { Policies []ACLRolePolicyLink `json:",omitempty"` // List of services to generate synthetic policies for. - ServiceIdentities []*ACLServiceIdentity `json:",omitempty"` + ServiceIdentities ACLServiceIdentities `json:",omitempty"` // List of nodes to generate synthetic policies for. - NodeIdentities []*ACLNodeIdentity `json:",omitempty"` + NodeIdentities ACLNodeIdentities `json:",omitempty"` // Hash of the contents of the role // This does not take into account the ID (which is immutable) diff --git a/agent/structs/acl_cache.go b/agent/structs/acl_cache.go index 1c25de63e8..65e20a1f4f 100644 --- a/agent/structs/acl_cache.go +++ b/agent/structs/acl_cache.go @@ -27,7 +27,6 @@ type ACLCaches struct { type IdentityCacheEntry struct { Identity ACLIdentity CacheTime time.Time - Error error } func (e *IdentityCacheEntry) Age() time.Duration { @@ -135,6 +134,12 @@ func (c *ACLCaches) GetIdentity(id string) *IdentityCacheEntry { return nil } +// GetIdentityWithSecretToken fetches the identity with the given secret token +// from the cache. +func (c *ACLCaches) GetIdentityWithSecretToken(secretToken string) *IdentityCacheEntry { + return c.GetIdentity(cacheIDSecretToken(secretToken)) +} + // GetPolicy fetches a policy from the cache and returns it func (c *ACLCaches) GetPolicy(policyID string) *PolicyCacheEntry { if c == nil || c.policies == nil { @@ -188,12 +193,28 @@ func (c *ACLCaches) GetRole(roleID string) *RoleCacheEntry { } // PutIdentity adds a new identity to the cache -func (c *ACLCaches) PutIdentity(id string, ident ACLIdentity, err error) { +func (c *ACLCaches) PutIdentity(id string, ident ACLIdentity) { if c == nil || c.identities == nil { return } - c.identities.Add(id, &IdentityCacheEntry{Identity: ident, CacheTime: time.Now(), Error: err}) + c.identities.Add(id, &IdentityCacheEntry{Identity: ident, CacheTime: time.Now()}) +} + +// PutIdentityWithSecretToken adds a new identity to the cache, keyed by the +// given secret token (with a prefix to prevent collisions). +func (c *ACLCaches) PutIdentityWithSecretToken(secretToken string, identity ACLIdentity) { + c.PutIdentity(cacheIDSecretToken(secretToken), identity) +} + +// RemoveIdentityWithSecretToken removes the identity from the cache with the +// given secret token. +func (c *ACLCaches) RemoveIdentityWithSecretToken(secretToken string) { + if c == nil || c.identities == nil { + return + } + + c.identities.Remove(cacheIDSecretToken(secretToken)) } func (c *ACLCaches) PutPolicy(policyId string, policy *ACLPolicy) { @@ -265,3 +286,7 @@ func (c *ACLCaches) Purge() { } } } + +func cacheIDSecretToken(token string) string { + return "token-secret:" + token +} diff --git a/agent/structs/acl_cache_test.go b/agent/structs/acl_cache_test.go index d5401075ec..e0a057d363 100644 --- a/agent/structs/acl_cache_test.go +++ b/agent/structs/acl_cache_test.go @@ -47,10 +47,18 @@ func TestStructs_ACLCaches(t *testing.T) { require.NoError(t, err) require.NotNil(t, cache) - cache.PutIdentity("foo", &ACLToken{}, nil) + cache.PutIdentity("foo", &ACLToken{}) entry := cache.GetIdentity("foo") require.NotNil(t, entry) require.NotNil(t, entry.Identity) + + cache.PutIdentityWithSecretToken("secret", &ACLToken{}) + entry = cache.GetIdentityWithSecretToken("secret") + require.NotNil(t, entry) + require.NotNil(t, entry.Identity) + cache.RemoveIdentityWithSecretToken("secret") + entry = cache.GetIdentityWithSecretToken("secret") + require.Nil(t, entry) }) t.Run("Policies", func(t *testing.T) { diff --git a/agent/structs/acl_test.go b/agent/structs/acl_test.go index 65afc6bf19..e0aae4c0ae 100644 --- a/agent/structs/acl_test.go +++ b/agent/structs/acl_test.go @@ -85,6 +85,36 @@ func TestStructs_ACLServiceIdentity_SyntheticPolicy(t *testing.T) { } } +func TestStructs_ACLServiceIdentities_Deduplicate(t *testing.T) { + identities := ACLServiceIdentities{ + {ServiceName: "web", Datacenters: []string{"dc1"}}, + {ServiceName: "web", Datacenters: []string{"dc2"}}, + {ServiceName: "db", Datacenters: []string{"dc3"}}, + } + + require.ElementsMatch(t, ACLServiceIdentities{ + {ServiceName: "web", Datacenters: []string{"dc1", "dc2"}}, + {ServiceName: "db", Datacenters: []string{"dc3"}}, + }, identities.Deduplicate()) + + require.Len(t, identities, 3, "original slice shouldn't have been mutated") +} + +func TestStructs_ACLNodeIdentities_Deduplicate(t *testing.T) { + identities := ACLNodeIdentities{ + {NodeName: "web", Datacenter: "dc1"}, + {NodeName: "web", Datacenter: "dc2"}, + {NodeName: "web", Datacenter: "dc1"}, + } + + require.Equal(t, ACLNodeIdentities{ + {NodeName: "web", Datacenter: "dc1"}, + {NodeName: "web", Datacenter: "dc2"}, + }, identities.Deduplicate()) + + require.Len(t, identities, 3, "original slice shouldn't have been mutated") +} + func TestStructs_ACLToken_SetHash(t *testing.T) { token := ACLToken{ diff --git a/agent/structs/structs.go b/agent/structs/structs.go index 2991bd1640..467c942742 100644 --- a/agent/structs/structs.go +++ b/agent/structs/structs.go @@ -18,11 +18,12 @@ import ( "github.com/golang/protobuf/ptypes" "github.com/golang/protobuf/ptypes/duration" "github.com/golang/protobuf/ptypes/timestamp" - "github.com/hashicorp/consul-net-rpc/go-msgpack/codec" "github.com/hashicorp/go-multierror" "github.com/hashicorp/serf/coordinate" "github.com/mitchellh/hashstructure" + "github.com/hashicorp/consul-net-rpc/go-msgpack/codec" + "github.com/hashicorp/consul/acl" "github.com/hashicorp/consul/agent/cache" "github.com/hashicorp/consul/api" diff --git a/command/logout/logout_test.go b/command/logout/logout_test.go index 92520976d7..c5130fdf1b 100644 --- a/command/logout/logout_test.go +++ b/command/logout/logout_test.go @@ -92,7 +92,7 @@ func TestLogoutCommand(t *testing.T) { code := cmd.Run(args) require.Equal(t, code, 1, "err: %s", ui.ErrorWriter.String()) - require.Contains(t, ui.ErrorWriter.String(), "403 (Permission denied)") + require.Contains(t, ui.ErrorWriter.String(), "403 (Permission denied: token wasn't created via login)") }) testSessionID := testauth.StartSession() @@ -222,7 +222,7 @@ func TestLogoutCommand_k8s(t *testing.T) { code := cmd.Run(args) require.Equal(t, code, 1, "err: %s", ui.ErrorWriter.String()) - require.Contains(t, ui.ErrorWriter.String(), "403 (Permission denied)") + require.Contains(t, ui.ErrorWriter.String(), "403 (Permission denied: token wasn't created via login)") }) // go to the trouble of creating a login token diff --git a/proto-public/pbacl/acl.pb.binary.go b/proto-public/pbacl/acl.pb.binary.go new file mode 100644 index 0000000000..3ecf9e78d3 --- /dev/null +++ b/proto-public/pbacl/acl.pb.binary.go @@ -0,0 +1,48 @@ +// Code generated by protoc-gen-go-binary. DO NOT EDIT. +// source: proto-public/pbacl/acl.proto + +package pbacl + +import ( + "github.com/golang/protobuf/proto" +) + +// MarshalBinary implements encoding.BinaryMarshaler +func (msg *LoginRequest) MarshalBinary() ([]byte, error) { + return proto.Marshal(msg) +} + +// UnmarshalBinary implements encoding.BinaryUnmarshaler +func (msg *LoginRequest) UnmarshalBinary(b []byte) error { + return proto.Unmarshal(b, msg) +} + +// MarshalBinary implements encoding.BinaryMarshaler +func (msg *LoginResponse) MarshalBinary() ([]byte, error) { + return proto.Marshal(msg) +} + +// UnmarshalBinary implements encoding.BinaryUnmarshaler +func (msg *LoginResponse) UnmarshalBinary(b []byte) error { + return proto.Unmarshal(b, msg) +} + +// MarshalBinary implements encoding.BinaryMarshaler +func (msg *LoginToken) MarshalBinary() ([]byte, error) { + return proto.Marshal(msg) +} + +// UnmarshalBinary implements encoding.BinaryUnmarshaler +func (msg *LoginToken) UnmarshalBinary(b []byte) error { + return proto.Unmarshal(b, msg) +} + +// MarshalBinary implements encoding.BinaryMarshaler +func (msg *LogoutRequest) MarshalBinary() ([]byte, error) { + return proto.Marshal(msg) +} + +// UnmarshalBinary implements encoding.BinaryUnmarshaler +func (msg *LogoutRequest) UnmarshalBinary(b []byte) error { + return proto.Unmarshal(b, msg) +} diff --git a/proto-public/pbacl/acl.pb.go b/proto-public/pbacl/acl.pb.go new file mode 100644 index 0000000000..cbb07749c7 --- /dev/null +++ b/proto-public/pbacl/acl.pb.go @@ -0,0 +1,574 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.23.0 +// protoc v3.15.8 +// source: proto-public/pbacl/acl.proto + +package pbacl + +import ( + context "context" + proto "github.com/golang/protobuf/proto" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + emptypb "google.golang.org/protobuf/types/known/emptypb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// This is a compile-time assertion that a sufficiently up-to-date version +// of the legacy proto package is being used. +const _ = proto.ProtoPackageIsVersion4 + +type LoginRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // auth_method is the name of the configured auth method that will be used to + // validate the presented bearer token. + AuthMethod string `protobuf:"bytes,1,opt,name=auth_method,json=authMethod,proto3" json:"auth_method,omitempty"` + // bearer_token is a token produced by a trusted identity provider as + // configured by the auth method. + BearerToken string `protobuf:"bytes,2,opt,name=bearer_token,json=bearerToken,proto3" json:"bearer_token,omitempty"` + // meta is a collection of arbitrary key-value pairs associated to the token, + // it is useful for tracking the origin of tokens. + Meta map[string]string `protobuf:"bytes,3,rep,name=meta,proto3" json:"meta,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + // namespace (enterprise only) is the namespace in which the auth method + // resides. + Namespace string `protobuf:"bytes,4,opt,name=namespace,proto3" json:"namespace,omitempty"` + // partition (enterprise only) is the partition in which the auth method + // resides. + Partition string `protobuf:"bytes,5,opt,name=partition,proto3" json:"partition,omitempty"` + // datacenter is the target datacenter in which the request will be processed. + Datacenter string `protobuf:"bytes,6,opt,name=datacenter,proto3" json:"datacenter,omitempty"` +} + +func (x *LoginRequest) Reset() { + *x = LoginRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_proto_public_pbacl_acl_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LoginRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LoginRequest) ProtoMessage() {} + +func (x *LoginRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_public_pbacl_acl_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LoginRequest.ProtoReflect.Descriptor instead. +func (*LoginRequest) Descriptor() ([]byte, []int) { + return file_proto_public_pbacl_acl_proto_rawDescGZIP(), []int{0} +} + +func (x *LoginRequest) GetAuthMethod() string { + if x != nil { + return x.AuthMethod + } + return "" +} + +func (x *LoginRequest) GetBearerToken() string { + if x != nil { + return x.BearerToken + } + return "" +} + +func (x *LoginRequest) GetMeta() map[string]string { + if x != nil { + return x.Meta + } + return nil +} + +func (x *LoginRequest) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *LoginRequest) GetPartition() string { + if x != nil { + return x.Partition + } + return "" +} + +func (x *LoginRequest) GetDatacenter() string { + if x != nil { + return x.Datacenter + } + return "" +} + +type LoginResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // token is the generated ACL token. + Token *LoginToken `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` +} + +func (x *LoginResponse) Reset() { + *x = LoginResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_proto_public_pbacl_acl_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LoginResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LoginResponse) ProtoMessage() {} + +func (x *LoginResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_public_pbacl_acl_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LoginResponse.ProtoReflect.Descriptor instead. +func (*LoginResponse) Descriptor() ([]byte, []int) { + return file_proto_public_pbacl_acl_proto_rawDescGZIP(), []int{1} +} + +func (x *LoginResponse) GetToken() *LoginToken { + if x != nil { + return x.Token + } + return nil +} + +type LoginToken struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // accessor_id is a UUID used to identify the ACL token. + AccessorId string `protobuf:"bytes,1,opt,name=accessor_id,json=accessorId,proto3" json:"accessor_id,omitempty"` + // secret_id is a UUID presented as a credential by clients. + SecretId string `protobuf:"bytes,2,opt,name=secret_id,json=secretId,proto3" json:"secret_id,omitempty"` +} + +func (x *LoginToken) Reset() { + *x = LoginToken{} + if protoimpl.UnsafeEnabled { + mi := &file_proto_public_pbacl_acl_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LoginToken) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LoginToken) ProtoMessage() {} + +func (x *LoginToken) ProtoReflect() protoreflect.Message { + mi := &file_proto_public_pbacl_acl_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LoginToken.ProtoReflect.Descriptor instead. +func (*LoginToken) Descriptor() ([]byte, []int) { + return file_proto_public_pbacl_acl_proto_rawDescGZIP(), []int{2} +} + +func (x *LoginToken) GetAccessorId() string { + if x != nil { + return x.AccessorId + } + return "" +} + +func (x *LoginToken) GetSecretId() string { + if x != nil { + return x.SecretId + } + return "" +} + +type LogoutRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // token is the ACL token's secret ID. + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + // datacenter is the target datacenter in which the request will be processed. + Datacenter string `protobuf:"bytes,2,opt,name=datacenter,proto3" json:"datacenter,omitempty"` +} + +func (x *LogoutRequest) Reset() { + *x = LogoutRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_proto_public_pbacl_acl_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LogoutRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LogoutRequest) ProtoMessage() {} + +func (x *LogoutRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_public_pbacl_acl_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead. +func (*LogoutRequest) Descriptor() ([]byte, []int) { + return file_proto_public_pbacl_acl_proto_rawDescGZIP(), []int{3} +} + +func (x *LogoutRequest) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *LogoutRequest) GetDatacenter() string { + if x != nil { + return x.Datacenter + } + return "" +} + +var File_proto_public_pbacl_acl_proto protoreflect.FileDescriptor + +var file_proto_public_pbacl_acl_proto_rawDesc = []byte{ + 0x0a, 0x1c, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2d, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x2f, 0x70, + 0x62, 0x61, 0x63, 0x6c, 0x2f, 0x61, 0x63, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, + 0x61, 0x63, 0x6c, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x22, 0x98, 0x02, 0x0a, 0x0c, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x75, 0x74, 0x68, 0x4d, 0x65, 0x74, 0x68, + 0x6f, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x62, 0x65, 0x61, 0x72, 0x65, 0x72, 0x5f, 0x74, 0x6f, 0x6b, + 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x62, 0x65, 0x61, 0x72, 0x65, 0x72, + 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2f, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x03, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x61, 0x63, 0x6c, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x1c, 0x0a, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, + 0x61, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x61, 0x6d, 0x65, 0x73, + 0x70, 0x61, 0x63, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, + 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x1e, 0x0a, 0x0a, 0x64, 0x61, 0x74, 0x61, 0x63, 0x65, 0x6e, 0x74, 0x65, 0x72, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x64, 0x61, 0x74, 0x61, 0x63, 0x65, 0x6e, 0x74, + 0x65, 0x72, 0x1a, 0x37, 0x0a, 0x09, 0x4d, 0x65, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, + 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, + 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x36, 0x0a, 0x0d, 0x4c, + 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x05, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x61, 0x63, + 0x6c, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x05, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x22, 0x4a, 0x0a, 0x0a, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x6f, 0x72, 0x5f, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x6f, 0x72, + 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x49, 0x64, 0x22, + 0x45, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x1e, 0x0a, 0x0a, 0x64, 0x61, 0x74, 0x61, 0x63, 0x65, + 0x6e, 0x74, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x64, 0x61, 0x74, 0x61, + 0x63, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x32, 0x76, 0x0a, 0x0a, 0x41, 0x43, 0x4c, 0x53, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x12, 0x30, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x11, 0x2e, + 0x61, 0x63, 0x6c, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x12, 0x2e, 0x61, 0x63, 0x6c, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x36, 0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, + 0x12, 0x12, 0x2e, 0x61, 0x63, 0x6c, 0x2e, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x42, 0x30, + 0x5a, 0x2e, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, + 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x63, 0x6f, 0x6e, 0x73, 0x75, 0x6c, 0x2f, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x2d, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x2f, 0x70, 0x62, 0x61, 0x63, 0x6c, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_proto_public_pbacl_acl_proto_rawDescOnce sync.Once + file_proto_public_pbacl_acl_proto_rawDescData = file_proto_public_pbacl_acl_proto_rawDesc +) + +func file_proto_public_pbacl_acl_proto_rawDescGZIP() []byte { + file_proto_public_pbacl_acl_proto_rawDescOnce.Do(func() { + file_proto_public_pbacl_acl_proto_rawDescData = protoimpl.X.CompressGZIP(file_proto_public_pbacl_acl_proto_rawDescData) + }) + return file_proto_public_pbacl_acl_proto_rawDescData +} + +var file_proto_public_pbacl_acl_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_proto_public_pbacl_acl_proto_goTypes = []interface{}{ + (*LoginRequest)(nil), // 0: acl.LoginRequest + (*LoginResponse)(nil), // 1: acl.LoginResponse + (*LoginToken)(nil), // 2: acl.LoginToken + (*LogoutRequest)(nil), // 3: acl.LogoutRequest + nil, // 4: acl.LoginRequest.MetaEntry + (*emptypb.Empty)(nil), // 5: google.protobuf.Empty +} +var file_proto_public_pbacl_acl_proto_depIdxs = []int32{ + 4, // 0: acl.LoginRequest.meta:type_name -> acl.LoginRequest.MetaEntry + 2, // 1: acl.LoginResponse.token:type_name -> acl.LoginToken + 0, // 2: acl.ACLService.Login:input_type -> acl.LoginRequest + 3, // 3: acl.ACLService.Logout:input_type -> acl.LogoutRequest + 1, // 4: acl.ACLService.Login:output_type -> acl.LoginResponse + 5, // 5: acl.ACLService.Logout:output_type -> google.protobuf.Empty + 4, // [4:6] is the sub-list for method output_type + 2, // [2:4] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_proto_public_pbacl_acl_proto_init() } +func file_proto_public_pbacl_acl_proto_init() { + if File_proto_public_pbacl_acl_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_proto_public_pbacl_acl_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*LoginRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proto_public_pbacl_acl_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*LoginResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proto_public_pbacl_acl_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*LoginToken); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_proto_public_pbacl_acl_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*LogoutRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_proto_public_pbacl_acl_proto_rawDesc, + NumEnums: 0, + NumMessages: 5, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_proto_public_pbacl_acl_proto_goTypes, + DependencyIndexes: file_proto_public_pbacl_acl_proto_depIdxs, + MessageInfos: file_proto_public_pbacl_acl_proto_msgTypes, + }.Build() + File_proto_public_pbacl_acl_proto = out.File + file_proto_public_pbacl_acl_proto_rawDesc = nil + file_proto_public_pbacl_acl_proto_goTypes = nil + file_proto_public_pbacl_acl_proto_depIdxs = nil +} + +// Reference imports to suppress errors if they are not otherwise used. +var _ context.Context +var _ grpc.ClientConnInterface + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +const _ = grpc.SupportPackageIsVersion6 + +// ACLServiceClient is the client API for ACLService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream. +type ACLServiceClient interface { + // Login exchanges the presented bearer token for a Consul ACL token using a + // configured auth method. + Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error) + // Logout destroys the given ACL token once the caller is done with it. + Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) +} + +type aCLServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewACLServiceClient(cc grpc.ClientConnInterface) ACLServiceClient { + return &aCLServiceClient{cc} +} + +func (c *aCLServiceClient) Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*LoginResponse, error) { + out := new(LoginResponse) + err := c.cc.Invoke(ctx, "/acl.ACLService/Login", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *aCLServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, "/acl.ACLService/Logout", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ACLServiceServer is the server API for ACLService service. +type ACLServiceServer interface { + // Login exchanges the presented bearer token for a Consul ACL token using a + // configured auth method. + Login(context.Context, *LoginRequest) (*LoginResponse, error) + // Logout destroys the given ACL token once the caller is done with it. + Logout(context.Context, *LogoutRequest) (*emptypb.Empty, error) +} + +// UnimplementedACLServiceServer can be embedded to have forward compatible implementations. +type UnimplementedACLServiceServer struct { +} + +func (*UnimplementedACLServiceServer) Login(context.Context, *LoginRequest) (*LoginResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Login not implemented") +} +func (*UnimplementedACLServiceServer) Logout(context.Context, *LogoutRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method Logout not implemented") +} + +func RegisterACLServiceServer(s *grpc.Server, srv ACLServiceServer) { + s.RegisterService(&_ACLService_serviceDesc, srv) +} + +func _ACLService_Login_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LoginRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ACLServiceServer).Login(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/acl.ACLService/Login", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ACLServiceServer).Login(ctx, req.(*LoginRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ACLService_Logout_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LogoutRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ACLServiceServer).Logout(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/acl.ACLService/Logout", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ACLServiceServer).Logout(ctx, req.(*LogoutRequest)) + } + return interceptor(ctx, in, info, handler) +} + +var _ACLService_serviceDesc = grpc.ServiceDesc{ + ServiceName: "acl.ACLService", + HandlerType: (*ACLServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Login", + Handler: _ACLService_Login_Handler, + }, + { + MethodName: "Logout", + Handler: _ACLService_Logout_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "proto-public/pbacl/acl.proto", +} diff --git a/proto-public/pbacl/acl.proto b/proto-public/pbacl/acl.proto new file mode 100644 index 0000000000..aa6adb0075 --- /dev/null +++ b/proto-public/pbacl/acl.proto @@ -0,0 +1,62 @@ +syntax = "proto3"; + +package acl; + +import "google/protobuf/empty.proto"; + +option go_package = "github.com/hashicorp/consul/proto-public/pbacl"; + +service ACLService { + // Login exchanges the presented bearer token for a Consul ACL token using a + // configured auth method. + rpc Login(LoginRequest) returns (LoginResponse) {} + + // Logout destroys the given ACL token once the caller is done with it. + rpc Logout(LogoutRequest) returns (google.protobuf.Empty) {} +} + +message LoginRequest { + // auth_method is the name of the configured auth method that will be used to + // validate the presented bearer token. + string auth_method = 1; + + // bearer_token is a token produced by a trusted identity provider as + // configured by the auth method. + string bearer_token = 2; + + // meta is a collection of arbitrary key-value pairs associated to the token, + // it is useful for tracking the origin of tokens. + map meta = 3; + + // namespace (enterprise only) is the namespace in which the auth method + // resides. + string namespace = 4; + + // partition (enterprise only) is the partition in which the auth method + // resides. + string partition = 5; + + // datacenter is the target datacenter in which the request will be processed. + string datacenter = 6; +} + +message LoginResponse { + // token is the generated ACL token. + LoginToken token = 1; +} + +message LoginToken { + // accessor_id is a UUID used to identify the ACL token. + string accessor_id = 1; + + // secret_id is a UUID presented as a credential by clients. + string secret_id = 2; +} + +message LogoutRequest { + // token is the ACL token's secret ID. + string token = 1; + + // datacenter is the target datacenter in which the request will be processed. + string datacenter = 2; +}