Repository navigation
feat(libs/go): a service declares an external identity and pins its server's authority; it never names a cloud - #205
Merged
antoinetoussaint-byte merged 3 commits intoOct 29, 2026
Conversation
…er a cloud 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 <noreply@anthropic.com>
…ed value 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 <noreply@anthropic.com>
…tions take the same path 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/<pid>/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 <noreply@anthropic.com>
This was referenced Oct 29, 2026
Merged
antoinetoussaint-byte
added a commit
that referenced
this pull request
Oct 29, 2026
…d its server's authority (#207) * feat(managed-bootstrap): the migration owner declares its identity and its server's authority The follow-up #205 named and deliberately left: bootstrap.Options grew an external-identity declaration and a server trust anchor, but the command could not see them, because the root module pins a published libs/go rather than replacing it with the tree. #206 is on main, so the pin moves and the command reads the two declarations. Delivered as configuration beside the connection they describe, never as flags. A Job's command line is readable by anything in its pod, and these two are the deployment's statement about where this database is. Neither is a secret — an issuer names who mints the credential, and a certificate authority is public information — while a credential minted from the issuer is, which is why it never appears here and is fetched per connection instead. Absent means the password-authenticated behaviour every existing deployment has. This is what lets migrations run against a managed server at all: the database exists but its schema and its application roles do not, and creating a login in the cloud grants CONNECT and nothing else — the in-database roles and grants are the migration owner's work. Verified: the root module builds and vets, and the bootstrap parity suite passes against a real Postgres. One of its four parallel subtests failed first on `all predefined address pools have been fully subnetted` — this machine had 30 Docker networks from concurrent worktrees, and the subtest passes alone. That flake is the environment, not this change. Not verified: no migration has run against a managed instance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: prune the superseded library entries go get left in go.sum `go get` adds the new version's hashes without removing the old module version's, and CI's tidiness gate runs `go mod tidy` and compares — so the commit has to carry what tidy produces, not what go get leaves. Three stale lines for the previous libs/go pseudo-version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A workload on managed Postgres should authenticate as itself — login is a cloud principal, password is a short-lived token, no password exists to seed, project or rotate. This makes that reachable from the library, for both the serving path and migrations, without any consuming service naming a cloud.
Classification
Fix, at the place that owns the behaviour. Every piece lands in the library that already owns "how a service reaches its database"; nothing is added above it, and no consumer learns a cloud's name. The one thing deliberately left undone is listed under Follow-ups rather than worked around.
What it adds
WithExternalIdentity(declaration)— the consumer forwards an opaque declaration from its configuration and does not interpret it. The library owns the vocabulary (azure-entra,gcp-cloud-sql), the resolution table and every cloud call. 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. Refused besideWithAccessTokenProvider, since both authenticate the same pools.The Cloud SQL issuer, which solves the part that actually matters: 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 picks the path — the pod's own service account uses its ambient credential, 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 twice: a service-account login is the email with
.gserviceaccount.comstripped, so appending it reverses that exactly and the two cannot drift. Two token sources, not one: the database credential is minted atsqlservice.loginand only the exchange is authorized atcloud-platform, so a token reaching Postgres can do nothing but log in.ConnectionProfile.ServerTrustAnchorPEM— the server's authority as a configured value, not a mounted file.sslrootcertis a file only because the DSN is a string; pgx takes atls.Configprogrammatically and this library already builds and checks one. A configuration plane that delivers values but not files can therefore deliver a verifiable connection. A certificate authority is public information, so this is ordinary configuration rather than a secret.PinnedAuthorityTLS— a transport that verifies the chain against a pinned authority and deliberately does not match the hostname. It exists because a managed instance can have no verifiable name: the instance publishes no DNS record and its certificate is issued to an instance connection name, soverify-fullhas nothing to match and refuses every connection. Pinning inverts the question — when the only trusted root is the instance's own per-instance authority, verifying the chain is establishing which instance answered, because no other server can present a certificate that authority signed. The name was never the identity. The anchor is therefore mandatory on this transport: without a pinned root the same shape accepts any public authority and is strictly weaker than the mode it replaces.Migrations take the same path.
bootstrap.Optionsgrows the same two declarations under the same names, through the same resolution table and the same anchor parse. The in-process owner connection takes the credential per backend viastdlib.OptionBeforeConnectand the authority as a pool. Themigratesubprocess can use neither — separate process, 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/<pid>/cmdlinefor the child's life. A fresh token per lineage, because a long run can outlive one.Also closed on the way through: an explicit profile accepted a password and a credential provider. The token replaces the password on every backend, so the URL's never authenticated — it was dead config reading as the live credential, and it hid an external-identity URL still carrying the seeded password it was meant to stop carrying.
Reuse
Reused:
AccessTokenProviderandinstallAccessTokenProvider, which already existed as the per-connection credential seam;ConnectionProfile/ParseConnectionas the single judge of a connection; the Azure Entra issuer, moved behind the declaration rather than rewritten;stdlib.OptionBeforeConnectfor the migration owner;x509.CertPooland pgx's ownverify-caverifier.Deliberately not built: a Cloud SQL connector dialer. It would have handled TLS and IAM together, but it is GCP-specific at the transport layer — the shape this change exists to abstract away — and pulls a large dependency into a library every service links. Deliberately not used:
google.golang.org/api/impersonate, whose module graph is far larger than the exchange it wraps; the IAM Credentials call is made directly, which kept the added dependency surface togolang.org/x/oauth2andcloud.google.com/go/compute/metadata. Swapping it back is a contained change if the owner prefers the wrapper. Deliberately not replaced:WithAccessTokenProvider, which remains correct for a service that mints its own credential — a projected token file rewritten by a sidecar is the right answer for a one-shot Job, and carries no cloud knowledge into this library.AzureEntraWorkloadIdentitybecomes unexported. It had no consumers, and leaving it exported leaves a cloud reachable from above the library, which is what this change exists to prevent.Verified live, in a cluster
A read-only probe Job ran on a GKE cluster against a private Cloud SQL instance, under a ServiceAccount federated to a database service account. Measured, in one run:
verify-caagainst system trust, which is how such a connection is often spelled, fails here. The anchor is not optional for this engine.The probe used no code from this PR, so a failure would have been environmental rather than mine. It wrote nothing:
SELECT 1and catalog reads. It has been deleted.Not verified
roles/iam.serviceAccountTokenCreatorbinding existed to exchange with. The multi-login path is covered by unit tests against a stub IAM endpoint and by the fail-closed refusals, but it has not run against the real API. It fails closed rather than reusing one credential for two logins.Follow-ups, not worked around
cmd/managed-bootstrapis not wired to the newbootstrap.Optionsfields. The root module pins a publishedlibs/gorather than replacing it with the tree, so the command cannot see them until the library is released and that pin moves. Adding areplacewould make the root build against local sources while its release builds against the pin — two states nobody can reproduce — so the ordering stands: publish, bump, then wire two environment reads.Size
~470 hand-written non-test lines, ~500 test lines, two files touched outside the new ones. All library packages pass
go test -race ./...;go vetclean; the root module builds and its conformance suite passes.