Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
337 changes: 205 additions & 132 deletions api/gen/proto/go/teleport/workloadidentity/v1/resource.pb.go

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions api/proto/teleport/workloadidentity/v1/resource.proto
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ syntax = "proto3";

package teleport.workloadidentity.v1;

import "google/protobuf/struct.proto";
import "teleport/header/v1/metadata.proto";

option go_package = "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1;workloadidentityv1";
Expand Down Expand Up @@ -134,6 +135,12 @@ message WorkloadIdentitySPIFFEX509 {
X509DistinguishedNameTemplate subject_template = 2;
}

// Configuration specific to the issuance of JWT-SVIDs.
message WorkloadIdentitySPIFFEJWT {
// Additional claims that will be added to the JWT.
google.protobuf.Struct extra_claims = 1;
}

// Configuration pertaining to the issuance of SPIFFE-compatible workload
// identity credentials.
message WorkloadIdentitySPIFFE {
Expand All @@ -149,6 +156,8 @@ message WorkloadIdentitySPIFFE {
string hint = 2;
// Configuration specific to X509-SVIDs.
WorkloadIdentitySPIFFEX509 x509 = 3;
// Configuration specific to JWT-SVIDs.
WorkloadIdentitySPIFFEJWT jwt = 4;
}

// The spec for the WorkloadIdentity resource.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ exclude_fields:
# Metadata (we id resources by name on our side)
- "WorkloadIdentity.metadata.id"

# The extra_claims field is a `google.protobuf.Struct` which isn't currently
# supported by protoc-gen-terraform. We omit the entire jwt message because
# there are no other fields so the generated code contains an unused variable
# declaration otherwise.
#
# https://github.com/gravitational/protoc-gen-terraform/issues/52
- "WorkloadIdentity.spec.spiffe.jwt"

# These fields will be marked as Computed: true
computed_fields:
# Metadata
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

78 changes: 78 additions & 0 deletions lib/auth/machineid/workloadidentityv1/decision.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ package workloadidentityv1

import (
"context"
"fmt"
"slices"
"strings"

"github.com/gravitational/trace"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/known/structpb"

workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
"github.com/gravitational/teleport/lib/auth/machineid/workloadidentityv1/expression"
Expand Down Expand Up @@ -118,6 +120,18 @@ func decide(
dst.OrganizationalUnit = templated
}

if ec := wi.GetSpec().GetSpiffe().GetJwt().GetExtraClaims(); ec != nil {
templated, err := templateExtraClaims(ec, attrs)
if err != nil {
d.reason = trace.Wrap(
err,
"templating spec.spiffe.jwt.extra_claims",
)
return d
}
d.templatedWorkloadIdentity.Spec.Spiffe.Jwt.ExtraClaims = templated
}

// Yay - made it to the end!
d.shouldIssue = true
return d
Expand Down Expand Up @@ -219,3 +233,67 @@ ruleLoop:
// TODO: Eventually, we'll need to work support for deny rules into here.
return trace.AccessDenied("no matching rule found")
}

func templateExtraClaims(templates *structpb.Struct, attrs *workloadidentityv1pb.Attrs) (*structpb.Struct, error) {
// render is called recursively on list elements and struct fields.
var render func(string, *structpb.Value, int) (*structpb.Value, error)

const maxDepth = 10
render = func(fieldName string, fieldValue *structpb.Value, depth int) (*structpb.Value, error) {
if depth >= maxDepth {
return nil, trace.BadParameter("extra_claims cannot contain more than %d levels of nesting", maxDepth)
}

switch value := fieldValue.GetKind().(type) {
// Numbers, booleans, and nulls can be emitted as-is.
case *structpb.Value_NumberValue, *structpb.Value_BoolValue, *structpb.Value_NullValue:
return fieldValue, nil

// We treat string values as templates.
case *structpb.Value_StringValue:
renderedString, err := expression.RenderTemplate(value.StringValue, attrs)
if err != nil {
return nil, trace.Wrap(err, "templating claim: %s", fieldName)
}
return structpb.NewStringValue(renderedString), nil

// For struct values, we call render on each of their fields.
case *structpb.Value_StructValue:
result := &structpb.Struct{Fields: make(map[string]*structpb.Value)}
for structKey, structValue := range value.StructValue.GetFields() {
keyWithPrefix := structKey
if fieldName != "" {
keyWithPrefix = fmt.Sprintf("%s.%s", fieldName, structKey)
}
v, err := render(keyWithPrefix, structValue, depth+1)
if err != nil {
return nil, err
}
result.Fields[structKey] = v
}
return structpb.NewStructValue(result), nil

// For list values, we call render on each of their elements.
case *structpb.Value_ListValue:
result := new(structpb.ListValue)
for idx, val := range value.ListValue.GetValues() {
v, err := render(fmt.Sprintf("%s[%d]", fieldName, idx), val, depth+1)
if err != nil {
return nil, err
}
result.Values = append(result.Values, v)
}
return structpb.NewListValue(result), nil

// At the time of writing, there are no other possible value types.
default:
return nil, trace.Errorf("unsupported field type: %T", value)
}
}

result, err := render("", structpb.NewStructValue(templates), 0)
if err != nil {
return nil, err
}
return result.GetStructValue(), nil
}
118 changes: 118 additions & 0 deletions lib/auth/machineid/workloadidentityv1/decision_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@ package workloadidentityv1

import (
"context"
"encoding/json"
"testing"

"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/testing/protocmp"
"google.golang.org/protobuf/types/known/structpb"

headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1"
Expand Down Expand Up @@ -401,3 +405,117 @@ func Test_evaluateRules(t *testing.T) {
})
}
}

