diff --git a/.changelog/27786.txt b/.changelog/27786.txt new file mode 100644 index 00000000000..bd9db08ea6d --- /dev/null +++ b/.changelog/27786.txt @@ -0,0 +1,3 @@ +```release-note:improvement +identity: allow additional claims to be added to workload identities +``` diff --git a/api/namespace.go b/api/namespace.go index 336d6a6bbcd..166ab91cd0a 100644 --- a/api/namespace.go +++ b/api/namespace.go @@ -80,6 +80,8 @@ type Namespace struct { Meta map[string]string CreateIndex uint64 ModifyIndex uint64 + RequiredExtraClaims map[string]string + OptionalExtraClaims map[string]string } // NamespaceCapabilities represents a set of capabilities allowed for this diff --git a/api/tasks.go b/api/tasks.go index a0624df90dc..843011fa882 100644 --- a/api/tasks.go +++ b/api/tasks.go @@ -1259,6 +1259,7 @@ type WorkloadIdentity struct { Filepath string `hcl:"filepath,optional"` ServiceName string `hcl:"service_name,optional"` TTL time.Duration `mapstructure:"ttl" hcl:"ttl,optional"` + ExtraClaims []string `mapstructure:"extra_claims" hcl:"extra_claims,optional"` } type Action struct { diff --git a/command/agent/http_test.go b/command/agent/http_test.go index dd9090e0279..4996e127797 100644 --- a/command/agent/http_test.go +++ b/command/agent/http_test.go @@ -1506,7 +1506,7 @@ func TestHTTPServer_ResolveToken(t *testing.T) { must.NoError(t, srv.State().UpsertACLPolicies(structs.MsgTypeTestSetup, 100, []*structs.ACLPolicy{policy})) must.NoError(t, srv.State().UpsertAllocs(structs.MsgTypeTestSetup, 100, []*structs.Allocation{alloc})) - claims := structs.NewIdentityClaimsBuilder(alloc.Job, alloc, wih, identity). + claims := structs.NewIdentityClaimsBuilder(alloc.Job, alloc, wih, identity, mock.Namespace()). WithTask(task).Build(time.Now()) testutil.WaitForKeyring(t, srv.RPC, "global") diff --git a/command/agent/job_endpoint.go b/command/agent/job_endpoint.go index d4baeb9f360..3deb5d4d18e 100644 --- a/command/agent/job_endpoint.go +++ b/command/agent/job_endpoint.go @@ -1809,6 +1809,7 @@ func apiWorkloadIdentityToStructs(in *api.WorkloadIdentity) *structs.WorkloadIde Filepath: in.Filepath, ServiceName: in.ServiceName, TTL: in.TTL, + ExtraClaims: slices.Clone(in.ExtraClaims), } } diff --git a/command/namespace_apply.go b/command/namespace_apply.go index 4c9509f9576..92930726889 100644 --- a/command/namespace_apply.go +++ b/command/namespace_apply.go @@ -229,6 +229,8 @@ func parseNamespaceSpecImpl(result *api.Namespace, list *ast.ObjectList) error { delete(m, "node_pool_config") delete(m, "vault") delete(m, "consul") + delete(m, "required_extra_claims") + delete(m, "optional_extra_claims") // Decode the rest if err := mapstructure.WeakDecode(m, result); err != nil { @@ -311,5 +313,29 @@ func parseNamespaceSpecImpl(result *api.Namespace, list *ast.ObjectList) error { } } + if reqClaimsO := list.Filter("required_extra_claims"); len(reqClaimsO.Items) > 0 { + for _, o := range reqClaimsO.Elem().Items { + var m map[string]interface{} + if err := hcl.DecodeObject(&m, o.Val); err != nil { + return err + } + if err := mapstructure.WeakDecode(m, &result.RequiredExtraClaims); err != nil { + return err + } + } + } + + if optClaimsO := list.Filter("optional_extra_claims"); len(optClaimsO.Items) > 0 { + for _, o := range optClaimsO.Elem().Items { + var m map[string]interface{} + if err := hcl.DecodeObject(&m, o.Val); err != nil { + return err + } + if err := mapstructure.WeakDecode(m, &result.OptionalExtraClaims); err != nil { + return err + } + } + } + return nil } diff --git a/command/namespace_apply_test.go b/command/namespace_apply_test.go index 2797459fd2c..f5ed356337b 100644 --- a/command/namespace_apply_test.go +++ b/command/namespace_apply_test.go @@ -99,7 +99,17 @@ consul { meta { dept = "eng" -}`, +} + +required_extra_claims { + foo = "${job.namespace}" +} + +optional_extra_claims { + bar = "class:${node.class}" + baz = "dc:${node.datacenter}" +} +`, expected: &api.Namespace{ Name: "test-namespace", Description: "Test namespace", @@ -123,6 +133,13 @@ meta { Meta: map[string]string{ "dept": "eng", }, + RequiredExtraClaims: map[string]string{ + "foo": "${job.namespace}", + }, + OptionalExtraClaims: map[string]string{ + "bar": "class:${node.class}", + "baz": "dc:${node.datacenter}", + }, }, }, { diff --git a/nomad/acl_endpoint_test.go b/nomad/acl_endpoint_test.go index 0f50adfee60..eceffb0d597 100644 --- a/nomad/acl_endpoint_test.go +++ b/nomad/acl_endpoint_test.go @@ -638,7 +638,7 @@ func TestACLEndpoint_GetListPolicies_WorkloadIdentity(t *testing.T) { WorkloadIdentifier: "t", WorkloadType: structs.WorkloadTypeTask, }, - task.Identity). + task.Identity, mock.Namespace()). WithTask(task). Build(time.Now().Add(-10 * time.Minute)) jwtToken, _, err := srv.encrypter.SignClaims(claims) @@ -2035,7 +2035,7 @@ func TestACLEndpoint_WhoAmI(t *testing.T) { task := alloc.LookupTask("web") claims := structs.NewIdentityClaimsBuilder(alloc.Job, alloc, wiHandle, // see encrypter_test.go - task.Identity). + task.Identity, mock.Namespace()). WithTask(task). Build(time.Now().Add(-10 * time.Minute)) jwtToken, _, err := s1.encrypter.SignClaims(claims) diff --git a/nomad/acl_test.go b/nomad/acl_test.go index 0551c291d4c..672d0f40f1a 100644 --- a/nomad/acl_test.go +++ b/nomad/acl_test.go @@ -134,7 +134,7 @@ func TestAuthenticate_mTLS(t *testing.T) { task1 := alloc1.LookupTask("web") claims1 := structs.NewIdentityClaimsBuilder(job, alloc1, wiHandle, - task1.Identity). + task1.Identity, mock.Namespace()). WithTask(task1). Build(time.Now()) @@ -144,7 +144,7 @@ func TestAuthenticate_mTLS(t *testing.T) { task2 := alloc2.LookupTask("web") claims2 := structs.NewIdentityClaimsBuilder(job, alloc2, wiHandle, - task2.Identity). + task2.Identity, mock.Namespace()). WithTask(task1). Build(time.Now()) diff --git a/nomad/alloc_endpoint.go b/nomad/alloc_endpoint.go index 5373bfa70da..17280e2350a 100644 --- a/nomad/alloc_endpoint.go +++ b/nomad/alloc_endpoint.go @@ -552,6 +552,10 @@ func (a *Alloc) SignIdentities(args *structs.AllocIdentitiesRequest, reply *stru } job := out.Job + ns, err := a.srv.State().NamespaceByName(nil, out.Namespace) + if err != nil { + return err + } switch idReq.WorkloadType { case structs.WorkloadTypeTask: @@ -565,7 +569,7 @@ func (a *Alloc) SignIdentities(args *structs.AllocIdentitiesRequest, reply *stru continue } - widFound, err := a.signTasks(task, out, idReq, reply, now) + widFound, err := a.signTasks(task, out, ns, idReq, reply, now) if err != nil { return err } @@ -579,7 +583,7 @@ func (a *Alloc) SignIdentities(args *structs.AllocIdentitiesRequest, reply *stru } case structs.WorkloadTypeService: - widFound, err := a.signServices(job, out, idReq, reply, now) + widFound, err := a.signServices(job, out, ns, idReq, reply, now) if err != nil { return err } @@ -599,6 +603,7 @@ func (a *Alloc) SignIdentities(args *structs.AllocIdentitiesRequest, reply *stru func (a *Alloc) signTasks( task *structs.Task, alloc *structs.Allocation, + ns *structs.Namespace, idReq *structs.WorkloadIdentityRequest, reply *structs.AllocIdentitiesResponse, now time.Time, @@ -609,7 +614,7 @@ func (a *Alloc) signTasks( } widFound = true - builder := structs.NewIdentityClaimsBuilder(alloc.Job, alloc, &idReq.WIHandle, wid). + builder := structs.NewIdentityClaimsBuilder(alloc.Job, alloc, &idReq.WIHandle, wid, ns). WithTask(task). WithConsul() @@ -635,6 +640,7 @@ func (a *Alloc) signTasks( func (a *Alloc) signServices( job *structs.Job, alloc *structs.Allocation, + ns *structs.Namespace, idReq *structs.WorkloadIdentityRequest, reply *structs.AllocIdentitiesResponse, now time.Time, @@ -646,7 +652,7 @@ func (a *Alloc) signServices( for _, service := range tg.Services { if service.IdentityHandle(nil).Equal(wid) { claims := structs.NewIdentityClaimsBuilder( - alloc.Job, alloc, &idReq.WIHandle, service.Identity). + alloc.Job, alloc, &idReq.WIHandle, service.Identity, ns). WithConsul(). WithService(service). Build(now) @@ -657,7 +663,7 @@ func (a *Alloc) signServices( for _, service := range task.Services { if service.IdentityHandle(nil).Equal(wid) { claims := structs.NewIdentityClaimsBuilder( - alloc.Job, alloc, &idReq.WIHandle, service.Identity). + alloc.Job, alloc, &idReq.WIHandle, service.Identity, ns). WithTask(task). WithConsul(). WithService(service). diff --git a/nomad/alloc_endpoint_test.go b/nomad/alloc_endpoint_test.go index 334f12e4ecb..70117eee643 100644 --- a/nomad/alloc_endpoint_test.go +++ b/nomad/alloc_endpoint_test.go @@ -1828,14 +1828,27 @@ func TestAlloc_SignIdentities_Bad(t *testing.T) { // Insert an alloc with an alternate identity alloc := mock.Alloc() + alloc.Job.Meta = map[string]string{"customer": "important"} alloc.Job.TaskGroups[0].Tasks[0].Identities = []*structs.WorkloadIdentity{ { Name: "alt", Audience: []string{"test"}, + ExtraClaims: []string{ + "customer_claim", + }, }, } summary := mock.JobSummary(alloc.JobID) state := s1.fsm.State() + must.NoError(t, state.UpsertNamespaces(100, []*structs.Namespace{{ + Name: structs.DefaultNamespace, + RequiredExtraClaims: map[string]string{ + "required_claim": "${job.namespace}", + }, + OptionalExtraClaims: map[string]string{ + "customer_claim": "acct-${job.meta.customer}", + }, + }})) must.NoError(t, state.UpsertJobSummary(100, summary)) must.NoError(t, state.UpsertJob(structs.MsgTypeTestSetup, 101, nil, alloc.Job)) must.NoError(t, state.UpsertAllocs(structs.MsgTypeTestSetup, 101, []*structs.Allocation{alloc})) @@ -1861,6 +1874,10 @@ func TestAlloc_SignIdentities_Bad(t *testing.T) { must.NoError(t, msgpackrpc.CallWithCodec(codec, "Alloc.SignIdentities", &req, &resp)) must.Len(t, 0, resp.Rejections) must.Len(t, 1, resp.SignedIdentities) + claims, err := s1.encrypter.VerifyClaim(resp.SignedIdentities[0].JWT) + must.NoError(t, err) + must.Eq(t, "default", claims.ExtraClaims["required_claim"]) + must.Eq(t, "acct-important", claims.ExtraClaims["customer_claim"]) // Looking for a missing alloc should return a rejection and a signed id req.Identities = append(req.Identities, &structs.WorkloadIdentityRequest{ diff --git a/nomad/auth/auth_test.go b/nomad/auth/auth_test.go index 6946ce24602..a30166df310 100644 --- a/nomad/auth/auth_test.go +++ b/nomad/auth/auth_test.go @@ -257,7 +257,8 @@ func TestAuthenticateDefault(t *testing.T) { claims := structs.NewIdentityClaimsBuilder(alloc.Job, alloc, wih, - identity). + identity, + mock.Namespace()). Build(time.Now()) auth := testAuthenticator(t, store, true, true) token, err := auth.encrypter.(*testEncrypter).signClaim(claims) @@ -317,7 +318,8 @@ func TestAuthenticateDefault(t *testing.T) { alloc.ClientStatus = structs.AllocClientStatusRunning claims := structs.NewIdentityClaimsBuilder(alloc.Job, alloc, wih, - identity). + identity, + mock.Namespace()). Build(time.Now()) auth := testAuthenticator(t, store, true, true) @@ -1295,7 +1297,7 @@ func TestIdentityToACLClaim(t *testing.T) { defaultWI := &structs.WorkloadIdentity{Name: "default"} claims := structs.NewIdentityClaimsBuilder(alloc.Job, alloc, task.IdentityHandle(defaultWI), - task.Identity). + task.Identity, mock.Namespace()). WithTask(task). Build(time.Now()) diff --git a/nomad/encrypter_test.go b/nomad/encrypter_test.go index 0f3fdd27530..f2b62b0e40c 100644 --- a/nomad/encrypter_test.go +++ b/nomad/encrypter_test.go @@ -685,7 +685,7 @@ func TestEncrypter_SignVerify(t *testing.T) { alloc := mock.Alloc() task := alloc.LookupTask("web") - claims := structs.NewIdentityClaimsBuilder(alloc.Job, alloc, wiHandle, task.Identity). + claims := structs.NewIdentityClaimsBuilder(alloc.Job, alloc, wiHandle, task.Identity, mock.Namespace()). WithTask(task). Build(time.Now()) e := srv.encrypter @@ -721,7 +721,7 @@ func TestEncrypter_SignVerify_Issuer(t *testing.T) { alloc := mock.Alloc() task := alloc.LookupTask("web") - claims := structs.NewIdentityClaimsBuilder(alloc.Job, alloc, wiHandle, task.Identity). + claims := structs.NewIdentityClaimsBuilder(alloc.Job, alloc, wiHandle, task.Identity, mock.Namespace()). WithTask(task). Build(time.Now()) @@ -750,7 +750,7 @@ func TestEncrypter_SignVerify_AlgNone(t *testing.T) { alloc := mock.Alloc() task := alloc.LookupTask("web") - claims := structs.NewIdentityClaimsBuilder(alloc.Job, alloc, wiHandle, task.Identity). + claims := structs.NewIdentityClaimsBuilder(alloc.Job, alloc, wiHandle, task.Identity, mock.Namespace()). WithTask(task). Build(time.Now()) diff --git a/nomad/plan_apply.go b/nomad/plan_apply.go index 6591ff5c055..2d09467d41e 100644 --- a/nomad/plan_apply.go +++ b/nomad/plan_apply.go @@ -280,7 +280,12 @@ func (p *planner) applyPlan(plan *structs.Plan, result *structs.PlanResult, snap // to approximate the scheduling time. updateAllocTimestamps(req.AllocsUpdated, unixNow) - if err := signAllocIdentities(p.srv.encrypter, job, req.AllocsUpdated, now); err != nil { + ns, err := snap.NamespaceByName(nil, job.Namespace) + if err != nil { + return nil, err + } + + if err := signAllocIdentities(p.srv.encrypter, job, req.AllocsUpdated, ns, now); err != nil { return nil, err } @@ -374,7 +379,7 @@ func updateAllocTimestamps(allocations []*structs.Allocation, timestamp int64) { } } -func signAllocIdentities(signer claimSigner, job *structs.Job, allocations []*structs.Allocation, now time.Time) error { +func signAllocIdentities(signer claimSigner, job *structs.Job, allocations []*structs.Allocation, ns *structs.Namespace, now time.Time) error { for _, alloc := range allocations { if alloc.SignedIdentities == nil { alloc.SignedIdentities = map[string]string{} @@ -388,7 +393,7 @@ func signAllocIdentities(signer claimSigner, job *structs.Job, allocations []*st defaultWI := &structs.WorkloadIdentity{Name: "default"} claims := structs.NewIdentityClaimsBuilder( - job, alloc, task.IdentityHandle(defaultWI), task.Identity). + job, alloc, task.IdentityHandle(defaultWI), task.Identity, ns). WithTask(task). Build(now) diff --git a/nomad/plan_apply_test.go b/nomad/plan_apply_test.go index 84ce4f5f057..3f2b3b943ec 100644 --- a/nomad/plan_apply_test.go +++ b/nomad/plan_apply_test.go @@ -415,17 +415,19 @@ func TestPlanApply_applyPlanWithNormalizedAllocs(t *testing.T) { func TestPlanApply_signAllocIdentities(t *testing.T) { // note: this is mutated by the method under test alloc := mockAlloc() - job := alloc.Job - taskName := job.TaskGroups[0].Tasks[0].Name // "web" - allocs := []*structs.Allocation{alloc} signErr := errors.New("could not sign the thing") cases := []struct { - name string - signer *mockSigner - expectErr error - callNum int + name string + signer *mockSigner + expectErr error + callNum int + alloc *structs.Allocation + ns *structs.Namespace + expectedToken string + expectedKeyID string + additionalChecks func(*testing.T, *structs.IdentityClaims) }{ { name: "signer error", @@ -434,6 +436,8 @@ func TestPlanApply_signAllocIdentities(t *testing.T) { }, expectErr: signErr, callNum: 1, + alloc: alloc, + ns: mock.Namespace(), }, { name: "first signing", @@ -441,7 +445,11 @@ func TestPlanApply_signAllocIdentities(t *testing.T) { nextToken: "first-token", nextKeyID: "first-key", }, - callNum: 1, + callNum: 1, + alloc: alloc, + ns: mock.Namespace(), + expectedToken: "first-token", + expectedKeyID: "first-key", }, { name: "second signing", @@ -449,14 +457,57 @@ func TestPlanApply_signAllocIdentities(t *testing.T) { nextToken: "dont-sign-token", nextKeyID: "dont-sign-key", }, - callNum: 0, + callNum: 0, + alloc: alloc, + ns: mock.Namespace(), + expectedToken: "first-token", + expectedKeyID: "first-key", + }, + { + name: "signing with additional claims", + signer: &mockSigner{ + nextToken: "signed-this-token", + nextKeyID: "signed-this-key", + }, + callNum: 1, + alloc: func() *structs.Allocation { + alloc := mockAlloc() + job := alloc.Job + job.Meta = map[string]string{"customer": "important"} + job.TaskGroups[0].Tasks[0].Identity = &structs.WorkloadIdentity{ + Name: structs.WorkloadIdentityDefaultName, + ExtraClaims: []string{ + "customer_claim", + }, + } + return alloc + }(), + ns: &structs.Namespace{ + Name: "default", + RequiredExtraClaims: map[string]string{ + "required_claim": "${job.namespace}", + }, + OptionalExtraClaims: map[string]string{ + "customer_claim": "acct-${job.meta.customer}", + }, + }, + expectedToken: "signed-this-token", + expectedKeyID: "signed-this-key", + expectErr: nil, + additionalChecks: func(t *testing.T, idClaims *structs.IdentityClaims) { + must.Eq(t, "default", idClaims.ExtraClaims["required_claim"]) + must.Eq(t, "acct-important", idClaims.ExtraClaims["customer_claim"]) + }, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - err := signAllocIdentities(tc.signer, job, allocs, time.Now()) + job := tc.alloc.Job + taskName := job.TaskGroups[0].Tasks[0].Name + + err := signAllocIdentities(tc.signer, job, []*structs.Allocation{tc.alloc}, tc.ns, time.Now()) if tc.expectErr != nil { must.Error(t, err) @@ -464,22 +515,24 @@ func TestPlanApply_signAllocIdentities(t *testing.T) { } else { must.NoError(t, err) // assert mutations happened - must.MapLen(t, 1, allocs[0].SignedIdentities) + must.MapLen(t, 1, tc.alloc.SignedIdentities) // we should always keep the first signing - must.Eq(t, "first-token", allocs[0].SignedIdentities[taskName]) - must.Eq(t, "first-key", allocs[0].SigningKeyID) + must.Eq(t, tc.expectedToken, tc.alloc.SignedIdentities[taskName]) + must.Eq(t, tc.expectedKeyID, tc.alloc.SigningKeyID) } must.Len(t, tc.callNum, tc.signer.calls, must.Sprint("unexpected call count")) if tc.callNum > 0 { call := tc.signer.calls[tc.callNum-1] must.NotNil(t, call) - must.Eq(t, call.AllocationID, alloc.ID) - must.Eq(t, call.Namespace, alloc.Namespace) + must.Eq(t, call.AllocationID, tc.alloc.ID) + must.Eq(t, call.Namespace, tc.alloc.Namespace) must.Eq(t, call.JobID, job.ID) must.Eq(t, call.TaskName, taskName) + if tc.additionalChecks != nil { + tc.additionalChecks(t, call) + } } - }) } } diff --git a/nomad/service_registration_endpoint_test.go b/nomad/service_registration_endpoint_test.go index 427e409073b..87bd99efea6 100644 --- a/nomad/service_registration_endpoint_test.go +++ b/nomad/service_registration_endpoint_test.go @@ -911,7 +911,7 @@ func TestServiceRegistration_List(t *testing.T) { job.Namespace = "platform" allocs[0].Namespace = "platform" require.NoError(t, s.State().UpsertJob(structs.MsgTypeTestSetup, 10, nil, job)) - signAllocIdentities(s.encrypter, job, allocs, time.Now()) + signAllocIdentities(s.encrypter, job, allocs, ns, time.Now()) require.NoError(t, s.State().UpsertAllocs(structs.MsgTypeTestSetup, 15, allocs)) signedToken := allocs[0].SignedIdentities["web"] @@ -1188,7 +1188,7 @@ func TestServiceRegistration_GetService(t *testing.T) { allocs := []*structs.Allocation{mock.Alloc()} job := allocs[0].Job require.NoError(t, s.State().UpsertJob(structs.MsgTypeTestSetup, 10, nil, job)) - signAllocIdentities(s.encrypter, job, allocs, time.Now()) + signAllocIdentities(s.encrypter, job, allocs, mock.Namespace(), time.Now()) require.NoError(t, s.State().UpsertAllocs(structs.MsgTypeTestSetup, 15, allocs)) signedToken := allocs[0].SignedIdentities["web"] diff --git a/nomad/structs/structs.go b/nomad/structs/structs.go index df142bab0dc..632e9325d2c 100644 --- a/nomad/structs/structs.go +++ b/nomad/structs/structs.go @@ -5543,6 +5543,10 @@ type Namespace struct { // Raft Indexes CreateIndex uint64 ModifyIndex uint64 + + // Additional claims for workload identities + RequiredExtraClaims map[string]string + OptionalExtraClaims map[string]string } // NamespaceCapabilities represents a set of capabilities allowed for this diff --git a/nomad/structs/workload_id.go b/nomad/structs/workload_id.go index 5bf0a52354f..287da2b467a 100644 --- a/nomad/structs/workload_id.go +++ b/nomad/structs/workload_id.go @@ -5,6 +5,7 @@ package structs import ( "fmt" + "maps" "regexp" "slices" "strings" @@ -53,6 +54,8 @@ var ( // be safe to use in filenames. validIdentityName = regexp.MustCompile("^[a-zA-Z0-9-_]{1,128}$") + wIClaimPattern = regexp.MustCompile(`\$\{([^}]+)\}`) + // MinNomadVersionVaultWID is the minimum version of Nomad that supports // workload identities for Vault. // "-a" is used here so that it is "less than" all pre-release versions of @@ -100,7 +103,7 @@ type WorkloadIdentityClaimsBuilder struct { // allocation and identity request. Because it may be called with a denormalized // Allocation in the plan applier, the Job must be passed in as a separate // parameter. -func NewIdentityClaimsBuilder(job *Job, alloc *Allocation, wihandle *WIHandle, wid *WorkloadIdentity) *WorkloadIdentityClaimsBuilder { +func NewIdentityClaimsBuilder(job *Job, alloc *Allocation, wihandle *WIHandle, wid *WorkloadIdentity, ns *Namespace) *WorkloadIdentityClaimsBuilder { tg := job.LookupTaskGroup(alloc.TaskGroup) if tg == nil { return nil @@ -109,13 +112,24 @@ func NewIdentityClaimsBuilder(job *Job, alloc *Allocation, wihandle *WIHandle, w wid = DefaultWorkloadIdentity() } + extras := maps.Clone(ns.RequiredExtraClaims) + if extras == nil { + extras = make(map[string]string) + } + + for _, claim := range wid.ExtraClaims { + if value, ok := ns.OptionalExtraClaims[claim]; ok { + extras[claim] = value + } + } + return &WorkloadIdentityClaimsBuilder{ alloc: alloc, job: job, wihandle: wihandle, wid: wid, tg: tg, - extras: map[string]string{}, + extras: extras, } } @@ -230,27 +244,50 @@ func (b *WorkloadIdentityClaimsBuilder) interpolate() { return } - r := strings.NewReplacer( - // attributes that always exist - "${job.region}", b.job.Region, - "${job.namespace}", b.job.Namespace, - "${job.id}", b.job.GetIDforWorkloadIdentity(), - "${job.node_pool}", b.job.NodePool, - "${group.name}", b.tg.Name, - "${alloc.id}", b.alloc.ID, - - // attributes that conditionally exist - "${node.id}", strAttrGet(b.node, func(n *Node) string { return n.ID }), - "${node.datacenter}", strAttrGet(b.node, func(n *Node) string { return n.Datacenter }), - "${node.pool}", strAttrGet(b.node, func(n *Node) string { return n.NodePool }), - "${node.class}", strAttrGet(b.node, func(n *Node) string { return n.NodeClass }), - "${task.name}", strAttrGet(b.task, func(t *Task) string { return t.Name }), - "${vault.cluster}", strAttrGet(b.vault, func(v *Vault) string { return v.Cluster }), - "${vault.namespace}", strAttrGet(b.vault, func(v *Vault) string { return v.Namespace }), - "${vault.role}", strAttrGet(b.vault, func(v *Vault) string { return v.Role }), - ) + staticValues := map[string]string{ + "${job.region}": b.job.Region, + "${job.namespace}": b.job.Namespace, + "${job.id}": b.job.GetIDforWorkloadIdentity(), + "${job.node_pool}": b.job.NodePool, + "${group.name}": b.tg.Name, + "${alloc.id}": b.alloc.ID, + "${node.id}": strAttrGet(b.node, func(n *Node) string { return n.ID }), + "${node.datacenter}": strAttrGet(b.node, func(n *Node) string { return n.Datacenter }), + "${node.pool}": strAttrGet(b.node, func(n *Node) string { return n.NodePool }), + "${node.class}": strAttrGet(b.node, func(n *Node) string { return n.NodeClass }), + "${task.name}": strAttrGet(b.task, func(t *Task) string { return t.Name }), + "${vault.cluster}": strAttrGet(b.vault, func(v *Vault) string { return v.Cluster }), + "${vault.namespace}": strAttrGet(b.vault, func(v *Vault) string { return v.Namespace }), + "${vault.role}": strAttrGet(b.vault, func(v *Vault) string { return v.Role }), + } + for k, v := range b.extras { - b.extras[k] = r.Replace(v) + val := wIClaimPattern.ReplaceAllStringFunc(v, func(token string) string { + if val, ok := staticValues[token]; ok { + return val + } + + return b.resolveDynamicToken(token) + }) + b.extras[k] = val + } +} + +func (b *WorkloadIdentityClaimsBuilder) resolveDynamicToken(token string) string { + path := strings.TrimSuffix(strings.TrimPrefix(token, "${"), "}") + switch { + case strings.HasPrefix(path, "job.meta."): + key := strings.TrimPrefix(path, "job.meta.") + if key == "" || b.job == nil || b.job.Meta == nil { + return token + } + value, ok := b.job.Meta[key] + if !ok { + return token + } + return value + default: + return token } } @@ -294,9 +331,8 @@ type WorkloadIdentity struct { // this identity (eg the JWT "exp" claim). TTL time.Duration - // Note: ExtraClaims is available on config/WorkloadIdentity but not - // available here on jobspecs because that might allow a job author to - // escalate their privileges if they know what claim mappings to expect. + // ExtraClaims are additional claims to add to the workload identity token, which are set based on the configuration of the namespace + ExtraClaims []string } func DefaultWorkloadIdentity() *WorkloadIdentity { @@ -339,6 +375,7 @@ func (wi *WorkloadIdentity) Copy() *WorkloadIdentity { Filepath: wi.Filepath, ServiceName: wi.ServiceName, TTL: wi.TTL, + ExtraClaims: slices.Clone(wi.ExtraClaims), } } @@ -383,6 +420,10 @@ func (wi *WorkloadIdentity) Equal(other *WorkloadIdentity) bool { return false } + if !slices.Equal(wi.ExtraClaims, other.ExtraClaims) { + return false + } + return true } diff --git a/nomad/structs/workload_id_test.go b/nomad/structs/workload_id_test.go index f4a26279a37..25254e690e0 100644 --- a/nomad/structs/workload_id_test.go +++ b/nomad/structs/workload_id_test.go @@ -18,12 +18,28 @@ import ( func TestNewIdentityClaims(t *testing.T) { ci.Parallel(t) + namespace := &Namespace{ + Name: "default", + OptionalExtraClaims: map[string]string{ + "other": "value", + "meta": "${job.meta.customer}/${job.meta.environment}", + "unknown_meta": "${job.meta.missing}", + "unknown_token": "${job.unknown}", + "empty_and_meta": "x${job.meta.customer}y", + "nomad_workload_id": "other", + }, + } + job := &Job{ ID: "job", ParentID: "parentJob", Name: "job", - Namespace: "default", + Namespace: namespace.Name, Region: "global", + Meta: map[string]string{ + "customer": "important", + "environment": "prod", + }, TaskGroups: []*TaskGroup{ { @@ -55,6 +71,21 @@ func TestNewIdentityClaims(t *testing.T) { Name: "vault_default", Audience: []string{"vault.io"}, }, + { + Name: "vault_overwrite", + Audience: []string{"vault.io"}, + ExtraClaims: []string{"nomad_workload_id"}, + }, + { + Name: "identity-optional-claims", + Audience: []string{"opt.example.com"}, + ExtraClaims: []string{"other", "meta", "unknown_meta", "unknown_token", "empty_and_meta", "nomad_workload_id"}, + }, + { + Name: "nonexistant-option", + Audience: []string{"opt.example.com"}, + ExtraClaims: []string{"secret"}, + }, }, Services: []*Service{{ Name: "task-service", @@ -175,6 +206,39 @@ func TestNewIdentityClaims(t *testing.T) { } job.Canonicalize() + otherNamespace := &Namespace{ + Name: "other", + RequiredExtraClaims: map[string]string{ + "required": "${job.meta.required}", + }, + } + job2 := &Job{ + ID: "other-job", + ParentID: "parentJob", + Name: "other-job", + Namespace: otherNamespace.Name, + Region: "global", + Meta: map[string]string{ + "required": "more-information", + "environment": "prod", + }, + + TaskGroups: []*TaskGroup{ + { + Name: "group", + Tasks: []*Task{ + { + Name: "task", + Identity: &WorkloadIdentity{ + Name: "default-identity", + Audience: []string{"example.com"}, + }, + }, + }, + }, + }, + } + expectedClaims := map[string]*IdentityClaims{ // group: no consul. "job/group/services/group-service": { @@ -248,6 +312,53 @@ func TestNewIdentityClaims(t *testing.T) { Audience: jwt.Audience{"vault.io"}, }, }, + "job/group/task/vault_overwrite": { + WorkloadIdentityClaims: &WorkloadIdentityClaims{ + VaultNamespace: "", + Namespace: "default", + JobID: "parentJob", + TaskName: "task", + VaultRole: "", // not specified in jobspec + ExtraClaims: map[string]string{ + "nomad_workload_id": "global:default:parentJob", + }, + }, + Claims: jwt.Claims{ + Subject: "global:default:parentJob:group:task:vault_overwrite", + Audience: jwt.Audience{"vault.io"}, + }, + }, + "job/group/task/identity-optional-claims": { + WorkloadIdentityClaims: &WorkloadIdentityClaims{ + Namespace: "default", + JobID: "parentJob", + TaskName: "task", + ExtraClaims: map[string]string{ + "empty_and_meta": "ximportanty", + "meta": "important/prod", + "nomad_workload_id": "other", + "other": "value", + "unknown_meta": "${job.meta.missing}", + "unknown_token": "${job.unknown}", + }, + }, + Claims: jwt.Claims{ + Subject: "global:default:parentJob:group:task:identity-optional-claims", + Audience: jwt.Audience{"opt.example.com"}, + }, + }, + "job/group/task/nonexistant-option": { + WorkloadIdentityClaims: &WorkloadIdentityClaims{ + Namespace: "default", + JobID: "parentJob", + TaskName: "task", + ExtraClaims: map[string]string{}, + }, + Claims: jwt.Claims{ + Subject: "global:default:parentJob:group:task:nonexistant-option", + Audience: jwt.Audience{"opt.example.com"}, + }, + }, "job/group/task/services/task-service": { WorkloadIdentityClaims: &WorkloadIdentityClaims{ Namespace: "default", @@ -476,6 +587,20 @@ func TestNewIdentityClaims(t *testing.T) { Audience: jwt.Audience{"task-service.consul.io"}, }, }, + "other-job/group/task/default-identity": { + WorkloadIdentityClaims: &WorkloadIdentityClaims{ + Namespace: "other", + JobID: "parentJob", + TaskName: "task", + ExtraClaims: map[string]string{ + "required": "more-information", + }, + }, + Claims: jwt.Claims{ + Subject: "global:other:parentJob:group:task:default-identity", + Audience: jwt.Audience{"example.com"}, + }, + }, } // Generate service identity names. @@ -499,6 +624,8 @@ func TestNewIdentityClaims(t *testing.T) { type testCase struct { name string group string + job *Job + ns *Namespace task *Task svc *Service wid *WorkloadIdentity @@ -515,6 +642,8 @@ func TestNewIdentityClaims(t *testing.T) { testCases = append(testCases, testCase{ name: path, group: tg.Name, + job: job, + ns: namespace, svc: s, wid: s.Identity, wiHandle: s.IdentityHandle(nil), @@ -533,6 +662,8 @@ func TestNewIdentityClaims(t *testing.T) { path := path + "/" + wid.Name testCases = append(testCases, testCase{ name: path, + job: job, + ns: namespace, group: tg.Name, task: t, wid: wid, @@ -545,6 +676,8 @@ func TestNewIdentityClaims(t *testing.T) { path := path + "/services/" + s.Name testCases = append(testCases, testCase{ name: path, + job: job, + ns: namespace, group: tg.Name, task: t, svc: s, @@ -556,21 +689,38 @@ func TestNewIdentityClaims(t *testing.T) { } } + testCases = append(testCases, testCase{ + name: "other-job/group/task/default-identity", + group: "group", + job: job2, + ns: otherNamespace, + wid: job2.TaskGroups[0].Tasks[0].Identity, + wiHandle: job2.TaskGroups[0].Tasks[0].IdentityHandle(job2.TaskGroups[0].Tasks[0].Identity), + task: &Task{ + Name: "task", + Identity: &WorkloadIdentity{ + Name: "default-identity", + Audience: []string{"example.com"}, + }, + }, + expectedClaims: expectedClaims["other-job/group/task/default-identity"], + }) + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { if tc.expectedClaims == nil { - t.Skip("missing expected claims") + t.Skip("missing expected claims", tc.name) } now := time.Now() alloc := &Allocation{ ID: uuid.Generate(), - Namespace: job.Namespace, - JobID: job.ID, + Namespace: tc.ns.Name, + JobID: tc.job.ID, TaskGroup: tc.group, } - got := NewIdentityClaimsBuilder(job, alloc, tc.wiHandle, tc.wid). + got := NewIdentityClaimsBuilder(tc.job, alloc, tc.wiHandle, tc.wid, tc.ns). WithTask(tc.task). WithService(tc.svc). WithConsul(). diff --git a/nomad/variables_endpoint_test.go b/nomad/variables_endpoint_test.go index 58d7b6939d3..e59e190d5ef 100644 --- a/nomad/variables_endpoint_test.go +++ b/nomad/variables_endpoint_test.go @@ -503,7 +503,9 @@ func TestVariablesEndpoint_auth(t *testing.T) { task1 := alloc1.LookupTask("web") claims1 := structs.NewIdentityClaimsBuilder(alloc1.Job, alloc1, wiHandle, - task1.Identity). + task1.Identity, + mock.Namespace(), + ). WithTask(task1). Build(time.Now()) idToken, _, err := srv.encrypter.SignClaims(claims1) @@ -512,7 +514,9 @@ func TestVariablesEndpoint_auth(t *testing.T) { task2 := alloc2.LookupTask("web") claims2 := structs.NewIdentityClaimsBuilder(alloc2.Job, alloc2, wiHandle, - task2.Identity). + task2.Identity, + mock.Namespace(), + ). WithTask(task2). Build(time.Now()) noPermissionsToken, _, err := srv.encrypter.SignClaims(claims2) @@ -521,7 +525,9 @@ func TestVariablesEndpoint_auth(t *testing.T) { task3 := alloc3.LookupTask("web") claims3 := structs.NewIdentityClaimsBuilder(alloc3.Job, alloc3, wiHandle, - task3.Identity). + task3.Identity, + mock.Namespace(), + ). WithTask(task3). Build(time.Now()) idDispatchToken, _, err := srv.encrypter.SignClaims(claims3) @@ -540,7 +546,9 @@ func TestVariablesEndpoint_auth(t *testing.T) { task4 := alloc4.LookupTask("web") claims4 := structs.NewIdentityClaimsBuilder(alloc4.Job, alloc4, wiHandle, - task4.Identity). + task4.Identity, + mock.Namespace(), + ). WithTask(task4). Build(time.Now()) wiOnlyToken, _, err := srv.encrypter.SignClaims(claims4) @@ -899,7 +907,7 @@ func TestVariablesEndpoint_ListFiltering(t *testing.T) { task := alloc.LookupTask("web") claims := structs.NewIdentityClaimsBuilder(alloc.Job, alloc, wiHandle, - task.Identity). + task.Identity, mock.Namespace()). WithTask(task). Build(time.Now()) token, _, err := srv.encrypter.SignClaims(claims)