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
80 changes: 80 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,86 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Security

- **Schema name validation at provider construction.** All constructors now
reject schema names that do not match `^[A-Za-z_][A-Za-z0-9_]*$`.
PostgreSQL identifiers cannot be bound as SQL parameters, so the schema
name is interpolated directly into the DDL and DML the provider issues.
Restricting the accepted character set up front eliminates the SQL
injection vector that would otherwise exist for callers that pass
attacker-controlled schema names. PostgreSQL's full identifier grammar
(including quoted identifiers) is broader; this validation is
intentionally conservative.

### Changed

- **BREAKING (unreleased API surface only):** `PostgresProvider::new_with_config`,
`new_with_schema`, and the deprecated Entra constructors now return an
error when `schema_name` contains characters outside
`[A-Za-z_][A-Za-z0-9_]*`. Previously such names were silently
interpolated into SQL. Callers passing only constants from their own code
(the common case) are unaffected. Already-shipped releases (`<= 0.1.33`)
are unaffected.

- **BREAKING (unreleased API surface only):** Collapsed all `*_with_config`
and Entra-specific constructors into a single
`PostgresProvider::new_with_config(ProviderConfig)`. `ProviderConfig` now
carries the connection variant via a new `ConnectionConfig` enum
(`Url(String)` or `Entra { host, port, database, user, options }`),
the optional schema name, and the migration policy. Construct via
`ProviderConfig::url(database_url)` or
`ProviderConfig::entra(host, port, db, user, options)` and adjust fields
as needed. The previously unreleased `new_with_config(url, config)` and
`new_with_schema_and_config(url, schema, config)` constructors are
removed. `new(url)` and `new_with_schema(url, schema)` remain as
convenience wrappers.

### Deprecated

- `PostgresProvider::new_with_entra` and
`PostgresProvider::new_with_schema_and_entra` are deprecated in favor of
`new_with_config(ProviderConfig::entra(...))`. They continue to work and
delegate to the new path; they will be removed in a future release.
- `PostgresProvider::initialize_schema` is deprecated. Every constructor
already runs the migration runner; this back-compat shim will be removed
in a future release.

### Added

- **Reject schemas ahead of the running binary.** Both `MigrationPolicy::ApplyAll`
and `MigrationPolicy::VerifyOnly` now fail fast when the `_duroxide_migrations`
tracking table records migration versions that are not bundled with the
running binary. Under `ApplyAll` the check runs under the migration advisory
lock and short-circuits before any DDL is executed, so an older binary
cannot rewrite a schema that is ahead of its code. The error message names
the unknown versions and instructs the operator to update the code.

- **Configurable migration policy at provider construction.** New
`MigrationPolicy` enum, `ProviderConfig` struct, and `ConnectionConfig`
enum, plus the single new constructor `PostgresProvider::new_with_config`.
The default policy is `MigrationPolicy::ApplyAll`, which preserves
pre-feature behavior — all existing constructors (`new`,
`new_with_schema`, and the deprecated `new_with_entra` /
`new_with_schema_and_entra`) continue to apply pending migrations on
startup. The new `MigrationPolicy::VerifyOnly` policy skips migration
application and instead verifies that the `_duroxide_migrations` tracking
table exists in the target schema and that every embedded migration has
already been applied, returning an error otherwise. Intended for
processes that must not run DDL — e.g. application backends, where a
separately privileged worker is responsible for applying schema
changes. `VerifyOnly` does not take the migration advisory lock and does
not create or modify any database objects.

- **Initialization regression tests.** Added integration tests for the
provider initialization paths: `VerifyOnly` against a missing schema, a
bare schema with no tracking table, and a schema whose tracking table is
behind the bundled migrations; and a concurrency test that exercises the
migration advisory lock by running two `ApplyAll` initializations against
the same fresh schema in parallel.

## [0.1.33] - 2026-05-13

### Fixed
Expand Down
62 changes: 62 additions & 0 deletions docs/migration-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Migration Policy

`duroxide-pg` supports separating schema migration from normal runtime database
operations. This matters in production because DDL usually belongs to a
controlled deploy or upgrade step, while application backends and workers often
run with lower-privilege DML-only roles.

## Policies

