Skip to content

Commit be90f98

Browse files
committed
CLOUDP-373260 Atlas Service Accounts support - phase 1
1 parent df930bf commit be90f98

11 files changed

Lines changed: 2190 additions & 47 deletions

File tree

docs/dev/td-service-accounts-phase1.md

Lines changed: 583 additions & 0 deletions
Large diffs are not rendered by default.

internal/controller/atlas/provider.go

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/mongodb-forks/digest"
2727
v20250312 "go.mongodb.org/atlas-sdk/v20250312018/admin"
2828
"go.uber.org/zap"
29+
"golang.org/x/oauth2"
2930

3031
"github.com/mongodb/mongodb-atlas-kubernetes/v2/api"
3132
akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1"
@@ -62,10 +63,11 @@ type ConnectionConfig struct {
6263
}
6364

6465
// Credentials is the type that holds credentials to authenticate against the Atlas API.
65-
// Currently, only API keys are support but more credential types could be added,
66-
// see https://www.mongodb.com/docs/atlas/configure-api-access/.
66+
// Either APIKeys or ServiceAccount must be set, but not both.
67+
// See https://www.mongodb.com/docs/atlas/configure-api-access/.
6768
type Credentials struct {
68-
APIKeys *APIKeys
69+
APIKeys *APIKeys
70+
ServiceAccount *ServiceAccountToken
6971
}
7072

7173
// APIKeys is the type that holds Public/Private API keys to authenticate against the Atlas API.
@@ -74,6 +76,12 @@ type APIKeys struct {
7476
PrivateKey string
7577
}
7678