func TestTemplateExtraClaims_Success(t *testing.T) {
const inputJSON = `
{
"simple-string": "hello world",
"simple-number": 1234,
"simple-bool": true,
"null": null,
"object": {
"message": "hello, {{user.name}}",
"workload": {
"podman": {
"pod_name": "{{workload.podman.pod.name}}",
"labels": ["{{workload.podman.pod.labels[\"a\"]}}", "{{workload.podman.pod.labels[\"b\"]}}", "c"]
}
}
}
}
`

const expectedOutputJSON = `
{
"simple-string": "hello world",
"simple-number": 1234,
"simple-bool": true,
"null": null,
"object": {
"message": "hello, Bobby",
"workload": {
"podman": {
"pod_name": "webserver",
"labels": ["a", "b", "c"]
}
}
}
}
`

var input, expectedOutput *structpb.Struct
err := json.Unmarshal([]byte(inputJSON), &input)
require.NoError(t, err)

err = json.Unmarshal([]byte(expectedOutputJSON), &expectedOutput)
require.NoError(t, err)

output, err := templateExtraClaims(input, &workloadidentityv1pb.Attrs{
User: &workloadidentityv1pb.UserAttrs{
Name: "Bobby",
},
Workload: &workloadidentityv1pb.WorkloadAttrs{
Podman: &workloadidentityv1pb.WorkloadAttrsPodman{
Pod: &workloadidentityv1pb.WorkloadAttrsPodmanPod{
Name: "webserver",
Labels: map[string]string{"a": "a", "b": "b"},
},
},
},
})
require.NoError(t, err)
require.Empty(t, cmp.Diff(expectedOutput, output, protocmp.Transform()))
}

func TestTemplateExtraClaims_Failure(t *testing.T) {
const claimsJSON = `
{
"foo": {
"bar": {
"baz": ["a", {"b":"{{blah}}"}, "c"]
}
}
}
`

var rawClaims *structpb.Struct
err := json.Unmarshal([]byte(claimsJSON), &rawClaims)
require.NoError(t, err)

_, err = templateExtraClaims(rawClaims, &workloadidentityv1pb.Attrs{})
require.ErrorContains(t, err, "templating claim: foo.bar.baz[1].b")
require.ErrorContains(t, err, `unknown identifier: "blah"`)
}

