Skip to content
Draft
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
14 changes: 13 additions & 1 deletion internal/credentials/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,14 @@ func resolveGCPWIFCredential(
return loadStoredGCPTokenSource(ctx, account, store, CredTypeGCPWIFConfig)
}

// gcpStoredJSONTypes pins the one Google credential JSON "type" each stored
// slot may hold, matching what the API accepts into it (#80): any other type
// fails to parse instead of becoming a token source.
var gcpStoredJSONTypes = map[string]google.CredentialsType{
CredTypeGCPServiceAccount: google.ServiceAccount,
CredTypeGCPWIFConfig: google.ExternalAccount,
}

// loadStoredGCPTokenSource reads a stored JSON credential of the
// given type and returns a TokenSource. Shared by both the
// service_account_key and legacy workload_identity_federation paths.
Expand All @@ -464,6 +472,10 @@ func loadStoredGCPTokenSource(
store CredentialStore,
credType string,
) (oauth2.TokenSource, error) {
jsonType, ok := gcpStoredJSONTypes[credType]
if !ok {
return nil, fmt.Errorf("credentials: no gcp credential json type pinned for %q (account %s)", credType, account.ID)
}
if store == nil {
return nil, fmt.Errorf("credentials: credential store required for gcp account %s", account.ID)
}
Expand All @@ -474,7 +486,7 @@ func loadStoredGCPTokenSource(
if raw == nil {
return nil, fmt.Errorf("credentials: no gcp credentials stored for account %s", account.ID)
}
creds, err := google.CredentialsFromJSON(ctx, raw, gcpCloudPlatformScope) //nolint:staticcheck // SA1019: google.CredentialsFromJSON: replacement API requires GCP SDK upgrade; credentials are operator-controlled bytes
creds, err := google.CredentialsFromJSONWithType(ctx, raw, jsonType, gcpCloudPlatformScope)
if err != nil {
return nil, fmt.Errorf("credentials: parse gcp credentials for account %s: %w", account.ID, err)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/credentials/resolver_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ func TestResolveAzureTokenCredential_ManagedIdentity(t *testing.T) {

func TestResolveGCPTokenSource_WIF_WithStoredConfig(t *testing.T) {
// Exercises the WIF branch where CredTypeGCPWIFConfig is used as the key.
// The JSON is intentionally invalid so google.CredentialsFromJSON fails,
// The JSON is intentionally invalid so google.CredentialsFromJSONWithType fails,
// but we cover the credType selection branch.
store := newMockStore()
store.data["acct1/gcp_workload_identity_config"] = []byte("not valid json")
Expand All @@ -446,7 +446,7 @@ func TestResolveGCPTokenSource_WIF_WithStoredConfig(t *testing.T) {
}

func TestResolveGCPTokenSource_ServiceAccountKey_ValidJSON(t *testing.T) {
// A minimal valid GCP service account JSON that google.CredentialsFromJSON accepts.
// A minimal valid GCP service account JSON that google.CredentialsFromJSONWithType accepts.
saJSON := []byte(`{
"type": "service_account",
"project_id": "test-project",
Expand Down
165 changes: 165 additions & 0 deletions internal/credentials/resolver_gcp_type_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package credentials

import (
"context"
"testing"

"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// Fixtures for the stored-JSON GCP path (issue #80). None of them reaches the
// network: building a token source is lazy and no test calls Token().

func gcpServiceAccountFixture() []byte {
return []byte(`{
"type": "service_account",
"project_id": "test-project",
"private_key_id": "key-id",
"private_key": "` + minimalRSAPEM() + `",
"client_email": "test@test-project.iam.gserviceaccount.com",
"client_id": "placeholder-client-id",
"token_uri": "https://oauth2.googleapis.com/token"
}`)
}

func gcpExternalAccountFixture() []byte {
return []byte(`{
"type": "external_account",
"audience": "//iam.googleapis.com/projects/0/locations/global/workloadIdentityPools/pool/providers/provider",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": "https://sts.googleapis.com/v1/token",
"credential_source": {"file": "/nonexistent/cudly-test-token"}
}`)
}

// gcpOtherTypeFixtures are credential JSONs the deprecated
// google.CredentialsFromJSON parsed successfully but that neither stored GCP
// slot may hold (the API accepts only "service_account" into
// gcp_service_account and only "external_account" into
// gcp_workload_identity_config, see validateCredentialPayload).
func gcpOtherTypeFixtures() map[string][]byte {
return map[string][]byte{
"authorized_user": []byte(`{
"type": "authorized_user",
"client_id": "placeholder-client-id",
"client_secret": "placeholder",
"refresh_token": "placeholder"
}`),
"impersonated_service_account": []byte(`{
"type": "impersonated_service_account",
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/sa@test-project.iam.gserviceaccount.com:generateAccessToken",
"source_credentials": {
"type": "authorized_user",
"client_id": "placeholder-client-id",
"client_secret": "placeholder",
"refresh_token": "placeholder"
}
}`),
"external_account_authorized_user": []byte(`{
"type": "external_account_authorized_user",
"audience": "//iam.googleapis.com/locations/global/workforcePools/pool/providers/provider",
"client_id": "placeholder-client-id",
"client_secret": "placeholder",
"refresh_token": "placeholder",
"token_url": "https://sts.googleapis.com/v1/oauthtoken"
}`),
// An OAuth client config (client_credentials.json) carries no "type";
// CredentialsFromJSON turned it into an interactive auth-code flow.
"installed client config, no type": []byte(`{
"installed": {
"client_id": "placeholder-client-id",
"client_secret": "placeholder",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"redirect_uris": ["http://localhost"]
}
}`),
}
}

func resolveStoredGCP(t *testing.T, authMode, credType string, raw []byte) error {
t.Helper()
store := newMockStore()
store.data["acct1/"+credType] = raw
account := &config.CloudAccount{ID: "acct1", GCPAuthMode: authMode}
src, err := ResolveGCPTokenSource(context.Background(), account, store)
if err == nil {
require.NotNil(t, src)
}
return err
}

func TestResolveGCPTokenSource_ServiceAccountSlot_AcceptsServiceAccountJSON(t *testing.T) {
for _, mode := range []string{"", "service_account_key"} {
err := resolveStoredGCP(t, mode, CredTypeGCPServiceAccount, gcpServiceAccountFixture())
require.NoError(t, err, "auth mode %q", mode)
}
}

func TestResolveGCPTokenSource_ServiceAccountSlot_RejectsOtherTypes(t *testing.T) {
cases := gcpOtherTypeFixtures()
cases["external_account"] = gcpExternalAccountFixture()
for name, raw := range cases {
t.Run(name, func(t *testing.T) {
err := resolveStoredGCP(t, "service_account_key", CredTypeGCPServiceAccount, raw)
require.Error(t, err)
assert.Contains(t, err.Error(), "parse gcp credentials")
assert.Contains(t, err.Error(), `expected credential type "service_account"`)
})
}
}

func TestResolveGCPTokenSource_WIFSlot_AcceptsExternalAccountJSON(t *testing.T) {
// No signer/audience, so the legacy stored-JSON branch is taken.
err := resolveStoredGCP(t, "workload_identity_federation", CredTypeGCPWIFConfig, gcpExternalAccountFixture())
require.NoError(t, err)
}

func TestResolveGCPTokenSource_WIFSlot_RejectsOtherTypes(t *testing.T) {
cases := gcpOtherTypeFixtures()
cases["service_account"] = gcpServiceAccountFixture()
for name, raw := range cases {
t.Run(name, func(t *testing.T) {
err := resolveStoredGCP(t, "workload_identity_federation", CredTypeGCPWIFConfig, raw)
require.Error(t, err)
assert.Contains(t, err.Error(), "parse gcp credentials")
assert.Contains(t, err.Error(), `expected credential type "external_account"`)
})
}
}

func TestResolveGCPTokenSource_StoredSlots_RejectMalformedJSON(t *testing.T) {
slots := map[string]string{
"service_account_key": CredTypeGCPServiceAccount,
"workload_identity_federation": CredTypeGCPWIFConfig,
}
inputs := map[string][]byte{
"not json": []byte("not valid json"),
"truncated": []byte(`{"type": "service_account"`),
"array": []byte(`["service_account"]`),
"type not text": []byte(`{"type": 1}`),
"empty object": []byte(`{}`),
}
for mode, credType := range slots {
for name, raw := range inputs {
t.Run(mode+"/"+name, func(t *testing.T) {
err := resolveStoredGCP(t, mode, credType, raw)
require.Error(t, err)
assert.Contains(t, err.Error(), "parse gcp credentials")
})
}
}
}

func TestLoadStoredGCPTokenSource_RejectsUnknownSlot(t *testing.T) {
// A credential slot without a pinned JSON type must fail closed rather
// than fall back to accepting any type.
store := newMockStore()
store.data["acct1/aws_access_keys"] = gcpServiceAccountFixture()
account := &config.CloudAccount{ID: "acct1"}
_, err := loadStoredGCPTokenSource(context.Background(), account, store, CredTypeAWSAccessKeys)
require.Error(t, err)
assert.Contains(t, err.Error(), "no gcp credential json type")
}
Loading