Skip to content

fix(credentials): pin stored GCP credential JSON types, drop deprecated CredentialsFromJSON - #1308

Draft
IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-80
Draft

IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-80

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Root cause

loadStoredGCPTokenSource (internal/credentials/resolver.go) turned stored GCP credential JSON into a token source with the deprecated google.CredentialsFromJSON. It was //nolint:staticcheck (SA1019) on the grounds that the replacement needed a GCP SDK upgrade and that the bytes are operator-controlled.

Neither reason holds:

  • No upgrade is needed. google.CredentialsFromJSONWithType is already in the pinned golang.org/x/oauth2 v0.36.0.
  • The function accepted any Google credential type in either stored slot. That covers authorized_user, impersonated_service_account, external_account_authorized_user, external_account in the service-account slot, and an OAuth client config ({"installed": …}, which has no type). The client config became an interactive auth-code token source. The API's validateCredentialPayload only lets service_account into gcp_service_account and external_account into gcp_workload_identity_config, but the read path never checked that.

Fix

  • A small map, gcpStoredJSONTypes, ties each stored slot to the one JSON type the API accepts for it:
    • gcp_service_account → google.ServiceAccount
    • gcp_workload_identity_config → google.ExternalAccount (the legacy stored-JSON WIF path needs external_account, so that slot has to keep accepting it)
  • google.CredentialsFromJSONWithType parses with that pinned type, and the //nolint is gone. Any other type fails with parse gcp credentials …: google: expected credential type "…", found "…".
  • A slot with no pinned type fails closed before the store is read. It does not fall back to accepting any type.
  • No go.mod / go.sum change.

Accepted types are only ever narrowed, never widened. Nothing is accepted that wasn't before. The only new rejections are types the API never let into those slots.

Closes #80

Merge-order and overlap notes

When it takes effect

This is a code-only change to the GCP credential resolver used by the scheduler and the purchase manager. It needs no deploy to merge. It takes effect in a deployment once that deployment runs a build that includes it. GCP deploys are paused right now, so GCP-hosted instances keep the old behavior until the pause lifts and they are redeployed. AWS/Azure-hosted deployments that manage GCP accounts pick it up on their next deploy.

Verification

All evidence below is unit tests with mock-store fixtures. No GCP call was made: building a token source is lazy, and no test calls Token().

New tests are in internal/credentials/resolver_gcp_type_test.go:

  • TestResolveGCPTokenSource_ServiceAccountSlot_AcceptsServiceAccountJSON: service-account JSON is accepted (auth mode "" and service_account_key).
  • TestResolveGCPTokenSource_ServiceAccountSlot_RejectsOtherTypes: external_account, authorized_user, impersonated_service_account, external_account_authorized_user and an untyped installed client config are rejected.
  • TestResolveGCPTokenSource_WIFSlot_AcceptsExternalAccountJSON: external_account JSON is accepted on the legacy stored-WIF path.
  • TestResolveGCPTokenSource_WIFSlot_RejectsOtherTypes: service_account plus the four types above are rejected.
  • TestResolveGCPTokenSource_StoredSlots_RejectMalformedJSON: both slots reject non-JSON, truncated JSON, a JSON array, a non-string type and {}. This guards behavior that was already correct.
  • TestLoadStoredGCPTokenSource_RejectsUnknownSlot: a slot with no pinned type fails closed.

Fail-before, on origin/main (a14dc8f2) with only the new test file added:

go test ./internal/credentials/ -count=1 -v -run 'TestResolveGCPTokenSource_(ServiceAccountSlot|WIFSlot|StoredSlots)|TestLoadStoredGCPTokenSource_RejectsUnknownSlot'
--- PASS: TestResolveGCPTokenSource_ServiceAccountSlot_AcceptsServiceAccountJSON
--- FAIL: TestResolveGCPTokenSource_ServiceAccountSlot_RejectsOtherTypes   (5/5 subtests: "An error is expected but got nil")
--- PASS: TestResolveGCPTokenSource_WIFSlot_AcceptsExternalAccountJSON
--- FAIL: TestResolveGCPTokenSource_WIFSlot_RejectsOtherTypes             (5/5 subtests: "An error is expected but got nil")
--- PASS: TestResolveGCPTokenSource_StoredSlots_RejectMalformedJSON        (10/10)
--- FAIL: TestLoadStoredGCPTokenSource_RejectsUnknownSlot

Pass-after, at 63405f9a: the same command passes all 6 tests and all 20 subtests.

Other checks run at 63405f9a, with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true:

