Skip to content

Commit 74c4478

Browse files
committed
[RFC-0014] spiffe,auth: JWT-SVIDs for objects via Broker API
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
1 parent 6102882 commit 74c4478

52 files changed

Lines changed: 2332 additions & 1094 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ There is **no top-level `go.mod`**. Each directory is its own module:
5151
- `runtime/` — largest module. Sub-packages: `conditions`, `patch`, `reconcile`, `events`, `metrics`, `features`, `cel`, `acl`, `controller`, `dependency`, `errors`, `jitter`, `leaderelection`, `logger`, `object`, `predicates`, `probes`, `pprof`, `secrets`, `statusreaders`, `testenv`, `transform`, `client`.
5252
- `ssa/` — server-side apply engine (`ResourceManager`): apply, diff, wait, delete, change sets. Sub-packages: `jsondiff`, `normalize`, `errors`, `utils`.
5353
- `git/` — git operations. `gogit/` sub-package is the concrete go-git implementation. `repository/` defines `Reader`/`Writer` interfaces.
54-
- `auth/` — cloud workload identity: `aws/`, `azure/`, `gcp/`, `generic/`, `githubapp/`, `utils/`. Central `GetAccessToken()` with caching.
54+
- `auth/` — cloud workload identity: `aws/`, `azure/`, `gcp/`, `generic/`, `githubapp/`, `utils/`. Central `NewAccessCredential()` with caching.
5555
- `artifact/` — artifact storage: `config/`, `digest/`, `server/`, `storage/`.
5656
- `oci/` — OCI registry client (push, pull, tag, list, build, diff, delete).
5757
- `kustomize/` — kustomize generator and variable substitution. `filesys/` provides secure filesystem implementations.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
### Authentication & Security
1616
- **[github.com/fluxcd/pkg/auth](./auth)** - OIDC-based authentication with cloud providers (AWS, Azure, GCP)
1717
- **[github.com/fluxcd/pkg/masktoken](./masktoken)** - Token redaction utilities for secure logging
18-
- **[github.com/fluxcd/pkg/spiffe](./spiffe)** - SPIFFE Workload API X.509-SVID utilities for mutual TLS
18+
- **[github.com/fluxcd/pkg/spiffe](./spiffe)** - SPIFFE Workload and Broker API utilities
1919
- **[github.com/fluxcd/pkg/ssh](./ssh)** - SSH host key scanning and management
2020

2121
### Artifact Storage & Serving