79+
// ServiceAccountToken holds a pre-fetched OAuth2 bearer token obtained
80+
// by the service-account controller via the client credentials flow.
81+
type ServiceAccountToken struct {
82+
BearerToken string
83+
}
84+
7785
func NewProductionProvider(atlasDomain string, dryRun, isLogInDebug bool) *ProductionProvider {
7886
return &ProductionProvider{
7987
domain: atlasDomain,
@@ -123,8 +131,21 @@ func (p *ProductionProvider) IsResourceSupported(resource api.AtlasCustomResourc
123131
}
124132

125133
func (p *ProductionProvider) SdkClientSet(ctx context.Context, creds *Credentials, log *zap.SugaredLogger) (*ClientSet, error) {
126-
var transport http.RoundTripper = digest.NewTransport(creds.APIKeys.PublicKey, creds.APIKeys.PrivateKey)
127-
transport = p.newTransport(transport, log)
134+
var baseTransport http.RoundTripper
135+
switch {
136+
case creds.ServiceAccount != nil:
137+
baseTransport = &oauth2.Transport{
138+
Source: oauth2.StaticTokenSource(&oauth2.Token{
139+
AccessToken: creds.ServiceAccount.BearerToken,
140+
}),
141+
}
142+
case creds.APIKeys != nil:
143+
baseTransport = digest.NewTransport(creds.APIKeys.PublicKey, creds.APIKeys.PrivateKey)
144+
default:
145+
return nil, fmt.Errorf("no credentials provided")
146+
}
147+
148+
transport := p.newTransport(baseTransport, log)
128149
transport = httputil.NewLoggingTransport(log, false, transport)
129150
if p.isLogInDebug {
130151
log.Debug("JSON payload diff is enabled for Atlas API requests (PATCH & PUT)")
@@ -133,7 +154,7 @@ func (p *ProductionProvider) SdkClientSet(ctx context.Context, creds *Credential
133154

134155
httpClient := &http.Client{Transport: transport}
135156

136-
return NewSDKClientSet(p.domain, creds.APIKeys.PublicKey, creds.APIKeys.PrivateKey, httpClient)
157+
return NewSDKClientSet(p.domain, httpClient)
137158
}
138159

139160
func (p *ProductionProvider) newTransport(delegate http.RoundTripper, log *zap.SugaredLogger) http.RoundTripper {
@@ -152,7 +173,7 @@ func operatorUserAgent() string {
152173
return fmt.Sprintf("%s/%s (%s;%s)", "MongoDBAtlasKubernetesOperator", version.Version, runtime.GOOS, runtime.GOARCH)
153174
}
154175

155-
func NewSDKClientSet(domain, publicKey, privateKey string, httpClient *http.Client) (*ClientSet, error) {
176+
func NewSDKClientSet(domain string, httpClient *http.Client) (*ClientSet, error) {
156177
clientv20250312, err := v20250312.NewClient(
157178
v20250312.UseBaseURL(domain),
158179
v20250312.UseHTTPClient(httpClient),

internal/controller/atlas/provider_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,15 @@
1515
package atlas
1616

1717
import (
18+
"context"
19+
"net/http"
20+
"net/http/httptest"
1821
"testing"
1922

2023
"github.com/stretchr/testify/assert"
2124
"github.com/stretchr/testify/require"
25+
"go.uber.org/zap"
26+
"golang.org/x/oauth2"
2227

2328
"github.com/mongodb/mongodb-atlas-kubernetes/v2/api"
2429
akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1"
@@ -143,6 +148,51 @@ func TestProvider_IsResourceSupported(t *testing.T) {
143148
}
144149
}
145150

151+
func TestProvider_SdkClientSet_NilCredentials(t *testing.T) {
152+
p := NewProductionProvider("https://cloud.mongodb.com", false, false)
153+
_, err := p.SdkClientSet(context.Background(), &Credentials{}, nil)
154+
require.Error(t, err)
155+
assert.Contains(t, err.Error(), "no credentials provided")
156+
}
157+
158+
func TestProvider_SdkClientSet_APIKeys(t *testing.T) {
159+
p := NewProductionProvider("https://cloud.mongodb.com", false, false)
160+
creds := &Credentials{APIKeys: &APIKeys{PublicKey: "pub", PrivateKey: "priv"}}
161+
cs, err := p.SdkClientSet(context.Background(), creds, zap.NewNop().Sugar())
162+
require.NoError(t, err)
163+
assert.NotNil(t, cs)
164+
assert.NotNil(t, cs.SdkClient20250312)
165+
}
166+
167+
func TestProvider_SdkClientSet_ServiceAccount(t *testing.T) {
168+
p := NewProductionProvider("https://cloud.mongodb.com", false, false)
169+
creds := &Credentials{ServiceAccount: &ServiceAccountToken{BearerToken: "test-token"}}
170+
cs, err := p.SdkClientSet(context.Background(), creds, zap.NewNop().Sugar())
171+
require.NoError(t, err)
172+
assert.NotNil(t, cs)
173+
assert.NotNil(t, cs.SdkClient20250312)
174+
}
175+
176+
func TestBearerTokenTransport(t *testing.T) {
177+
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
178+
assert.Equal(t, "Bearer my-token", r.Header.Get("Authorization"))
179+
w.WriteHeader(http.StatusOK)
180+
})
181+
server := httptest.NewServer(handler)
182+
defer server.Close()
183+
184+
transport := &oauth2.Transport{
185+
Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "my-token"}),
186+
}
187+
httpClient := &http.Client{Transport: transport}
188+
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
189+
require.NoError(t, err)
190+
resp, err := httpClient.Do(req)
191+
require.NoError(t, err)
192+
defer resp.Body.Close()
193+
assert.Equal(t, http.StatusOK, resp.StatusCode)
194+
}
195+
146196
func TestOperatorUserAgent(t *testing.T) {
147197
userAgent := operatorUserAgent()
148198

internal/controller/reconciler/credentials.go

Lines changed: 130 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,13 @@ package reconciler
1616

1717
import (
1818
"context"
19+
"errors"
1920
"fmt"
21+
"hash/fnv"
2022

2123
corev1 "k8s.io/api/core/v1"
24+
apierrors "k8s.io/apimachinery/pkg/api/errors"
25+
"k8s.io/apimachinery/pkg/util/rand"
2226
"sigs.k8s.io/controller-runtime/pkg/client"
2327

2428
"github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/atlas"
@@ -29,8 +33,29 @@ const (
2933
orgIDKey = "orgId"
3034
publicAPIKey = "publicApiKey"
3135
privateAPIKey = "privateApiKey"
36+
37+
clientIDKey = "clientId"
38+
clientSecretKey = "clientSecret"
39+
40+
accessTokenKey = "accessToken"
41+
credentialsHashKey = "credentialsHash"
42+
accessTokenSecretPrefix = "atlas-access-token-"
3243
)
3344

45+
// CredentialsHash returns a deterministic, non-cryptographic fingerprint of
46+
// the (clientID, clientSecret) pair. The service-account-token controller
47+
// stores this fingerprint on the Access Token Secret under credentialsHashKey
48+
// so any component reading the Secret can detect that the source credentials
49+
// have been rotated since the cached bearer token was issued. The nul
50+
// separator disambiguates ("ab","c") from ("a","bc").
51+
func CredentialsHash(clientID, clientSecret string) (string, error) {
52+
h := fnv.New64a()
53+
if _, err := h.Write([]byte(clientID + "\x00" + clientSecret)); err != nil {
54+
return "", fmt.Errorf("failed to compute credentials hash: %w", err)
55+
}
56+
return fmt.Sprint(h.Sum64()), nil
57+
}
58+
3459
func (r *AtlasReconciler) ResolveConnectionConfig(ctx context.Context, referrer project.ProjectReferrerObject) (*atlas.ConnectionConfig, error) {
3560
connectionSecret := r.connectionSecretRef(referrer)
3661
if connectionSecret != nil && connectionSecret.Name != "" {
@@ -78,49 +103,136 @@ func GetConnectionConfig(ctx context.Context, k8sClient client.Client, secretRef
78103
return nil, fmt.Errorf("failed to read Atlas API credentials from the secret %s: %w", secretRef.String(), err)
79104
}
80105

81-
cfg := &atlas.ConnectionConfig{
106+
if err := validateConnectionSecret(secret); err != nil {
107+
return nil, fmt.Errorf("invalid connection secret %s: %w", secretRef, err)
108+
}
109+
110+
if isServiceAccountCredentials(secret) {
111+
bearerToken, err := getServiceAccountAccessToken(ctx, k8sClient, secret)
112+
if err != nil {
113+
return nil, err
114+
}
115+
116+
return &atlas.ConnectionConfig{
117+
OrgID: string(secret.Data[orgIDKey]),
118+
Credentials: &atlas.Credentials{
119+
ServiceAccount: &atlas.ServiceAccountToken{
120+
BearerToken: bearerToken,
121+
},
122+
},
123+
}, nil
124+
}
125+
126+
return &atlas.ConnectionConfig{
82127
OrgID: string(secret.Data[orgIDKey]),
83128
Credentials: &atlas.Credentials{
84129
APIKeys: &atlas.APIKeys{
85130
PublicKey: string(secret.Data[publicAPIKey]),
86131
PrivateKey: string(secret.Data[privateAPIKey]),
87132
},
88133
},
134+
}, nil
135+
}
136+
137+
// DeriveAccessTokenSecretName returns the deterministic name of the Access Token Secret for a given Connection Secret.
138+
// The Connection Secret name is included literally for operator debuggability; it is truncated when the total
139+
// exceeds the Kubernetes 253-character DNS-subdomain limit.
140+
func DeriveAccessTokenSecretName(namespace, connectionSecretName string) (string, error) {
141+
hasher := fnv.New64a()
142+
_, err := hasher.Write([]byte(namespace + "/" + connectionSecretName))
143+
if err != nil {
144+
return "", fmt.Errorf("failed to compute hash for access token secret name: %w", err)
89145
}
146+
hash := rand.SafeEncodeString(fmt.Sprint(hasher.Sum64()))
90147

91-
if missingFields, valid := validate(cfg); !valid {
92-
return nil, fmt.Errorf("the following fields are missing in the secret %v: %v", secretRef, missingFields)
148+
const k8sNameLimit = 253
149+
maxNameLen := k8sNameLimit - len(accessTokenSecretPrefix) - 1 - len(hash)
150+
name := connectionSecretName
151+
if len(name) > maxNameLen {
152+
name = name[:maxNameLen]
93153
}
94154

95-
return cfg, nil
155+
return accessTokenSecretPrefix + name + "-" + hash, nil
96156
}
97157

98-
func validate(cfg *atlas.ConnectionConfig) ([]string, bool) {
99-
missingFields := make([]string, 0, 3)
158+
func getServiceAccountAccessToken(ctx context.Context, k8sClient client.Client, secret *corev1.Secret) (string, error) {
159+
tokenSecretName, err := DeriveAccessTokenSecretName(secret.Namespace, secret.Name)
160+
if err != nil {
161+
return "", err
162+
}
163+
tokenRef := client.ObjectKey{Namespace: secret.Namespace, Name: tokenSecretName}
100164

101-
if cfg == nil {
102-
return []string{orgIDKey, publicAPIKey, privateAPIKey}, false
165+
tokenSecret := &corev1.Secret{}
166+
if err := k8sClient.Get(ctx, tokenRef, tokenSecret); err != nil {
167+
if apierrors.IsNotFound(err) {
168+
return "", fmt.Errorf("access token secret %s does not exist yet", tokenRef.String())
169+
}
170+
return "", fmt.Errorf("failed to read access token secret %s: %w", tokenRef.String(), err)
103171
}
104172

105-
if cfg.OrgID == "" {
106-
missingFields = append(missingFields, orgIDKey)
173+
// Guard against a stale cached token — if the credential Secret was
174+
// rotated since the token was issued, the service-account-token controller
175+
// may not have caught up yet. Returning an error prompts the downstream
176+
// reconciler to retry rather than hitting Atlas with revoked credentials.
177+
currentHash, err := CredentialsHash(string(secret.Data[clientIDKey]), string(secret.Data[clientSecretKey]))
178+
if err != nil {
179+
return "", err
107180
}
181+
if string(tokenSecret.Data[credentialsHashKey]) != currentHash {
182+
return "", fmt.Errorf("access token secret %s is stale (credentials rotated); waiting for the service-account-token controller to refresh", tokenRef.String())
183+
}
184+
185+
bearerToken := string(tokenSecret.Data[accessTokenKey])
186+
if bearerToken == "" {
187+
return "", fmt.Errorf("access token secret %s has an empty accessToken field", tokenRef.String())
188+
}
189+
190+
return bearerToken, nil
191+
}
192+
193+
func isServiceAccountCredentials(credentials *corev1.Secret) bool {
194+
clientID := credentials.Data[clientIDKey]
195+
clientSecret := credentials.Data[clientSecretKey]
108196

109-
if cfg.Credentials == nil || cfg.Credentials.APIKeys == nil {
110-
return append(missingFields, []string{publicAPIKey, privateAPIKey}...), false
197+
return len(clientID) > 0 && len(clientSecret) > 0
198+
}
199+
200+
func validateConnectionSecret(secret *corev1.Secret) error {
201+
hasAnyAPIKey := len(secret.Data[publicAPIKey]) > 0 || len(secret.Data[privateAPIKey]) > 0
202+
hasAnySA := len(secret.Data[clientIDKey]) > 0 || len(secret.Data[clientSecretKey]) > 0
203+
204+
if hasAnyAPIKey && hasAnySA {
205+
return errors.New("secret contains both API key and service account credentials; only one type is allowed")
111206
}
112207

113-
if cfg.Credentials.APIKeys.PublicKey == "" {
114-
missingFields = append(missingFields, publicAPIKey)
208+
var missingFields []string
209+
210+
if len(secret.Data[orgIDKey]) == 0 {
211+
missingFields = append(missingFields, orgIDKey)
115212
}
116213

117-
if cfg.Credentials.APIKeys.PrivateKey == "" {
118-
missingFields = append(missingFields, privateAPIKey)
214+
if hasAnyAPIKey {
215+
if len(secret.Data[publicAPIKey]) == 0 {
216+
missingFields = append(missingFields, publicAPIKey)
217+
}
218+
if len(secret.Data[privateAPIKey]) == 0 {
219+
missingFields = append(missingFields, privateAPIKey)
220+
}
221+
} else if hasAnySA {
222+
if len(secret.Data[clientIDKey]) == 0 {
223+
missingFields = append(missingFields, clientIDKey)
224+
}
225+
if len(secret.Data[clientSecretKey]) == 0 {
226+
missingFields = append(missingFields, clientSecretKey)
227+
}
228+
} else {
229+
//By default, we are expecting API keys
230+
missingFields = append(missingFields, publicAPIKey, privateAPIKey)
119231
}
120232

121233
if len(missingFields) > 0 {
122-
return missingFields, false
234+
return fmt.Errorf("missing required fields: %v", missingFields)
123235
}
124236

125-
return nil, true
237+
return nil
126238
}

0 commit comments

Comments
 (0)