Skip to content
Open
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
40 changes: 36 additions & 4 deletions pkg/agent/manager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,10 +292,19 @@ func (m *manager) FetchJWTSVID(ctx context.Context, entry *common.RegistrationEn
return nil, errors.New("Invalid SPIFFE ID: " + err.Error())
}

// Calculate effective policy for the requested audiences.
// UNIQUE policy means every request gets a fresh token (no caching).
effectivePolicy := effectiveJWTSVIDPolicy(audience, entry.JwtSvidDefaultAudiencePolicy, entry.JwtSvidAudiencePolicies)
bypassCache := effectivePolicy == common.JWTSVIDAudiencePolicy_JWT_SVID_AUDIENCE_POLICY_UNIQUE

now := m.clk.Now()
cachedSVID, ok := m.cache.GetJWTSVID(spiffeID, audience)
if ok && !m.c.RotationStrategy.JWTSVIDExpiresSoon(cachedSVID, now) {
return cachedSVID, nil
var cachedSVID *client.JWTSVID
var ok bool
if !bypassCache {
cachedSVID, ok = m.cache.GetJWTSVID(spiffeID, audience)
if ok && !m.c.RotationStrategy.JWTSVIDExpiresSoon(cachedSVID, now) {
return cachedSVID, nil
}
}

newSVID, err := m.client.NewJWTSVID(ctx, entry.EntryId, audience)
Expand All @@ -310,10 +319,33 @@ func (m *manager) FetchJWTSVID(ctx context.Context, entry *common.RegistrationEn
return cachedSVID, nil
}

m.cache.SetJWTSVID(spiffeID, audience, newSVID)
// Only cache if not using UNIQUE policy
if !bypassCache {
m.cache.SetJWTSVID(spiffeID, audience, newSVID)
}
return newSVID, nil
}

// effectiveJWTSVIDPolicy determines the effective policy for a JWT-SVID request
// based on the requested audiences. It uses the "most restrictive wins" approach:
// if any audience has a stricter policy, that policy applies to the whole token.
// Policy strictness order: DEFAULT(0) < AUDITABLE(1) < UNIQUE(2)
func effectiveJWTSVIDPolicy(audiences []string, defaultPolicy common.JWTSVIDAudiencePolicy, audiencePolicies map[string]common.JWTSVIDAudiencePolicy) common.JWTSVIDAudiencePolicy {
maxPolicy := defaultPolicy
for _, audience := range audiences {
var policy common.JWTSVIDAudiencePolicy
if p, ok := audiencePolicies[audience]; ok {
policy = p
} else {
policy = defaultPolicy
}
if policy > maxPolicy {
maxPolicy = policy
}
}
return maxPolicy
}

