Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion libs/go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions libs/go/azureentra.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
87 changes: 86 additions & 1 deletion libs/go/bootstrap/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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/<pid>/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.
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading