chore: support Entra authentication for Azure Database for PostgreSQL (#31855)

* chore: support Entra authentication for Azure Database for PostgreSQL

Signed-off-by: Arjan Randhawa <arjan_randhawa@outlook.com>

* chore: refactor methods (pr comments)

Signed-off-by: Arjan Randhawa <arjan_randhawa@outlook.com>

* chore: remove unsued helper function

Signed-off-by: Arjan Randhawa <arjan_randhawa@outlook.com>

* chore: Use Config over ConfigReader (PR Comments)

Signed-off-by: Arjan <arjan_randhawa@outlook.com>

* chore: Use consistent config name

Signed-off-by: Arjan <arjan_randhawa@outlook.com>

---------

Signed-off-by: Arjan Randhawa <arjan_randhawa@outlook.com>
Signed-off-by: Arjan <arjan_randhawa@outlook.com>
This commit is contained in:
Arjan
2026-01-30 14:27:53 +00:00
committed by GitHub
parent 2141c20618
commit 8dd518a8ff
8 changed files with 592 additions and 17 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-defaults': patch
---
Support `connection.type: azure` in database client to use Microsoft Entra authentication with Azure database for PostgreSQL
@@ -345,6 +345,7 @@ parallelization
param
params
parseable
passwordless
Patrik
pattison
Peloton
+82
View File
@@ -106,6 +106,88 @@ When filling these out, you have 2 choices,
If you opt for the second option of replacing the entire string, take care to not commit your `app-config.yaml` to source control. It may contain passwords that you don't want leaked.
## Passwordless PostgreSQL in the Cloud
If you want to host your PostgreSQL server in the cloud with passwordless authentication, you can use Azure Database for PostgreSQL with Microsoft Entra authentication or Google Cloud SQL for PostgreSQL with Cloud IAM.
### Azure with Entra authentication
Remove `password` from the connection configuration and set `type` to `azure`.
Optionally set `tokenCredential` with the following properties. If no credential information is provided, it will default to using Default Azure Credential and a tokenRenewalOffsetTime of 5 minutes.
#### Credential Selection
The credential type is automatically inferred based on the fields you provide:
- Client Secret Credential is used when all three are provided:
- `tenantId`
- `clientId`
- `clientSecret`
- Managed Identity Credential is used when only `clientId` is provided. This enables user-assigned managed identity.
- Default Azure Credential is used when no credential fields are provided. Default Azure Credential supports [many credential types](https://learn.microsoft.com/azure/developer/javascript/sdk/authentication/credential-chains#use-defaultazurecredential-for-flexibility), choosing one based on the runtime environment.
#### Token Renewal
Set `tokenRenewalOffsetTime` to control how early OAuth tokens should be refreshed.
The value may be:
- A human-readable string such as '1d', '2 hours', '30 seconds'
- A duration object, e.g. { minutes: 3, seconds: 30 }
Azure PostgreSQL uses short-lived Entra ID access tokens.
By default, the database connector refreshes tokens 5 minutes before they expire.
#### User Configuration
Set `user` to the display name of your Entra ID group, service principal, or managed identity. Set it to the user principal name if you're authenticating with a user's credentials.
#### Example
```yaml title="app-config.yaml"
backend:
database:
client: pg
connection:
# highlight-add-start
type: azure
tokenCredential:
tokenRenewalOffsetTime: 5 minutes
# highlight-add-end
host: ${POSTGRES_HOST}
port: ${POSTGRES_PORT}
user: ${POSTGRES_USER}
# highlight-remove-start
password: ${POSTGRES_PASSWORD}
# highlight-remove-end
```
### Google with Cloud IAM
Remove `password` from the connection configuration and set `type` to `cloudsql`.
Under the hood, this implements [Automatic IAM Database Authentication](https://github.com/GoogleCloudPlatform/cloud-sql-nodejs-connector?tab=readme-ov-file#automatic-iam-database-authentication).
For an IAM user account, set `user` to the user's email address. For a service account, set `user` to the service account's email without the .gserviceaccount.com domain suffix.
```yaml title="app-config.yaml"
backend:
database:
client: pg
connection:
# highlight-add-start
type: cloudsql
instance: my-project:region:my-instance
# highlight-add-end
host: ${POSTGRES_HOST}
port: ${POSTGRES_PORT}
user: ${POSTGRES_USER}
# highlight-remove-start
password: ${POSTGRES_PASSWORD}
# highlight-remove-end
```
:::
[Start the Backstage app](../index.md#2-run-the-backstage-app):
+26
View File
@@ -592,6 +592,32 @@ export interface Config {
*/
connection:
| string
| {
/**
* The specific config for Azure database for PostgreSQL connections with Entra authentication
*/
type: 'azure';
/**
* Optional Azure token credential configuration
*/
tokenCredential?: {
/**
* How early before an access token expires to refresh it with a new one.
* Defaults to 5 minutes
* Supported formats:
* - A string in the format of '1d', '2 seconds' etc. as supported by the `ms` library.
* - A standard ISO formatted duration string, e.g. 'P2DT6H' or 'PT1M'.
* - An object with individual units (in plural) as keys, e.g. `{ days: 2, hours: 6 }`.
*/
tokenRenewableOffsetTime?: string | HumanDuration;
clientId?: string;
/**
* @visibility secret
*/
clientSecret?: string;
tenantId?: string;
};
}
| {
/**
* The specific config for cloudsql connections
+1
View File
@@ -131,6 +131,7 @@
"@aws-sdk/client-s3": "^3.350.0",
"@aws-sdk/credential-providers": "^3.350.0",
"@aws-sdk/types": "^3.347.0",
"@azure/identity": "^4.0.0",
"@azure/storage-blob": "^12.5.0",
"@backstage/backend-app-api": "workspace:^",
"@backstage/backend-dev-utils": "workspace:^",
@@ -25,6 +25,7 @@ import {
import { type Knex } from 'knex';
jest.mock('@google-cloud/cloud-sql-connector');
jest.mock('@azure/identity');
describe('postgres', () => {
const createMockConnection = () => ({
@@ -140,6 +141,271 @@ describe('postgres', () => {
});
});
it('should default to using default azure credentials when type is azure with no credentials', async () => {
const { DefaultAzureCredential } = jest.requireMock(
'@azure/identity',
) as jest.Mocked<typeof import('@azure/identity')>;
const tokenExpirationTimestamp = new Date(
'2025-01-01T12:34:56.789',
).valueOf();
DefaultAzureCredential.prototype.getToken.mockResolvedValue({
token: 'afaketoken',
expiresOnTimestamp: tokenExpirationTimestamp,
});
const configResult = await buildPgDatabaseConfig(
new ConfigReader({
client: 'pg',
connection: {
type: 'azure',
user: 'some-user@domain.com',
port: 5423,
database: 'other_db',
},
}),
);
expect(DefaultAzureCredential).toHaveBeenCalled();
expect(configResult).toMatchObject({
client: 'pg',
connection: expect.any(Function),
useNullAsDefault: true,
});
const connectionResult = await configResult.connection();
expect(connectionResult).toMatchObject({
user: 'some-user@domain.com',
password: 'afaketoken',
port: 5423,
expirationChecker: expect.any(Function),
});
expect(connectionResult).not.toHaveProperty('allowedClockSkewMs');
expect(connectionResult).not.toHaveProperty('type');
expect(connectionResult).not.toHaveProperty('tokenCredential');
});
it('uses the correct config when using azure managed identity', async () => {
const { ManagedIdentityCredential } = jest.requireMock(
'@azure/identity',
) as jest.Mocked<typeof import('@azure/identity')>;
const tokenExpirationTimestamp = new Date(
'2025-01-01T12:34:56.789',
).valueOf();
ManagedIdentityCredential.prototype.getToken.mockResolvedValue({
token: 'afaketoken',
expiresOnTimestamp: tokenExpirationTimestamp,
});
const configResult = await buildPgDatabaseConfig(
new ConfigReader({
client: 'pg',
connection: {
type: 'azure',
user: 'some-user@domain.com',
port: 5423,
database: 'other_db',
tokenCredential: {
clientId: 'my-client-id',
},
},
}),
);
expect(ManagedIdentityCredential).toHaveBeenCalledWith('my-client-id');
expect(configResult).toMatchObject({
client: 'pg',
connection: expect.any(Function),
useNullAsDefault: true,
});
const connectionResult = await configResult.connection();
expect(connectionResult).toMatchObject({
user: 'some-user@domain.com',
password: 'afaketoken',
port: 5423,
expirationChecker: expect.any(Function),
});
expect(connectionResult).not.toHaveProperty('type');
expect(connectionResult).not.toHaveProperty('tokenCredential');
});
it('uses the correct config when using azure client secret credentials', async () => {
const { ClientSecretCredential } = jest.requireMock(
'@azure/identity',
) as jest.Mocked<typeof import('@azure/identity')>;
const tokenExpirationTimestamp = new Date(
'2025-01-01T12:34:56.789',
).valueOf();
ClientSecretCredential.prototype.getToken.mockResolvedValue({
token: 'afaketoken',
expiresOnTimestamp: tokenExpirationTimestamp,
});
const configResult = await buildPgDatabaseConfig(
new ConfigReader({
client: 'pg',
connection: {
type: 'azure',
user: 'some-user@domain.com',
port: 5423,
database: 'other_db',
tokenCredential: {
clientId: 'my-client-id',
tenantId: 'my-tenant-id',
clientSecret: 'my-client-secret',
},
},
}),
);
expect(ClientSecretCredential).toHaveBeenCalledWith(
'my-tenant-id',
'my-client-id',
'my-client-secret',
);
expect(configResult).toMatchObject({
client: 'pg',
connection: expect.any(Function),
useNullAsDefault: true,
});
const connectionResult = await configResult.connection();
expect(connectionResult).toMatchObject({
user: 'some-user@domain.com',
password: 'afaketoken',
port: 5423,
expirationChecker: expect.any(Function),
});
expect(connectionResult).not.toHaveProperty('type');
expect(connectionResult).not.toHaveProperty('tokenCredential');
});
it('removes tokenCredential from the final connection', async () => {
const { DefaultAzureCredential } = jest.requireMock(
'@azure/identity',
) as jest.Mocked<typeof import('@azure/identity')>;
DefaultAzureCredential.prototype.getToken.mockResolvedValue({
token: 't',
expiresOnTimestamp: Date.now() + 1000,
});
const config = new ConfigReader({
client: 'pg',
connection: {
type: 'azure',
instance: 'unused',
tokenCredential: { clientId: 'x' },
},
});
const configResult = await buildPgDatabaseConfig(config);
const connection = await configResult.connection();
expect(connection).not.toHaveProperty('tokenCredential');
});
it('instructs knex to get a new connection object when the old azure token expires', async () => {
const { DefaultAzureCredential } = jest.requireMock(
'@azure/identity',
) as jest.Mocked<typeof import('@azure/identity')>;
const tokenExpirationTimestamp = new Date(
'2025-01-01T12:34:56.789',
).valueOf();
DefaultAzureCredential.prototype.getToken.mockResolvedValue({
token: 'afaketoken',
expiresOnTimestamp: tokenExpirationTimestamp,
});
let configResult = await buildPgDatabaseConfig(
new ConfigReader({
client: 'pg',
connection: {
type: 'azure',
tokenCredential: {
tokenRenewableOffsetTime: '1 minute',
},
user: 'some-user@domain.com',
database: 'other_db',
port: 5423,
},
}),
);
let connectionResult = await configResult.connection();
jest.useFakeTimers({ now: tokenExpirationTimestamp - 90_000 });
let expirationResult = await connectionResult.expirationChecker();
expect(expirationResult).toBe(false);
jest.useFakeTimers({ now: tokenExpirationTimestamp - 60_000 });
expirationResult = await connectionResult.expirationChecker();
expect(expirationResult).toBe(true);
jest.useFakeTimers({ now: tokenExpirationTimestamp });
expirationResult = await connectionResult.expirationChecker();
expect(expirationResult).toBe(true);
// Check the default tokenRenewableOffsetTime of 5 minutes
configResult = await buildPgDatabaseConfig(
new ConfigReader({
client: 'pg',
connection: {
type: 'azure',
user: 'user@contoso.com',
database: 'other_db',
port: 5423,
},
}),
);
connectionResult = await configResult.connection();
jest.useFakeTimers({ now: tokenExpirationTimestamp - 450_000 });
expirationResult = await connectionResult.expirationChecker();
expect(expirationResult).toBe(false);
jest.useFakeTimers({ now: tokenExpirationTimestamp - 300_000 });
expirationResult = await connectionResult.expirationChecker();
expect(expirationResult).toBe(true);
jest.useFakeTimers({ now: tokenExpirationTimestamp });
expirationResult = await connectionResult.expirationChecker();
expect(expirationResult).toBe(true);
});
it('throws an error when Azure token acquisition fails', async () => {
const { DefaultAzureCredential } = jest.requireMock(
'@azure/identity',
) as jest.Mocked<typeof import('@azure/identity')>;
DefaultAzureCredential.prototype.getToken.mockResolvedValue(null as any);
const configResult = await buildPgDatabaseConfig(
new ConfigReader({
client: 'pg',
connection: {
type: 'azure',
user: 'some-user@domain.com',
database: 'other_db',
},
}),
);
await expect(configResult.connection()).rejects.toThrow(
'Failed to acquire Azure access token for database authentication',
);
});
it('uses the correct config when using cloudsql', async () => {
expect(
await buildPgDatabaseConfig(
@@ -251,6 +517,47 @@ describe('postgres', () => {
});
});
it('passes configured ipType to connector.getOptions', async () => {
const { Connector } = jest.requireMock(
'@google-cloud/cloud-sql-connector',
) as jest.Mocked<typeof import('@google-cloud/cloud-sql-connector')>;
const mockStream = (): any => {};
Connector.prototype.getOptions.mockResolvedValue({ stream: mockStream });
await buildPgDatabaseConfig(
new ConfigReader({
client: 'pg',
connection: {
type: 'cloudsql',
instance: 'proj:region:inst',
ipAddressType: 'PUBLIC',
},
}),
);
expect(Connector.prototype.getOptions).toHaveBeenCalledWith({
authType: 'IAM',
instanceConnectionName: 'proj:region:inst',
ipType: 'PUBLIC',
});
});
it('throws when connection.ipAddressType is invalid', async () => {
await expect(
buildPgDatabaseConfig(
new ConfigReader({
client: 'pg',
connection: {
type: 'cloudsql',
instance: 'proj:region:inst',
ipAddressType: 'INVALID',
},
}),
),
).rejects.toThrow(/Invalid connection.ipAddressType/);
});
it('passes ip settings to cloud-sql-connector', async () => {
const { Connector } = jest.requireMock(
'@google-cloud/cloud-sql-connector',
@@ -15,9 +15,17 @@
*/
import { LifecycleService, LoggerService } from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import {
Config,
ConfigReader,
readDurationFromConfig,
} from '@backstage/config';
import { ForwardedError } from '@backstage/errors';
import { JsonObject } from '@backstage/types';
import {
durationToMilliseconds,
HumanDuration,
JsonObject,
} from '@backstage/types';
import knexFactory, { Knex } from 'knex';
import { merge, omit } from 'lodash';
import limiterFactory from 'p-limit';
@@ -25,6 +33,7 @@ import { Client } from 'pg';
import { Connector } from '../types';
import { mergeDatabaseConfig } from './mergeDatabaseConfig';
import format from 'pg-format';
import { TokenCredential } from '@azure/identity';
// Limits the number of concurrent DDL operations to 1
const ddlLimiter = limiterFactory(1);
@@ -74,26 +83,142 @@ export async function buildPgDatabaseConfig(
},
overrides,
);
const sanitizedConfig = JSON.parse(JSON.stringify(config));
// Trim additional properties from the connection object passed to knex
delete sanitizedConfig.connection.type;
delete sanitizedConfig.connection.instance;
const mergedConfigReader = new ConfigReader(config);
if (config.connection.type === 'default' || !config.connection.type) {
return sanitizedConfig;
const connectionValue = config.connection;
const sanitizedConnection =
typeof connectionValue === 'string' || connectionValue instanceof String
? connectionValue
: // connection is an object, omit config-only props
omit(connectionValue as Record<string, unknown>, [
'type',
'instance',
'tokenCredential',
]);
return {
...config,
connection: sanitizedConnection,
};
}
if (config.connection.type !== 'cloudsql') {
throw new Error(`Unknown connection type: ${config.connection.type}`);
switch (config.connection.type) {
case 'azure':
return buildAzurePgConfig(mergedConfigReader);
case 'cloudsql':
return buildCloudSqlConfig(mergedConfigReader);
default:
throw new Error(`Unknown connection type: ${config.connection.type}`);
}
}
/* Note: the following type definition is intentionally duplicated in
* /packages/backend-defaults/config.d.ts so the clientSecret property
* can be annotated with "@visibility secret" there.
*/
export type AzureTokenCredentialConfig = {
/**
* How early before an access token expires to refresh it with a new one.
* Defaults to 5 minutes
* Supported formats:
* - A string in the format of '1d', '2 seconds' etc. as supported by the `ms`
* library.
* - A standard ISO formatted duration string, e.g. 'P2DT6H' or 'PT1M'.
* - An object with individual units (in plural) as keys, e.g. `{ days: 2, hours: 6 }`.
*/
tokenRenewableOffsetTime?: string | HumanDuration;
/**
* The client ID of a user-assigned managed identity.
* If not provided, the system-assigned managed identity is used.
*/
clientId?: string;
clientSecret?: string;
tenantId?: string;
};
export async function buildAzurePgConfig(config: Config): Promise<Knex.Config> {
const {
DefaultAzureCredential,
ManagedIdentityCredential,
ClientSecretCredential,
} = require('@azure/identity');
const tokenConfig = config.getOptionalConfig('connection.tokenCredential');
const tokenRenewableOffsetTime = durationToMilliseconds(
tokenConfig?.has('tokenRenewableOffsetTime')
? readDurationFromConfig(tokenConfig, { key: 'tokenRenewableOffsetTime' })
: { minutes: 5 },
);
const clientId = tokenConfig?.getOptionalString('clientId');
const tenantId = tokenConfig?.getOptionalString('tenantId');
const clientSecret = tokenConfig?.getOptionalString('clientSecret');
let credential: TokenCredential;
/**
* Determine which TokenCredential to use based on provided config
* 1. If clientId, tenantId and clientSecret are provided, use ClientSecretCredential
* 2. If only clientId is provided, use ManagedIdentityCredential with user-assigned identity
* 3. Otherwise, use DefaultAzureCredential (which may use system-assigned identity among other methods)
*/
if (clientId && tenantId && clientSecret) {
credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
} else if (clientId) {
credential = new ManagedIdentityCredential(clientId);
} else {
credential = new DefaultAzureCredential();
}
if (config.client !== 'pg') {
const rawConfig = config.get() as Record<string, unknown>;
const normalized = normalizeConnection(rawConfig.connection as any);
const sanitizedConnection = omit(normalized, [
'type',
'instance',
'tokenCredential',
]) as Partial<Knex.StaticConnectionConfig>;
async function getConnectionConfig() {
const token = await credential.getToken(
'https://ossrdbms-aad.database.windows.net/.default',
);
if (!token) {
throw new Error(
'Failed to acquire Azure access token for database authentication',
);
}
const connectionConfig = {
...sanitizedConnection,
password: token.token,
expirationChecker: () =>
/* return true if the token is within the renewable offset time */
token.expiresOnTimestamp - tokenRenewableOffsetTime <= Date.now(),
};
return connectionConfig;
}
return {
...(rawConfig as Record<string, unknown>),
connection: getConnectionConfig,
};
}
export async function buildCloudSqlConfig(
config: Config,
): Promise<Knex.Config> {
const client = config.getOptionalString('client');
if (client && client !== 'pg') {
throw new Error('Cloud SQL only supports the pg client');
}
if (!config.connection.instance) {
const instance = config.getOptionalString('connection.instance');
if (!instance) {
throw new Error('Missing instance connection name for Cloud SQL');
}
@@ -103,17 +228,44 @@ export async function buildPgDatabaseConfig(
AuthTypes,
} = require('@google-cloud/cloud-sql-connector') as typeof import('@google-cloud/cloud-sql-connector');
const connector = new CloudSqlConnector();
type IpType = (typeof IpAddressTypes)[keyof typeof IpAddressTypes];
const ipTypeRaw = config.getOptionalString('connection.ipAddressType');
let ipType: IpType | undefined;
if (ipTypeRaw !== undefined) {
if (
!(Object.values(IpAddressTypes) as Array<string | number>).includes(
ipTypeRaw as any,
)
) {
throw new Error(
`Invalid connection.ipAddressType: ${ipTypeRaw}; valid values: ${Object.values(
IpAddressTypes,
).join(', ')}`,
);
}
ipType = ipTypeRaw as unknown as IpType;
}
const clientOpts = await connector.getOptions({
instanceConnectionName: config.connection.instance,
ipType: config.connection.ipAddressType ?? IpAddressTypes.PUBLIC,
instanceConnectionName: instance,
ipType: ipType ?? IpAddressTypes.PUBLIC,
authType: AuthTypes.IAM,
});
const rawConfig = config.get() as Record<string, unknown>;
const normalized = normalizeConnection(rawConfig.connection as any);
const sanitizedConnection = omit(normalized, [
'type',
'instance',
]) as Partial<Knex.StaticConnectionConfig>;
return {
...sanitizedConfig,
...(rawConfig as Record<string, unknown>),
client: 'pg',
connection: {
...sanitizedConfig.connection,
...sanitizedConnection,
...clientOpts,
},
};
+1
View File
@@ -2969,6 +2969,7 @@ __metadata:
"@aws-sdk/credential-providers": "npm:^3.350.0"
"@aws-sdk/types": "npm:^3.347.0"
"@aws-sdk/util-stream-node": "npm:^3.350.0"
"@azure/identity": "npm:^4.0.0"
"@azure/storage-blob": "npm:^12.5.0"
"@backstage/backend-app-api": "workspace:^"
"@backstage/backend-dev-utils": "workspace:^"