‎auth/access_credential.go‎

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
/*
2+
Copyright 2025 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package auth
18+
19+
import (
20+
"context"
21+
"errors"
22+
"fmt"
23+
"strings"
24+
"time"
25+
26+
"github.com/golang-jwt/jwt/v5"
27+
28+
cryptov1 "github.com/fluxcd/pkg/apis/crypto/v1"
29+
"github.com/fluxcd/pkg/auth/kubernetes"
30+
"github.com/fluxcd/pkg/cache"
31+
"github.com/fluxcd/pkg/spiffe/broker"
32+
)
33+
34+
// NewAccessCredential returns an access credential for accessing resources in
35+
// the given cloud provider.
36+
func NewAccessCredential(ctx context.Context, provider Provider, opts ...Option) (Credential, error) {
37+
38+
var o Options
39+
o.Apply(opts...)
40+
41+
// Initialize access credential fetcher for the controller.
42+
newAccessCredential := func() (Credential, error) {
43+
credential, err := provider.NewControllerToken(ctx, opts...)
44+
if err != nil {
45+
return nil, fmt.Errorf("failed to create provider access credential for the controller: %w", err)
46+
}
47+
return credential, nil
48+
}
49+
50+
// Update access credential fetcher for a ServiceAccount if specified.
51+
var serviceAccount *kubernetes.ServiceAccountReference
52+
var providerIdentity string
53+
var audiences []string
54+
if o.objectLevelWorkloadIdentity() {
55+
var err error
56+
serviceAccount, audiences, providerIdentity, err =
57+
getServiceAccountAndProviderInfo(ctx, provider, opts...)
58+
if err != nil {
59+
return nil, err
60+
}
61+
62+
// Update the function to create an access credential using the service account.
63+
newAccessCredential = func() (Credential, error) {
64+
identity, err := o.oidcCredential(ctx, audiences)
65+
if err != nil {
66+
return nil, err
67+
}
68+
69+
// Exchange the identity credential for a provider access credential.
70+
credential, err := provider.NewAccessCredentialForIdentity(ctx, identity, opts...)
71+
if err != nil {
72+
return nil, fmt.Errorf("failed to create provider access credential for service account '%s': %w",
73+
serviceAccount, err)
74+
}
75+
76+
return credential, nil
77+
}
78+
}
79+
80+
// Bail out early if cache is disabled.
81+
if o.Cache == nil {
82+
return newAccessCredential()
83+
}
84+
85+
// Build cache key.
86+
cacheKey := buildAccessCredentialCacheKey(provider, audiences, providerIdentity, serviceAccount, opts...)
87+
88+
// Get credential from cache.
89+
credential, _, err := o.Cache.GetOrSet(ctx, cacheKey, func(ctx context.Context) (cache.Token, error) {
90+
return newAccessCredential()
91+
}, cache.WithInvolvedObject(o.involvedObject()))
92+
if err != nil {
93+
return nil, err
94+
}
95+
96+
return credential, nil
97+
}
98+
99+
// oidcCredential returns the OIDC credential used to obtain a provider access
100+
// credential for the object. Kubernetes issues a ServiceAccount token, while
101+
// SPIFFE fetches a JWT-SVID for the object from the SPIFFE Broker.
102+
func (o *Options) oidcCredential(ctx context.Context, audiences []string) (Credential, error) {
103+
switch o.credentialProvider() {
104+
case cryptov1.CredentialProviderKubernetes:
105+
sa := o.ServiceAccountRef()
106+
token, err := kubernetes.CreateJWT(ctx, o.Client, sa, audiences, o.CredentialExpiration)
107+
if err != nil {
108+
return nil, fmt.Errorf("failed to create kubernetes token for service account '%s': %w", sa, err)
109+
}
110+
return newJWTCredential(cryptov1.CredentialProviderKubernetes, token)
111+
112+
case cryptov1.CredentialProviderSPIFFE:
113+
if o.credentialType() != cryptov1.CredentialTypeJWT {
114+
return nil, fmt.Errorf("credential type '%s' for the %s credential provider is not implemented yet",
115+
o.credentialType(), o.credentialProvider())
116+
}
117+
if o.SPIFFEBroker == nil {
118+
return nil, errors.New("SPIFFE Broker client is required for the SPIFFE credential provider")
119+
}
120+
if o.ObjectReference.Kind == "" {
121+
return nil, errors.New("object reference is required for the SPIFFE credential provider")
122+
}
123+
124+
svid, err := o.SPIFFEBroker.FetchJWTSVID(ctx, broker.KubernetesObjectReference{
125+
Group: o.ObjectReference.APIGroup,
126+
Kind: o.ObjectReference.Kind,
127+
Namespace: o.ObjectReference.Namespace,
128+
Name: o.ObjectReference.Name,
129+
UID: o.ObjectReference.UID,
130+
}, audiences)
131+
if err != nil {
132+
return nil, fmt.Errorf("failed to fetch SPIFFE JWT-SVID for object '%s/%s %s/%s': %w",
133+
o.ObjectReference.APIGroup, o.ObjectReference.Kind,
134+
o.ObjectReference.Namespace, o.ObjectReference.Name, err)
135+
}
136+
return newJWTCredential(cryptov1.CredentialProviderSPIFFE, svid.Marshal())
137+
138+
default:
139+
return nil, fmt.Errorf("credential provider '%s' is not supported", o.credentialProvider())
140+
}
141+
}
142+
143+
// newJWTCredential returns a JWTCredential for the given provider and
144+
// serialized JWT, extracting the expiration time from the token.
145+
func newJWTCredential(provider, token string) (*JWTCredential, error) {
146+
parsed, _, err := jwt.NewParser().ParseUnverified(token, jwt.MapClaims{})
147+
if err != nil {
148+
return nil, fmt.Errorf("failed to parse JWT: %w", err)
149+
}
150+
expiration, err := parsed.Claims.GetExpirationTime()
151+
if err != nil {
152+
return nil, fmt.Errorf("failed to get JWT expiration: %w", err)
153+
}
154+
var expiresAt time.Time
155+
if expiration != nil {
156+
expiresAt = expiration.Time
157+
}
158+
return &JWTCredential{
159+
Provider: provider,
160+
Token: token,
161+
ExpiresAt: expiresAt,
162+
}, nil
163+
}
164+
165+
func getServiceAccountAndProviderInfo(ctx context.Context, provider Provider,
166+
opts ...Option) (*kubernetes.ServiceAccountReference, []string, string, error) {
167+
var o Options
168+
o.Apply(opts...)
169+
170+
sa := o.ServiceAccountRef()
171+
172+
// Get provider audience. The provider decides whether any configured
173+
// credential audiences are used.
174+
audiences, err := provider.GetAudiences(ctx, opts...)
175+
if err != nil {
176+
return nil, nil, "", fmt.Errorf("failed to get provider audience: %w", err)
177+
}
178+
179+
// Get provider identity.
180+
providerIdentity, err := provider.GetIdentity(opts...)
181+
if err != nil {
182+
return nil, nil, "", fmt.Errorf("failed to get provider identity from service account '%s': %w",
183+
sa, err)
184+
}
185+
186+
return sa, audiences, providerIdentity, nil
187+
}
188+
189+
func buildAccessCredentialCacheKey(provider Provider, audiences []string, providerIdentity string,
190+
serviceAccount *kubernetes.ServiceAccountReference, opts ...Option) string {
191+
192+
var o Options
193+
o.Apply(opts...)
194+
195+
var parts []string
196+
197+
parts = append(parts, fmt.Sprintf("provider=%s", provider.GetName()))
198+
199+
if o.CredentialProvider != "" {
200+
parts = append(parts, fmt.Sprintf("credentialProvider=%s", o.CredentialProvider))
201+
parts = append(parts, fmt.Sprintf("credentialType=%s", o.credentialType()))
202+
}
203+
204+
if len(audiences) == 0 {
205+
audiences = o.CredentialAudiences
206+
}
207+
if len(audiences) > 0 {
208+
parts = append(parts, fmt.Sprintf("audiences=%s", strings.Join(audiences, ",")))
209+
}
210+
211+
if serviceAccount != nil {
212+
parts = append(parts, fmt.Sprintf("providerIdentity=%s", providerIdentity))
213+
if o.credentialProvider() == cryptov1.CredentialProviderSPIFFE {
214+
// Even if the object shares its SPIFFE ID with others in pratice,
215+
// Flux has to assume it doesn't, so the full object reference must
216+
// be included in the cache key for SPIFFE. This means different
217+
// objects will never share cached credentials when using SPIFFE
218+
// (but the same object may still reuse its cached credentials).
219+
parts = append(parts,
220+
fmt.Sprintf("objectGroup=%s", o.ObjectReference.APIGroup),
221+
fmt.Sprintf("objectKind=%s", o.ObjectReference.Kind),
222+
fmt.Sprintf("objectNamespace=%s", o.ObjectReference.Namespace),
223+
fmt.Sprintf("objectName=%s", o.ObjectReference.Name),
224+
fmt.Sprintf("objectUID=%s", o.ObjectReference.UID),
225+
)
226+
} else {
227+
parts = append(parts,
228+
fmt.Sprintf("serviceAccountName=%s", serviceAccount.Name),
229+
fmt.Sprintf("serviceAccountNamespace=%s", serviceAccount.Namespace),
230+
)
231+
}
232+
}
233+
234+
if len(o.Scopes) > 0 {
235+
parts = append(parts, fmt.Sprintf("scopes=%s", strings.Join(o.Scopes, ",")))
236+
}
237+
238+
if o.AWSRegion != "" {
239+
parts = append(parts, fmt.Sprintf("stsRegion=%s", o.AWSRegion))
240+
}
241+
242+
if o.AWSSTSEndpoint != "" {
243+
parts = append(parts, fmt.Sprintf("stsEndpoint=%s", o.AWSSTSEndpoint))
244+
}
245+
246+
if o.ProxyURL != nil {
247+
parts = append(parts, fmt.Sprintf("proxyURL=%s", o.ProxyURL))
248+
}
249+
250+
if o.CAData != "" {
251+
parts = append(parts, fmt.Sprintf("caData=%s", o.CAData))
252+
}
253+
254+
return buildCacheKey(parts...)
255+
}

0 commit comments

Comments
 (0)