open-consul/agent/consul/auth/token_writer_test.go
Dan Upton 6bfdb48560
acl: gRPC login and logout endpoints (#12935)
Introduces two new public gRPC endpoints (`Login` and `Logout`) and
includes refactoring of the equivalent net/rpc endpoints to enable the
majority of logic to be reused (i.e. by extracting the `Binder` and
`TokenWriter` types).

This contains the OSS portions of the following enterprise commits:

- 75fcdbfcfa6af21d7128cb2544829ead0b1df603
- bce14b714151af74a7f0110843d640204082630a
- cc508b70fbf58eda144d9af3d71bd0f483985893
2022-05-04 17:38:45 +01:00

704 lines
21 KiB
Go

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,
})
}