func (m *manager) runSynchronizer(ctx context.Context) error {
syncInterval := min(m.synchronizeBackoff.NextBackOff(), defaultSyncInterval)
for {
Expand Down
16 changes: 12 additions & 4 deletions pkg/server/ca/ca.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ type WorkloadJWTSVIDParams struct {

// Audience is used for audience claims
Audience []string

// JWTSVIDDefaultAudiencePolicy is the default policy for audiences not in the map
JWTSVIDDefaultAudiencePolicy int32

// JWTSVIDAudiencePolicies maps specific audiences to their policies
JWTSVIDAudiencePolicies map[string]int32
}

type X509CA struct {
Expand Down Expand Up @@ -325,10 +331,12 @@ func (ca *CA) SignWorkloadJWTSVID(ctx context.Context, params WorkloadJWTSVIDPar
}

claims, err := ca.c.CredBuilder.BuildWorkloadJWTSVIDClaims(ctx, credtemplate.WorkloadJWTSVIDParams{
SPIFFEID: params.SPIFFEID,
Audience: params.Audience,
TTL: params.TTL,
ExpirationCap: jwtKey.NotAfter,
SPIFFEID: params.SPIFFEID,
Audience: params.Audience,
TTL: params.TTL,
ExpirationCap: jwtKey.NotAfter,
JWTSVIDDefaultAudiencePolicy: params.JWTSVIDDefaultAudiencePolicy,
JWTSVIDAudiencePolicies: params.JWTSVIDAudiencePolicies,
})
if err != nil {
return "", err
Expand Down
35 changes: 35 additions & 0 deletions pkg/server/credtemplate/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/andres-erbsen/clock"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/gofrs/uuid/v5"
"github.com/spiffe/go-spiffe/v2/spiffeid"
"github.com/spiffe/spire/pkg/common/idutil"
"github.com/spiffe/spire/pkg/common/tlspolicy"
Expand Down Expand Up @@ -97,6 +98,10 @@ type WorkloadJWTSVIDParams struct {
Audience []string
TTL time.Duration
ExpirationCap time.Time
// JWTSVIDDefaultAudiencePolicy is the default policy for audiences not in the map
JWTSVIDDefaultAudiencePolicy int32
// JWTSVIDAudiencePolicies maps specific audiences to their policies
JWTSVIDAudiencePolicies map[string]int32
}

type Config struct {
Expand Down Expand Up @@ -330,6 +335,15 @@ func (b *Builder) BuildWorkloadJWTSVIDClaims(ctx context.Context, params Workloa
"iat": jwt.NewNumericDate(now),
},
}

// Determine the effective policy for this JWT-SVID based on the requested audiences.
// Uses "most restrictive wins" approach when multiple audiences are requested.
effectivePolicy := effectiveJWTSVIDPolicy(params.Audience, params.JWTSVIDDefaultAudiencePolicy, params.JWTSVIDAudiencePolicies)

// Add JTI claim if policy is AUDITABLE (1) or UNIQUE (2)
if effectivePolicy > 0 {
attributes.Claims["jti"] = uuid.Must(uuid.NewV4()).String()
}
if b.config.JWTIssuer != "" {
attributes.Claims["iss"] = b.config.JWTIssuer
}
Expand Down Expand Up @@ -506,3 +520,24 @@ func dropEmptyValues(ss []string) []string {
ss = ss[:next]
return ss
}

// effectiveJWTSVIDPolicy determines the effective policy for a JWT-SVID request.
// When multiple audiences are requested, it uses "most restrictive wins":
// UNIQUE (2) > AUDITABLE (1) > DEFAULT (0)
func effectiveJWTSVIDPolicy(audiences []string, defaultPolicy int32, audiencePolicies map[string]int32) int32 {
var maxPolicy int32 = defaultPolicy

for _, audience := range audiences {
var policy int32
if p, ok := audiencePolicies[audience]; ok {
policy = p
} else {
policy = defaultPolicy
}
if policy > maxPolicy {
maxPolicy = policy
}
}

return maxPolicy
}
138 changes: 138 additions & 0 deletions pkg/server/credtemplate/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"time"

"github.com/go-jose/go-jose/v4/jwt"
"github.com/gofrs/uuid/v5"
"github.com/spiffe/go-spiffe/v2/spiffeid"
credentialcomposerv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/credentialcomposer/v1"
"github.com/spiffe/spire/pkg/common/catalog"
Expand Down Expand Up @@ -1192,6 +1193,17 @@ func TestBuildWorkloadJWTSVIDClaims(t *testing.T) {
}
require.NoError(t, err)

// jti claim is only present for AUDITABLE or UNIQUE policies (not DEFAULT)
// For default tests, jti should not be present
if jti, ok := template["jti"]; ok {
jtiStr, isString := jti.(string)
require.True(t, isString, "jti claim should be a string")
_, err = uuid.FromString(jtiStr)
require.NoError(t, err, "jti should be a valid UUID")
// Remove jti from template for comparison since it's unique per call
delete(template, "jti")
}

expected := map[string]any{
"aud": []string{"AUDIENCE"},
"iat": jwt.NewNumericDate(now),
Expand All @@ -1207,6 +1219,132 @@ func TestBuildWorkloadJWTSVIDClaims(t *testing.T) {
}
}

func TestBuildWorkloadJWTSVIDClaimsJTIUniqueness(t *testing.T) {
testBuilder(t, nil, func(t *testing.T, credBuilder *credtemplate.Builder) {
// Use AUDITABLE policy (1) to trigger JTI inclusion
params := credtemplate.WorkloadJWTSVIDParams{
SPIFFEID: workloadID,
Audience: []string{"AUDIENCE"},
JWTSVIDDefaultAudiencePolicy: 1, // AUDITABLE - includes JTI
}

// Generate multiple JWT-SVIDs and verify jti uniqueness
jtis := make(map[string]bool)
for i := 0; i < 10; i++ {
template, err := credBuilder.BuildWorkloadJWTSVIDClaims(ctx, params)
require.NoError(t, err)

jti, ok := template["jti"].(string)
require.True(t, ok, "jti claim should be a string")
require.NotEmpty(t, jti, "jti should not be empty")

// Verify jti is a valid UUID
_, err = uuid.FromString(jti)
require.NoError(t, err, "jti should be a valid UUID")

// Verify uniqueness
require.False(t, jtis[jti], "jti should be unique across calls")
jtis[jti] = true
}
})
}

func TestBuildWorkloadJWTSVIDClaimsAudiencePolicy(t *testing.T) {
for _, tc := range []struct {
desc string
defaultPolicy int32
audiencePolicies map[string]int32
audiences []string
expectJTI bool
}{
{
desc: "default policy (0) - no JTI",
defaultPolicy: 0, // DEFAULT
audiences: []string{"aud1"},
expectJTI: false,
},
{
desc: "auditable policy (1) - includes JTI",
defaultPolicy: 1, // AUDITABLE
audiences: []string{"aud1"},
expectJTI: true,
},
{
desc: "unique policy (2) - includes JTI",
defaultPolicy: 2, // UNIQUE
audiences: []string{"aud1"},
expectJTI: true,
},
{
desc: "per-audience override to auditable",
defaultPolicy: 0, // DEFAULT
audiencePolicies: map[string]int32{"aud1": 1}, // AUDITABLE for aud1
audiences: []string{"aud1"},
expectJTI: true,
},
{
desc: "per-audience override to unique",
defaultPolicy: 0, // DEFAULT
audiencePolicies: map[string]int32{"aud1": 2}, // UNIQUE for aud1
audiences: []string{"aud1"},
expectJTI: true,
},
{
desc: "multi-audience most restrictive wins - one unique",
defaultPolicy: 0, // DEFAULT
audiencePolicies: map[string]int32{"aud2": 2}, // UNIQUE for aud2
audiences: []string{"aud1", "aud2"},
expectJTI: true,
},
{
desc: "multi-audience most restrictive wins - one auditable",
defaultPolicy: 0, // DEFAULT
audiencePolicies: map[string]int32{"aud2": 1}, // AUDITABLE for aud2
audiences: []string{"aud1", "aud2"},
expectJTI: true,
},
{
desc: "multi-audience all default",
defaultPolicy: 0, // DEFAULT
audiencePolicies: map[string]int32{},
audiences: []string{"aud1", "aud2"},
expectJTI: false,
},
{
desc: "audience not in policy map uses default",
defaultPolicy: 1, // AUDITABLE
audiencePolicies: map[string]int32{"other": 0}, // DEFAULT for other
audiences: []string{"aud1"}, // aud1 not in map, uses default AUDITABLE
expectJTI: true,
},
} {
t.Run(tc.desc, func(t *testing.T) {
testBuilder(t, nil, func(t *testing.T, credBuilder *credtemplate.Builder) {
params := credtemplate.WorkloadJWTSVIDParams{
SPIFFEID: workloadID,
Audience: tc.audiences,
JWTSVIDDefaultAudiencePolicy: tc.defaultPolicy,
JWTSVIDAudiencePolicies: tc.audiencePolicies,
}

template, err := credBuilder.BuildWorkloadJWTSVIDClaims(ctx, params)
require.NoError(t, err)

jti, hasJTI := template["jti"]
if tc.expectJTI {
require.True(t, hasJTI, "expected jti claim to be present")
jtiStr, ok := jti.(string)
require.True(t, ok, "jti should be a string")
_, err = uuid.FromString(jtiStr)
require.NoError(t, err, "jti should be a valid UUID")
} else {
require.False(t, hasJTI, "expected jti claim to not be present")
}
})
})
}
}

func testBuilder(t *testing.T, overrideConfig func(config *credtemplate.Config), fn func(*testing.T, *credtemplate.Builder)) {
config := credtemplate.Config{
TrustDomain: td,
Expand Down
21 changes: 18 additions & 3 deletions pkg/server/datastore/sqlstore/migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,12 +267,12 @@ import (
// | v1.13.2 | | |
// | v1.13.3 | | |
// |*********|********|***************************************************************************|
// | v1.14.0 | | |
// | v1.14.0 | 24 | Added JWT-SVID audience policies (default policy and per-audience map) |
// ================================================================================================

const (
// the latest schema version of the database in the code
latestSchemaVersion = 23
latestSchemaVersion = 24

// lastMinorReleaseSchemaVersion is the schema version supported by the
// last minor release. When the migrations are opportunistically pruned
Expand Down Expand Up @@ -432,6 +432,7 @@ func initDB(db *gorm.DB, dbType string, log logrus.FieldLogger) (err error) {
&DNSName{},
&FederatedTrustDomain{},
CAJournal{},
&EntryAudiencePolicy{},
}

if err := tableOptionsForDialect(tx, dbType).AutoMigrate(tables...).Error; err != nil {
Expand Down Expand Up @@ -502,7 +503,9 @@ func migrateVersion(tx *gorm.DB, currVersion int, log logrus.FieldLogger) (versi
// return nil
// }
//
switch currVersion { //nolint: gocritic,revive // No upgrade required yet, keeping switch for future additions
switch currVersion {
case 23:
err = migrateToV24(tx)
default:
err = newSQLError("no migration support for unknown schema version %d", currVersion)
}
Expand All @@ -513,6 +516,18 @@ func migrateVersion(tx *gorm.DB, currVersion int, log logrus.FieldLogger) (versi
return nextVersion, nil
}

func migrateToV24(tx *gorm.DB) error {
// Add jwt_svid_default_audience_policy column to registered_entries
if err := tx.AutoMigrate(&RegisteredEntry{}).Error; err != nil {
return newWrappedSQLError(err)
}
// Create entry_audience_policies table for per-audience JWT-SVID policy configuration
if err := tx.AutoMigrate(&EntryAudiencePolicy{}).Error; err != nil {
return newWrappedSQLError(err)
}
return nil
}

func addFederatedRegistrationEntriesRegisteredEntryIDIndex(tx *gorm.DB) error {
// GORM creates the federated_registration_entries implicitly with a primary
// key tuple (bundle_id, registered_entry_id). Unfortunately, MySQL5 does
Expand Down
20 changes: 20 additions & 0 deletions pkg/server/datastore/sqlstore/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ type RegisteredEntry struct {

// TTL of JWT identities derived from this entry
JWTSvidTTL int32 `gorm:"column:jwt_svid_ttl"`

// Default JWT-SVID audience policy for audiences not explicitly configured
JWTSvidDefaultAudiencePolicy int32 `gorm:"column:jwt_svid_default_audience_policy"`

// Per-audience JWT-SVID policy overrides
AudiencePolicies []EntryAudiencePolicy
}

// RegisteredEntryEvent holds the entry id of a registered entry that had an event
Expand Down Expand Up @@ -129,6 +135,20 @@ type Selector struct {
Value string `gorm:"unique_index:idx_selector_entry;index:idx_selectors_type_value"`
}

// EntryAudiencePolicy holds JWT-SVID audience policy configuration for a registration entry
type EntryAudiencePolicy struct {
Model

RegisteredEntryID uint `gorm:"unique_index:idx_audience_policy_entry"`
Audience string `gorm:"unique_index:idx_audience_policy_entry"`
Policy int32
}

// TableName gets table name for EntryAudiencePolicy
func (EntryAudiencePolicy) TableName() string {
return "entry_audience_policies"
}

// DNSName holds a DNS for a registration entry
type DNSName struct {
Model
Expand Down
Loading