From 020230fbf36286b7768b5ab9be0111f55163e929 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:33:11 -0400 Subject: [PATCH 1/3] feat(libs/go): a consuming service declares an external identity, never a cloud MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workload on managed Postgres authenticates as itself: the login is a cloud principal and the password is a short-lived token. Which cloud issues it is a property of the deployment, so the consuming service forwards the declaration it was configured with and names no cloud, no cloud SDK and no identity endpoint. `WithExternalIdentity(declaration)` resolves an exact enum — `azure-entra` or `gcp-cloud-sql` — to the issuer that mints the credential. There is no default: an empty or unrecognized declaration is refused at startup, naming the set it could have been, because the absence of a declaration and a declaration of absence are different states. Resolution runs where a connection is opened rather than where the option is applied, so minting the first credential belongs to the caller's context and timeout. Declaring it beside `WithAccessTokenProvider` is refused: both authenticate the same pools. `gcp-cloud-sql` serves several distinct logins from one pod. A pod holds one ServiceAccount and therefore one primary cloud identity, while this library keeps its capabilities on separate logins on purpose. The login each connection names decides the path: the pod's own service account uses its ambient credential, and any other login is exchanged for that principal's token through the IAM Credentials API. The cloud principal is derived from the login rather than declared a second time — Cloud SQL registers a service-account login as the email with `.gserviceaccount.com` stripped, so appending it reverses that exactly and the two cannot drift. Two token sources, not one: the database credential is minted at `sqlservice.login`, and only the exchange is authorized at `cloud-platform`, so what reaches Postgres can do nothing but log in. `AzureEntraWorkloadIdentity` becomes unexported. It had no consumers, and leaving it exported leaves the cloud reachable from above the library, which is the thing this change exists to prevent. Also refuse a password beside a credential provider on an explicit profile. The token replaces it on every backend, so the URL's password never authenticates; accepting both leaves a dead secret in the deployment that reads as the live one, and hides an external-identity URL still carrying the seeded password it was meant to stop carrying. Tests cover the resolution table, both refusals, ambient-vs-exchanged selection, the derived principal and request shape, per-login caching and refresh inside the expiry margin, that distinct logins never share a credential, that a login which is not a service-account login never reaches the API, and that a failed exchange carries the principal but no response material. Not verified against a live Cloud SQL instance or a real workload identity: the IAM Credentials exchange is exercised against a stub. Co-Authored-By: Claude Opus 5 --- libs/go/README.md | 43 +++- libs/go/azureentra.go | 14 +- libs/go/connection_profile.go | 8 + libs/go/connection_profile_test.go | 30 +++ libs/go/externalidentity.go | 116 ++++++++++ libs/go/externalidentity_test.go | 90 ++++++++ libs/go/gcpcloudsql.go | 254 +++++++++++++++++++++ libs/go/gcpcloudsql_test.go | 345 +++++++++++++++++++++++++++++ libs/go/go.mod | 2 + libs/go/go.sum | 6 + libs/go/maintenance.go | 3 + libs/go/open.go | 3 + libs/go/store.go | 1 + 13 files changed, 909 insertions(+), 6 deletions(-) create mode 100644 libs/go/externalidentity.go create mode 100644 libs/go/externalidentity_test.go create mode 100644 libs/go/gcpcloudsql.go create mode 100644 libs/go/gcpcloudsql_test.go diff --git a/libs/go/README.md b/libs/go/README.md index 56ae564..6eb9926 100644 --- a/libs/go/README.md +++ b/libs/go/README.md @@ -213,9 +213,50 @@ writer := postgres.ConnectionProfile{ // contain no role. Memberships and SQL grants remain separately provisioned. factory, close, err := postgres.Open(ctx, readerURL, writerURL, authenticator, postgres.WithConnectionProfiles(reader, writer), - postgres.WithAccessTokenProvider(tokenProvider)) + postgres.WithExternalIdentity(declaredIssuer)) ``` +## External identity: the consuming service names no cloud + +`WithExternalIdentity(declaration)` authenticates every pooled connection as the +workload's own cloud identity: the login is a cloud principal, the password is a +short-lived token minted per physical connection, and no password exists to be +seeded, projected or rotated. A capability URL must then carry its login and no +password — one beside a credential provider is refused, because the token +replaces it on every backend and the URL's would be dead config reading as the +live credential. + +`declaration` is the deployment's statement of where the workload runs. The +consuming service forwards the value it was configured with and does not +interpret it: no application names a cloud, a cloud SDK or an identity endpoint, +and adding a cloud changes this library alone. Declarable values are +`azure-entra` (Entra ID on Azure Database for PostgreSQL) and `gcp-cloud-sql` +(IAM database authentication on Cloud SQL, which the instance must have on +through the `cloudsql.iam_authentication` flag). There is no default: an empty +or unrecognized declaration is refused at startup, naming the set it could have +been, because the absence of a declaration and a declaration of absence are +different states. + +`gcp-cloud-sql` authenticates several distinct logins from one pod. A pod holds +one Kubernetes ServiceAccount and therefore one primary cloud identity, while +this library deliberately keeps its capabilities on separate logins. The login +each connection names decides the path: the pod's own service account uses its +ambient credential directly, and any other login is exchanged for that +principal's token through the IAM Credentials API, which requires +`roles/iam.serviceAccountTokenCreator` on exactly that account. The cloud +principal is derived from the login rather than declared twice — Cloud SQL +registers a service-account login as the email with `.gserviceaccount.com` +stripped, so appending it reverses that exactly. A login that is not a +service-account login never reaches the API. Tokens are cached per login and +retired before expiry; distinct logins never share one, which is the whole point +of keeping them distinct. + +`WithExternalIdentity` and `WithAccessTokenProvider` are mutually exclusive: +both authenticate the same pools, and a deployment declaring two credentials for +one login has not said which is authoritative. `WithAccessTokenProvider` remains +for a service that mints its own credential — a projected token file rewritten +by a sidecar, say — and carries no cloud knowledge into this library. + `VerifiedTLS` requires an explicit PostgreSQL URL, login, database, a single TCP host and `sslmode=verify-full`; every effective endpoint is checked and driver fallbacks are rejected. Explicit CA/client-certificate files and reviewed pool diff --git a/libs/go/azureentra.go b/libs/go/azureentra.go index a81e861..50bac69 100644 --- a/libs/go/azureentra.go +++ b/libs/go/azureentra.go @@ -15,12 +15,16 @@ import ( // connection password; the DSN user is the Entra principal name. const azureEntraScope = "https://ossrdbms-aad.database.windows.net/.default" -// AzureEntraWorkloadIdentity builds an AccessTokenProvider backed by the pod's +// azureEntraWorkloadIdentity builds an AccessTokenProvider backed by the pod's // federated workload identity (DefaultAzureCredential resolves the projected -// service-account token in-cluster). Use it with WithAccessTokenProvider so a -// deployed workload authenticates to managed Azure Postgres with no stored -// password. -func AzureEntraWorkloadIdentity() (AccessTokenProvider, error) { +// service-account token in-cluster), so a deployed workload authenticates to +// managed Azure Postgres with no stored password. +// +// Unexported on purpose: it is reached through WithExternalIdentity and the +// IssuerAzureEntra declaration, never by a consumer naming this cloud. The +// context is the caller's, carried for symmetry with the other issuers — +// DefaultAzureCredential resolves its chain without one. +func azureEntraWorkloadIdentity(_ context.Context) (AccessTokenProvider, error) { credential, err := azidentity.NewDefaultAzureCredential(nil) if err != nil { return nil, fmt.Errorf("initialize Azure workload identity credential: %w", err) diff --git a/libs/go/connection_profile.go b/libs/go/connection_profile.go index 4b86811..94c2993 100644 --- a/libs/go/connection_profile.go +++ b/libs/go/connection_profile.go @@ -169,6 +169,14 @@ func ParseConnection(connection string, profile ConnectionProfile, tokenHook boo if strings.ContainsAny(password, "\x00\r\n") { return nil, ErrConnectionProfile } + // A credential provider replaces the password on every new backend, so a + // password in the URL is never the one that authenticates. Accepting both + // leaves a dead secret in the deployment that reads as the live one, and + // hides the case this is really guarding: an external-identity DSN that + // still carries the seeded password it was supposed to stop carrying. + if tokenHook && hasPassword { + return nil, ErrConnectionProfile + } if profile.Transport == LocalIdentityProxy { host = q.Get("host") if u.Host != "" || hasPassword || tokenHook || q.Get("sslmode") != "disable" || q.Get("passfile") != "/dev/null" || !filepath.IsAbs(host) || filepath.Clean(host) != host || host == "/" || strings.ContainsAny(host, ",\x00\r\n") || strings.Contains(host, ".s.PGSQL.") { diff --git a/libs/go/connection_profile_test.go b/libs/go/connection_profile_test.go index aed67ef..6110d18 100644 --- a/libs/go/connection_profile_test.go +++ b/libs/go/connection_profile_test.go @@ -3,6 +3,7 @@ package postgres import ( "context" "errors" + "fmt" "net/url" "os" "strings" @@ -138,3 +139,32 @@ func TestConnectionProfilesValidateBothBeforePoolOrToken(t *testing.T) { t.Fatal("service opt-in scope changed", err) } } + +// A provider-authenticated capability carries its login and no password: the +// token replaces it per connection, so a password in the URL is dead config +// that reads as the live credential. +func TestParseConnectionRefusesAPasswordBesideACredentialProvider(t *testing.T) { + for _, transport := range []ConnectionTransport{VerifiedTLS, MeshProtected, LocalLoopback} { + host := "store.identity.internal.example.com" + sslmode := "verify-full" + if transport == MeshProtected || transport == LocalLoopback { + sslmode = "disable" + } + if transport == LocalLoopback { + host = "localhost" + } + profile := ConnectionProfile{Transport: transport} + passwordless := fmt.Sprintf("postgresql://writer@%s/saas?sslmode=%s", host, sslmode) + withPassword := fmt.Sprintf("postgresql://writer:seeded@%s/saas?sslmode=%s", host, sslmode) + if _, err := ParseConnection(passwordless, profile, true); err != nil { + t.Fatalf("%s: a passwordless provider connection was refused: %v", transport, err) + } + if _, err := ParseConnection(withPassword, profile, true); !errors.Is(err, ErrConnectionProfile) { + t.Fatalf("%s: a password beside a credential provider was accepted: %v", transport, err) + } + // Without a provider the password is the credential and still stands. + if _, err := ParseConnection(withPassword, profile, false); err != nil { + t.Fatalf("%s: a password-authenticated connection was refused: %v", transport, err) + } + } +} diff --git a/libs/go/externalidentity.go b/libs/go/externalidentity.go new file mode 100644 index 0000000..5e6c5ad --- /dev/null +++ b/libs/go/externalidentity.go @@ -0,0 +1,116 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" +) + +// An external identity is a credential the cloud issues to the workload itself: +// the pod authenticates to managed Postgres as its own identity, and no password +// exists to be seeded, projected, rotated or leaked. +// +// WHICH cloud issues it is a property of the deployment, never of the +// application. A consuming service forwards the declaration it was handed and +// names no cloud, no cloud SDK and no identity endpoint; adding a cloud is a +// change to this file and the issuer beside it, and to nothing above the +// library. That boundary is the point: a service that reaches for one cloud's +// credential API has encoded its deployment into its source, and the next cloud +// becomes a second code path in every consumer instead of one entry here. +// +// The declaration is an exact enum with no default. An empty or unrecognized +// value is refused rather than treated as "no external identity", because the +// absence of a declaration and the declaration of absence are different states, +// and collapsing them is how a workload ends up on whatever credential happened +// to be reachable. +const ( + // IssuerAzureEntra: Azure Database for PostgreSQL Flexible Server, where the + // login is an Entra principal and the password is an Entra access token. + IssuerAzureEntra = "azure-entra" + // IssuerGCPCloudSQL: Cloud SQL for PostgreSQL with + // `cloudsql.iam_authentication` on, where the login is a service account and + // the password is an IAM access token. + IssuerGCPCloudSQL = "gcp-cloud-sql" +) + +// ErrExternalIdentity reports a declaration this library cannot honour. It is +// returned before any pool exists, so a deployment that names a cloud this +// build does not implement fails at startup rather than on its first query. +var ErrExternalIdentity = errors.New("invalid Postgres external identity declaration") + +// externalIdentityIssuers maps a declaration to the constructor that mints its +// credential. A table rather than a switch so a refusal can state what IS +// declarable, which is the one thing the operator who mis-typed it needs. +var externalIdentityIssuers = map[string]func(context.Context) (AccessTokenProvider, error){ + IssuerAzureEntra: azureEntraWorkloadIdentity, + IssuerGCPCloudSQL: gcpCloudSQLIAM, +} + +// WithExternalIdentity authenticates every pooled connection as the workload's +// own cloud identity. declaration is the deployment's statement of where this +// workload runs; the caller forwards it verbatim from its configuration and does +// not interpret it. +// +// The read-only and read-write DSNs must then carry their login principal and no +// password. The credential is minted per physical connection, so rotation is +// picked up as the pool grows or reconnects, and nothing durable holds a secret. +// +// It is refused alongside WithAccessTokenProvider: both authenticate the same +// pools, and a deployment that declares two credentials for one login has not +// said which one is authoritative. +func WithExternalIdentity(declaration string) Option { + return func(configuration *config) error { + issuer := strings.TrimSpace(declaration) + if _, known := externalIdentityIssuers[issuer]; !known { + return fmt.Errorf("%w: %q is not one of %s", ErrExternalIdentity, elide(declaration), strings.Join(declarableIssuers(), ", ")) + } + configuration.externalIdentity = issuer + return nil + } +} + +// resolveExternalIdentity builds the declared provider. It runs where a +// connection is opened rather than where the option is applied, because minting +// the first credential reaches the cloud's identity endpoint — work that belongs +// to the caller's context and its timeout, not to the construction of a config +// value. +func resolveExternalIdentity(ctx context.Context, configuration *config) error { + if configuration.externalIdentity == "" { + return nil + } + if configuration.accessTokenProvider != nil { + return fmt.Errorf("%w: an external identity and an explicit access-token provider both authenticate the same pools", ErrExternalIdentity) + } + issue, known := externalIdentityIssuers[configuration.externalIdentity] + if !known { + return fmt.Errorf("%w: %q is not one of %s", ErrExternalIdentity, configuration.externalIdentity, strings.Join(declarableIssuers(), ", ")) + } + provider, err := issue(ctx) + if err != nil { + return fmt.Errorf("%s external identity: %w", configuration.externalIdentity, err) + } + configuration.accessTokenProvider = provider + return nil +} + +func declarableIssuers() []string { + issuers := make([]string, 0, len(externalIdentityIssuers)) + for issuer := range externalIdentityIssuers { + issuers = append(issuers, issuer) + } + sort.Strings(issuers) + return issuers +} + +// elide bounds a rejected declaration before it reaches a log line. The value is +// configuration rather than a credential, but an unbounded one is still an +// unbounded write driven by something outside this process. +func elide(value string) string { + const limit = 64 + if len(value) <= limit { + return value + } + return value[:limit] + "…" +} diff --git a/libs/go/externalidentity_test.go b/libs/go/externalidentity_test.go new file mode 100644 index 0000000..9bfe4f6 --- /dev/null +++ b/libs/go/externalidentity_test.go @@ -0,0 +1,90 @@ +package postgres + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestWithExternalIdentityRefusesAnUndeclaredIssuer(t *testing.T) { + for _, declaration := range []string{"", " ", "gcp", "GCP-CLOUD-SQL", "aws-rds", strings.Repeat("x", 512)} { + var configuration config + err := WithExternalIdentity(declaration)(&configuration) + if !errors.Is(err, ErrExternalIdentity) { + t.Fatalf("declaration %q: err = %v, want ErrExternalIdentity", declaration, err) + } + if configuration.externalIdentity != "" { + t.Fatalf("declaration %q was recorded despite being refused", declaration) + } + // The operator who mistyped it needs the declarable set, and a rejected + // value must not be echoed back unbounded. + for _, issuer := range declarableIssuers() { + if !strings.Contains(err.Error(), issuer) { + t.Fatalf("declaration %q: refusal does not name %q: %v", declaration, issuer, err) + } + } + if len(err.Error()) > 256 { + t.Fatalf("declaration %q: refusal is %d bytes", declaration, len(err.Error())) + } + } +} + +func TestWithExternalIdentityAcceptsEveryDeclarableIssuer(t *testing.T) { + for _, issuer := range declarableIssuers() { + var configuration config + if err := WithExternalIdentity(" " + issuer + " ")(&configuration); err != nil { + t.Fatalf("issuer %q: %v", issuer, err) + } + if configuration.externalIdentity != issuer { + t.Fatalf("issuer %q recorded as %q", issuer, configuration.externalIdentity) + } + } +} + +// Every issuer named by a published constant must be declarable, and every +// declarable issuer must be reachable: a constant with no entry is a value a +// deployment can write and no build can honour. +func TestPublishedIssuerConstantsAreDeclarable(t *testing.T) { + for _, issuer := range []string{IssuerAzureEntra, IssuerGCPCloudSQL} { + if _, known := externalIdentityIssuers[issuer]; !known { + t.Fatalf("published issuer %q has no constructor", issuer) + } + } + for issuer, construct := range externalIdentityIssuers { + if construct == nil { + t.Fatalf("declarable issuer %q has a nil constructor", issuer) + } + } +} + +func TestResolveExternalIdentityRefusesTwoCredentialsForOnePool(t *testing.T) { + configuration := config{ + externalIdentity: IssuerGCPCloudSQL, + accessTokenProvider: func(context.Context, string) (string, error) { return "token", nil }, + } + err := resolveExternalIdentity(context.Background(), &configuration) + if !errors.Is(err, ErrExternalIdentity) { + t.Fatalf("err = %v, want ErrExternalIdentity", err) + } +} + +func TestResolveExternalIdentityLeavesAnUndeclaredConfigAlone(t *testing.T) { + configuration := config{} + if err := resolveExternalIdentity(context.Background(), &configuration); err != nil { + t.Fatal(err) + } + if configuration.accessTokenProvider != nil { + t.Fatal("an undeclared external identity installed a provider") + } +} + +// A declaration this build cannot honour must fail where the deployment is +// wrong. resolveExternalIdentity is also reached by callers that set the field +// directly, so it refuses rather than trusting WithExternalIdentity's check. +func TestResolveExternalIdentityRefusesAnUnknownIssuerItWasHandedDirectly(t *testing.T) { + configuration := config{externalIdentity: "azure-managed-identity"} + if err := resolveExternalIdentity(context.Background(), &configuration); !errors.Is(err, ErrExternalIdentity) { + t.Fatalf("err = %v, want ErrExternalIdentity", err) + } +} diff --git a/libs/go/gcpcloudsql.go b/libs/go/gcpcloudsql.go new file mode 100644 index 0000000..ccfb0a9 --- /dev/null +++ b/libs/go/gcpcloudsql.go @@ -0,0 +1,254 @@ +package postgres + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "time" + + "cloud.google.com/go/compute/metadata" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +// gcpCloudSQLScope is the scope whose access token Cloud SQL accepts in place of +// a password for a CLOUD_IAM_SERVICE_ACCOUNT login. Deliberately not +// cloud-platform: the database credential is minted at the narrowest scope that +// can log in, so a token that leaves this process can do nothing but log in. +const gcpCloudSQLScope = "https://www.googleapis.com/auth/sqlservice.login" + +// gcpImpersonationScope is the scope the IAM Credentials API requires of the +// CALLER asking for another principal's token. It authorizes the exchange and +// never the database: what comes back out carries gcpCloudSQLScope. Collapsing +// the two sources into one would send a cloud-platform token to Postgres as a +// password. +const gcpImpersonationScope = "https://www.googleapis.com/auth/cloud-platform" + +// gcpServiceAccountSuffix is what Cloud SQL strips from a service account's +// email to form its SQL login — infra registers the login as +// trimsuffix(email, ".gserviceaccount.com"). Appending it to the DSN login +// reverses that derivation exactly, so the mapping from login to cloud +// principal is computed rather than declared a second time somewhere it can +// drift from the first. +const gcpServiceAccountSuffix = ".gserviceaccount.com" + +const gcpIAMCredentialsEndpoint = "https://iamcredentials.googleapis.com" + +// gcpTokenLifetime is the longest an impersonated token may be issued for +// without a project policy raising the ceiling; gcpTokenRefreshMargin retires it +// early so a connection opened at the edge of validity does not race expiry. +const ( + gcpTokenLifetime = time.Hour + gcpTokenRefreshMargin = 2 * time.Minute + gcpExchangeTimeout = 15 * time.Second + gcpMaxResponseBytes = 1 << 16 +) + +// gcpSQLLoginPattern is the exact shape of a Cloud SQL service-account login: +// a service account's account id, an "@", its project, and the ".iam" that +// remains once the email suffix is stripped. The login arrives from a +// connection string and becomes a path segment in an IAM request, so it is +// matched against that shape rather than escaped and hoped for. +var gcpSQLLoginPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{4,28}[a-z0-9]@[a-z0-9][a-z0-9.:-]{4,28}[a-z0-9]\.iam$`) + +// gcpIdentity mints Cloud SQL login credentials for the workload's own identity +// and, for any other login, by asking IAM for that principal's token. +// +// A pod has one Kubernetes ServiceAccount and therefore one primary cloud +// identity, while this library opens several logins deliberately kept distinct +// (a request login that reaches only the tenant role, and a control-plane login +// that reaches the cross-tenant roles). The reference deployment annotates the +// ServiceAccount with one of them and grants it +// roles/iam.serviceAccountTokenCreator on the others, so the login named by each +// connection decides whether the ambient credential is used directly or +// exchanged for that principal's. +type gcpIdentity struct { + ownLogin string + own oauth2.TokenSource + caller oauth2.TokenSource + endpoint string + client *http.Client + + mu sync.Mutex + borrowed map[string]oauth2.TokenSource +} + +// gcpCloudSQLIAM resolves the pod's workload identity and returns the provider +// that authenticates each login with it. Every failure here is a deployment that +// is wrong — no federated credential, no metadata server — so it is returned at +// startup instead of per connection. +func gcpCloudSQLIAM(ctx context.Context) (AccessTokenProvider, error) { + own, err := google.DefaultTokenSource(ctx, gcpCloudSQLScope) + if err != nil { + return nil, fmt.Errorf("resolve workload identity credentials: %w", err) + } + caller, err := google.DefaultTokenSource(ctx, gcpImpersonationScope) + if err != nil { + return nil, fmt.Errorf("resolve workload identity credentials for impersonation: %w", err) + } + email, err := metadata.EmailWithContext(ctx, "default") + if err != nil { + // Without the workload's own identity every login looks like another + // principal's, and the provider would ask IAM to impersonate the account + // it already is — a grant the reference deployment does not make, + // failing every connection with a 403 that names the wrong cause. + return nil, fmt.Errorf("resolve the workload's own service account: %w", err) + } + identity, err := newGCPIdentity(strings.TrimSuffix(strings.TrimSpace(email), gcpServiceAccountSuffix), own, caller, gcpIAMCredentialsEndpoint, nil) + if err != nil { + return nil, err + } + return identity.provider(), nil +} + +func newGCPIdentity(ownLogin string, own, caller oauth2.TokenSource, endpoint string, client *http.Client) (*gcpIdentity, error) { + if own == nil || caller == nil { + return nil, errors.New("Cloud SQL IAM identity requires both its own and its impersonating token source") + } + if !gcpSQLLoginPattern.MatchString(ownLogin) { + return nil, fmt.Errorf("the workload's own service account %q is not a Cloud SQL service-account login", elide(ownLogin)) + } + if client == nil { + client = &http.Client{Timeout: gcpExchangeTimeout} + } + return &gcpIdentity{ + ownLogin: ownLogin, + own: own, + caller: caller, + endpoint: strings.TrimSuffix(endpoint, "/"), + client: client, + borrowed: map[string]oauth2.TokenSource{}, + }, nil +} + +func (g *gcpIdentity) provider() AccessTokenProvider { + return func(_ context.Context, principal string) (string, error) { + return g.token(principal) + } +} + +// token selects the credential for the login the connection names. The +// connection's context is deliberately not used to mint it: a credential shared +// by a pool must not inherit the deadline of whichever request happened to grow +// the pool, or one cancelled request would fail connections for every other. +func (g *gcpIdentity) token(principal string) (string, error) { + login := strings.TrimSpace(principal) + if login == "" { + return "", errors.New("Cloud SQL IAM connection names no login, so there is no principal to authenticate as") + } + if login == g.ownLogin { + return accessTokenFrom(g.own) + } + source, err := g.borrow(login) + if err != nil { + return "", err + } + return accessTokenFrom(source) +} + +// borrow memoizes one reusable token source per impersonated login. Caching is +// the provider's own job: pgx asks for a credential on every physical +// connection, and a pool that grows under load would otherwise mint an IAM +// token per connection. +func (g *gcpIdentity) borrow(login string) (oauth2.TokenSource, error) { + if !gcpSQLLoginPattern.MatchString(login) { + return nil, errors.New("Cloud SQL IAM login is not a service-account login") + } + g.mu.Lock() + defer g.mu.Unlock() + if source, cached := g.borrowed[login]; cached { + return source, nil + } + source := oauth2.ReuseTokenSourceWithExpiry(nil, &gcpBorrowedTokens{ + identity: g, + principal: login + gcpServiceAccountSuffix, + }, gcpTokenRefreshMargin) + g.borrowed[login] = source + return source, nil +} + +// gcpBorrowedTokens exchanges the workload's own credential for one principal's. +type gcpBorrowedTokens struct { + identity *gcpIdentity + principal string +} + +func (b *gcpBorrowedTokens) Token() (*oauth2.Token, error) { + ctx, cancel := context.WithTimeout(context.Background(), gcpExchangeTimeout) + defer cancel() + return b.identity.exchange(ctx, b.principal) +} + +func (g *gcpIdentity) exchange(ctx context.Context, principal string) (*oauth2.Token, error) { + caller, err := g.caller.Token() + if err != nil { + return nil, fmt.Errorf("acquire the impersonating workload's own token: %w", err) + } + body, err := json.Marshal(map[string]any{ + "scope": []string{gcpCloudSQLScope}, + "lifetime": fmt.Sprintf("%ds", int(gcpTokenLifetime.Seconds())), + }) + if err != nil { + return nil, fmt.Errorf("encode the Cloud SQL token request: %w", err) + } + endpoint := g.endpoint + "/v1/projects/-/serviceAccounts/" + url.PathEscape(principal) + ":generateAccessToken" + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("build the Cloud SQL token request: %w", err) + } + request.Header.Set("Content-Type", "application/json") + caller.SetAuthHeader(request) + response, err := g.client.Do(request) + if err != nil { + return nil, fmt.Errorf("ask IAM for a Cloud SQL token for %s: %w", principal, err) + } + defer response.Body.Close() + payload, err := io.ReadAll(io.LimitReader(response.Body, gcpMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("read IAM's Cloud SQL token response for %s: %w", principal, err) + } + if response.StatusCode != http.StatusOK { + // The principal, not the body: a refusal here is almost always the + // missing roles/iam.serviceAccountTokenCreator grant on exactly this + // account, which is the one fact worth logging, and the body is the + // cloud's prose rather than ours. + return nil, fmt.Errorf("IAM refused a Cloud SQL token for %s: HTTP %d", principal, response.StatusCode) + } + var minted struct { + AccessToken string `json:"accessToken"` + ExpireTime string `json:"expireTime"` + } + if err := json.Unmarshal(payload, &minted); err != nil { + return nil, fmt.Errorf("decode IAM's Cloud SQL token response for %s: %w", principal, err) + } + if minted.AccessToken == "" { + return nil, fmt.Errorf("IAM returned an empty Cloud SQL token for %s", principal) + } + // An unparseable or absent expiry would leave the zero time, which oauth2 + // reads as a token that never expires: it would be reused for the life of + // the process and start failing silently an hour in, with no refresh. + expiry, err := time.Parse(time.RFC3339, minted.ExpireTime) + if err != nil { + return nil, fmt.Errorf("IAM returned a Cloud SQL token for %s with no usable expiry: %w", principal, err) + } + return &oauth2.Token{AccessToken: minted.AccessToken, TokenType: "Bearer", Expiry: expiry}, nil +} + +func accessTokenFrom(source oauth2.TokenSource) (string, error) { + token, err := source.Token() + if err != nil { + return "", fmt.Errorf("acquire Cloud SQL IAM token: %w", err) + } + if token.AccessToken == "" { + return "", errors.New("Cloud SQL IAM token source returned an empty token") + } + return token.AccessToken, nil +} diff --git a/libs/go/gcpcloudsql_test.go b/libs/go/gcpcloudsql_test.go new file mode 100644 index 0000000..f6235ee --- /dev/null +++ b/libs/go/gcpcloudsql_test.go @@ -0,0 +1,345 @@ +package postgres + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "golang.org/x/oauth2" +) + +const ( + testOwnLogin = "cell-db-accounts-writer@example-project.iam" + testOtherLogin = "cell-db-accounts-reader@example-project.iam" +) + +type fixedTokenSource struct { + token string + err error + calls atomic.Int64 +} + +func (f *fixedTokenSource) Token() (*oauth2.Token, error) { + f.calls.Add(1) + if f.err != nil { + return nil, f.err + } + return &oauth2.Token{AccessToken: f.token, TokenType: "Bearer", Expiry: time.Now().Add(time.Hour)}, nil +} + +type exchangeRecord struct { + path string + authorize string + scopes []string + lifetime string +} + +// iamStub answers generateAccessToken, recording what was actually asked for. +func iamStub(t *testing.T, mint func(principal string) (int, string)) (*gcpIdentity, *[]exchangeRecord, func()) { + t.Helper() + var mu sync.Mutex + records := []exchangeRecord{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<16)) + var request struct { + Scope []string `json:"scope"` + Lifetime string `json:"lifetime"` + } + _ = json.Unmarshal(body, &request) + mu.Lock() + records = append(records, exchangeRecord{ + path: r.URL.Path, + authorize: r.Header.Get("Authorization"), + scopes: request.Scope, + lifetime: request.Lifetime, + }) + mu.Unlock() + principal := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/v1/projects/-/serviceAccounts/"), ":generateAccessToken") + status, payload := mint(principal) + w.WriteHeader(status) + _, _ = w.Write([]byte(payload)) + })) + identity, err := newGCPIdentity( + testOwnLogin, + &fixedTokenSource{token: "own-sqlservice-token"}, + &fixedTokenSource{token: "caller-cloud-platform-token"}, + server.URL, + server.Client(), + ) + if err != nil { + server.Close() + t.Fatal(err) + } + return identity, &records, server.Close +} + +func mintedToken(token string, expiry time.Time) string { + payload, _ := json.Marshal(map[string]string{"accessToken": token, "expireTime": expiry.Format(time.RFC3339)}) + return string(payload) +} + +// The pod's own login must not go through IAM at all: the reference deployment +// grants it no token-creator role on itself, so an exchange here would 403. +func TestGCPOwnLoginUsesTheAmbientCredentialWithoutAnExchange(t *testing.T) { + identity, records, close := iamStub(t, func(string) (int, string) { + t.Error("the workload's own login was exchanged through IAM") + return http.StatusForbidden, "{}" + }) + defer close() + token, err := identity.token(" " + testOwnLogin + " ") + if err != nil { + t.Fatal(err) + } + if token != "own-sqlservice-token" { + t.Fatalf("token = %q, want the ambient sqlservice token", token) + } + if len(*records) != 0 { + t.Fatalf("own login reached IAM: %+v", *records) + } +} + +func TestGCPOtherLoginIsExchangedForThatPrincipal(t *testing.T) { + identity, records, close := iamStub(t, func(principal string) (int, string) { + return http.StatusOK, mintedToken("borrowed-for-"+principal, time.Now().Add(time.Hour)) + }) + defer close() + token, err := identity.token(testOtherLogin) + if err != nil { + t.Fatal(err) + } + want := "borrowed-for-" + testOtherLogin + gcpServiceAccountSuffix + if token != want { + t.Fatalf("token = %q, want %q", token, want) + } + if len(*records) != 1 { + t.Fatalf("records = %+v, want exactly one exchange", *records) + } + record := (*records)[0] + // The DSN login is reversed into the service-account email Cloud SQL + // stripped it from, with no second declaration mapping the two. + if wantPath := "/v1/projects/-/serviceAccounts/" + testOtherLogin + gcpServiceAccountSuffix + ":generateAccessToken"; record.path != wantPath { + t.Fatalf("path = %q, want %q", record.path, wantPath) + } + // The exchange is authorized with cloud-platform; what it mints is scoped to + // logging in and nothing else. + if record.authorize != "Bearer caller-cloud-platform-token" { + t.Fatalf("authorization = %q", record.authorize) + } + if len(record.scopes) != 1 || record.scopes[0] != gcpCloudSQLScope { + t.Fatalf("scopes = %v, want only %q", record.scopes, gcpCloudSQLScope) + } + if record.lifetime != "3600s" { + t.Fatalf("lifetime = %q", record.lifetime) + } +} + +// The failure the infra record names outright: one credential standing in for +// two logins, which would hand the request login the control plane's authority. +func TestGCPDistinctLoginsNeverShareACredential(t *testing.T) { + identity, _, close := iamStub(t, func(principal string) (int, string) { + return http.StatusOK, mintedToken("borrowed-for-"+principal, time.Now().Add(time.Hour)) + }) + defer close() + third := "cell-db-accounts-control@example-project.iam" + seen := map[string]string{} + for _, login := range []string{testOwnLogin, testOtherLogin, third} { + token, err := identity.token(login) + if err != nil { + t.Fatalf("login %q: %v", login, err) + } + if owner, duplicate := seen[token]; duplicate { + t.Fatalf("logins %q and %q authenticate with the same token", owner, login) + } + seen[token] = login + } +} + +func TestGCPBorrowedTokensAreCachedUntilTheyNearExpiry(t *testing.T) { + var exchanges atomic.Int64 + identity, _, close := iamStub(t, func(principal string) (int, string) { + exchanges.Add(1) + return http.StatusOK, mintedToken(fmt.Sprintf("borrowed-%d", exchanges.Load()), time.Now().Add(time.Hour)) + }) + defer close() + for range 5 { + if _, err := identity.token(testOtherLogin); err != nil { + t.Fatal(err) + } + } + if exchanges.Load() != 1 { + t.Fatalf("exchanges = %d, want 1: pgx asks per physical connection, so an uncached provider mints one IAM token per connection", exchanges.Load()) + } +} + +func TestGCPBorrowedTokenIsReExchangedOnceItIsInsideTheRefreshMargin(t *testing.T) { + var exchanges atomic.Int64 + identity, _, close := iamStub(t, func(string) (int, string) { + exchanges.Add(1) + return http.StatusOK, mintedToken(fmt.Sprintf("borrowed-%d", exchanges.Load()), time.Now().Add(gcpTokenRefreshMargin/2)) + }) + defer close() + first, err := identity.token(testOtherLogin) + if err != nil { + t.Fatal(err) + } + second, err := identity.token(testOtherLogin) + if err != nil { + t.Fatal(err) + } + if first == second || exchanges.Load() != 2 { + t.Fatalf("a token inside the refresh margin was reused: %q then %q over %d exchanges", first, second, exchanges.Load()) + } +} + +func TestGCPRefusesALoginThatIsNotAServiceAccount(t *testing.T) { + identity, records, close := iamStub(t, func(string) (int, string) { + t.Error("a login that is not a service account reached IAM") + return http.StatusOK, mintedToken("leaked", time.Now().Add(time.Hour)) + }) + defer close() + for _, login := range []string{ + "", + " ", + "app_tenant", + "postgres", + "../../projects/other/serviceAccounts/victim@example-project.iam", + "writer@example-project.iam:generateAccessToken", + "writer@example-project.iam.gserviceaccount.com", + "writer@example-project.iam\nX-Injected: 1", + "UPPER@example-project.iam", + } { + if _, err := identity.token(login); err == nil { + t.Fatalf("login %q was accepted", login) + } + } + if len(*records) != 0 { + t.Fatalf("a refused login still reached IAM: %+v", *records) + } +} + +func TestGCPExchangeFailuresFailClosedWithoutEchoingTheResponse(t *testing.T) { + secret := "ya29.super-secret-token-material" + for name, answer := range map[string]func(string) (int, string){ + "refused": func(string) (int, string) { return http.StatusForbidden, `{"error":{"message":"` + secret + `"}}` }, + "empty token": func(string) (int, string) { + return http.StatusOK, `{"accessToken":"","expireTime":"2030-01-01T00:00:00Z"}` + }, + "no expiry": func(string) (int, string) { return http.StatusOK, `{"accessToken":"` + secret + `"}` }, + "unusable expiry": func(string) (int, string) { + return http.StatusOK, `{"accessToken":"` + secret + `","expireTime":"soon"}` + }, + "not json": func(string) (int, string) { return http.StatusOK, "" + secret + "" }, + "server error": func(string) (int, string) { return http.StatusInternalServerError, secret }, + "empty 200 body": func(string) (int, string) { return http.StatusOK, "" }, + "token in a field": func(string) (int, string) { return http.StatusOK, `{"token":"` + secret + `"}` }, + } { + t.Run(name, func(t *testing.T) { + identity, _, close := iamStub(t, answer) + defer close() + token, err := identity.token(testOtherLogin) + if err == nil { + t.Fatalf("token = %q, want a refusal", token) + } + if token != "" { + t.Fatalf("a failed exchange returned %q", token) + } + // A credential the exchange happened to carry must not reach a log + // line through the error, and the principal must, because it names + // the missing grant. + if strings.Contains(err.Error(), secret) { + t.Fatalf("error carries response material: %v", err) + } + if !strings.Contains(err.Error(), testOtherLogin) { + t.Fatalf("error does not name the principal: %v", err) + } + }) + } +} + +func TestGCPAmbientTokenFailureFailsClosed(t *testing.T) { + identity, err := newGCPIdentity(testOwnLogin, + &fixedTokenSource{err: fmt.Errorf("no federated credential")}, + &fixedTokenSource{token: "caller"}, + "https://iamcredentials.invalid", nil) + if err != nil { + t.Fatal(err) + } + if token, err := identity.token(testOwnLogin); err == nil { + t.Fatalf("token = %q, want a refusal", token) + } + if token, err := identity.token(testOwnLogin); err == nil || token != "" { + t.Fatal("a failed ambient credential was cached as a usable one") + } +} + +func TestNewGCPIdentityRefusesAnIncompleteBinding(t *testing.T) { + source := &fixedTokenSource{token: "t"} + for name, build := range map[string]func() (*gcpIdentity, error){ + "no own source": func() (*gcpIdentity, error) { return newGCPIdentity(testOwnLogin, nil, source, "", nil) }, + "no caller source": func() (*gcpIdentity, error) { return newGCPIdentity(testOwnLogin, source, nil, "", nil) }, + "no own login": func() (*gcpIdentity, error) { return newGCPIdentity("", source, source, "", nil) }, + "own login is a database role": func() (*gcpIdentity, error) { + return newGCPIdentity("app_tenant", source, source, "", nil) + }, + "own login kept its email suffix": func() (*gcpIdentity, error) { + return newGCPIdentity(testOwnLogin+gcpServiceAccountSuffix, source, source, "", nil) + }, + } { + t.Run(name, func(t *testing.T) { + if _, err := build(); err == nil { + t.Fatal("an incomplete binding was accepted") + } + }) + } +} + +// The provider is handed to pgx's BeforeConnect, which supplies the login of the +// connection being opened. It must read that argument rather than any ambient +// notion of "the" identity. +func TestGCPProviderAuthenticatesTheLoginItIsGiven(t *testing.T) { + identity, _, close := iamStub(t, func(principal string) (int, string) { + return http.StatusOK, mintedToken("borrowed-for-"+principal, time.Now().Add(time.Hour)) + }) + defer close() + provider := identity.provider() + own, err := provider(t.Context(), testOwnLogin) + if err != nil { + t.Fatal(err) + } + other, err := provider(t.Context(), testOtherLogin) + if err != nil { + t.Fatal(err) + } + if own == other { + t.Fatal("the provider ignored the login it was given") + } +} + +// The login shapes a cell actually registers, transcribed with placeholder +// names of identical length and character classes: infra derives a service +// account as --db- in a hyphenated project, and the +// pattern has to accept the whole family rather than the one example it was +// written against. +func TestGCPLoginPatternAcceptsTheShapesACellRegisters(t *testing.T) { + for _, login := range []string{ + "acmex-use1-db-writer@acme-acmex-staging.iam", + "acmex-use1-db-reader@acme-acmex-staging.iam", + "acmex-use1-db-migrator@acme-acmex-staging.iam", + "acmex-use1-db-control-plane@acme-acmex-staging.iam", + // A GCP project id is 6-30 characters, so these are the + // shortest account id and project a login can really carry. + "a-cell-db-rw@acme-1.iam", + "service-account@example.com:scoped-project.iam", + } { + if !gcpSQLLoginPattern.MatchString(login) { + t.Fatalf("login shape %q was refused", login) + } + } +} diff --git a/libs/go/go.mod b/libs/go/go.mod index f521264..b3c3c88 100644 --- a/libs/go/go.mod +++ b/libs/go/go.mod @@ -3,10 +3,12 @@ module github.com/codefly-dev/service-postgres/libs/go go 1.27.2 require ( + cloud.google.com/go/compute/metadata v0.10.0 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 github.com/jackc/pgx/v5 v5.10.0 github.com/lib/pq v1.12.3 + golang.org/x/oauth2 v0.37.0 ) require ( diff --git a/libs/go/go.sum b/libs/go/go.sum index cfcdc01..6b3949a 100644 --- a/libs/go/go.sum +++ b/libs/go/go.sum @@ -1,3 +1,5 @@ +cloud.google.com/go/compute/metadata v0.10.0 h1:pyKMUQSwchgkIBBJGdILqQbs/BNJXqwSA7Ej6LAvvtY= +cloud.google.com/go/compute/metadata v0.10.0/go.mod h1:rGFHRrIif570kSibjFTMbt6/4/tzgJWFGI/HVol4GIk= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.0 h1:4gRPBpN1f6xt88yi4WR26m7XaD9OlWtVT6bWPdGUIok= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.0/go.mod h1:G7QVLxw1j1JVyrO1MA95S8m8HStaaleDZYTcfGgjB2o= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU= @@ -15,6 +17,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -44,6 +48,8 @@ golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/libs/go/maintenance.go b/libs/go/maintenance.go index 37913eb..5221e14 100644 --- a/libs/go/maintenance.go +++ b/libs/go/maintenance.go @@ -59,6 +59,9 @@ func OpenMaintenance(ctx context.Context, connection, applicationRole string, op if err != nil { return nil, nil, err } + if err := resolveExternalIdentity(ctx, &c); err != nil { + return nil, nil, err + } if c.operationTimeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, c.operationTimeout) diff --git a/libs/go/open.go b/libs/go/open.go index 4fbd88a..582ba80 100644 --- a/libs/go/open.go +++ b/libs/go/open.go @@ -27,6 +27,9 @@ func Open( if err != nil { return nil, nil, err } + if err := resolveExternalIdentity(ctx, &configuration); err != nil { + return nil, nil, err + } if configuration.operationTimeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, configuration.operationTimeout) diff --git a/libs/go/store.go b/libs/go/store.go index 83a9e59..c4a3c40 100644 --- a/libs/go/store.go +++ b/libs/go/store.go @@ -154,6 +154,7 @@ type config struct { operationTimeout time.Duration readIsolation pgx.TxIsoLevel accessTokenProvider AccessTokenProvider + externalIdentity string restrictedSession bool deniedRoles []string readerProfile ConnectionProfile From 80f23e2e5ffb4c26f04af2844ca4f81fb17d40d3 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:59:01 -0400 Subject: [PATCH 2/3] feat(libs/go): a capability pins its server's authority from a declared value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A managed instance often presents a certificate signed by an authority private to it. Cloud SQL's default serverCaMode signs with a per-instance, self-signed CA — measured on a live cell, subject equal to issuer with an instance-scoped dnQualifier — whose root is in no system trust store, so `sslmode=verify-full` against system trust cannot verify it at all. pgx reads an anchor from `sslrootcert`, a FILE. A file has to be mounted, and a service agent may have no mount seam, which turns a verifiable connection into a deployment it cannot express. So `ConnectionProfile.ServerTrustAnchorPEM` takes the authority as a configured value and this library builds the trust pool itself: the configuration plane already delivers values, and nothing has to deliver a file. A certificate authority is public information, so this is ordinary configuration rather than a secret. Pinning narrows which authority may have signed the certificate and changes nothing else: hostname verification stays on, InsecureSkipVerify stays false, and driver fallbacks remain refused. It is VerifiedTLS only — no other transport verifies a certificate — and it is refused beside `sslrootcert` in the URL, which would be two answers to one question with the undeliverable one winning. An anchor is refused rather than falling through to system trust when it parses to no certificate, the empty string included: a deployment that meant to pin something and shipped nothing must not silently trust a store that cannot verify a private authority. Also refused, because a lenient parse accepts each and defers the failure to a handshake: a non-certificate PEM block, a server's own leaf certificate in place of its authority, and an expired authority. The decisive test mints two authorities and a server certificate signed by one: it verifies against that authority's pool, fails against the other's, and still fails on a wrong hostname, so the pin is a control rather than a comment. The profile options now propagate validate's reason instead of collapsing it, which leaves errors.Is unchanged for every existing case and tells an operator which anchor was wrong. Not verified against a live instance: no connection was opened to Cloud SQL. Co-Authored-By: Claude Opus 5 --- libs/go/README.md | 26 ++++ libs/go/connection_profile.go | 48 +++++++- libs/go/trustanchor.go | 79 ++++++++++++ libs/go/trustanchor_test.go | 226 ++++++++++++++++++++++++++++++++++ 4 files changed, 374 insertions(+), 5 deletions(-) create mode 100644 libs/go/trustanchor.go create mode 100644 libs/go/trustanchor_test.go diff --git a/libs/go/README.md b/libs/go/README.md index 6eb9926..473a0d4 100644 --- a/libs/go/README.md +++ b/libs/go/README.md @@ -257,6 +257,32 @@ one login has not said which is authoritative. `WithAccessTokenProvider` remains for a service that mints its own credential — a projected token file rewritten by a sidecar, say — and carries no cloud knowledge into this library. +## The server's trust anchor arrives as a value, not a file + +A managed instance often presents a certificate signed by an authority private +to it: Cloud SQL's default `serverCaMode` signs with a per-instance, self-signed +CA whose root is in no system trust store, so `sslmode=verify-full` against +system trust cannot verify it at all. pgx reads an anchor from `sslrootcert`, a +file — and a file has to be mounted, which is a seam a service agent may not +have. + +`ConnectionProfile.ServerTrustAnchorPEM` takes the authority as a configured +value and the library builds the trust pool itself, so the configuration plane +delivers a value and nothing delivers a file. A certificate authority is public +information: this is ordinary configuration, not a secret. Pinning it is +stronger than system trust rather than weaker — verification then means "this +instance's own authority" instead of "any authority a public trust store +carries" — and it narrows the authority only: hostname verification stays on and +`InsecureSkipVerify` stays false. + +It is `VerifiedTLS` only, since no other transport verifies a certificate. +Declaring it beside `sslrootcert` in the URL is refused: two answers to one +question, one of which is the file the deployment could not deliver. An anchor +that parses to no certificate — including the empty string — is refused rather +than falling through to system trust, as are a private-key block, a server's own +leaf certificate and an expired authority, each of which a lenient parse accepts +and turns into an unverifiable connection far from its cause. + `VerifiedTLS` requires an explicit PostgreSQL URL, login, database, a single TCP host and `sslmode=verify-full`; every effective endpoint is checked and driver fallbacks are rejected. Explicit CA/client-certificate files and reviewed pool diff --git a/libs/go/connection_profile.go b/libs/go/connection_profile.go index 94c2993..7de3a1a 100644 --- a/libs/go/connection_profile.go +++ b/libs/go/connection_profile.go @@ -2,6 +2,7 @@ package postgres import ( "errors" + "fmt" "net" "net/url" "os" @@ -58,14 +59,27 @@ type ConnectionProfile struct { Port uint16 Database string User string + // ServerTrustAnchorPEM pins the certificate authority this capability's + // server must chain to, as a configured value rather than a mounted file + // (trustanchor.go says why). Empty keeps system trust, which is correct for + // a server signed by a public authority and cannot verify one signed by a + // private per-instance authority. VerifiedTLS only: the other transports + // present no certificate to verify. + ServerTrustAnchorPEM string } // WithConnectionProfiles validates both request capabilities before either pool // is created. Each capability retains its independent transport and role policy. func WithConnectionProfiles(reader, writer ConnectionProfile) Option { return func(c *config) error { - if reader.validate() != nil || writer.validate() != nil { - return ErrConnectionProfile + // Propagated rather than collapsed: every existing reason is already + // ErrConnectionProfile, so errors.Is is unchanged, and a trust anchor's + // reason is something the operator who supplied it can act on. + if err := reader.validate(); err != nil { + return err + } + if err := writer.validate(); err != nil { + return err } c.readerProfile, c.writerProfile = reader, writer return nil @@ -76,8 +90,8 @@ func WithConnectionProfiles(reader, writer ConnectionProfile) Option { // capability before its pool is created. It does not change request profiles. func WithMaintenanceConnectionProfile(profile ConnectionProfile) Option { return func(c *config) error { - if profile.validate() != nil { - return ErrConnectionProfile + if err := profile.validate(); err != nil { + return err } c.maintenanceProfile = profile return nil @@ -101,6 +115,14 @@ func (p ConnectionProfile) validate() error { default: return ErrConnectionProfile } + if p.ServerTrustAnchorPEM != "" { + if p.Transport != VerifiedTLS { + return fmt.Errorf("%w: transport %q verifies no server certificate", ErrServerTrustAnchor, p.Transport) + } + if _, err := serverTrustAnchor(p.ServerTrustAnchorPEM); err != nil { + return err + } + } return nil } @@ -152,7 +174,13 @@ func ParseConnection(connection string, profile ConnectionProfile, tokenHook boo if profile.Transport != LocalIdentityProxy { return nil, ErrConnectionProfile } - case "sslrootcert", "sslcert", "sslkey", "connect_timeout", "pool_max_conns", "pool_min_conns", "pool_min_idle_conns", "pool_max_conn_lifetime", "pool_max_conn_idle_time", "pool_health_check_period", "pool_max_conn_lifetime_jitter", "application_name": + case "sslrootcert": + // A declared anchor and a named file are two answers to one + // question, and the file is the one this deployment cannot deliver. + if profile.Transport != VerifiedTLS || profile.ServerTrustAnchorPEM != "" { + return nil, ErrConnectionProfile + } + case "sslcert", "sslkey", "connect_timeout", "pool_max_conns", "pool_min_conns", "pool_min_idle_conns", "pool_max_conn_lifetime", "pool_max_conn_idle_time", "pool_health_check_period", "pool_max_conn_lifetime_jitter", "application_name": if profile.Transport != VerifiedTLS { return nil, ErrConnectionProfile } @@ -249,5 +277,15 @@ func ParseConnection(connection string, profile ConnectionProfile, tokenHook boo } else if c.TLSConfig == nil || c.TLSConfig.InsecureSkipVerify || c.TLSConfig.ServerName != host || c.TLSConfig.VerifyPeerCertificate != nil || c.TLSConfig.VerifyConnection != nil { return nil, ErrConnectionProfile } + if profile.ServerTrustAnchorPEM != "" { + pool, err := serverTrustAnchor(profile.ServerTrustAnchorPEM) + if err != nil { + return nil, err + } + // Pinned IN ADDITION to the checks above, never instead of them: the + // anchor narrows which authority may have signed the certificate and + // changes nothing about hostname verification, which stays on. + c.TLSConfig.RootCAs = pool + } return pc, nil } diff --git a/libs/go/trustanchor.go b/libs/go/trustanchor.go new file mode 100644 index 0000000..8c4a490 --- /dev/null +++ b/libs/go/trustanchor.go @@ -0,0 +1,79 @@ +package postgres + +import ( + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "time" +) + +// ErrServerTrustAnchor reports a declared trust anchor this library will not +// verify against. It is distinct from ErrConnectionProfile because the fault is +// in a configured value rather than in a connection URL, and the operator who +// supplied it cannot act on "invalid connection profile". +var ErrServerTrustAnchor = errors.New("invalid Postgres server trust anchor") + +// A managed Postgres server presents a certificate signed by a certificate +// authority that is often private to the instance: Cloud SQL's default mode +// signs with a per-instance, self-signed CA whose root is in no system trust +// store, so `sslmode=verify-full` against system roots cannot verify it at all. +// +// The anchor therefore has to be supplied. pgx reads one from `sslrootcert`, a +// FILE — and a file has to be mounted, which is a seam a service agent may not +// have, and which turns a verifiable connection into a deployment problem. So +// this library takes the anchor as a configured VALUE and builds the trust pool +// itself: the configuration plane already delivers values, and nothing has to +// deliver a file. +// +// A certificate authority is public information, so the anchor is ordinary +// configuration and not a secret. Pinning it is stronger than system trust, not +// weaker: verification then means "this instance's own authority" rather than +// "any authority a public trust store happens to carry". +// +// serverTrustAnchor parses the declared PEM into a pool. It refuses what a +// lenient parse would silently accept, because each of those is a configuration +// mistake that would otherwise surface as an unverifiable connection far from +// its cause. +func serverTrustAnchor(anchor string) (*x509.CertPool, error) { + pool := x509.NewCertPool() + rest := []byte(anchor) + certificates := 0 + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + // x509.CertPool.AppendCertsFromPEM skips any block that is not a + // certificate, so a pasted private key or a PEM with a typo'd header + // yields an empty pool and a connection that trusts nothing, reported as + // a handshake failure. Name it here instead. + if block.Type != "CERTIFICATE" { + return nil, fmt.Errorf("%w: it carries a %q block, and an anchor is certificates only", ErrServerTrustAnchor, block.Type) + } + certificate, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrServerTrustAnchor, err) + } + // A server's own leaf certificate is the thing most easily mistaken for + // its authority. As an anchor it cannot verify anything (it signs no + // certificate), so the connection would fail with nothing pointing at + // the swap. + if !certificate.IsCA { + return nil, fmt.Errorf("%w: %q is not a certificate authority", ErrServerTrustAnchor, certificate.Subject.CommonName) + } + if now := time.Now(); now.After(certificate.NotAfter) { + return nil, fmt.Errorf("%w: %q expired on %s", ErrServerTrustAnchor, certificate.Subject.CommonName, certificate.NotAfter.UTC().Format(time.RFC3339)) + } + pool.AddCert(certificate) + certificates++ + } + if certificates == 0 { + // Including the empty string: an anchor declared as "" is a deployment + // that meant to pin something and shipped nothing, which must not fall + // through to system roots that cannot verify a private authority. + return nil, fmt.Errorf("%w: it contains no certificate", ErrServerTrustAnchor) + } + return pool, nil +} diff --git a/libs/go/trustanchor_test.go b/libs/go/trustanchor_test.go new file mode 100644 index 0000000..0b62021 --- /dev/null +++ b/libs/go/trustanchor_test.go @@ -0,0 +1,226 @@ +package postgres + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "math/big" + "os" + "path/filepath" + "testing" + "time" +) + +func encodeCertificate(t *testing.T, der []byte) string { + t.Helper() + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +} + +// authority mints a self-signed CA in the shape Cloud SQL's default mode uses: +// subject equal to issuer, with a long life. +func authority(t *testing.T, name string, notAfter time.Time) (string, *x509.Certificate, *ecdsa.PrivateKey) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: name}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: notAfter, + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + parsed, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return encodeCertificate(t, der), parsed, key +} + +func serverCertificate(t *testing.T, host string, issuer *x509.Certificate, issuerKey *ecdsa.PrivateKey) *x509.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: host}, + DNSNames: []string{host}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, issuer, &key.PublicKey, issuerKey) + if err != nil { + t.Fatal(err) + } + parsed, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return parsed +} + +// The property the whole option exists for: the pool admits the instance's own +// authority and refuses every other one. Without this, pinning would be a +// comment rather than a control. +func TestServerTrustAnchorAdmitsOnlyTheDeclaredAuthority(t *testing.T) { + ownPEM, own, ownKey := authority(t, "Cloud SQL Server CA own", time.Now().Add(10*365*24*time.Hour)) + otherPEM, _, _ := authority(t, "Cloud SQL Server CA other", time.Now().Add(10*365*24*time.Hour)) + server := serverCertificate(t, "store.identity.internal.example.com", own, ownKey) + + ownPool, err := serverTrustAnchor(ownPEM) + if err != nil { + t.Fatal(err) + } + if _, err := server.Verify(x509.VerifyOptions{Roots: ownPool, DNSName: "store.identity.internal.example.com"}); err != nil { + t.Fatalf("the instance's own authority did not verify its server: %v", err) + } + otherPool, err := serverTrustAnchor(otherPEM) + if err != nil { + t.Fatal(err) + } + if _, err := server.Verify(x509.VerifyOptions{Roots: otherPool, DNSName: "store.identity.internal.example.com"}); err == nil { + t.Fatal("a server verified against an authority that did not sign it") + } + // Pinning narrows the authority and leaves hostname verification alone. + if _, err := server.Verify(x509.VerifyOptions{Roots: ownPool, DNSName: "attacker.example.com"}); err == nil { + t.Fatal("pinning an authority suppressed hostname verification") + } +} + +func TestServerTrustAnchorAcceptsABundle(t *testing.T) { + first, _, _ := authority(t, "CA one", time.Now().Add(time.Hour)) + second, _, _ := authority(t, "CA two", time.Now().Add(time.Hour)) + if _, err := serverTrustAnchor(first + second); err != nil { + t.Fatalf("a two-authority bundle was refused: %v", err) + } +} + +func TestServerTrustAnchorRefusesWhatALenientParseWouldSwallow(t *testing.T) { + _, leafIssuer, leafIssuerKey := authority(t, "CA", time.Now().Add(time.Hour)) + leaf := serverCertificate(t, "store.example.com", leafIssuer, leafIssuerKey) + expired, _, _ := authority(t, "Expired CA", time.Now().Add(-time.Minute)) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatal(err) + } + privateKeyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})) + + for name, anchor := range map[string]string{ + "empty": "", + "whitespace": " \n ", + "not pem": "-- this is a certificate, promise --", + "escaped newlines": "-----BEGIN CERTIFICATE-----\\nMIIB\\n-----END CERTIFICATE-----", + "a private key": privateKeyPEM, + "a server leaf, not a CA": encodeCertificate(t, leaf.Raw), + "an expired authority": expired, + "truncated body": "-----BEGIN CERTIFICATE-----\nnotbase64!!\n-----END CERTIFICATE-----\n", + } { + t.Run(name, func(t *testing.T) { + pool, err := serverTrustAnchor(anchor) + if !errors.Is(err, ErrServerTrustAnchor) { + t.Fatalf("err = %v, want ErrServerTrustAnchor", err) + } + if pool != nil { + t.Fatal("a refused anchor returned a pool") + } + }) + } +} + +func TestParseConnectionPinsTheDeclaredAuthorityWithoutWeakeningAnything(t *testing.T) { + cleanProfileEnvironment(t) + anchorPEM, _, _ := authority(t, "Cloud SQL Server CA", time.Now().Add(time.Hour)) + host := "store.identity.internal.example.com" + dsn := "postgresql://writer@" + host + "/saas?sslmode=verify-full" + profile := ConnectionProfile{Transport: VerifiedTLS, ServerTrustAnchorPEM: anchorPEM} + + parsed, err := ParseConnection(dsn, profile, true) + if err != nil { + t.Fatal(err) + } + tls := parsed.ConnConfig.TLSConfig + if tls == nil || tls.RootCAs == nil { + t.Fatal("the declared authority did not reach the driver configuration") + } + if tls.InsecureSkipVerify { + t.Fatal("pinning an authority turned verification off") + } + if tls.ServerName != host { + t.Fatalf("ServerName = %q, want %q", tls.ServerName, host) + } + if len(parsed.ConnConfig.Fallbacks) != 0 { + t.Fatal("a pinned connection kept a fallback that is not pinned") + } +} + +// The file form and the declared value are two answers to one question, and the +// file is the one a deployment with no mount seam cannot deliver. +func TestParseConnectionRefusesAFileAnchorBesideADeclaredOne(t *testing.T) { + cleanProfileEnvironment(t) + anchorPEM, _, _ := authority(t, "Cloud SQL Server CA", time.Now().Add(time.Hour)) + host := "store.identity.internal.example.com" + // pgx reads the named file while parsing, so the file form is only + // exercisable against one that exists. + onDisk := filepath.Join(t.TempDir(), "server-ca.pem") + if err := os.WriteFile(onDisk, []byte(anchorPEM), 0o600); err != nil { + t.Fatal(err) + } + withFile := "postgresql://writer@" + host + "/saas?sslmode=verify-full&sslrootcert=" + onDisk + if _, err := ParseConnection(withFile, ConnectionProfile{Transport: VerifiedTLS, ServerTrustAnchorPEM: anchorPEM}, false); !errors.Is(err, ErrConnectionProfile) { + t.Fatalf("err = %v, want ErrConnectionProfile", err) + } + // Without a declared anchor the file form is still the supported spelling. + if _, err := ParseConnection(withFile, ConnectionProfile{Transport: VerifiedTLS}, false); err != nil { + t.Fatalf("a file anchor alone was refused: %v", err) + } +} + +func TestConnectionProfileRefusesAnAnchorOnATransportThatVerifiesNothing(t *testing.T) { + anchorPEM, _, _ := authority(t, "Cloud SQL Server CA", time.Now().Add(time.Hour)) + for _, transport := range []ConnectionTransport{LocalIdentityProxy, MeshProtected, LocalLoopback} { + profile := ConnectionProfile{Transport: transport, ServerTrustAnchorPEM: anchorPEM} + if err := profile.validate(); !errors.Is(err, ErrServerTrustAnchor) { + t.Fatalf("transport %q: err = %v, want ErrServerTrustAnchor", transport, err) + } + } +} + +// The reason has to survive the option, or the operator who supplied a bad +// anchor is told only that a connection profile is invalid. +func TestWithConnectionProfilesReportsWhyAnAnchorWasRefused(t *testing.T) { + var configuration config + bad := ConnectionProfile{Transport: VerifiedTLS, ServerTrustAnchorPEM: "not a certificate"} + good := ConnectionProfile{Transport: VerifiedTLS} + if err := WithConnectionProfiles(bad, good)(&configuration); !errors.Is(err, ErrServerTrustAnchor) { + t.Fatalf("reader anchor: err = %v, want ErrServerTrustAnchor", err) + } + if err := WithConnectionProfiles(good, bad)(&configuration); !errors.Is(err, ErrServerTrustAnchor) { + t.Fatalf("writer anchor: err = %v, want ErrServerTrustAnchor", err) + } + if err := WithMaintenanceConnectionProfile(bad)(&configuration); !errors.Is(err, ErrServerTrustAnchor) { + t.Fatalf("maintenance anchor: err = %v, want ErrServerTrustAnchor", err) + } + // An unsupported transport still collapses to the profile error it always did. + if err := WithConnectionProfiles(ConnectionProfile{Transport: "carrier-pigeon"}, good)(&configuration); !errors.Is(err, ErrConnectionProfile) { + t.Fatalf("err = %v, want ErrConnectionProfile", err) + } +} From 6adc65a8a182019fb844127681941a28bb6fbb21 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:16:22 -0400 Subject: [PATCH 3/3] feat(libs/go): a pinned authority is the server's identity, and migrations take the same path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps found by measuring a live cell rather than reasoning about it. **verify-full cannot be satisfied.** A Cloud SQL instance publishes no DNS name (dnsName empty on the cell's instance) and issues its server certificate to an instance connection name, so verify-full has nothing to match and refuses every connection. The fleet's own reconcile Job already works around this with sslmode=verify-ca against the system bundle — verifying the chain against every public authority on earth, for a server signed by a per-instance private one that bundle cannot verify at all. PinnedAuthorityTLS inverts it. The chain is verified against a PINNED authority and the hostname deliberately not matched, because when the only trusted root is the instance's own authority, verifying the chain IS establishing which instance answered: no other server can present a certificate that authority signed. The name was never the identity. So the transport REQUIRES ServerTrustAnchorPEM — without a pinned root the same shape accepts any public authority and is strictly weaker than the mode it replaces. The driver shape is asserted rather than assumed: pgx's verify-ca turns Go's own verification off and installs a chain-only verifier that reads RootCAs through the config at handshake time, which is what makes the pinned pool the one actually consulted — read in pgx 5.10.0 rather than hoped for, because a verifier that captured the pool earlier would have made the pin silently ineffective. The test drives that verifier directly: a leaf whose name matches nothing verifies, and a leaf from another authority does not. **Migrations were a fourth connection path.** bootstrap.Options takes the same two declarations under the same names, resolved through the same table and the same anchor parse — exported for it, so there is one vocabulary rather than a copy wherever a connection gets opened. The in-process owner connection takes the credential per backend through stdlib.OptionBeforeConnect and the authority as a pool. The migrate subprocess cannot: it is a separate process with its own driver, so it takes the authority as a file staged beside the migrations the run already removes, and the credential through its environment — never its arguments, which stay world-readable in /proc//cmdline for the life of the child, which the existing comment there was careful about. A fresh token per lineage, because a long run can outlive one. An authority declared on a connection that negotiates no TLS is refused, as is one declared beside an sslrootcert the caller named: two anchors is no anchor, and the caller's is the one this process never validated. cmd/managed-bootstrap is NOT wired to the new fields. The root module pins a published libs/go rather than replacing it with the tree, so the command cannot see them until the library is released and that pin moves. Adding a replace would make the root build against local sources and its release against the pin — two states — so the ordering stands instead: publish, bump, then wire. Not verified against a live instance: no connection was opened to Cloud SQL and no migration was run. Co-Authored-By: Claude Opus 5 --- libs/go/bootstrap/run.go | 87 ++++++++++++++++++++++++++++++++++- libs/go/connection_profile.go | 53 +++++++++++++++++---- libs/go/externalidentity.go | 19 ++++++++ libs/go/trustanchor.go | 6 +++ libs/go/trustanchor_test.go | 63 +++++++++++++++++++++++++ 5 files changed, 218 insertions(+), 10 deletions(-) diff --git a/libs/go/bootstrap/run.go b/libs/go/bootstrap/run.go index f34dff9..c5fea6f 100644 --- a/libs/go/bootstrap/run.go +++ b/libs/go/bootstrap/run.go @@ -14,6 +14,7 @@ import ( "strings" "time" + postgres "github.com/codefly-dev/service-postgres/libs/go" "github.com/codefly-dev/service-postgres/libs/go/controlplane" "github.com/codefly-dev/service-postgres/libs/go/schemaplan" "github.com/jackc/pgx/v5" @@ -32,6 +33,19 @@ type Options struct { Timeout time.Duration LockTimeout time.Duration StatementTimeout time.Duration + // ExternalIdentity names the cloud credential issuer, exactly as a served + // process declares it (postgres.WithExternalIdentity). Empty keeps the + // password-authenticated behaviour every existing caller has. The migration + // owner is a cloud principal like any other login: it needs the same + // credential by the same declaration, resolved through the same table, or the + // job that creates the schema becomes the one place a password still has to + // exist. + ExternalIdentity string + // ServerTrustAnchorPEM pins the authority the managed server must chain to. + // It reaches two consumers with different needs: this process, which takes a + // pool, and the migrate subprocess, which takes a file — so it is declared + // once as a value here and materialized for the child. + ServerTrustAnchorPEM string } type Result struct { @@ -67,13 +81,56 @@ func Run(ctx context.Context, o Options) (result Result, err error) { if e != nil { return result, e } + // The anchor is written beside the staged migrations, which are already + // removed on every exit path: the migrate subprocess reads a file, and this + // is the one directory whose lifetime already matches the run's. + anchorFile := "" + if o.ServerTrustAnchorPEM != "" { + anchorFile = filepath.Join(staged, "server-authority.pem") + if e = os.WriteFile(anchorFile, []byte(o.ServerTrustAnchorPEM), 0o600); e != nil { + return result, errors.New("managed server authority could not be staged") + } + dsn, e = withAnchorFile(dsn, anchorFile) + if e != nil { + return result, e + } + } + var credential postgres.AccessTokenProvider + if o.ExternalIdentity != "" { + if credential, e = postgres.ExternalIdentityProvider(ctx, o.ExternalIdentity); e != nil { + return result, fmt.Errorf("managed bootstrap identity: %w", e) + } + } cfg, e := managedConfig(dsn, o) if e != nil { return result, e } + if o.ServerTrustAnchorPEM != "" { + pool, anchorErr := postgres.ServerTrustAnchor(o.ServerTrustAnchorPEM) + if anchorErr != nil { + return result, anchorErr + } + if cfg.TLSConfig == nil { + // A pinned authority with TLS off is a deployment that believes it is + // verifying a server and is not. + return result, errors.New("managed server authority declared on a connection that negotiates no TLS") + } + cfg.TLSConfig.RootCAs = pool + } ctx, cancel := context.WithTimeout(ctx, o.Timeout) defer cancel() - db := stdlib.OpenDB(*cfg) + openOptions := []stdlib.OptionOpenDB{} + if credential != nil { + openOptions = append(openOptions, stdlib.OptionBeforeConnect(func(ctx context.Context, c *pgx.ConnConfig) error { + token, err := credential(ctx, c.User) + if err != nil { + return err + } + c.Password = token + return nil + })) + } + db := stdlib.OpenDB(*cfg, openOptions...) db.SetMaxOpenConns(1) db.SetMaxIdleConns(0) defer db.Close() @@ -117,7 +174,18 @@ func Run(ctx context.Context, o Options) (result Result, err error) { command := exec.CommandContext(ctx, o.MigrateExecutable, "-path", filepath.Join(staged, l.Stage), "-database", u.String(), "up") // No inherited PG service/password settings, cloud credentials or unrelated // secrets. The DSN is passwordless, including in the child process arguments. + // Still no inherited settings, and still nothing in the arguments: a + // credential there would be world-readable in /proc//cmdline for the + // life of the child, while its environment is readable only by its own + // user. A fresh token per lineage, because a long run can outlive one. command.Env = []string{"PGPASSFILE=/dev/null"} + if credential != nil { + token, tokenErr := credential(ctx, o.Binding.OwnerRole) + if tokenErr != nil { + return result, errors.New("managed bootstrap could not mint the migration owner's credential") + } + command.Env = append(command.Env, "PGPASSWORD="+token) + } command.WaitDelay = time.Second runErr := command.Run() // Killing a client does not prove its server-side statement has stopped. @@ -233,6 +301,23 @@ func connection(o Options, p schemaplan.Plan) (string, error) { return u.String(), nil } +// withAnchorFile names the staged authority in the DSN both consumers share. +// It refuses to displace one the caller already named: two anchors is no +// anchor, and the caller's is the one this process cannot have validated. +func withAnchorFile(dsn, path string) (string, error) { + u, err := url.Parse(dsn) + if err != nil { + return "", errors.New("invalid managed connection") + } + q := u.Query() + if existing := q.Get("sslrootcert"); existing != "" && existing != path { + return "", errors.New("managed connection names a server authority file and a declared authority") + } + q.Set("sslrootcert", path) + u.RawQuery = q.Encode() + return u.String(), nil +} + func randomIdentity() [16]byte { var id [16]byte; rand.Read(id[:]); return id } func drainChild(conn *sql.Conn, application string) error { diff --git a/libs/go/connection_profile.go b/libs/go/connection_profile.go index 7de3a1a..0aeb6a5 100644 --- a/libs/go/connection_profile.go +++ b/libs/go/connection_profile.go @@ -29,6 +29,22 @@ const ( // password travels only inside the mesh tunnel. This is the transport the // Postgres agent renders for an in-cluster store. MeshProtected ConnectionTransport = "mesh-protected" + // PinnedAuthorityTLS is a TLS connection whose chain is verified against a + // PINNED authority and whose hostname is deliberately not matched. It exists + // because a managed instance can have no verifiable name: Cloud SQL issues its + // server certificate to an instance connection name and publishes no DNS + // record for it, so `verify-full` has nothing to match and refuses every + // connection — which is why the fleet's own jobs fall back to `verify-ca` + // against the system bundle, trusting every public authority on earth. + // + // Pinning inverts that. When the only trusted root is the instance's own + // per-instance authority, verifying the chain IS establishing which instance + // answered: no other server can present a certificate that authority signed. + // The hostname adds nothing, because the name was never the identity — the + // authority is. So this transport REQUIRES ServerTrustAnchorPEM; without a + // pinned root, chain-only verification would accept any public authority and + // be strictly weaker than the mode it replaces. + PinnedAuthorityTLS ConnectionTransport = "pinned-authority-tls" // LocalLoopback is a plaintext TCP connection to a database on the same // machine: the host is literal loopback (127.0.0.0/8, ::1) or "localhost", // and sslmode is the literal "disable". It is what a local development run @@ -111,17 +127,21 @@ func (p ConnectionProfile) validate() error { if p != (ConnectionProfile{}) { return ErrConnectionProfile } - case VerifiedTLS, LocalIdentityProxy, MeshProtected, LocalLoopback: + case VerifiedTLS, PinnedAuthorityTLS, LocalIdentityProxy, MeshProtected, LocalLoopback: default: return ErrConnectionProfile } - if p.ServerTrustAnchorPEM != "" { - if p.Transport != VerifiedTLS { - return fmt.Errorf("%w: transport %q verifies no server certificate", ErrServerTrustAnchor, p.Transport) - } - if _, err := serverTrustAnchor(p.ServerTrustAnchorPEM); err != nil { - return err + if p.ServerTrustAnchorPEM == "" { + if p.Transport == PinnedAuthorityTLS { + return fmt.Errorf("%w: transport %q verifies the chain and not the hostname, so the pinned authority IS the server's identity and cannot be omitted", ErrServerTrustAnchor, p.Transport) } + return nil + } + if p.Transport != VerifiedTLS && p.Transport != PinnedAuthorityTLS { + return fmt.Errorf("%w: transport %q verifies no server certificate", ErrServerTrustAnchor, p.Transport) + } + if _, err := serverTrustAnchor(p.ServerTrustAnchorPEM); err != nil { + return err } return nil } @@ -181,7 +201,7 @@ func ParseConnection(connection string, profile ConnectionProfile, tokenHook boo return nil, ErrConnectionProfile } case "sslcert", "sslkey", "connect_timeout", "pool_max_conns", "pool_min_conns", "pool_min_idle_conns", "pool_max_conn_lifetime", "pool_max_conn_idle_time", "pool_health_check_period", "pool_max_conn_lifetime_jitter", "application_name": - if profile.Transport != VerifiedTLS { + if profile.Transport != VerifiedTLS && profile.Transport != PinnedAuthorityTLS { return nil, ErrConnectionProfile } default: @@ -216,8 +236,14 @@ func ParseConnection(connection string, profile ConnectionProfile, tokenHook boo } } else { sslmode := "verify-full" - if profile.plaintext() { + switch { + case profile.plaintext(): sslmode = "disable" + case profile.Transport == PinnedAuthorityTLS: + // Stated, not derived: this is the mode whose driver shape skips + // hostname verification, and a URL saying anything else is asking for a + // guarantee this transport does not give. + sslmode = "verify-ca" } if host == "" || strings.ContainsAny(host, ",/\x00\r\n") || q.Get("sslmode") != sslmode { return nil, ErrConnectionProfile @@ -274,6 +300,15 @@ func ParseConnection(connection string, profile ConnectionProfile, tokenHook boo if c.TLSConfig != nil { return nil, ErrConnectionProfile } + } else if profile.Transport == PinnedAuthorityTLS { + // pgx's verify-ca shape, asserted rather than assumed: Go's own + // verification is off and a chain-only verifier is installed that reads + // RootCAs through this config at handshake time — which is what makes the + // pool installed below the one actually consulted. No ServerName, so + // nothing compares a hostname anywhere. + if c.TLSConfig == nil || !c.TLSConfig.InsecureSkipVerify || c.TLSConfig.VerifyPeerCertificate == nil || c.TLSConfig.ServerName != "" || c.TLSConfig.VerifyConnection != nil { + return nil, ErrConnectionProfile + } } else if c.TLSConfig == nil || c.TLSConfig.InsecureSkipVerify || c.TLSConfig.ServerName != host || c.TLSConfig.VerifyPeerCertificate != nil || c.TLSConfig.VerifyConnection != nil { return nil, ErrConnectionProfile } diff --git a/libs/go/externalidentity.go b/libs/go/externalidentity.go index 5e6c5ad..8ef9456 100644 --- a/libs/go/externalidentity.go +++ b/libs/go/externalidentity.go @@ -114,3 +114,22 @@ func elide(value string) string { } return value[:limit] + "…" } + +// ExternalIdentityProvider resolves a declaration to the provider that mints +// its credential, for a caller that opens its own connection rather than going +// through Open — the managed bootstrap, which runs migrations as a subprocess +// and so cannot use a pool's connect hook for them. +// +// It is the same table WithExternalIdentity uses, exported so there is one +// vocabulary and one resolution for every path to the database rather than a +// second copy wherever a connection happens to be opened. +func ExternalIdentityProvider(ctx context.Context, declaration string) (AccessTokenProvider, error) { + configuration := config{} + if err := WithExternalIdentity(declaration)(&configuration); err != nil { + return nil, err + } + if err := resolveExternalIdentity(ctx, &configuration); err != nil { + return nil, err + } + return configuration.accessTokenProvider, nil +} diff --git a/libs/go/trustanchor.go b/libs/go/trustanchor.go index 8c4a490..3d383da 100644 --- a/libs/go/trustanchor.go +++ b/libs/go/trustanchor.go @@ -77,3 +77,9 @@ func serverTrustAnchor(anchor string) (*x509.CertPool, error) { } return pool, nil } + +// ServerTrustAnchor parses a declared authority into a trust pool, for a caller +// that builds its own driver configuration rather than going through a +// ConnectionProfile. Same parse, same refusals: one anchor vocabulary for every +// path to the database. +func ServerTrustAnchor(anchor string) (*x509.CertPool, error) { return serverTrustAnchor(anchor) } diff --git a/libs/go/trustanchor_test.go b/libs/go/trustanchor_test.go index 0b62021..8a0b25f 100644 --- a/libs/go/trustanchor_test.go +++ b/libs/go/trustanchor_test.go @@ -224,3 +224,66 @@ func TestWithConnectionProfilesReportsWhyAnAnchorWasRefused(t *testing.T) { t.Fatalf("err = %v, want ErrConnectionProfile", err) } } + +// The transport exists for an instance with no verifiable name, so the pinned +// authority is the whole of the server's identity and may not be omitted. +func TestPinnedAuthorityRequiresItsAnchor(t *testing.T) { + if err := (ConnectionProfile{Transport: PinnedAuthorityTLS}).validate(); !errors.Is(err, ErrServerTrustAnchor) { + t.Fatalf("err = %v, want ErrServerTrustAnchor", err) + } + anchorPEM, _, _ := authority(t, "Cloud SQL Server CA", time.Now().Add(time.Hour)) + if err := (ConnectionProfile{Transport: PinnedAuthorityTLS, ServerTrustAnchorPEM: anchorPEM}).validate(); err != nil { + t.Fatalf("a pinned profile with an anchor was refused: %v", err) + } +} + +func TestPinnedAuthorityProducesTheChainOnlyDriverShape(t *testing.T) { + cleanProfileEnvironment(t) + anchorPEM, own, ownKey := authority(t, "Cloud SQL Server CA", time.Now().Add(10*365*24*time.Hour)) + // A Cloud SQL leaf is issued to the instance connection name, which is not + // the address anything dials and resolves nowhere. + server := serverCertificate(t, "acme-example-data:us-east1:shared", own, ownKey) + host := "10.0.0.7" + profile := ConnectionProfile{Transport: PinnedAuthorityTLS, ServerTrustAnchorPEM: anchorPEM} + + // verify-full is refused: there is no name for it to match. + if _, err := ParseConnection("postgresql://writer@"+host+"/postgres?sslmode=verify-full", profile, true); !errors.Is(err, ErrConnectionProfile) { + t.Fatal("a pinned profile accepted verify-full, which cannot be satisfied by an instance with no DNS name") + } + parsed, err := ParseConnection("postgresql://writer@"+host+"/postgres?sslmode=verify-ca", profile, true) + if err != nil { + t.Fatal(err) + } + tls := parsed.ConnConfig.TLSConfig + if tls == nil || tls.RootCAs == nil || !tls.InsecureSkipVerify || tls.VerifyPeerCertificate == nil || tls.ServerName != "" { + t.Fatalf("unexpected driver shape: %+v", tls) + } + + // The property that matters: pgx's verifier reads RootCAs through the config + // at handshake time, so the pool installed above is the one consulted. Drive + // it exactly as the driver would — a leaf whose name matches nothing, which + // must still verify, and a leaf from another authority, which must not. + if err := tls.VerifyPeerCertificate([][]byte{server.Raw}, nil); err != nil { + t.Fatalf("the pinned authority did not verify its own instance: %v", err) + } + _, other, otherKey := authority(t, "Cloud SQL Server CA elsewhere", time.Now().Add(time.Hour)) + impostor := serverCertificate(t, "acme-example-data:us-east1:shared", other, otherKey) + if err := tls.VerifyPeerCertificate([][]byte{impostor.Raw}, nil); err == nil { + t.Fatal("a server from an unpinned authority verified: the pin is not being consulted") + } +} + +// Without the anchor the same chain-only shape would accept any public +// authority, which is why the transport refuses to exist without one. This +// pins the reasoning: an empty pool must verify nothing. +func TestPinnedAuthorityWithoutItsPoolVerifiesNothing(t *testing.T) { + _, own, ownKey := authority(t, "Cloud SQL Server CA", time.Now().Add(time.Hour)) + server := serverCertificate(t, "acme-example-data:us-east1:shared", own, ownKey) + empty, err := serverTrustAnchor("") + if !errors.Is(err, ErrServerTrustAnchor) || empty != nil { + t.Fatalf("an empty anchor produced a pool: %v", err) + } + if _, err := server.Verify(x509.VerifyOptions{Roots: x509.NewCertPool()}); err == nil { + t.Fatal("an empty pool verified a certificate") + } +}