Skip to content

Commit

Permalink
allow ACL policies to be associated with workload identity
Browse files Browse the repository at this point in the history
The original design for workload identities and ACLs allows for operators to
extend the automatic capabilities of a workload by using a specially-named
policy. This has shown to be potentially unsafe because of naming collisions, so
instead we'll allow operators to explicitly attach a policy to a workload
identity.

This changeset adds workload identity fields to ACL policy objects and threads
that all the way down to the command line. It also a new secondary index to the
ACL policy table on namespace and job so that claim resolution can efficiently
query for related policies.
  • Loading branch information
tgross committed Aug 16, 2022
1 parent c1abf47 commit 5571c01
Show file tree
Hide file tree
Showing 14 changed files with 342 additions and 68 deletions.
14 changes: 9 additions & 5 deletions api/acl.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,11 +212,15 @@ type ACLPolicyListStub struct {

// ACLPolicy is used to represent an ACL policy
type ACLPolicy struct {
Name string
Description string
Rules string
CreateIndex uint64
ModifyIndex uint64
Name string
Description string
Rules string
JobNamespace string
JobID string
Group string
Task string
CreateIndex uint64
ModifyIndex uint64
}

// ACLToken represents a client token which is used to Authenticate
Expand Down
25 changes: 21 additions & 4 deletions command/acl_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,16 +130,33 @@ func (c *ACLBootstrapCommand) Run(args []string) int {
return 0
}

// formatKVPolicy returns a K/V formatted policy
func formatKVPolicy(policy *api.ACLPolicy) string {
// formatACLPolicy returns formatted policy
func formatACLPolicy(policy *api.ACLPolicy) string {
output := []string{
fmt.Sprintf("Name|%s", policy.Name),
fmt.Sprintf("Description|%s", policy.Description),
fmt.Sprintf("Rules|%s", policy.Rules),
fmt.Sprintf("CreateIndex|%v", policy.CreateIndex),
fmt.Sprintf("ModifyIndex|%v", policy.ModifyIndex),
}
return formatKV(output)

formattedOut := formatKV(output)

if policy.JobNamespace != "" {
output := []string{
fmt.Sprintf("Namespace|%v", policy.JobNamespace),
fmt.Sprintf("JobID|%v", policy.JobID),
fmt.Sprintf("Group|%v", policy.Group),
fmt.Sprintf("Task|%v", policy.Task),
}
formattedOut += "\n\n[bold]Associated Workload[reset]\n"
formattedOut += formatKV(output)
}

// these are potentially large blobs so leave till the end
formattedOut += "\n\n[bold]Rules[reset]\n\n"
formattedOut += policy.Rules

return formattedOut
}

// formatKVACLToken returns a K/V formatted ACL token
Expand Down
48 changes: 45 additions & 3 deletions command/acl_policy_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ Apply Options:
-description
Specifies a human readable description for the policy.
-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.
-task
Attaches the policy to the specified task. Requires that -namespace, -job
and -group are also set.
`
return strings.TrimSpace(helpText)
}
Expand All @@ -53,9 +68,16 @@ func (c *ACLPolicyApplyCommand) Name() string { return "acl policy apply" }

func (c *ACLPolicyApplyCommand) Run(args []string) int {
var description string
var jobID, group, task string // namespace is included in default flagset

flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.StringVar(&description, "description", "", "")

flags.StringVar(&jobID, "job", "", "job to attach this policy to")
flags.StringVar(&group, "group", "", "group to attach this policy to")
flags.StringVar(&task, "task", "", "task to attach this policy to")

if err := flags.Parse(args); err != nil {
return 1
}
Expand Down Expand Up @@ -89,11 +111,31 @@ func (c *ACLPolicyApplyCommand) Run(args []string) int {
}
}

f := flags.Lookup("namespace")
namespace := f.Value.String()

if namespace == "" && jobID != "" {
c.Ui.Error("-namespace is required if -job is set")
return 1
}
if jobID == "" && group != "" {
c.Ui.Error("-job is required if -group is set")
return 1
}
if group == "" && task != "" {
c.Ui.Error("-group is required if -task is set")
return 1
}

// Construct the policy
ap := &api.ACLPolicy{
Name: policyName,
Description: description,
Rules: string(rawPolicy),
Name: policyName,
Description: description,
Rules: string(rawPolicy),
JobNamespace: namespace,
JobID: jobID,
Group: group,
Task: task,
}

// Get the HTTP client
Expand Down
2 changes: 1 addition & 1 deletion command/acl_policy_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,6 @@ func (c *ACLPolicyInfoCommand) Run(args []string) int {
return 1
}

c.Ui.Output(formatKVPolicy(policy))
c.Ui.Output(c.Colorize().Color(formatACLPolicy(policy)))
return 0
}
39 changes: 21 additions & 18 deletions nomad/acl.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,27 +180,30 @@ func (s *Server) resolvePoliciesForClaims(claims *structs.IdentityClaims) ([]*st
return nil, fmt.Errorf("allocation does not exist")
}

// Find any implicit policies associated with this task
policies := []*structs.ACLPolicy{}
implicitPolicyNames := []string{
fmt.Sprintf("_:%s/%s/%s/%s", alloc.Namespace, alloc.Job.ID, alloc.TaskGroup, claims.TaskName),
fmt.Sprintf("_:%s/%s/%s", alloc.Namespace, alloc.Job.ID, alloc.TaskGroup),
fmt.Sprintf("_:%s/%s", alloc.Namespace, alloc.Job.ID),
fmt.Sprintf("_:%s", alloc.Namespace),
// Find any policies attached to the job
iter, err := snap.ACLPolicyByJob(nil, alloc.Namespace, alloc.Job.ID)
if err != nil {
return nil, err
}

for _, policyName := range implicitPolicyNames {
policy, err := snap.ACLPolicyByName(nil, policyName)
if err != nil {
return nil, err
policies := []*structs.ACLPolicy{}
for {
raw := iter.Next()
if raw == nil {
break
}
if policy == nil {
// Ignore policies that don't exist, since they don't
// grant any more privilege
continue
policy := raw.(*structs.ACLPolicy)

switch {
case policy.Group == "":
policies = append(policies, policy)
case policy.Group != alloc.TaskGroup:
continue // don't bother checking task
case policy.Task == "":
policies = append(policies, policy)
case policy.Task == claims.TaskName:
policies = append(policies, policy)
}

policies = append(policies, policy)
}

return policies, nil
}
105 changes: 103 additions & 2 deletions nomad/acl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ import (
"testing"

lru "github.com/hashicorp/golang-lru"
"github.com/shoenig/test/must"
"github.com/stretchr/testify/assert"

"github.com/hashicorp/nomad/acl"
"github.com/hashicorp/nomad/ci"
"github.com/hashicorp/nomad/helper/uuid"
"github.com/hashicorp/nomad/nomad/mock"
"github.com/hashicorp/nomad/nomad/state"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/hashicorp/nomad/testutil"
"github.com/stretchr/testify/assert"
)

func TestResolveACLToken(t *testing.T) {
Expand Down Expand Up @@ -63,7 +65,7 @@ func TestResolveACLToken(t *testing.T) {
assert.Nil(t, err)
assert.NotNil(t, aclObj)

// Check that the ACL object is sane
// Check that the ACL object looks reasonable
assert.Equal(t, false, aclObj.IsManagement())
allowed := aclObj.AllowNamespaceOperation("default", acl.NamespaceCapabilityListJobs)
assert.Equal(t, true, allowed)
Expand Down Expand Up @@ -132,3 +134,102 @@ func TestResolveSecretToken(t *testing.T) {
}

}

func TestResolveClaims(t *testing.T) {
ci.Parallel(t)

srv, _, cleanup := TestACLServer(t, nil)
defer cleanup()

store := srv.fsm.State()
index := uint64(100)

alloc := mock.Alloc()

claims := &structs.IdentityClaims{
Namespace: alloc.Namespace,
JobID: alloc.Job.ID,
AllocationID: alloc.ID,
TaskName: alloc.Job.TaskGroups[0].Tasks[0].Name,
}

// unrelated policy
policy0 := mock.ACLPolicy()

// policy for job
policy1 := mock.ACLPolicy()
policy1.JobNamespace = claims.Namespace
policy1.JobID = claims.JobID

// policy for job and group
policy2 := mock.ACLPolicy()
policy2.JobNamespace = claims.Namespace
policy2.JobID = claims.JobID
policy2.Group = alloc.Job.TaskGroups[0].Name

// policy for job and group and task
policy3 := mock.ACLPolicy()
policy3.JobNamespace = claims.Namespace
policy3.JobID = claims.JobID
policy3.Group = alloc.Job.TaskGroups[0].Name
policy3.Task = claims.TaskName

// policy for job and group but different task
policy4 := mock.ACLPolicy()
policy4.JobNamespace = claims.Namespace
policy4.JobID = claims.JobID
policy4.Group = alloc.Job.TaskGroups[0].Name
policy4.Task = "another"

// policy for job but different group
policy5 := mock.ACLPolicy()
policy5.JobNamespace = claims.Namespace
policy5.JobID = claims.JobID
policy5.Group = "another"

// policy for same namespace but different job
policy6 := mock.ACLPolicy()
policy6.JobNamespace = claims.Namespace
policy6.JobID = "another"

// policy for same job in different namespace
policy7 := mock.ACLPolicy()
policy7.JobNamespace = "another"
policy7.JobID = claims.JobID

index++
err := store.UpsertACLPolicies(structs.MsgTypeTestSetup, index, []*structs.ACLPolicy{
policy0, policy1, policy2, policy3, policy4, policy5, policy6, policy7})
must.NoError(t, err)

aclObj, err := srv.ResolveClaims(claims)
must.Nil(t, aclObj)
must.EqError(t, err, "allocation does not exist")

// upsert the allocation
index++
err = store.UpsertAllocs(structs.MsgTypeTestSetup, index, []*structs.Allocation{alloc})
must.NoError(t, err)

aclObj, err = srv.ResolveClaims(claims)
must.NoError(t, err)
must.NotNil(t, aclObj)

// Check that the ACL object looks reasonable
must.False(t, aclObj.IsManagement())
must.True(t, aclObj.AllowNamespaceOperation("default", acl.NamespaceCapabilityListJobs))
must.False(t, aclObj.AllowNamespaceOperation("other", acl.NamespaceCapabilityListJobs))

// Resolve the same claim again, should get cache value
aclObj2, err := srv.ResolveClaims(claims)
must.NoError(t, err)
must.NotNil(t, aclObj)
must.Eq(t, aclObj, aclObj2, must.Sprintf("expected cached value"))

policies, err := srv.resolvePoliciesForClaims(claims)
must.NoError(t, err)
must.Len(t, 3, policies)
must.Contains(t, policies, policy1)
must.Contains(t, policies, policy2)
must.Contains(t, policies, policy3)
}
4 changes: 3 additions & 1 deletion nomad/secure_variables_endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,14 @@ func TestSecureVariablesEndpoint_auth(t *testing.T) {
invalidIDToken := strings.Join(idTokenParts, ".")

policy := mock.ACLPolicy()
policy.Name = fmt.Sprintf("_:%s/%s/%s", ns, jobID, alloc1.TaskGroup)
policy.Rules = `namespace "nondefault-namespace" {
secure_variables {
path "nomad/jobs/*" { capabilities = ["read"] }
path "other/path" { capabilities = ["read"] }
}}`
policy.JobNamespace = ns
policy.JobID = jobID
policy.Group = alloc1.TaskGroup
policy.SetHash()
err = store.UpsertACLPolicies(structs.MsgTypeTestSetup, 1100, []*structs.ACLPolicy{policy})
must.NoError(t, err)
Expand Down
15 changes: 15 additions & 0 deletions nomad/state/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,21 @@ func aclPolicyTableSchema() *memdb.TableSchema {
Field: "Name",
},
},
"job": {
Name: "job",
AllowMissing: true,
Unique: false,
Indexer: &memdb.CompoundIndex{
Indexes: []memdb.Indexer{
&memdb.StringFieldIndex{
Field: "JobNamespace",
},
&memdb.StringFieldIndex{
Field: "JobID",
},
},
},
},
},
}
}
Expand Down
14 changes: 14 additions & 0 deletions nomad/state/state_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -5570,6 +5570,20 @@ func (s *StateStore) ACLPolicyByNamePrefix(ws memdb.WatchSet, prefix string) (me
return iter, nil
}

// ACLPolicyByJob is used to lookup policies that have been attached to a
// specific job
func (s *StateStore) ACLPolicyByJob(ws memdb.WatchSet, ns, jobID string) (memdb.ResultIterator, error) {
txn := s.db.ReadTxn()

iter, err := txn.Get("acl_policy", "job_prefix", ns, jobID)
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()
Expand Down
Loading

0 comments on commit 5571c01

Please sign in to comment.