diff --git a/nomad/acl.go b/nomad/acl.go index 76fe29096..dcdc07991 100644 --- a/nomad/acl.go +++ b/nomad/acl.go @@ -82,9 +82,9 @@ func (s *Server) ResolveClaims(claims *structs.IdentityClaims) (*acl.ACL, error) return aclObj, nil } -// resolveTokenFromSnapshotCache is used to resolve an ACL object from a snapshot of state, -// using a cache to avoid parsing and ACL construction when possible. It is split from resolveToken -// to simplify testing. +// resolveTokenFromSnapshotCache is used to resolve an ACL object from a +// snapshot of state, using a cache to avoid parsing and ACL construction when +// possible. It is split from resolveToken to simplify testing. func resolveTokenFromSnapshotCache(snap *state.StateSnapshot, cache *lru.TwoQueueCache, secretID string) (*acl.ACL, error) { // Lookup the ACL Token var token *structs.ACLToken @@ -111,22 +111,61 @@ func resolveTokenFromSnapshotCache(snap *state.StateSnapshot, cache *lru.TwoQueu return acl.ManagementACL, nil } - // Get all associated policies - policies := make([]*structs.ACLPolicy, 0, len(token.Policies)) + // Store all policies detailed in the token request, this includes the + // named policies and those referenced within the role link. + policies := make([]*structs.ACLPolicy, 0, len(token.Policies)+len(token.Roles)) + + // Iterate all the token policies and add these to our policy tracking + // array. for _, policyName := range token.Policies { policy, err := snap.ACLPolicyByName(nil, policyName) if err != nil { return nil, err } if policy == nil { - // Ignore policies that don't exist, since they don't grant any more privilege + // Ignore policies that don't exist, since they don't grant any + // more privilege. continue } - // Save the policy and update the cache key + // Add the policy to the tracking array. policies = append(policies, policy) } + // Iterate all the token role links, so we can unpack these and identify + // the ACL policies. + for _, roleLink := range token.Roles { + + // Any error reading the role means we cannot move forward. We just + // ignore any roles that have been detailed but are not within our + // state. + role, err := snap.GetACLRoleByID(nil, roleLink.ID) + if err != nil { + return nil, err + } + if role == nil { + continue + } + + // Unpack the policies held within the ACL role to form a single list + // of ACL policies that this token has available. + for _, policyLink := range role.Policies { + policy, err := snap.ACLPolicyByName(nil, policyLink.Name) + if err != nil { + return nil, err + } + + // Ignore policies that don't exist, since they don't grant any + // more privilege. + if policy == nil { + continue + } + + // Add the policy to the tracking array. + policies = append(policies, policy) + } + } + // Compile and cache the ACL object aclObj, err := structs.CompileACLObject(cache, policies) if err != nil { diff --git a/nomad/acl_endpoint.go b/nomad/acl_endpoint.go index 70b386f49..d4161dd30 100644 --- a/nomad/acl_endpoint.go +++ b/nomad/acl_endpoint.go @@ -543,6 +543,54 @@ func (a *ACL) UpsertTokens(args *structs.ACLTokenUpsertRequest, reply *structs.A return structs.NewErrRPCCodedf(http.StatusBadRequest, "token %d invalid: %v", idx, err) } + var normalizedRoleLinks []*structs.ACLTokenRoleLink + uniqueRoleIDs := make(map[string]struct{}) + + // Iterate, check, and normalize the ACL role links that the token has. + for _, roleLink := range token.Roles { + + var ( + existing *structs.ACLRole + roleIdentifier string + lookupErr error + ) + + // In the event the caller specified the role name, we need to + // identify the immutable ID. In either case, we need to ensure the + // role exists. + switch roleLink.ID { + case "": + roleIdentifier = roleLink.Name + existing, lookupErr = stateSnapshot.GetACLRoleByName(nil, roleIdentifier) + default: + roleIdentifier = roleLink.ID + existing, lookupErr = stateSnapshot.GetACLRoleByID(nil, roleIdentifier) + } + + // Handle any state lookup error or inability to locate the role + // within state. + if lookupErr != nil { + return structs.NewErrRPCCodedf(http.StatusInternalServerError, "role lookup failed: %v", lookupErr) + } + if existing == nil { + return structs.NewErrRPCCodedf(http.StatusBadRequest, "cannot find role %s", roleIdentifier) + } + + // Ensure the role ID is written to the object and that the name is + // emptied as it is possible the role name is updated in the future. + roleLink.ID = existing.ID + roleLink.Name = "" + + // Deduplicate role links by their ID. + if _, ok := uniqueRoleIDs[roleLink.ID]; !ok { + normalizedRoleLinks = append(normalizedRoleLinks, roleLink) + uniqueRoleIDs[roleLink.ID] = struct{}{} + } + } + + // Write the normalized array of ACL role links back to the token. + token.Roles = normalizedRoleLinks + // Compute the token hash token.SetHash() } diff --git a/nomad/acl_endpoint_test.go b/nomad/acl_endpoint_test.go index a3ff68460..dfff2caff 100644 --- a/nomad/acl_endpoint_test.go +++ b/nomad/acl_endpoint_test.go @@ -1618,6 +1618,129 @@ func TestACLEndpoint_UpsertTokens(t *testing.T) { require.Empty(t, tokenResp.Tokens) }, }, + { + name: "token with role links", + testFn: func(testServer *Server, aclToken *structs.ACLToken) { + + // Attempt to create a token with a link to a role that does + // not exist in state. + tokenReq1 := &structs.ACLTokenUpsertRequest{ + Tokens: []*structs.ACLToken{ + { + Name: "my-lovely-token-" + uuid.Generate(), + Type: structs.ACLClientToken, + Roles: []*structs.ACLTokenRoleLink{{Name: "cant-find-me"}}, + }, + }, + WriteRequest: structs.WriteRequest{ + Region: DefaultRegion, + AuthToken: aclToken.SecretID, + }, + } + + // Send the RPC request and ensure the expiration time is as + // expected. + var tokenResp1 structs.ACLTokenUpsertResponse + err := msgpackrpc.CallWithCodec(codec, structs.ACLUpsertTokensRPCMethod, tokenReq1, &tokenResp1) + require.ErrorContains(t, err, "cannot find role cant-find-me") + require.Empty(t, tokenResp1.Tokens) + + // Create an ACL policy that will be linked from an ACL role + // and enter this into state. + policy1 := mock.ACLPolicy() + + require.NoError(t, testServer.fsm.State().UpsertACLPolicies( + structs.MsgTypeTestSetup, 10, []*structs.ACLPolicy{policy1})) + + // Create an ACL role that links to the above policy. + aclRole1 := mock.ACLRole() + aclRole1.Policies = []*structs.ACLRolePolicyLink{{Name: policy1.Name}} + + require.NoError(t, testServer.fsm.State().UpsertACLRoles( + structs.MsgTypeTestSetup, 20, []*structs.ACLRole{aclRole1})) + + // Create a token which references the created ACL role. This + // role reference is duplicated to ensure the handler + // de-duplicates this before putting it into state. + // not exist in state. + tokenReq2 := &structs.ACLTokenUpsertRequest{ + Tokens: []*structs.ACLToken{ + { + Name: "my-lovely-token-" + uuid.Generate(), + Type: structs.ACLClientToken, + Roles: []*structs.ACLTokenRoleLink{ + {ID: aclRole1.ID}, + {ID: aclRole1.ID}, + {ID: aclRole1.ID}, + }, + }, + }, + WriteRequest: structs.WriteRequest{ + Region: DefaultRegion, + AuthToken: aclToken.SecretID, + }, + } + + // Send the RPC request and ensure the returned token is as + // expected. + var tokenResp2 structs.ACLTokenUpsertResponse + err = msgpackrpc.CallWithCodec(codec, structs.ACLUpsertTokensRPCMethod, tokenReq2, &tokenResp2) + require.NoError(t, err) + require.Len(t, tokenResp2.Tokens, 1) + require.Len(t, tokenResp2.Tokens[0].Policies, 0) + require.Len(t, tokenResp2.Tokens[0].Roles, 1) + require.Equal(t, []*structs.ACLTokenRoleLink{{ + ID: aclRole1.ID, Name: aclRole1.Name}}, tokenResp2.Tokens[0].Roles) + }, + }, + { + name: "token with role and policy links", + testFn: func(testServer *Server, aclToken *structs.ACLToken) { + + // Create two ACL policies that will be used for ACL role and + // policy linking. + policy1 := mock.ACLPolicy() + policy2 := mock.ACLPolicy() + + require.NoError(t, testServer.fsm.State().UpsertACLPolicies( + structs.MsgTypeTestSetup, 10, []*structs.ACLPolicy{policy1, policy2})) + + // Create an ACL role that links to one of the above policies. + aclRole1 := mock.ACLRole() + aclRole1.Policies = []*structs.ACLRolePolicyLink{{Name: policy1.Name}} + + require.NoError(t, testServer.fsm.State().UpsertACLRoles( + structs.MsgTypeTestSetup, 20, []*structs.ACLRole{aclRole1})) + + // Create an ACL token with both ACL role and policy links. + tokenReq1 := &structs.ACLTokenUpsertRequest{ + Tokens: []*structs.ACLToken{ + { + Name: "my-lovely-token-" + uuid.Generate(), + Type: structs.ACLClientToken, + Policies: []string{policy2.Name}, + Roles: []*structs.ACLTokenRoleLink{{ID: aclRole1.ID}}, + }, + }, + WriteRequest: structs.WriteRequest{ + Region: DefaultRegion, + AuthToken: aclToken.SecretID, + }, + } + + // Send the RPC request and ensure the returned token has + // policy and ACL role links as expected. + var tokenResp1 structs.ACLTokenUpsertResponse + err := msgpackrpc.CallWithCodec(codec, structs.ACLUpsertTokensRPCMethod, tokenReq1, &tokenResp1) + require.NoError(t, err) + require.Len(t, tokenResp1.Tokens, 1) + require.Len(t, tokenResp1.Tokens[0].Policies, 1) + require.Len(t, tokenResp1.Tokens[0].Roles, 1) + require.Equal(t, policy2.Name, tokenResp1.Tokens[0].Policies[0]) + require.Equal(t, []*structs.ACLTokenRoleLink{{ + ID: aclRole1.ID, Name: aclRole1.Name}}, tokenResp1.Tokens[0].Roles) + }, + }, } for _, tc := range testCases { diff --git a/nomad/acl_test.go b/nomad/acl_test.go index 14104a1df..acd5455db 100644 --- a/nomad/acl_test.go +++ b/nomad/acl_test.go @@ -17,17 +17,17 @@ import ( func TestResolveACLToken(t *testing.T) { ci.Parallel(t) - testServer, _, testServerCleanup := TestACLServer(t, nil) - defer testServerCleanup() - testutil.WaitForLeader(t, testServer.RPC) - testCases := []struct { name string - testFn func(testServer *Server) + testFn func() }{ { name: "leader token", - testFn: func(testServer *Server) { + testFn: func() { + + testServer, _, testServerCleanup := TestACLServer(t, nil) + defer testServerCleanup() + testutil.WaitForLeader(t, testServer.RPC) // Check the leader ACL token is correctly set. leaderACL := testServer.getLeaderAcl() @@ -42,7 +42,11 @@ func TestResolveACLToken(t *testing.T) { }, { name: "anonymous token", - testFn: func(testServer *Server) { + testFn: func() { + + testServer, _, testServerCleanup := TestACLServer(t, nil) + defer testServerCleanup() + testutil.WaitForLeader(t, testServer.RPC) // Call the function with an empty input secret ID which is // classed as representing anonymous access in clusters with @@ -55,7 +59,11 @@ func TestResolveACLToken(t *testing.T) { }, { name: "token not found", - testFn: func(testServer *Server) { + testFn: func() { + + testServer, _, testServerCleanup := TestACLServer(t, nil) + defer testServerCleanup() + testutil.WaitForLeader(t, testServer.RPC) // Call the function with randomly generated secret ID which // does not exist within state. @@ -66,7 +74,11 @@ func TestResolveACLToken(t *testing.T) { }, { name: "token expired", - testFn: func(testServer *Server) { + testFn: func() { + + testServer, _, testServerCleanup := TestACLServer(t, nil) + defer testServerCleanup() + testutil.WaitForLeader(t, testServer.RPC) // Create a mock token with an expiration time long in the // past, and upsert. @@ -87,7 +99,11 @@ func TestResolveACLToken(t *testing.T) { }, { name: "management token", - testFn: func(testServer *Server) { + testFn: func() { + + testServer, _, testServerCleanup := TestACLServer(t, nil) + defer testServerCleanup() + testutil.WaitForLeader(t, testServer.RPC) // Generate a management token and upsert this. managementToken := mock.ACLToken() @@ -108,8 +124,12 @@ func TestResolveACLToken(t *testing.T) { }, }, { - name: "client token", - testFn: func(testServer *Server) { + name: "client token with policies only", + testFn: func() { + + testServer, _, testServerCleanup := TestACLServer(t, nil) + defer testServerCleanup() + testutil.WaitForLeader(t, testServer.RPC) // Generate a client token with associated policies and upsert // these. @@ -155,11 +175,125 @@ func TestResolveACLToken(t *testing.T) { require.NotEqual(t, aclResp2, aclResp3) }, }, + { + name: "client token with roles only", + testFn: func() { + + testServer, _, testServerCleanup := TestACLServer(t, nil) + defer testServerCleanup() + testutil.WaitForLeader(t, testServer.RPC) + + // Create a client token that only has a link to a role. + policy1 := mock.ACLPolicy() + policy2 := mock.ACLPolicy() + err := testServer.State().UpsertACLPolicies( + structs.MsgTypeTestSetup, 10, []*structs.ACLPolicy{policy1, policy2}) + + aclRole := mock.ACLRole() + aclRole.Policies = []*structs.ACLRolePolicyLink{ + {Name: policy1.Name}, + {Name: policy2.Name}, + } + err = testServer.State().UpsertACLRoles( + structs.MsgTypeTestSetup, 30, []*structs.ACLRole{aclRole}) + require.NoError(t, err) + + clientToken := mock.ACLToken() + clientToken.Policies = []string{} + clientToken.Roles = []*structs.ACLTokenRoleLink{{ID: aclRole.ID}} + err = testServer.State().UpsertACLTokens( + structs.MsgTypeTestSetup, 30, []*structs.ACLToken{clientToken}) + require.NoError(t, err) + + // Resolve the token and check that we received a client + // ACL with appropriate permissions. + aclResp, err := testServer.ResolveToken(clientToken.SecretID) + require.Nil(t, err) + require.NotNil(t, aclResp) + require.False(t, aclResp.IsManagement()) + + allowed := aclResp.AllowNamespaceOperation("default", acl.NamespaceCapabilityListJobs) + require.True(t, allowed) + allowed = aclResp.AllowNamespaceOperation("other", acl.NamespaceCapabilityListJobs) + require.False(t, allowed) + + // Remove the policies from the ACL role and ensure the resolution + // permissions are updated. + aclRole.Policies = []*structs.ACLRolePolicyLink{} + err = testServer.State().UpsertACLRoles( + structs.MsgTypeTestSetup, 40, []*structs.ACLRole{aclRole}) + require.NoError(t, err) + + aclResp, err = testServer.ResolveToken(clientToken.SecretID) + require.Nil(t, err) + require.NotNil(t, aclResp) + require.False(t, aclResp.IsManagement()) + require.False(t, aclResp.AllowNamespaceOperation("default", acl.NamespaceCapabilityListJobs)) + }, + }, + { + name: "client with roles and policies", + testFn: func() { + + testServer, _, testServerCleanup := TestACLServer(t, nil) + defer testServerCleanup() + testutil.WaitForLeader(t, testServer.RPC) + + // Generate two policies, each with a different namespace + // permission set. + policy1 := &structs.ACLPolicy{ + Name: "policy-" + uuid.Generate(), + Rules: `namespace "platform" { policy = "write"}`, + CreateIndex: 10, + ModifyIndex: 10, + } + policy1.SetHash() + policy2 := &structs.ACLPolicy{ + Name: "policy-" + uuid.Generate(), + Rules: `namespace "web" { policy = "write"}`, + CreateIndex: 10, + ModifyIndex: 10, + } + policy2.SetHash() + + err := testServer.State().UpsertACLPolicies( + structs.MsgTypeTestSetup, 10, []*structs.ACLPolicy{policy1, policy2}) + require.NoError(t, err) + + // Create a role which references the policy that has access to + // the web namespace. + aclRole := mock.ACLRole() + aclRole.Policies = []*structs.ACLRolePolicyLink{{Name: policy2.Name}} + err = testServer.State().UpsertACLRoles( + structs.MsgTypeTestSetup, 20, []*structs.ACLRole{aclRole}) + require.NoError(t, err) + + // Create a token which references the policy and role. + clientToken := mock.ACLToken() + clientToken.Policies = []string{policy1.Name} + clientToken.Roles = []*structs.ACLTokenRoleLink{{ID: aclRole.ID}} + err = testServer.State().UpsertACLTokens( + structs.MsgTypeTestSetup, 30, []*structs.ACLToken{clientToken}) + require.NoError(t, err) + + // Resolve the token and check that we received a client + // ACL with appropriate permissions. + aclResp, err := testServer.ResolveToken(clientToken.SecretID) + require.Nil(t, err) + require.NotNil(t, aclResp) + require.False(t, aclResp.IsManagement()) + + allowed := aclResp.AllowNamespaceOperation("platform", acl.NamespaceCapabilityListJobs) + require.True(t, allowed) + allowed = aclResp.AllowNamespaceOperation("web", acl.NamespaceCapabilityListJobs) + require.True(t, allowed) + }, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - tc.testFn(testServer) + tc.testFn() }) } } diff --git a/nomad/state/state_store.go b/nomad/state/state_store.go index 737dd6b22..84d59370b 100644 --- a/nomad/state/state_store.go +++ b/nomad/state/state_store.go @@ -5660,10 +5660,20 @@ func (s *StateStore) ACLTokenByAccessorID(ws memdb.WatchSet, id string) (*struct } ws.Add(watchCh) - if existing != nil { - return existing.(*structs.ACLToken), nil + // If the existing token is nil, this indicates it does not exist in state. + if existing == nil { + return nil, nil } - return nil, nil + + // Assert the token type which allows us to perform additional work on the + // token that is needed before returning the call. + token := existing.(*structs.ACLToken) + + // Handle potential staleness of ACL role links. + if token, err = s.fixTokenRoleLinks(txn, token); err != nil { + return nil, err + } + return token, nil } // ACLTokenBySecretID is used to lookup a token by secret ID diff --git a/nomad/state/state_store_acl.go b/nomad/state/state_store_acl.go index 60df4e506..1f9f273ca 100644 --- a/nomad/state/state_store_acl.go +++ b/nomad/state/state_store_acl.go @@ -6,6 +6,7 @@ import ( "github.com/hashicorp/go-memdb" "github.com/hashicorp/nomad/nomad/structs" + "golang.org/x/exp/slices" ) // ACLTokensByExpired returns an array accessor IDs of expired ACL tokens. @@ -189,6 +190,14 @@ func (s *StateStore) GetACLRoles(ws memdb.WatchSet) (memdb.ResultIterator, error // of the caller to check for this. func (s *StateStore) GetACLRoleByID(ws memdb.WatchSet, roleID string) (*structs.ACLRole, error) { txn := s.db.ReadTxn() + return s.getACLRoleByIDTxn(txn, ws, roleID) +} + +// getACLRoleByIDTxn allows callers to pass a read transaction in order to read +// a single ACL role specified by the input ID. The role object will be nil, if +// no matching entry was found; it is the responsibility of the caller to check +// for this. +func (s *StateStore) getACLRoleByIDTxn(txn ReadTxn, ws memdb.WatchSet, roleID string) (*structs.ACLRole, error) { // Perform the ACL role lookup using the "id" index. watchCh, existing, err := txn.FirstWatch(TableACLRoles, indexID, roleID) @@ -235,3 +244,61 @@ func (s *StateStore) GetACLRoleByIDPrefix(ws memdb.WatchSet, idPrefix string) (m return iter, nil } + +// fixTokenRoleLinks is a state helper that ensures the returned ACL token has +// an accurate representation of ACL role links. The role links could have +// become stale when a linked role was deleted or renamed. This will correct +// them and generates a newly allocated token only when fixes are needed. If +// the role links are still accurate, we just return the original token. +func (s *StateStore) fixTokenRoleLinks(txn ReadTxn, original *structs.ACLToken) (*structs.ACLToken, error) { + + // Track whether we have made an initial copy to ensure we are not + // operating on the token directly from state. + copied := false + + token := original + + // copyTokenFn is a helper function which copies the ACL token along with + // a certain number of ACL role links. + copyTokenFn := func(t *structs.ACLToken, numLinks int) *structs.ACLToken { + clone := t.Copy() + clone.Roles = slices.Clone(t.Roles[:numLinks]) + return clone + } + + for linkIndex, link := range original.Roles { + + // This should never happen, but guard against it anyway, so we log an + // error rather than panic. + if link.ID == "" { + return nil, errors.New("detected corrupted token within the state store: missing role link ID") + } + + role, err := s.getACLRoleByIDTxn(txn, nil, link.ID) + if err != nil { + return nil, err + } + + if role == nil { + if !copied { + // clone the token as we cannot touch the original + token = copyTokenFn(original, linkIndex) + copied = true + } + // if already owned then we just don't append it. + } else if role.Name != link.Name { + if !copied { + token = copyTokenFn(original, linkIndex) + copied = true + } + + // append the corrected policy + token.Roles = append(token.Roles, &structs.ACLTokenRoleLink{ID: link.ID, Name: role.Name}) + + } else if copied { + token.Roles = append(token.Roles, link) + } + } + + return token, nil +} diff --git a/nomad/state/state_store_acl_test.go b/nomad/state/state_store_acl_test.go index 6cc6a0ae4..16e375579 100644 --- a/nomad/state/state_store_acl_test.go +++ b/nomad/state/state_store_acl_test.go @@ -488,3 +488,135 @@ func TestStateStore_GetACLRoleByIDPrefix(t *testing.T) { } require.Len(t, aclRoles, 2) } + +func TestStateStore_fixTokenRoleLinks(t *testing.T) { + ci.Parallel(t) + + testCases := []struct { + name string + testFn func() + }{ + { + name: "no fix needed", + testFn: func() { + testState := testStateStore(t) + + // Create the policies our ACL roles wants to link to. + policy1 := mock.ACLPolicy() + policy1.Name = "mocked-test-policy-1" + policy2 := mock.ACLPolicy() + policy2.Name = "mocked-test-policy-2" + + require.NoError(t, testState.UpsertACLPolicies( + structs.MsgTypeTestSetup, 10, []*structs.ACLPolicy{policy1, policy2})) + + // Generate a some mocked ACL roles for testing and upsert these straight + // into state. + mockedACLRoles := []*structs.ACLRole{mock.ACLRole(), mock.ACLRole()} + require.NoError(t, testState.UpsertACLRoles(structs.MsgTypeTestSetup, 20, mockedACLRoles)) + + // Create an ACL token linking to the ACL role. + token1 := mock.ACLToken() + token1.Roles = []*structs.ACLTokenRoleLink{{ID: mockedACLRoles[0].ID}} + require.NoError(t, testState.UpsertACLTokens( + structs.MsgTypeTestSetup, 20, []*structs.ACLToken{token1})) + + // Perform the fix and check the returned token contains the + // correct roles. + readTxn := testState.db.ReadTxn() + outputToken, err := testState.fixTokenRoleLinks(readTxn, token1) + require.NoError(t, err) + require.Equal(t, outputToken.Roles, []*structs.ACLTokenRoleLink{{ + Name: mockedACLRoles[0].Name, ID: mockedACLRoles[0].ID, + }}) + }, + }, + { + name: "acl role from link deleted", + testFn: func() { + testState := testStateStore(t) + + // Create the policies our ACL roles wants to link to. + policy1 := mock.ACLPolicy() + policy1.Name = "mocked-test-policy-1" + policy2 := mock.ACLPolicy() + policy2.Name = "mocked-test-policy-2" + + require.NoError(t, testState.UpsertACLPolicies( + structs.MsgTypeTestSetup, 10, []*structs.ACLPolicy{policy1, policy2})) + + // Generate a some mocked ACL roles for testing and upsert these straight + // into state. + mockedACLRoles := []*structs.ACLRole{mock.ACLRole(), mock.ACLRole()} + require.NoError(t, testState.UpsertACLRoles(structs.MsgTypeTestSetup, 20, mockedACLRoles)) + + // Create an ACL token linking to the ACL roles. + token1 := mock.ACLToken() + token1.Roles = []*structs.ACLTokenRoleLink{{ID: mockedACLRoles[0].ID}, {ID: mockedACLRoles[1].ID}} + require.NoError(t, testState.UpsertACLTokens( + structs.MsgTypeTestSetup, 30, []*structs.ACLToken{token1})) + + // Now delete one of the ACL roles from state. + require.NoError(t, testState.DeleteACLRolesByID( + structs.MsgTypeTestSetup, 40, []string{mockedACLRoles[0].ID})) + + // Perform the fix and check the returned token contains the + // correct roles. + readTxn := testState.db.ReadTxn() + outputToken, err := testState.fixTokenRoleLinks(readTxn, token1) + require.NoError(t, err) + require.Len(t, outputToken.Roles, 1) + require.Equal(t, outputToken.Roles, []*structs.ACLTokenRoleLink{{ + Name: mockedACLRoles[1].Name, ID: mockedACLRoles[1].ID, + }}) + }, + }, + { + name: "acl role from link name changed", + testFn: func() { + testState := testStateStore(t) + + // Create the policies our ACL roles wants to link to. + policy1 := mock.ACLPolicy() + policy1.Name = "mocked-test-policy-1" + policy2 := mock.ACLPolicy() + policy2.Name = "mocked-test-policy-2" + + require.NoError(t, testState.UpsertACLPolicies( + structs.MsgTypeTestSetup, 10, []*structs.ACLPolicy{policy1, policy2})) + + // Generate a some mocked ACL roles for testing and upsert these straight + // into state. + mockedACLRoles := []*structs.ACLRole{mock.ACLRole(), mock.ACLRole()} + require.NoError(t, testState.UpsertACLRoles(structs.MsgTypeTestSetup, 20, mockedACLRoles)) + + // Create an ACL token linking to the ACL roles. + token1 := mock.ACLToken() + token1.Roles = []*structs.ACLTokenRoleLink{{ID: mockedACLRoles[0].ID}, {ID: mockedACLRoles[1].ID}} + require.NoError(t, testState.UpsertACLTokens( + structs.MsgTypeTestSetup, 30, []*structs.ACLToken{token1})) + + // Now change the name of one of the ACL roles. + mockedACLRoles[0].Name = "badger-badger-badger" + require.NoError(t, testState.UpsertACLRoles(structs.MsgTypeTestSetup, 40, mockedACLRoles)) + + // Perform the fix and check the returned token contains the + // correct roles. + readTxn := testState.db.ReadTxn() + outputToken, err := testState.fixTokenRoleLinks(readTxn, token1) + require.NoError(t, err) + require.Len(t, outputToken.Roles, 2) + require.ElementsMatch(t, outputToken.Roles, []*structs.ACLTokenRoleLink{ + {Name: mockedACLRoles[0].Name, ID: mockedACLRoles[0].ID}, + {Name: mockedACLRoles[1].Name, ID: mockedACLRoles[1].ID}, + }) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tc.testFn() + }) + } +} diff --git a/nomad/structs/acl.go b/nomad/structs/acl.go index e40a89d42..8ab6862b1 100644 --- a/nomad/structs/acl.go +++ b/nomad/structs/acl.go @@ -88,6 +88,22 @@ var ( validACLRoleName = regexp.MustCompile("^[a-zA-Z0-9-]{1,128}$") ) +// ACLTokenRoleLink is used to link an ACL token to an ACL role. The ACL token +// can therefore inherit all the ACL policy permissions that the ACL role +// contains. +type ACLTokenRoleLink struct { + + // ID is the ACLRole.ID UUID. This field is immutable and represents the + // absolute truth for the link. + ID string + + // Name is the human friendly identifier for the ACL role and is a + // convenience field for operators. This field is always resolved to the + // ID and discarded before the token is stored in state. This is because + // operators can change the name of an ACL role. + Name string +} + // Canonicalize performs basic canonicalization on the ACL token object. It is // important for callers to understand certain fields such as AccessorID are // set if it is empty, so copies should be taken if needed before calling this @@ -120,16 +136,16 @@ func (a *ACLToken) Validate(minTTL, maxTTL time.Duration, existing *ACLToken) er } // The type of an ACL token must be set. An ACL token of type client must - // have associated policies, whereas a management token cannot be + // have associated policies or roles, whereas a management token cannot be // associated with policies. switch a.Type { case ACLClientToken: - if len(a.Policies) == 0 { - mErr.Errors = append(mErr.Errors, errors.New("client token missing policies")) + if len(a.Policies) == 0 && len(a.Roles) == 0 { + mErr.Errors = append(mErr.Errors, errors.New("client token missing policies or roles")) } case ACLManagementToken: - if len(a.Policies) != 0 { - mErr.Errors = append(mErr.Errors, errors.New("management token cannot be associated with policies")) + if len(a.Policies) != 0 || len(a.Roles) != 0 { + mErr.Errors = append(mErr.Errors, errors.New("management token cannot be associated with policies or roles")) } default: mErr.Errors = append(mErr.Errors, errors.New("token type must be client or management")) diff --git a/nomad/structs/acl_test.go b/nomad/structs/acl_test.go index 22e263e5e..1a9b426c4 100644 --- a/nomad/structs/acl_test.go +++ b/nomad/structs/acl_test.go @@ -25,6 +25,7 @@ func TestACLToken_Canonicalize(t *testing.T) { Name: "my cool token " + uuid.Generate(), Type: "client", Policies: []string{"foo", "bar"}, + Roles: []*ACLTokenRoleLink{}, Global: false, CreateTime: time.Now().UTC(), CreateIndex: 10, @@ -96,12 +97,12 @@ func TestACLTokenValidate(t *testing.T) { expectedErrorContains: "client or management", }, { - name: "missing policies", + name: "missing policies or roles", inputACLToken: &ACLToken{ Type: ACLClientToken, }, inputExistingACLToken: nil, - expectedErrorContains: "missing policies", + expectedErrorContains: "missing policies or roles", }, { name: "invalid policies", @@ -110,7 +111,16 @@ func TestACLTokenValidate(t *testing.T) { Policies: []string{"foo"}, }, inputExistingACLToken: nil, - expectedErrorContains: "associated with policies", + expectedErrorContains: "associated with policies or roles", + }, + { + name: "invalid roles", + inputACLToken: &ACLToken{ + Type: ACLManagementToken, + Roles: []*ACLTokenRoleLink{{Name: "foo"}}, + }, + inputExistingACLToken: nil, + expectedErrorContains: "associated with policies or roles", }, { name: "name too long", diff --git a/nomad/structs/structs.go b/nomad/structs/structs.go index f163a25d9..24f91ab7b 100644 --- a/nomad/structs/structs.go +++ b/nomad/structs/structs.go @@ -11908,7 +11908,12 @@ type ACLToken struct { Name string // Human friendly name Type string // Client or Management Policies []string // Policies this token ties to - Global bool // Global or Region local + + // Roles represents the ACL roles that this token is tied to. The token + // will inherit the permissions of all policies detailed within the role. + Roles []*ACLTokenRoleLink + + Global bool // Global or Region local Hash []byte CreateTime time.Time // Time of creation @@ -11950,9 +11955,13 @@ func (a *ACLToken) Copy() *ACLToken { c.Policies = make([]string, len(a.Policies)) copy(c.Policies, a.Policies) + c.Hash = make([]byte, len(a.Hash)) copy(c.Hash, a.Hash) + c.Roles = make([]*ACLTokenRoleLink, len(a.Roles)) + copy(c.Roles, a.Roles) + return c } @@ -11973,6 +11982,7 @@ type ACLTokenListStub struct { Name string Type string Policies []string + Roles []*ACLTokenRoleLink Global bool Hash []byte CreateTime time.Time @@ -12003,6 +12013,13 @@ func (a *ACLToken) SetHash() []byte { _, _ = hash.Write([]byte("local")) } + // Iterate the ACL role links and hash the ID. The ID is immutable and the + // canonical way to reference a role. The name can be modified by + // operators, but won't impact the ACL token resolution. + for _, roleLink := range a.Roles { + _, _ = hash.Write([]byte(roleLink.ID)) + } + // Finalize the hash hashVal := hash.Sum(nil) @@ -12017,6 +12034,7 @@ func (a *ACLToken) Stub() *ACLTokenListStub { Name: a.Name, Type: a.Type, Policies: a.Policies, + Roles: a.Roles, Global: a.Global, Hash: a.Hash, CreateTime: a.CreateTime,