mirror of
https://github.com/kemko/nomad.git
synced 2026-01-01 16:05:42 +03:00
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:
3
.changelog/26772.txt
Normal file
3
.changelog/26772.txt
Normal 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
|
||||||
|
```
|
||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/hashicorp/go-memdb"
|
"github.com/hashicorp/go-memdb"
|
||||||
metrics "github.com/hashicorp/go-metrics/compat"
|
metrics "github.com/hashicorp/go-metrics/compat"
|
||||||
"github.com/hashicorp/go-set/v3"
|
"github.com/hashicorp/go-set/v3"
|
||||||
|
"github.com/hashicorp/nomad/acl"
|
||||||
policy "github.com/hashicorp/nomad/acl"
|
policy "github.com/hashicorp/nomad/acl"
|
||||||
"github.com/hashicorp/nomad/helper"
|
"github.com/hashicorp/nomad/helper"
|
||||||
"github.com/hashicorp/nomad/helper/uuid"
|
"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())
|
defer metrics.MeasureSince([]string{"nomad", "acl", "list_policies"}, time.Now())
|
||||||
|
|
||||||
// Check management level permissions
|
aclObj, err := a.srv.ResolveACL(args)
|
||||||
acl, err := a.srv.ResolveACL(args)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if acl == nil {
|
|
||||||
return structs.ErrPermissionDenied
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it is not a management token determine the policies that may be listed
|
// If it is not a management token determine the policies that may be listed
|
||||||
mgt := acl.IsManagement()
|
allowedPolicyFn, err := a.getPolicyAuthorizationFunc(aclObj, *args.GetIdentity())
|
||||||
tokenPolicyNames := set.New[string](0)
|
if err != nil {
|
||||||
if !mgt {
|
return err
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup the blocking query
|
// Setup the blocking query
|
||||||
@@ -227,7 +208,6 @@ func (a *ACL) ListPolicies(args *structs.ACLPolicyListRequest, reply *structs.AC
|
|||||||
queryOpts: &args.QueryOptions,
|
queryOpts: &args.QueryOptions,
|
||||||
queryMeta: &reply.QueryMeta,
|
queryMeta: &reply.QueryMeta,
|
||||||
run: func(ws memdb.WatchSet, state *state.StateStore) error {
|
run: func(ws memdb.WatchSet, state *state.StateStore) error {
|
||||||
// Iterate over all the policies
|
|
||||||
var err error
|
var err error
|
||||||
var iter memdb.ResultIterator
|
var iter memdb.ResultIterator
|
||||||
if prefix := args.QueryOptions.Prefix; prefix != "" {
|
if prefix := args.QueryOptions.Prefix; prefix != "" {
|
||||||
@@ -238,7 +218,6 @@ func (a *ACL) ListPolicies(args *structs.ACLPolicyListRequest, reply *structs.AC
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert all the policies to a list stub
|
// Convert all the policies to a list stub
|
||||||
reply.Policies = nil
|
reply.Policies = nil
|
||||||
for {
|
for {
|
||||||
@@ -247,7 +226,7 @@ func (a *ACL) ListPolicies(args *structs.ACLPolicyListRequest, reply *structs.AC
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
realPolicy := raw.(*structs.ACLPolicy)
|
realPolicy := raw.(*structs.ACLPolicy)
|
||||||
if mgt || tokenPolicyNames.Contains(realPolicy.Name) {
|
if allowedPolicyFn(realPolicy.Name) {
|
||||||
reply.Policies = append(reply.Policies, realPolicy.Stub())
|
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())
|
defer metrics.MeasureSince([]string{"nomad", "acl", "get_policy"}, time.Now())
|
||||||
|
|
||||||
// Check management level permissions
|
aclObj, err := a.srv.ResolveACL(args)
|
||||||
acl, err := a.srv.ResolveACL(args)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if acl == nil {
|
|
||||||
return structs.ErrPermissionDenied
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the policy is the anonymous one, anyone can get it
|
// If the policy is the anonymous one, anyone can get it. Otherwise
|
||||||
// If it is not a management token determine if it can get this policy
|
// management tokens or tokens that have the policy can get it. If it is not
|
||||||
mgt := acl.IsManagement()
|
// a management token determine the policies that may be listed
|
||||||
if !mgt && args.Name != "anonymous" {
|
allowedPolicyFn, err := a.getPolicyAuthorizationFunc(aclObj, *args.GetIdentity())
|
||||||
token, err := a.requestACLToken(args.AuthToken)
|
if err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
if args.Name != "anonymous" && !allowedPolicyFn(args.Name) {
|
||||||
if token == nil {
|
return structs.ErrPermissionDenied
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup the blocking query
|
// Setup the blocking query
|
||||||
@@ -325,7 +285,6 @@ func (a *ACL) GetPolicy(args *structs.ACLPolicySpecificRequest, reply *structs.S
|
|||||||
queryOpts: &args.QueryOptions,
|
queryOpts: &args.QueryOptions,
|
||||||
queryMeta: &reply.QueryMeta,
|
queryMeta: &reply.QueryMeta,
|
||||||
run: func(ws memdb.WatchSet, state *state.StateStore) error {
|
run: func(ws memdb.WatchSet, state *state.StateStore) error {
|
||||||
// Look for the policy
|
|
||||||
out, err := state.ACLPolicyByName(ws, args.Name)
|
out, err := state.ACLPolicyByName(ws, args.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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())
|
defer metrics.MeasureSince([]string{"nomad", "acl", "get_policies"}, time.Now())
|
||||||
|
|
||||||
// For client typed tokens, allow them to query any policies associated with that token.
|
aclObj, err := a.srv.ResolveACL(args)
|
||||||
// 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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the token policies which are directly referenced into the set.
|
// For client-typed tokens, allow them to query any policies associated with
|
||||||
tokenPolicyNames.InsertSlice(token.Policies)
|
// 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
|
// Setup the blocking query
|
||||||
opts := blockingOptions{
|
opts := blockingOptions{
|
||||||
@@ -423,7 +378,7 @@ func (a *ACL) GetPolicies(args *structs.ACLPolicySetRequest, reply *structs.ACLP
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if token.Type != structs.ACLManagementToken && !tokenPolicyNames.Contains(policyName) {
|
if !allowedPolicyFn(policyName) {
|
||||||
return structs.ErrPermissionDenied
|
return structs.ErrPermissionDenied
|
||||||
}
|
}
|
||||||
reply.Policies[policyName] = out
|
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
|
// policyNamesFromRoleLinks resolves the policy names which are linked via the
|
||||||
// passed role links. This is useful when you need to understand what polices
|
// 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
|
// an ACL token has access to and need to include role links. The function will
|
||||||
|
|||||||
@@ -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) {
|
func TestACLEndpoint_ListPolicies_Blocking(t *testing.T) {
|
||||||
ci.Parallel(t)
|
ci.Parallel(t)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user