Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@ jobs:
sudo apt-get update
sudo apt-get install -y \
pkg-config \
openssl \
python3 \
libssl-dev \
libclang-dev \
clang \
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ All notable changes to this project are documented in this file. The format is b

Pre-1.0 note: while `pg_durable` is in major version `0`, minor releases may include breaking changes.

## [Unreleased]

### Added

- **Managed identity HTTP authentication:** normal and multipart endpoints can
use a single user-assigned identity configured at startup with
`pg_durable.managed_identity_client_id`. An empty setting disables MI without
affecting other HTTP authentication. Tokens are acquired and cached inside
activities, with audiences derived from supported destination hosts.
- **Delegated MI administration:** `EXECUTE` on `df.managed_identity_admin()`
permits endpoint configuration without PostgreSQL superuser. The endpoint
owner's capability and the caller's endpoint/HTTP privileges are checked for
each attempt. Upgrade each installation using MI to 0.2.10 and grant the
capability explicitly; existing general administration grants do not include it.

## [0.2.9] - 2026-10-07

> **Upgrade:** Install/restart the new binary and wait for the control runtime
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pg_durable"
version = "0.2.9"
version = "0.2.10"
edition = "2021"
license = "PostgreSQL"
repository = "https://github.com/microsoft/pg_durable"
Expand Down
123 changes: 116 additions & 7 deletions USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -969,8 +969,9 @@ no round trip through a table — see [Multipart Uploads](#multipart-uploads).
### Endpoint Credential Catalog

Endpoint definitions use a handler-less `pg_durable_fdw`. A foreign server holds
the base URL and authentication scheme; each caller's user mapping holds its
credentials. There are no foreign tables or scans.
the base URL and authentication scheme. Static credentials belong in each caller's
user mapping; managed identity obtains tokens at execution time without a mapping.
There are no foreign tables or scans.

`auth_scheme` is required. `base_url` may be omitted only with `auth_scheme 'none'`
for a server used solely for named secrets. A supplied base URL must be a nonempty,
Expand All @@ -984,10 +985,11 @@ a server does not authorize network access or bypass HTTP destination restrictio
| `bearer` | None | `token` (without the `Bearer ` prefix) |
| `header` | `header_name`, such as `x-api-key` | `header_value` |
| `query` | None | `query_string`, already URL-encoded, optionally starting with `?` |
| `managed-identity` | None; uses the startup-configured identity | None |

Unknown options and authentication schemes are rejected. `managed-identity` is
reserved and rejected until its authentication controls are available. The
catalog does not accept free-form `resource`, `scope` or `client_id` settings.
Unknown options and authentication schemes are rejected. The catalog does not
accept `client_id`, `resource` or `scope` settings. Identity selection belongs to
deployment configuration, not individual endpoints.
Header names cannot override routing, framing or multipart content type.
Credential values must be nonempty and valid for their transport; validation
errors do not echo those values. The mapping validator checks individual options;
Expand Down Expand Up @@ -1024,8 +1026,9 @@ Server owners can change the destination, so they must be trusted with credentia
sent through their servers. Catalog masking does not prevent an owner from
redirecting a subsequent request to a destination they control.

The catalog resolver checks server `USAGE` and reads the authenticated caller's
mapping on every attempt. Rotation affects the next lookup, not an already-sent
The catalog resolver checks server `USAGE` on every attempt and reads the
authenticated caller's mapping when the scheme requires it. Rotation affects the
next lookup, not an already-sent
request. Missing or inaccessible credentials fail without privileged fallback.
Mappings also accept individual `secret.<key>` options for explicit bindings.
These are separate from `token`, `header_value` and `query_string`; other option
Expand All @@ -1039,6 +1042,112 @@ in provisioning DDL can appear in PostgreSQL logs. Treat backup/restore and
credential provisioning accordingly. Dropping the extension with `CASCADE`
also removes dependent servers and mappings.

### Managed Identity

**Added in 0.2.10.** Install and restart the new binary, then run
`ALTER EXTENSION pg_durable UPDATE TO '0.2.10'` in each database where MI
endpoints will be configured. The 0.2.9 endpoint API does not include MI's
administration capability.

Managed identity authenticates both normal and multipart endpoint requests with
an Azure access token acquired inside the HTTP activity. Tokens are not exposed
through `df.secret`, stored in user mappings, or added to workflow inputs or
results. A destination can still return sensitive data, including an echoed
request header; response contents are not automatically redacted.

The server administrator selects one user-assigned identity for all MI-backed
endpoints, then restarts PostgreSQL:

```ini
pg_durable.managed_identity_client_id = '11111111-1111-1111-1111-111111111111'
```

The value must be a nonzero client UUID. An empty value, the default, disables MI
requests without affecting other HTTP authentication. There is no implicit
system-assigned or provider-default identity. The identity must be available to
the token provider and authorized for the target resources. Select an identity
intended for customer-configured requests, not merely one used by a service for
internal operations.

Creating or altering an MI endpoint requires `EXECUTE` on
`df.managed_identity_admin()`, in addition to native FDW and ownership permissions.
This is a database-local capability with no `PUBLIC` grant. Calling the function
returns no token and changes no settings. `df.grant_usage`, even with
`with_grant => true`, does not grant this capability or FDW creation permission.
Endpoint definitions and capability grants belong to the workflow's origin
installation. A grant in the control database does not grant MI administration
in satellites. The startup identity and provider settings apply to the shared
worker and therefore to all installations it serves.

A privileged provisioner can delegate configuration to an existing administrator
role without making it a PostgreSQL superuser:

```sql
GRANT USAGE ON SCHEMA df TO endpoint_admin;
GRANT USAGE ON FOREIGN DATA WRAPPER pg_durable_fdw TO endpoint_admin;
GRANT EXECUTE ON FUNCTION df.managed_identity_admin() TO endpoint_admin;
SELECT df.grant_usage('app_role', include_http => true);

SET ROLE endpoint_admin;
CREATE SERVER storage_identity FOREIGN DATA WRAPPER pg_durable_fdw
OPTIONS (base_url 'https://account.blob.core.windows.net',
auth_scheme 'managed-identity');
GRANT USAGE ON FOREIGN SERVER storage_identity TO app_role;
RESET ROLE;

SET ROLE app_role;
SELECT df.start(
df.http(df.endpoint('storage_identity', '/container/blob'), 'GET',
headers => '{"x-ms-version":"2023-11-03"}'),
'read-blob'
);
RESET ROLE;
```

Each request rechecks that the endpoint owner retains the capability and that the
caller has server `USAGE` and HTTP execution privileges. Revoking the owner's
capability, or transferring ownership to an unauthorized role, blocks subsequent
attempts even if a token is cached. Native privilege inheritance applies; another
direct or inherited grant can still supply the capability. Revocation does not
recall an already-authorized request.

Endpoint administration does not permit changing the client-ID or provider GUCs.
Endpoint options, workflow paths, headers, secret bindings and user mappings cannot
select another identity, change its token resource, or replace its `Authorization`
header. Pinning the identity controls pg_durable's requests, not host-wide access
to credentials; provider isolation remains a deployment responsibility.

The destination must use HTTPS on port 443. Token resources are fixed by hostname:

| Destination | Token resource |
|---|---|
| `*.openai.azure.com`, `*.cognitiveservices.azure.com`, `*.services.ai.azure.com` | `https://cognitiveservices.azure.com` |
| `*.blob.core.windows.net`, `*.blob.storage.azure.net`, `*.dfs.core.windows.net`, `*.queue.core.windows.net`, `*.table.core.windows.net`, `*.file.core.windows.net` | `https://storage.azure.com/` |
| `*.vault.azure.net` | `https://vault.azure.net` |
| `management.azure.com` | `https://management.azure.com/` |

Other hosts, custom application audiences and sovereign-cloud endpoints are not
supported for managed identity. The HTTP security mode, domain allow-list and DNS/IP
checks still apply. Some mapped hosts, including `management.azure.com`, require
an explicit addition to `pg_durable.http_allowed_domains`.

By default, tokens come from the public [Azure IMDS token endpoint](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-use-vm-token).
No Azure connection is made at startup or for other authentication schemes.
The same binary works outside Azure; selecting managed identity there requires an
available token provider. There is no automatic CLI, environment-credential or
alternate-provider fallback. If a deployment does not expose IMDS, its administrator
must supply an IMDS-compatible adapter and configure the startup-only
[`pg_durable.managed_identity_endpoint`](docs/api-reference.md#pg_durablemanaged_identity_endpoint).
Such adapters are not included in the extension.

The worker caches one token per resource for the configured identity, refreshes before expiry, and
never returns a stale token after a refresh failure. Permissions and destination
configuration are still checked on every request, including cache hits. Provider
failures fail the activity without persisting the provider's response body.
Changing the client ID and restarting clears the cache and changes the identity
used by subsequent requests, including pending workflows and retries. Recorded
activity results replay without acquiring tokens.

### Calling an Endpoint

Pass `df.endpoint(server, path)` as the destination of either HTTP constructor:
Expand Down
17 changes: 17 additions & 0 deletions docs/E2E_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ The test suite is organized into 23 files. Files `01`–`09` open with `SET SESS
| `16_heartbeat.sql` | Worker heartbeat liveness — `df._worker_epoch.last_seen_at` advances over time |
| `52_node_id_collision_across_instances.sql` | Cross-instance node-ID collision — two instances own the same 8-hex node id; asserts composite-PK coexistence, that `(instance_id, id)` addresses exactly one row, `df.result()` is instance-scoped, and a scoped `update_node_status`-style UPDATE affects exactly one row (issue #129) |
| `68_long_caller_transaction.sql` | Caller-transaction handoff beyond five seconds, transient graph-probe failure, whole rollback, and savepoint rollback |
| `76_managed_identity_unconfigured.sql` | Unset identity disables MI for both HTTP forms while ordinary HTTP still succeeds |

### Startup-Phase Specific

Expand All @@ -89,6 +90,22 @@ restart with the phase's configuration, not a rebuild.
| `48_http_allow_all.sql` | `http-allow-all` | Unrestricted mode admits plaintext HTTP, unlisted domains, and private destinations for both HTTP activities |
| `69_http_allowed_domains.sql` | `http-custom-domains` | Custom allow-list enforcement, malformed startup rejection, protected mode settings, and restart-only policy changes |
| `70_http_allowed_domains_empty.sql` | `http-empty-domains` | Empty restricted-mode allow-list denies all domains |
| `75_managed_identity.sql` | `managed-identity` | Offline startup-pinned UAMI requests, delegated ownership, revocation with a warm cache, override rejection, restart-only configuration and token non-persistence |

The managed-identity phase needs Bash 4+, Python 3 and the `openssl` command, but
no Azure account or network service. The runner starts the local token and TLS
fixtures in [managed_identity_mock.py](../tests/e2e/managed_identity_mock.py),
generates a temporary certificate, and removes the fixtures on exit. It selects
`pg_durable.http_security = 'unrestricted'` to route the approved hostname through a local HTTPS
proxy; production code has no mock-provider bypass. The custom-domain and
HTTP-disabled phases separately verify that managed identity cannot override
those policies. With `--keep`, PostgreSQL remains available for state inspection,
but the identity fixtures stop when the runner exits. The separate unconfigured
test runs in the standard phase and uses the usual `httpbingo.org` HTTP probe.

```bash
./scripts/test-e2e-local.sh managed_identity
```

## Test Structure

Expand Down
108 changes: 107 additions & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,8 @@ the FDW validator invoked by PostgreSQL on creation and alteration; it returns
`void` or raises an error without echoing credential values.

The server options are `base_url`, `auth_scheme`, and `header_name` (only for
header authentication). `auth_scheme` is required; `base_url` may be omitted only
header authentication). `client_id`, `resource` and `scope` are not accepted.
`auth_scheme` is required; `base_url` may be omitted only
with `auth_scheme 'none'` for named-secret storage. A supplied URL retains all
validation requirements, and endpoint requests fail explicitly if it is absent.
Mapping options are `token`, `header_value`,
Expand All @@ -365,6 +366,43 @@ native per-option `ADD`, `SET` and `DROP`. See
[Endpoint Credential Catalog](../USER_GUIDE.md#endpoint-credential-catalog)
for option combinations, grants, rotation and backup implications.

Since v0.2.10, `auth_scheme 'managed-identity'` requires `EXECUTE` on
`df.managed_identity_admin()` for configuration and for the endpoint owner at
execution. It requires no user mapping for authentication and uses the single
identity pinned by `pg_durable.managed_identity_client_id` at startup.
The destination must be a supported public Azure hostname over HTTPS on port 443;
the token resource is derived from that host, never supplied by the workflow.
Server `USAGE`, HTTP function permissions and destination policy remain mandatory.
See [Managed Identity](../USER_GUIDE.md#managed-identity) for supported hosts and
deployment requirements.

### df.managed_identity_admin()

**Added in 0.2.10.** Update the extension in the database where the endpoint is
defined before granting this capability.

An extension-owned, no-op function returning `void`. Its `EXECUTE` privilege
authorizes managed-identity endpoint configuration; calling it never acquires a
token or changes configuration. There is no `PUBLIC` execution grant.

```sql
GRANT EXECUTE ON FUNCTION df.managed_identity_admin() TO endpoint_admin;
REVOKE EXECUTE ON FUNCTION df.managed_identity_admin() FROM endpoint_admin;
```

Native FDW and ownership privileges remain required. The capability does not
authorize changing provider settings, imply HTTP execution privileges, or permit
selecting a different identity. It is not included in `df.grant_usage` or
`df.revoke_usage`; manage it explicitly with `GRANT` and `REVOKE`.

The endpoint owner's effective `EXECUTE` privilege is rechecked on every attempt,
including token-cache hits. A direct or inherited grant counts; `NOINHERIT`
memberships alone do not. Losing the capability or transferring ownership to an
unauthorized role blocks subsequent attempts, not already-authorized requests.
A missing or non-extension-owned capability function fails closed.
The capability and endpoints are local to the workflow's origin installation;
control-database grants do not authorize satellite endpoint administration.

### df.http_multipart(url [, method, parts, headers, timeout])

Makes an HTTP request with a `multipart/form-data` body. Requires the same
Expand Down Expand Up @@ -1013,6 +1051,74 @@ database, not the explicit SQL target. Excess callers wait up to

---

### pg_durable.managed_identity_client_id

The user-assigned identity used by all managed-identity endpoint requests.
Available since v0.2.10. The shared worker uses this setting for control and
satellite requests alike; it is not a per-database identity setting.

| Property | Value |
|----------|-------|
| Type | `string`, containing a nonzero client UUID or empty |
| Default | Empty (managed identity disabled) |
| Context | `POSTMASTER` (requires a PostgreSQL restart) |
| Visibility | Superusers and roles with `pg_read_all_settings` |

```ini
pg_durable.managed_identity_client_id = '11111111-1111-1111-1111-111111111111'
```

An unset or empty value makes MI requests fail before contacting a token provider.
Other HTTP authentication is unaffected. Whitespace, malformed UUIDs and the nil
UUID are rejected, including at server startup. Automatic system-assigned or
provider-default identity selection is not supported.

Configure this in `postgresql.conf` or through an authorized `ALTER SYSTEM SET`.
Session, role and database overrides are rejected; reload alone does not apply a
change. MI endpoint administration grants do not confer configuration privileges.
Changing the value and restarting changes the identity for subsequent attempts,
including pending workflows and retries, and clears the token cache. The configured
identity is not added to workflow inputs; recorded results replay unchanged.

---

### pg_durable.managed_identity_endpoint

The token-provider URL for endpoint managed-identity authentication. Available
since v0.2.10; it is unused by other authentication schemes.

| Property | Value |
|----------|-------|
| Type | `string` |
| Default | `http://169.254.169.254/metadata/identity/oauth2/token` |
| Context | `POSTMASTER` (requires a PostgreSQL restart) |
| Visibility | Superusers and roles with configuration-reading privileges |

The provider must implement the public IMDS GET token protocol: `Metadata: true`,
`api-version=2018-02-01`, a host-derived `resource`, and the configured `client_id`.
The provider must honor that identity selection rather than silently substituting
another identity.
Success must include `access_token`, `token_type` (`Bearer`), the requested
`resource`, and an `expires_on` Unix timestamp or `expires_in` seconds. Expiry
values may be JSON numbers or strings. Tokens must have more than two minutes
remaining and must already be valid.

Only administrators configure this URL. It must be HTTPS, or HTTP with a literal
loopback or IMDS IP address, without userinfo, query or fragment. Session, role and
database overrides are rejected. An invalid startup setting prevents startup.
An adapter for a different identity service must be provided separately; there is
no provider auto-discovery or fallback on failure.

Token requests use a separate client with no redirects or environment proxies,
a two-second connection timeout, a ten-second total acquisition deadline
(including cache waits), and a 64 KiB response limit. Failures expose only local
diagnostics or HTTP status, not the provider's body. The worker holds one cache
entry per supported token resource for its pinned identity and coalesces
concurrent fetches for each entry.
The cache is in memory only and is cleared by worker restart.

---

### pg_durable.enable_superuser_instances

Controls whether pg_durable allows durable function instances whose `submitted_by` role is a PostgreSQL superuser.
Expand Down
Loading
Loading