go build ./...                                   # ok
go vet ./...                                     # ok
go vet -tags integration ./...                   # ok
go test ./internal/credentials/ -count=1         # ok
go test ./internal/mocks/ -count=1               # ok
go test ./internal/scheduler/ -count=1           # ok (caller of ResolveGCPTokenSourceWithOpts)
go test ./internal/purchase/ -count=1            # ok (caller of ResolveGCPTokenSourceWithOpts)
golangci-lint v2.10.1 run --allow-parallel-runners ./internal/credentials/   # 0 issues
gocyclo -over 10 -ignore "_test\.go" internal/credentials/resolver.go        # none (loadStoredGCPTokenSource = 6)

Not verified: a real GCP token exchange end to end. GCP calls are off-limits here, and GCP deploys are paused.

Combined-tree verification. This branch was tested together with the other queued branches on a local, never-pushed tree: queue/platform @ b56afb2b = origin/main a14dc8f2 plus these heads, merged in this order:

Branch Tested head
fix/issue-204 76f85a4
fix/issue-438 78fe011
fix/issue-838 120b07f
fix/issue-143 aef1256
fix/issue-349 cfbb551
fix/issue-80 63405f9
fix/issue-199 36b9f8f
fix/issue-41 96fc237
fix/issue-299 6af14ed
fix/issue-324 3ab6729

fix/issue-324 and fix/issue-204 both edit .github/workflows/ci.yml next to the azure-kv-access-policy job. The tree kept #324's new "Run Function URL reader self-tests" step and #204's updated job comment; whichever PR merges second takes that same resolution.

Linux, Go 1.26.9, AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers. The full suite ran on ed0c7174, which differs from b56afb2b only by one .gitallowed line on fix/issue-324:

  • Passed: go build ./..., go vet ./..., go vet -tags integration ./... and the full go test -race -short -count=1 ./....
  • Static checks: golangci-lint v2.10.1 found 0 issues on every changed package, with and without --build-tags integration. gocyclo -over 10 is silent on the changed non-test files.
  • Integration tests, every changed package that has them, one package at a time (-tags integration -count=1, 60-minute local timeout): internal/api, internal/purchase, internal/scheduler and internal/server all pass.
  • Secrets: the first run caught a bare placeholder account ID in fix/issue-324's new tftest, now allowlisted with an anchored .gitallowed line. Re-run at b56afb2b: git-secrets, using the repo's scripts/setup-git-secrets.sh patterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged. scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.
  • Nothing was applied to any cloud.

Out of scope (follow-ups worth filing)

  • Unvalidated credentials from the public registration flow. POST /api/register (submitRegistration → encryptRegistrationCredential) stores credential_type and credential_payload with no validCredentialTypes or validateCredentialPayload check. On admin approval, storeRegistrationCredentials writes them straight into account_credentials. So the "operator-controlled bytes" assumption in the old //nolint comment only holds as far as an admin reviews the payload. This PR closes the type-confusion part on the read side (e.g. an authorized_user or external_account JSON in the service-account slot). The write side should still validate registrations the same way PUT /api/accounts/:id/credentials does.
  • external_account content isn't validated. The WIF slot still accepts any external_account config, which is what the API and the legacy path allow today. Google's guidance is to validate such configs from external sources: token_url, service_account_impersonation_url, and credential_source (file / url), because a hostile config can make the server read a local file or call an arbitrary URL and send the result to an attacker-chosen token_url. validateGCPWIFPayload only checks that keys are present. Restricting those URLs (e.g. to https://sts.googleapis.com and https://iamcredentials.googleapis.com) is a behavior and policy decision for the owner, so it isn't done here.

Maintainer: please mirror the issue's triage labels onto this PR: impact/internal, type/chore, triaged, priority/p3, severity/low, urgency/eventually, effort/m.

🤖 Generated with Claude Code

…ed CredentialsFromJSON

loadStoredGCPTokenSource parsed stored GCP JSON with the deprecated
google.CredentialsFromJSON (SA1019, nolint'd), which accepts any Google
credential type: authorized_user, impersonated_service_account,
external_account_authorized_user, an OAuth client config with no type,
and external_account in the service-account slot.

Use google.CredentialsFromJSONWithType, already in golang.org/x/oauth2
v0.36.0 (no go.mod change), and pin each slot to the one type the API
accepts into it: gcp_service_account -> service_account,
gcp_workload_identity_config -> external_account. A slot with no pinned
type fails closed before the store is read.

Closes LeanerCloud#80

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added impact/internal Team-internal only type/chore Maintenance / non-user-visible triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm urgency/eventually No deadline effort/m Days labels Oct 23, 2026
@coderabbitai

coderabbitai Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/internal Team-internal only priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/chore Maintenance / non-user-visible urgency/eventually No deadline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tech-debt: migrate off deprecated google.CredentialsFromJSON (SA1019) via GCP SDK upgrade

1 participant