From 3ef25e5867baba83aefa2f91d6470c89ea6c7eb7 Mon Sep 17 00:00:00 2001 From: Tim Gross Date: Thu, 18 Sep 2025 09:10:37 -0400 Subject: [PATCH] ACL: allow workload identities to list/get their own policies (#26772) In most RPC endpoints we use the resolved ACL object to determine whether a given auth token or identity has access to the object of interest to the RPC. In #15870 we adjusted this across most of the RPCs to handle workload identity. But in the ACL endpoints that read policies, we can't use the resolved ACL object and have to go back to the original token and lookup the policies it has access to. So we need to resolve any workload-associated policies during that lookup as well. Fixes: https://github.com/hashicorp/nomad/issues/26764 Ref: https://hashicorp.atlassian.net/browse/NMD-990 Ref: https://github.com/hashicorp/nomad/pull/15870 --- .changelog/26772.txt | 3 + nomad/acl_endpoint.go | 156 ++++++++++++++++++++----------------- nomad/acl_endpoint_test.go | 82 +++++++++++++++++++ 3 files changed, 170 insertions(+), 71 deletions(-) create mode 100644 .changelog/26772.txt 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)