func TestTemplateExtraClaims_TooDeeplyNested(t *testing.T) {
const claimsJSON = `
{
"1": {
"2": {
"3": {
"4": {
"5": {
"6": {
"7": {
"8": {
"9": {
"10": "very deep"
}
}
}
}
}
}
}
}
}
}
`

var rawClaims *structpb.Struct
err := json.Unmarshal([]byte(claimsJSON), &rawClaims)
require.NoError(t, err)

_, err = templateExtraClaims(rawClaims, &workloadidentityv1pb.Attrs{})
require.ErrorContains(t, err, "cannot contain more than 10 levels of nesting")
}
2 changes: 2 additions & 0 deletions lib/auth/machineid/workloadidentityv1/issuer_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,8 @@ func (s *IssuanceService) issueJWTSVID(

SetIssuedAt: now,
SetExpiry: notAfter,

PrivateClaims: wid.GetSpec().GetSpiffe().GetJwt().GetExtraClaims().AsMap(),
})
if err != nil {
return nil, trace.Wrap(err, "signing jwt")
Expand Down
55 changes: 55 additions & 0 deletions lib/auth/machineid/workloadidentityv1/workloadidentityv1_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import (
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/testing/protocmp"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb"

apiproto "github.com/gravitational/teleport/api/client/proto"
Expand Down Expand Up @@ -466,6 +467,31 @@ func TestIssueWorkloadIdentity(t *testing.T) {
})
require.NoError(t, err)

extraClaimTemplates, err := structpb.NewStruct(map[string]any{
"user_name": "{{user.name}}",
"k8s": map[string]any{
"names": []any{"{{workload.kubernetes.pod_name}}"},
},
})
require.NoError(t, err)

extraClaims, err := tp.srv.Auth().CreateWorkloadIdentity(ctx, &workloadidentityv1pb.WorkloadIdentity{
Kind: types.KindWorkloadIdentity,
Version: types.V1,
Metadata: &headerv1.Metadata{
Name: "extra-claims",
},
Spec: &workloadidentityv1pb.WorkloadIdentitySpec{
Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{
Id: "/foo",
Jwt: &workloadidentityv1pb.WorkloadIdentitySPIFFEJWT{
ExtraClaims: extraClaimTemplates,
},
},
},
})
require.NoError(t, err)

workloadAttrs := func(f func(attrs *workloadidentityv1pb.WorkloadAttrs)) *workloadidentityv1pb.WorkloadAttrs {
attrs := &workloadidentityv1pb.WorkloadAttrs{
Kubernetes: &workloadidentityv1pb.WorkloadAttrsKubernetes{
Expand Down Expand Up @@ -577,6 +603,35 @@ func TestIssueWorkloadIdentity(t *testing.T) {
))
},
},
{
name: "jwt svid - extra claims",
client: wilcardAccessClient,
req: &workloadidentityv1pb.IssueWorkloadIdentityRequest{
Name: extraClaims.GetMetadata().GetName(),
Credential: &workloadidentityv1pb.IssueWorkloadIdentityRequest_JwtSvidParams{
JwtSvidParams: &workloadidentityv1pb.JWTSVIDParams{
Audiences: []string{"example.com"},
},
},
WorkloadAttrs: workloadAttrs(nil),
},
requireErr: require.NoError,
assert: func(t *testing.T, res *workloadidentityv1pb.IssueWorkloadIdentityResponse) {
parsed, err := jwt.ParseSigned(res.GetCredential().GetJwtSvid().GetJwt())
require.NoError(t, err)

var claims struct {
UserName string `json:"user_name"`
K8s struct {
Names []string `json:"names"`
} `json:"k8s"`
}
err = parsed.Claims(tp.spiffeJWTSigner.Public(), &claims)
require.NoError(t, err)
require.Equal(t, "dog", claims.UserName)
require.Equal(t, []string{"test"}, claims.K8s.Names)
},
},
{
name: "x509 svid",
client: wilcardAccessClient,
Expand Down
Loading
Loading