diff --git a/.changelog/26772.txt b/.changelog/26772.txt new file mode 100644 index 000000000..e762498f5 --- /dev/null +++ b/.changelog/26772.txt @@ -0,0 +1,3 @@ +```release-note:bug +auth: Fixed a bug where workload identity tokens could not be used to list or get policies from the ACL API +``` diff --git a/nomad/acl_endpoint.go b/nomad/acl_endpoint.go index 5298acdc9..b3cd2b2db 100644 --- a/nomad/acl_endpoint.go +++ b/nomad/acl_endpoint.go @@ -20,6 +20,7 @@ import ( "github.com/hashicorp/go-memdb" metrics "github.com/hashicorp/go-metrics/compat" "github.com/hashicorp/go-set/v3" + "github.com/hashicorp/nomad/acl" policy "github.com/hashicorp/nomad/acl" "github.com/hashicorp/nomad/helper" "github.com/hashicorp/nomad/helper/uuid" @@ -191,35 +192,15 @@ func (a *ACL) ListPolicies(args *structs.ACLPolicyListRequest, reply *structs.AC } defer metrics.MeasureSince([]string{"nomad", "acl", "list_policies"}, time.Now()) - // Check management level permissions - acl, err := a.srv.ResolveACL(args) + aclObj, err := a.srv.ResolveACL(args) if err != nil { return err - } else if acl == nil { - return structs.ErrPermissionDenied } // If it is not a management token determine the policies that may be listed - mgt := acl.IsManagement() - tokenPolicyNames := set.New[string](0) - if !mgt { - token, err := a.requestACLToken(args.AuthToken) - if err != nil { - return err - } - if token == nil { - return structs.ErrTokenNotFound - } - - // Generate a set of policy names. This is initially generated from the - // ACL role links. - tokenPolicyNames, err = a.policyNamesFromRoleLinks(token.Roles) - if err != nil { - return err - } - - // Add the token policies which are directly referenced into the set. - tokenPolicyNames.InsertSlice(token.Policies) + allowedPolicyFn, err := a.getPolicyAuthorizationFunc(aclObj, *args.GetIdentity()) + if err != nil { + return err } // Setup the blocking query @@ -227,7 +208,6 @@ func (a *ACL) ListPolicies(args *structs.ACLPolicyListRequest, reply *structs.AC queryOpts: &args.QueryOptions, queryMeta: &reply.QueryMeta, run: func(ws memdb.WatchSet, state *state.StateStore) error { - // Iterate over all the policies var err error var iter memdb.ResultIterator if prefix := args.QueryOptions.Prefix; prefix != "" { @@ -238,7 +218,6 @@ func (a *ACL) ListPolicies(args *structs.ACLPolicyListRequest, reply *structs.AC if err != nil { return err } - // Convert all the policies to a list stub reply.Policies = nil for { @@ -247,7 +226,7 @@ func (a *ACL) ListPolicies(args *structs.ACLPolicyListRequest, reply *structs.AC break } realPolicy := raw.(*structs.ACLPolicy) - if mgt || tokenPolicyNames.Contains(realPolicy.Name) { + if allowedPolicyFn(realPolicy.Name) { reply.Policies = append(reply.Policies, realPolicy.Stub()) } } @@ -285,39 +264,20 @@ func (a *ACL) GetPolicy(args *structs.ACLPolicySpecificRequest, reply *structs.S } defer metrics.MeasureSince([]string{"nomad", "acl", "get_policy"}, time.Now()) - // Check management level permissions - acl, err := a.srv.ResolveACL(args) + aclObj, err := a.srv.ResolveACL(args) if err != nil { return err - } else if acl == nil { - return structs.ErrPermissionDenied } - // If the policy is the anonymous one, anyone can get it - // If it is not a management token determine if it can get this policy - mgt := acl.IsManagement() - if !mgt && args.Name != "anonymous" { - token, err := a.requestACLToken(args.AuthToken) - if err != nil { - return err - } - if token == nil { - return structs.ErrTokenNotFound - } - - // Generate a set of policy names. This is initially generated from the - // ACL role links. - tokenPolicyNames, err := a.policyNamesFromRoleLinks(token.Roles) - if err != nil { - return err - } - - // Add the token policies which are directly referenced into the set. - tokenPolicyNames.InsertSlice(token.Policies) - - if !tokenPolicyNames.Contains(args.Name) { - return structs.ErrPermissionDenied - } + // If the policy is the anonymous one, anyone can get it. Otherwise + // management tokens or tokens that have the policy can get it. If it is not + // a management token determine the policies that may be listed + allowedPolicyFn, err := a.getPolicyAuthorizationFunc(aclObj, *args.GetIdentity()) + if err != nil { + return err + } + if args.Name != "anonymous" && !allowedPolicyFn(args.Name) { + return structs.ErrPermissionDenied } // Setup the blocking query @@ -325,7 +285,6 @@ func (a *ACL) GetPolicy(args *structs.ACLPolicySpecificRequest, reply *structs.S queryOpts: &args.QueryOptions, queryMeta: &reply.QueryMeta, run: func(ws memdb.WatchSet, state *state.StateStore) error { - // Look for the policy out, err := state.ACLPolicyByName(ws, args.Name) if err != nil { return err @@ -382,23 +341,19 @@ func (a *ACL) GetPolicies(args *structs.ACLPolicySetRequest, reply *structs.ACLP } defer metrics.MeasureSince([]string{"nomad", "acl", "get_policies"}, time.Now()) - // For client typed tokens, allow them to query any policies associated with that token. - // This is used by clients which are resolving the policies to enforce. Any associated - // policies need to be fetched so that the client can determine what to allow. - token := args.GetIdentity().GetACLToken() - if token == nil { - return structs.ErrPermissionDenied - } - - // Generate a set of policy names. This is initially generated from the - // ACL role links. - tokenPolicyNames, err := a.policyNamesFromRoleLinks(token.Roles) + aclObj, err := a.srv.ResolveACL(args) if err != nil { return err } - // Add the token policies which are directly referenced into the set. - tokenPolicyNames.InsertSlice(token.Policies) + // For client-typed tokens, allow them to query any policies associated with + // that token. This is used by Nomad clients which are resolving the + // policies to enforce. Any associated policies need to be fetched so that + // the client can determine what to allow. + allowedPolicyFn, err := a.getPolicyAuthorizationFunc(aclObj, *args.GetIdentity()) + if err != nil { + return err + } // Setup the blocking query opts := blockingOptions{ @@ -423,7 +378,7 @@ func (a *ACL) GetPolicies(args *structs.ACLPolicySetRequest, reply *structs.ACLP continue } - if token.Type != structs.ACLManagementToken && !tokenPolicyNames.Contains(policyName) { + if !allowedPolicyFn(policyName) { return structs.ErrPermissionDenied } reply.Policies[policyName] = out @@ -1778,6 +1733,65 @@ func (a *ACL) GetRoleByName( }) } +// getPolicyAuthorizationFunc returns a function that can be used to test +// whether a given ACL object / identity has access to a given policy. For +// client-typed tokens, we allow them to query any policies associated with that +// token. This is used by Nomad clients which are resolving the policies to +// enforce. Any associated policies need to be fetched so that the client can +// determine what to allow. +func (a *ACL) getPolicyAuthorizationFunc( + aclObj *acl.ACL, + identity structs.AuthenticatedIdentity, +) (func(string) bool, error) { + if aclObj.IsManagement() { + return func(string) bool { return true }, nil + } + + tokenPolicyNames, err := a.getPoliciesForIdentity(identity) + if err != nil { + return nil, err + } + + return func(policy string) bool { + return tokenPolicyNames.Contains(policy) + }, nil +} + +// getPoliciesForIdentity resolves the policy names which are linked to the +// identity, whether that identity comes from a workload identity or ACL +// token. We'll always return an empty set (not nil), even on error. +func (a *ACL) getPoliciesForIdentity(identity structs.AuthenticatedIdentity) (*set.Set[string], error) { + tokenPolicyNames := set.New[string](0) + token := identity.GetACLToken() + if token == nil { + claims := identity.GetClaims() + if claims == nil { + return tokenPolicyNames, structs.ErrPermissionDenied + } + if claims.IsNode() || claims.IsNodeIntroduction() { + return tokenPolicyNames, nil + } + policies, err := a.srv.auth.ResolvePoliciesForClaims(claims) + if err != nil { + return tokenPolicyNames, err + } + for _, policy := range policies { + tokenPolicyNames.Insert(policy.Name) + } + } else { + // role-linked policies + var err error + tokenPolicyNames, err = a.policyNamesFromRoleLinks(token.Roles) + if err != nil { + return tokenPolicyNames, err + } + + // directly referenced policies + tokenPolicyNames.InsertSlice(token.Policies) + } + return tokenPolicyNames, nil +} + // policyNamesFromRoleLinks resolves the policy names which are linked via the // passed role links. This is useful when you need to understand what polices // an ACL token has access to and need to include role links. The function will diff --git a/nomad/acl_endpoint_test.go b/nomad/acl_endpoint_test.go index 7545a3ecd..5d582d32d 100644 --- a/nomad/acl_endpoint_test.go +++ b/nomad/acl_endpoint_test.go @@ -606,6 +606,88 @@ func TestACLEndpoint_ListPolicies_Unauthenticated(t *testing.T) { }) } +// TestACLEndpoint_GetListPolicies_WorkloadIdentity verifies that workload +// identities can List and Get any workload-associated policies +func TestACLEndpoint_GetListPolicies_WorkloadIdentity(t *testing.T) { + ci.Parallel(t) + + srv, _, cleanupSrv := TestACLServer(t, nil) + t.Cleanup(cleanupSrv) + codec := rpcClient(t, srv) + store := srv.fsm.State() + + testutil.WaitForKeyring(t, srv.RPC, srv.Region()) + + job := mock.MinJob() + must.NoError(t, store.UpsertJob(structs.MsgTypeTestSetup, 100, nil, job)) + + // setup one policy associated with the job and one not + jobPolicy := mock.ACLPolicy() + jobPolicy.JobACL = &structs.JobACL{Namespace: job.Namespace, JobID: job.ID} + jobPolicy.SetHash() + nonJobPolicy := mock.ACLPolicy() + must.NoError(t, store.UpsertACLPolicies(structs.MsgTypeTestSetup, 150, + []*structs.ACLPolicy{jobPolicy, nonJobPolicy})) + + // create an alloc with a signed identity + alloc := mock.MinAllocForJob(job) + store.UpsertAllocs(structs.MsgTypeTestSetup, 200, []*structs.Allocation{alloc}) + task := alloc.LookupTask("t") + claims := structs.NewIdentityClaimsBuilder(alloc.Job, alloc, + &structs.WIHandle{ + WorkloadIdentifier: "t", + WorkloadType: structs.WorkloadTypeTask, + }, + task.Identity). + WithTask(task). + Build(time.Now().Add(-10 * time.Minute)) + jwtToken, _, err := srv.encrypter.SignClaims(claims) + must.NoError(t, err) + + listReq := &structs.ACLPolicyListRequest{ + QueryOptions: structs.QueryOptions{ + Region: srv.Region(), + AuthToken: jwtToken, + }, + } + var listResp structs.ACLPolicyListResponse + must.NoError(t, msgpackrpc.CallWithCodec(codec, "ACL.ListPolicies", listReq, &listResp)) + must.Len(t, 1, listResp.Policies) + must.Eq(t, jobPolicy.Name, listResp.Policies[0].Name) + + getReq := &structs.ACLPolicySpecificRequest{ + Name: jobPolicy.Name, + QueryOptions: structs.QueryOptions{ + Region: srv.Region(), + AuthToken: jwtToken, + }, + } + var getResp structs.SingleACLPolicyResponse + must.NoError(t, msgpackrpc.CallWithCodec(codec, "ACL.GetPolicy", getReq, &getResp)) + must.NotNil(t, getResp.Policy) + + // can't get other policies + getReq.Name = nonJobPolicy.Name + must.EqError(t, msgpackrpc.CallWithCodec(codec, "ACL.GetPolicy", getReq, &getResp), + structs.ErrPermissionDenied.Error()) + + getSetReq := &structs.ACLPolicySetRequest{ + Names: []string{jobPolicy.Name}, + QueryOptions: structs.QueryOptions{ + Region: srv.Region(), + AuthToken: jwtToken, + }, + } + var getSetResp structs.ACLPolicySetResponse + must.NoError(t, msgpackrpc.CallWithCodec(codec, "ACL.GetPolicies", getSetReq, &getSetResp)) + must.MapLen(t, 1, getSetResp.Policies) + + // can't get other policies, even if some of the set is ok + getSetReq.Names = append(getSetReq.Names, nonJobPolicy.Name) + must.EqError(t, msgpackrpc.CallWithCodec(codec, "ACL.GetPolicies", getSetReq, &getSetResp), + structs.ErrPermissionDenied.Error()) +} + func TestACLEndpoint_ListPolicies_Blocking(t *testing.T) { ci.Parallel(t)