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
This commit is contained in:
Tim Gross
2025-09-18 09:10:37 -04:00
committed by GitHub
parent a206ff3858
commit 3ef25e5867
3 changed files with 170 additions and 71 deletions

3
.changelog/26772.txt Normal file
View File

@@ -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
```

View File

@@ -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

View File

@@ -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)