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
+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,
},
};