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:
+26
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user