`MigrationPolicy::ApplyAll` is the default. It preserves the original provider
behavior: provider construction creates the target schema when needed, creates
the `_duroxide_migrations` tracking table, rejects schemas that contain unknown
future migration versions, and applies any bundled migrations that have not yet
been recorded.

`MigrationPolicy::VerifyOnly` executes no DDL. It verifies that the migration
tracking table exists, that the schema has no unknown future migration versions,
that every bundled migration has already been applied, and that core provider
tables still exist. This is intended for client/backend/worker processes where a
separate privileged process has already run migrations.

## Behavior Matrix

| Scenario | `ApplyAll` | `VerifyOnly` |
|---|---|---|
| Schema does not exist | Creates it | Error |
| Schema exists but tracking table is missing | Creates tracking table and runs migrations | Error |
| Schema is behind bundled migrations | Applies pending migrations | Error |
| Schema has unknown future migrations | Error before DDL | Error |
| Migrations are recorded but core tables are missing | Re-applies migrations | Error |
| Schema is fully migrated | No-op | No-op |

`ApplyAll` takes a PostgreSQL session advisory lock scoped to the target schema
before checking or applying migrations. That serializes concurrent provider
startup so multiple nodes do not race to apply the same migration.

## Error Messages

`VerifyOnly` fails with messages in these shapes:

- Missing tracking table: `duroxide migrations not initialized: schema "..." does not contain _duroxide_migrations...`
- Missing bundled migration versions: `duroxide migrations not up to date in schema "...": missing versions [...]...`
- Unknown future migration versions: `schema "..." has migrations not recognized by this version of the code: [...]...`
- Missing core tables after complete migration records: `duroxide migrations recorded as complete in schema "...", but core tables are missing...`

`ApplyAll` also rejects unknown future migration versions before it runs DDL, so
an older binary will not mutate a schema that appears ahead of its code.

## Example

```rust
use duroxide_pg::{MigrationPolicy, PostgresProvider, ProviderConfig};

# async fn example(database_url: &str) -> anyhow::Result<()> {
let mut config = ProviderConfig::url(database_url);
config.schema_name = Some("duroxide".to_string());
config.migration_policy = MigrationPolicy::VerifyOnly;

let provider = PostgresProvider::new_with_config(config).await?;
# Ok(())
# }
```
5 changes: 2 additions & 3 deletions src/entra.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
//! for [`PostgresProvider`](crate::PostgresProvider).
//!
//! This module exposes [`EntraAuthOptions`] — the configuration type passed to
//! `PostgresProvider::new_with_entra` and `PostgresProvider::new_with_schema_and_entra`
//! (added in Phase 2) — plus the internal credential abstractions used to
//! fetch and rotate Entra access tokens.
//! [`ProviderConfig::entra`](crate::ProviderConfig::entra) — plus the internal
//! credential abstractions used to fetch and rotate Entra access tokens.
//!
//! Azure SDK types (`azure_core::credentials::TokenCredential`,
//! `azure_identity::ManagedIdentityCredential`, etc.) are intentionally **not
Expand Down
10 changes: 5 additions & 5 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,17 @@
//! [`EntraAuthOptions`] for tunables.
//!
//! ```rust,no_run
//! use duroxide_pg::{EntraAuthOptions, PostgresProvider};
//! use duroxide_pg::{EntraAuthOptions, PostgresProvider, ProviderConfig};
//!
//! # async fn example() -> anyhow::Result<()> {
//! let provider = PostgresProvider::new_with_entra(
//! let config = ProviderConfig::entra(
//! "myserver.postgres.database.azure.com",
//! 5432,
//! "mydb",
//! "my-entra-principal@contoso.onmicrosoft.com",
//! EntraAuthOptions::new(),
//! )
//! .await?;
//! );
//! let provider = PostgresProvider::new_with_config(config).await?;
//! # Ok(())
//! # }
//! ```
Expand Down Expand Up @@ -82,4 +82,4 @@ pub mod migrations;
pub mod provider;

