Support applying policy to all jobs within namespace (#25871)

Workflow identities currently support ACL policies being applied
to a job ID within a namespace. With this update an ACL policy
can be applied to a namespace. This results in the ACL policy
being applied to all jobs within the namespace.
This commit is contained in:
Chris Roberts
2025-05-21 07:44:14 -07:00
committed by GitHub
parent 41cf1b03b4
commit 1aa416e2f2
8 changed files with 124 additions and 15 deletions

3
.changelog/25871.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:improvement
identity: Allow ACL policies to be applied to a namespace
```

View File

@@ -35,14 +35,13 @@ Apply Options:
-description
Specifies a human readable description for the policy.
-namespace
Attaches the policy to the specified namespace.
-job
Attaches the policy to the specified job. Requires that -namespace is
also set.
-namespace
Attaches the policy to the specified namespace. Requires that -job is
also set.
-group
Attaches the policy to the specified task group. Requires that -namespace
and -job are also set.

View File

@@ -641,5 +641,23 @@ func (s *Authenticator) ResolvePoliciesForClaims(claims *structs.IdentityClaims)
}
}
iter, err = snap.ACLPolicyByNamespace(nil, alloc.Namespace)
if err != nil {
return nil, err
}
for {
raw := iter.Next()
if raw == nil {
break
}
policy := raw.(*structs.ACLPolicy)
if policy.JobACL == nil {
continue
}
policies = append(policies, policy)
}
return policies, nil
}

View File

@@ -1109,6 +1109,18 @@ func TestResolveClaims(t *testing.T) {
JobID: claims.JobID,
}
// policy for namespace
policy8 := mock.ACLPolicy()
policy8.JobACL = &structs.JobACL{
Namespace: claims.Namespace,
}
// policy for different namespace
policy9 := mock.ACLPolicy()
policy9.JobACL = &structs.JobACL{
Namespace: "another",
}
aclObj, err := auth.resolveClaims(claims)
must.Nil(t, aclObj)
must.EqError(t, err, "allocation does not exist")
@@ -1127,7 +1139,7 @@ func TestResolveClaims(t *testing.T) {
// Add the policies
index++
err = auth.getState().UpsertACLPolicies(structs.MsgTypeTestSetup, index, []*structs.ACLPolicy{
policy0, policy1, policy2, policy3, policy4, policy5, policy6, policy7})
policy0, policy1, policy2, policy3, policy4, policy5, policy6, policy7, policy8, policy9})
must.NoError(t, err)
// Re-resolve and check that the resulting ACL looks reasonable
@@ -1146,8 +1158,8 @@ func TestResolveClaims(t *testing.T) {
policies, err := auth.ResolvePoliciesForClaims(claims)
must.NoError(t, err)
must.Len(t, 3, policies)
must.SliceContainsAll(t, policies, []*structs.ACLPolicy{policy1, policy2, policy3})
must.Len(t, 4, policies)
must.SliceContainsAll(t, policies, []*structs.ACLPolicy{policy1, policy2, policy3, policy8})
// Check the dispatch claims
aclObj3, err := auth.resolveClaims(dispatchClaims)
@@ -1157,8 +1169,8 @@ func TestResolveClaims(t *testing.T) {
dispatchPolicies, err := auth.ResolvePoliciesForClaims(dispatchClaims)
must.NoError(t, err)
must.Len(t, 3, dispatchPolicies)
must.SliceContainsAll(t, dispatchPolicies, []*structs.ACLPolicy{policy1, policy2, policy3})
must.Len(t, 4, dispatchPolicies)
must.SliceContainsAll(t, dispatchPolicies, []*structs.ACLPolicy{policy1, policy2, policy3, policy8})
}

View File

@@ -907,10 +907,10 @@ func (a *ACLPolicyJobACLFieldIndex) FromObject(obj interface{}) (bool, []byte, e
if ns == "" {
return false, nil, nil
}
jobID := policy.JobACL.JobID
if jobID == "" {
return false, nil, fmt.Errorf(
"object %#v is not a valid ACLPolicy: Namespace without JobID", obj)
return true, []byte(ns + "\x00\x00"), nil
}
val := ns + "\x00" + jobID + "\x00"
@@ -919,19 +919,27 @@ func (a *ACLPolicyJobACLFieldIndex) FromObject(obj interface{}) (bool, []byte, e
// FromArgs is used to build an exact index lookup based on arguments
func (a *ACLPolicyJobACLFieldIndex) FromArgs(args ...interface{}) ([]byte, error) {
if len(args) != 2 {
return nil, fmt.Errorf("must provide two arguments")
if len(args) < 1 || len(args) > 2 {
return nil, fmt.Errorf("must provide one or two arguments")
}
arg0, ok := args[0].(string)
if !ok {
return nil, fmt.Errorf("argument must be a string: %#v", args[0])
}
if len(args) == 1 {
// Add two null characters to fully terminate a
// namespace only entry
return []byte(arg0 + "\x00\x00"), nil
}
arg1, ok := args[1].(string)
if !ok {
return nil, fmt.Errorf("argument must be a string: %#v", args[0])
}
// Add the null character as a terminator
// Add the null character as a separator between the
// namespace and job id and one for the terminator
arg0 += "\x00" + arg1 + "\x00"
return []byte(arg0), nil
}

View File

@@ -6127,6 +6127,18 @@ func (s *StateStore) ACLPolicyByJob(ws memdb.WatchSet, ns, jobID string) (memdb.
return iter, nil
}
func (s *StateStore) ACLPolicyByNamespace(ws memdb.WatchSet, ns string) (memdb.ResultIterator, error) {
txn := s.db.ReadTxn()
iter, err := txn.Get("acl_policy", "job", ns)
if err != nil {
return nil, fmt.Errorf("acl policy lookup failed: %v", err)
}
ws.Add(iter.WatchCh())
return iter, nil
}
// ACLPolicies returns an iterator over all the acl policies
func (s *StateStore) ACLPolicies(ws memdb.WatchSet) (memdb.ResultIterator, error) {
txn := s.db.ReadTxn()

View File

@@ -9142,6 +9142,55 @@ func TestStateStore_UpsertACLPolicy(t *testing.T) {
}
}
func TestStateStore_ACLPolicyByNamespace(t *testing.T) {
ci.Parallel(t)
state := testStateStore(t)
policy := mock.ACLPolicy()
policy.JobACL = &structs.JobACL{
Namespace: "default",
}
policy1 := mock.ACLPolicy()
policy1.JobACL = &structs.JobACL{
Namespace: "default",
JobID: "test-ack-job-name",
}
policy2 := mock.ACLPolicy()
policy2.JobACL = &structs.JobACL{
Namespace: "default",
JobID: "testing-job",
}
policy3 := mock.ACLPolicy()
policy3.JobACL = &structs.JobACL{
Namespace: "testing",
JobID: "test-job",
}
err := state.UpsertACLPolicies(structs.MsgTypeTestSetup, 1000, []*structs.ACLPolicy{policy, policy1, policy2})
must.NoError(t, err)
iter, err := state.ACLPolicyByNamespace(nil, "default")
must.NoError(t, err)
out := iter.Next()
must.NotNil(t, out)
must.Eq(t, policy, out.(*structs.ACLPolicy))
count := 0
for {
if iter.Next() == nil {
break
}
count++
}
must.Eq(t, 0, count)
iter, err = state.ACLPolicyByNamespace(nil, "testing")
must.NoError(t, err)
must.Nil(t, iter.Next())
}
func TestStateStore_DeleteACLPolicy(t *testing.T) {
ci.Parallel(t)

View File

@@ -155,7 +155,7 @@ nomad acl policy apply \
redis-policy ./policy.hcl
```
And you can apply this policy to all groups in the job by omitting both the
You can apply this policy to all groups in the job by omitting both the
`-group` and `-task` flag:
```shell-session
@@ -164,6 +164,14 @@ nomad acl policy apply \
redis-policy ./policy.hcl
```
And you can apply this policy to all jobs in the namespace by omitting the
`-job`, `-group`, and `-task` flag:
```shell-session
nomad acl policy apply \
-namespace default redis-policy ./policy.hcl
```
### Task API
It can be convenient to combine workload identity with Nomad's [Task API]