diff --git a/libs/go/README.md b/libs/go/README.md index 56ae564..473a0d4 100644 --- a/libs/go/README.md +++ b/libs/go/README.md @@ -213,9 +213,76 @@ 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. + +## 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/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/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 4b86811..0aeb6a5 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" @@ -28,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 @@ -58,14 +75,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 +106,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 @@ -97,10 +127,22 @@ 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 == 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 } @@ -152,8 +194,14 @@ 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": - if profile.Transport != VerifiedTLS { + 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 && profile.Transport != PinnedAuthorityTLS { return nil, ErrConnectionProfile } default: @@ -169,6 +217,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.") { @@ -180,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 @@ -238,8 +300,27 @@ 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 } + 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/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..8ef9456 --- /dev/null +++ b/libs/go/externalidentity.go @@ -0,0 +1,135 @@ +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] + "…" +} + +// 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/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 diff --git a/libs/go/trustanchor.go b/libs/go/trustanchor.go new file mode 100644 index 0000000..3d383da --- /dev/null +++ b/libs/go/trustanchor.go @@ -0,0 +1,85 @@ +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 +} + +// 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 new file mode 100644 index 0000000..8a0b25f --- /dev/null +++ b/libs/go/trustanchor_test.go @@ -0,0 +1,289 @@ +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) + } +} + +// 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") + } +}