pub use entra::EntraAuthOptions;
pub use provider::PostgresProvider;
pub use provider::{ConnectionConfig, MigrationPolicy, PostgresProvider, ProviderConfig};
133 changes: 132 additions & 1 deletion src/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,143 @@ impl MigrationRunner {
let conn = &mut *conn;
self.lock_for_migrations(conn).await?;

let result = self.migrate_inner(conn).await;
// Reject unknown migrations while holding the advisory lock so that an
// older binary cannot rewrite a schema that is ahead of its code.
// Short-circuit: do NOT run migrate_inner if unknown migrations are
// detected.
let result = match self.check_no_unknown_migrations(conn).await {
Ok(()) => self.migrate_inner(conn).await,
Err(e) => Err(e),
};
self.unlock_for_migrations(conn).await;

result
}

/// Verify that the migration tracking table exists and that every embedded
/// migration has already been applied. Does not take the migration
/// advisory lock and does not create or modify any database objects.
///
/// Returns an error if the `_duroxide_migrations` table is missing in
/// `schema_name` or if any bundled migration version is absent from it.
///
/// Intended for processes that must not perform DDL (e.g. application
/// backends, where a separately privileged worker is responsible for
/// applying schema changes).
pub async fn verify(&self) -> Result<()> {
let mut conn = self.pool.acquire().await?;
let conn = &mut *conn;

// Check that the tracking table exists in the target schema.
let table_exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM information_schema.tables \
WHERE table_schema = $1 AND table_name = '_duroxide_migrations')",
)
.bind(&self.schema_name)
.fetch_one(&mut *conn)
.await?;

if !table_exists {
anyhow::bail!(
"duroxide migrations not initialized: schema {:?} does not \
contain _duroxide_migrations. Construct a provider with \
MigrationPolicy::ApplyAll (the default) from a process with \
DDL privileges before using MigrationPolicy::VerifyOnly.",
self.schema_name
);
}

// Reject schemas that have migrations the running binary does not
// recognize (schema is ahead of the code).
self.check_no_unknown_migrations(conn).await?;

let migrations = self.load_migrations()?;
let applied: std::collections::HashSet<i64> =
self.get_applied_versions(conn).await?.into_iter().collect();

let mut missing: Vec<i64> = migrations
.iter()
.map(|m| m.version)
.filter(|v| !applied.contains(v))
.collect();
missing.sort_unstable();

if !missing.is_empty() {
anyhow::bail!(
"duroxide migrations not up to date in schema {:?}: missing \
versions {:?}. Run migrations from a provider configured with \
MigrationPolicy::ApplyAll before constructing VerifyOnly \
providers.",
self.schema_name,
missing,
);
}

if !self.check_tables_exist(conn).await.unwrap_or(false) {
anyhow::bail!(
"duroxide migrations recorded as complete in schema {:?}, but \
core tables are missing. The schema may be corrupted; run \
migrations from a provider configured with \
MigrationPolicy::ApplyAll before constructing VerifyOnly \
providers.",
self.schema_name,
);
}

Ok(())
}

/// Check that the database has no migrations the running binary does not
/// recognize. Used by both `migrate()` (to refuse running DDL against a
/// schema ahead of the code) and `verify()` (to refuse claiming
/// successful verification of an unknown schema).
///
/// Returns `Ok(())` if the tracking table does not yet exist: under
/// `ApplyAll` it will be created by `migrate_inner`, and under
/// `VerifyOnly` the missing table is reported separately before this is
/// called.
async fn check_no_unknown_migrations(
&self,
conn: &mut sqlx::postgres::PgConnection,
) -> Result<()> {
let tracking_exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM information_schema.tables \
WHERE table_schema = $1 AND table_name = '_duroxide_migrations')",
)
.bind(&self.schema_name)
.fetch_one(&mut *conn)
.await?;

if !tracking_exists {
return Ok(());
}

let applied = self.get_applied_versions(conn).await?;
let expected: std::collections::HashSet<i64> = self
.load_migrations()?
.into_iter()
.map(|m| m.version)
.collect();

let mut unknown: Vec<i64> = applied
.into_iter()
.filter(|v| !expected.contains(v))
.collect();
unknown.sort_unstable();

if !unknown.is_empty() {
anyhow::bail!(
"schema {:?} has migrations not recognized by this version of \
the code: {:?}. The database schema is ahead of the code. \
Update the code to a compatible version.",
self.schema_name,
unknown,
);
}

Ok(())
}

async fn migrate_inner(&self, conn: &mut sqlx::postgres::PgConnection) -> Result<()> {
// Ensure schema exists
if self.schema_name != "public" {
Expand Down
Loading
Loading