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
76 changes: 76 additions & 0 deletions internal/controller/accesstoken/accesstoken.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright 2025 MongoDB Inc
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package accesstoken holds the schema of the Access Token Secret produced
// by the service-account-token controller and consumed by
// reconciler.GetConnectionConfig, plus the two helpers that operate on it:
// DeriveSecretName (namespace+name → deterministic Secret name) and
// CredentialsHash (clientId+clientSecret → staleness fingerprint).
//
// This package has no operator-specific imports by design — it is a pure
// data-model/helper package that both the producer and the consumer depend on.
package accesstoken

import (
"fmt"
"hash/fnv"

"k8s.io/apimachinery/pkg/util/rand"
)

// Data-field keys on an Access Token Secret.
const (
// AccessTokenKey holds the OAuth bearer token.
AccessTokenKey = "accessToken"
// ExpiryKey holds the RFC3339 timestamp at which the bearer token expires.
ExpiryKey = "expiry"
// CredentialsHashKey holds the FNV fingerprint of the (clientId, clientSecret)
// pair used to mint the current bearer token. Used to detect credential
// rotation before the cached bearer is used.
CredentialsHashKey = "credentialsHash"

// secretNamePrefix is the name prefix of every Access Token Secret.
// Changing this would invalidate every existing Access Token Secret across
// all deployments.
secretNamePrefix = "atlas-access-token-"
)

// DeriveSecretName returns the deterministic Access Token Secret name for a
// given Connection Secret. The connection secret name is included literally
// in the result for operator debuggability; it is truncated when the total
// would exceed the Kubernetes 253-character DNS-subdomain limit. Uniqueness
// is guaranteed by the hash suffix, which always uses the full (untruncated)
// name as input.
func DeriveSecretName(namespace, connectionSecretName string) string {
hasher := fnv.New64a()
_, _ = hasher.Write([]byte(namespace + "/" + connectionSecretName))
hash := rand.SafeEncodeString(fmt.Sprint(hasher.Sum64()))

const k8sNameLimit = 253
maxNameLen := k8sNameLimit - len(secretNamePrefix) - 1 - len(hash)
name := connectionSecretName
if len(name) > maxNameLen {
name = name[:maxNameLen]
}
return secretNamePrefix + name + "-" + hash
}

// CredentialsHash returns a non-cryptographic fingerprint of the credential
// pair. The nul separator disambiguates ("ab","c") from ("a","bc") — without
// it, both would concatenate to the same input and collide.
func CredentialsHash(clientID, clientSecret string) string {
h := fnv.New64a()
_, _ = h.Write([]byte(clientID + "\x00" + clientSecret))
return fmt.Sprint(h.Sum64())
}
121 changes: 121 additions & 0 deletions internal/controller/accesstoken/accesstoken_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright 2025 MongoDB Inc
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package accesstoken_test

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"

"github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/accesstoken"
)

func TestDeriveSecretName(t *testing.T) {
dataProvider := map[string]struct {
namespace string
connectionSecretName string
expected string
}{
"pinned output for known input": {
namespace: "atlas-operator",
connectionSecretName: "my-sa-creds",
expected: "atlas-access-token-my-sa-creds-587997bcdf678bb69ff7",
},
"namespace sensitivity: ns-a + creds": {
namespace: "ns-a",
connectionSecretName: "creds",
expected: "atlas-access-token-creds-58694dcd4586644b4778",
},
"namespace sensitivity: ns-b + creds": {
namespace: "ns-b",
connectionSecretName: "creds",
expected: "atlas-access-token-creds-5477fbbc7d7fb85964bc",
},
"connection-name sensitivity: ns + creds-a": {
namespace: "ns",
connectionSecretName: "creds-a",
expected: "atlas-access-token-creds-a-7dbc78d46547845579d",
},
"connection-name sensitivity: ns + creds-b": {
namespace: "ns",
connectionSecretName: "creds-b",
expected: "atlas-access-token-creds-b-7dbc78bf659667d758c",
},
"long connection name is truncated to the DNS-1123 253-char limit": {
namespace: "ns",
connectionSecretName: strings.Repeat("a", 500),
expected: "atlas-access-token-" + strings.Repeat("a", 214) + "-cf5697c4fb6ddb7dcff",
},
"short connection name is preserved literally": {
namespace: "ns",
connectionSecretName: "short-name",
expected: "atlas-access-token-short-name-ccf99675cf99c8ffb85",
},
}

for desc, data := range dataProvider {
t.Run(desc, func(t *testing.T) {
got := accesstoken.DeriveSecretName(data.namespace, data.connectionSecretName)
assert.Equal(t, data.expected, got)
})
}
}

