From 6a26bdae60f124d5c291f911652a9fa0b54c3a23 Mon Sep 17 00:00:00 2001 From: James Rasell Date: Wed, 17 Aug 2022 14:45:01 +0100 Subject: [PATCH] core: add ACL role functionality to ACL tokens. ACL tokens can now utilize ACL roles in order to provide API authorization. Each ACL token can be created and linked to an array of policies as well as an array of ACL role links. The link can be provided via the role name or ID, but internally, is always resolved to the ID as this is immutable whereas the name can be changed by operators. When resolving an ACL token, the policies linked from an ACL role are unpacked and combined with the policy array to form the complete auth set for the token. The ACL token creation endpoint handles deduplicating ACL role links as well as ensuring they exist within state. When reading a token, Nomad will also ensure the ACL role link is current. This handles ACL roles being deleted from under a token from a UX standpoint. --- nomad/acl.go | 53 +++++++-- nomad/acl_endpoint.go | 48 +++++++++ nomad/acl_endpoint_test.go | 123 +++++++++++++++++++++ nomad/acl_test.go | 160 +++++++++++++++++++++++++--- nomad/state/state_store.go | 16 ++- nomad/state/state_store_acl.go | 67 ++++++++++++ nomad/state/state_store_acl_test.go | 132 +++++++++++++++++++++++ nomad/structs/acl.go | 26 ++++- nomad/structs/acl_test.go | 16 ++- nomad/structs/structs.go | 20 +++- 10 files changed, 629 insertions(+), 32 deletions(-) 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,