func TestCredentialsHash(t *testing.T) {
dataProvider := map[string]struct {
clientID string
clientSecret string
expected string
}{
"pinned output for known input": {
clientID: "client-id",
clientSecret: "client-secret",
expected: "3974328787184052522",
},
"distinctness: id-1 + secret-1": {
clientID: "id-1",
clientSecret: "secret-1",
expected: "6130960229688205592",
},
"distinctness: different clientID (id-2 + secret-1)": {
clientID: "id-2",
clientSecret: "secret-1",
expected: "1640858821594590263",
},
"distinctness: different clientSecret (id-1 + secret-2)": {
clientID: "id-1",
clientSecret: "secret-2",
expected: "6130963528223090225",
},
"nul separator: ab + c": {
clientID: "ab",
clientSecret: "c",
expected: "18258086037221804135",
},
"nul separator: a + bc": {
clientID: "a",
clientSecret: "bc",
expected: "12340134017423684899",
},
}

for desc, data := range dataProvider {
t.Run(desc, func(t *testing.T) {
got := accesstoken.CredentialsHash(data.clientID, data.clientSecret)
assert.Equal(t, data.expected, got)
})
}
}
35 changes: 28 additions & 7 deletions internal/controller/atlas/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/mongodb-forks/digest"
v20250312 "go.mongodb.org/atlas-sdk/v20250312018/admin"
"go.uber.org/zap"
"golang.org/x/oauth2"

"github.com/mongodb/mongodb-atlas-kubernetes/v2/api"
akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1"
Expand Down Expand Up @@ -62,10 +63,11 @@ type ConnectionConfig struct {
}

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

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

// ServiceAccountToken holds a pre-fetched OAuth2 bearer token obtained
// by the service-account controller via the client credentials flow.
type ServiceAccountToken struct {
BearerToken string
}

func NewProductionProvider(atlasDomain string, dryRun, isLogInDebug bool) *ProductionProvider {
return &ProductionProvider{
domain: atlasDomain,
Expand Down Expand Up @@ -123,8 +131,21 @@ func (p *ProductionProvider) IsResourceSupported(resource api.AtlasCustomResourc
}

func (p *ProductionProvider) SdkClientSet(ctx context.Context, creds *Credentials, log *zap.SugaredLogger) (*ClientSet, error) {
var transport http.RoundTripper = digest.NewTransport(creds.APIKeys.PublicKey, creds.APIKeys.PrivateKey)
transport = p.newTransport(transport, log)
var baseTransport http.RoundTripper
switch {
case creds.ServiceAccount != nil:
baseTransport = &oauth2.Transport{
Source: oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: creds.ServiceAccount.BearerToken,
}),
}
case creds.APIKeys != nil:
baseTransport = digest.NewTransport(creds.APIKeys.PublicKey, creds.APIKeys.PrivateKey)
default:
return nil, fmt.Errorf("no credentials provided")
}

transport := p.newTransport(baseTransport, log)
transport = httputil.NewLoggingTransport(log, false, transport)
if p.isLogInDebug {
log.Debug("JSON payload diff is enabled for Atlas API requests (PATCH & PUT)")
Expand All @@ -133,7 +154,7 @@ func (p *ProductionProvider) SdkClientSet(ctx context.Context, creds *Credential

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

return NewSDKClientSet(p.domain, creds.APIKeys.PublicKey, creds.APIKeys.PrivateKey, httpClient)
return NewSDKClientSet(p.domain, httpClient)
}

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

func NewSDKClientSet(domain, publicKey, privateKey string, httpClient *http.Client) (*ClientSet, error) {
func NewSDKClientSet(domain string, httpClient *http.Client) (*ClientSet, error) {
clientv20250312, err := v20250312.NewClient(
v20250312.UseBaseURL(domain),
v20250312.UseHTTPClient(httpClient),
Expand Down
58 changes: 58 additions & 0 deletions internal/controller/atlas/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,15 @@
package atlas

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"golang.org/x/oauth2"

"github.com/mongodb/mongodb-atlas-kubernetes/v2/api"
akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1"
Expand Down Expand Up @@ -143,6 +148,59 @@ func TestProvider_IsResourceSupported(t *testing.T) {
}
}

func TestProvider_SdkClientSet(t *testing.T) {
dataProvider := map[string]struct {
credentials *Credentials
errorSubstring string // empty means no error expected
}{
"nil credentials returns error": {
credentials: &Credentials{},
errorSubstring: "no credentials provided",
},
"API key credentials return a client set": {
credentials: &Credentials{APIKeys: &APIKeys{PublicKey: "pub", PrivateKey: "priv"}},
},
"service account credentials return a client set": {
credentials: &Credentials{ServiceAccount: &ServiceAccountToken{BearerToken: "test-token"}},
},
}

for desc, data := range dataProvider {
t.Run(desc, func(t *testing.T) {
p := NewProductionProvider("https://cloud.mongodb.com", false, false)
cs, err := p.SdkClientSet(context.Background(), data.credentials, zap.NewNop().Sugar())
if data.errorSubstring != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), data.errorSubstring)
return
}
require.NoError(t, err)
assert.NotNil(t, cs)
assert.NotNil(t, cs.SdkClient20250312)
})
}
}

func TestBearerTokenTransport(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "Bearer my-token", r.Header.Get("Authorization"))
w.WriteHeader(http.StatusOK)
})
server := httptest.NewServer(handler)
defer server.Close()

transport := &oauth2.Transport{
Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "my-token"}),
}
httpClient := &http.Client{Transport: transport}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
require.NoError(t, err)
resp, err := httpClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
}

func TestOperatorUserAgent(t *testing.T) {
userAgent := operatorUserAgent()

Expand Down
Loading