Skip to content

Commit

Permalink
feat(core): allow using short lived tokens in config
Browse files Browse the repository at this point in the history
Many cloud providers include alternative methods for connecting to database instances
using short-lived authentication tokens. MikroORM supports dynamic passwords via
a callback function, either synchronous or asynchronous. The callback function must
resolve to a string.

```ts
MikroORM.init({
  type: 'mysql',
  dbName: 'my_db_name',
  password: async () => someCallToGetTheToken(),
});
```

The password callback value will be cached, to invalidate this cache we can specify
`expirationChecker` callback:

```ts
MikroORM.init({
  type: 'mysql',
  dbName: 'my_db_name',
  password: async () => {
    const { token, tokenExpiration } = await someCallToGetTheToken();
    return { password: token, expirationChecker: () => tokenExpiration <= Date.now() }
  },
});
```

Closes #1818
  • Loading branch information
B4nan committed May 31, 2021
1 parent 0b198e6 commit 4499838
Show file tree
Hide file tree
Showing 8 changed files with 175 additions and 45 deletions.
38 changes: 37 additions & 1 deletion docs/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,19 @@ Each platform (driver) provides default connection string, you can override it a
through `clientUrl`, or partially through one of following options:

```typescript
export interface DynamicPassword {
password: string;
expirationChecker?: () => boolean;
}

export interface ConnectionOptions {
dbName?: string;
name?: string; // for logging only (when replicas are used)
clientUrl?: string;
host?: string;
port?: number;
user?: string;
password?: string;
password?: string | (() => string | Promise<string> | DynamicPassword | Promise<DynamicPassword>);
charset?: string;
multipleStatements?: boolean; // for mysql driver
pool?: PoolConfig; // provided by `knex`
Expand All @@ -136,6 +141,8 @@ Following table shows default client connection strings:
| `mariadb` | `mysql://root@127.0.0.1:3306` |
| `postgresql` | `postgresql://postgres@127.0.0.1:5432` |

### Read Replicas

To set up read replicas, you can use `replicas` option. You can provide only those parts of the
`ConnectionOptions` interface, they will be used to override the `master` connection options.

Expand All @@ -156,6 +163,35 @@ MikroORM.init({

Read more about this in [Installation](installation.md) and [Read Connections](read-connections.md) sections.

### Using short-lived tokens

Many cloud providers include alternative methods for connecting to database instances
using short-lived authentication tokens. MikroORM supports dynamic passwords via
a callback function, either synchronous or asynchronous. The callback function must
resolve to a string.

```ts
MikroORM.init({
type: 'mysql',
dbName: 'my_db_name',
password: async () => someCallToGetTheToken(),
});
```

The password callback value will be cached, to invalidate this cache we can specify
`expirationChecker` callback:

```ts
MikroORM.init({
type: 'mysql',
dbName: 'my_db_name',
password: async () => {
const { token, tokenExpiration } = await someCallToGetTheToken();
return { password: token, expirationChecker: () => tokenExpiration <= Date.now() }
},
});
```

## Naming Strategy

When mapping your entities to database tables and columns, their names will be defined by naming
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/connections/Connection.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { URL } from 'url';
import c from 'ansi-colors';

import { Configuration, ConnectionOptions, Utils } from '../utils';
import { Configuration, ConnectionOptions, DynamicPassword, Utils } from '../utils';
import { MetadataStorage } from '../metadata';
import { Dictionary } from '../typings';
import { Dictionary, MaybePromise } from '../typings';
import { Platform } from '../platforms/Platform';
import { TransactionEventBroadcaster } from '../events/TransactionEventBroadcaster';
import { IsolationLevel } from '../enums';
Expand Down Expand Up @@ -139,7 +139,7 @@ export interface ConnectionConfig {
host?: string;
port?: number;
user?: string;
password?: string;
password?: string | (() => MaybePromise<string> | MaybePromise<DynamicPassword>);
database?: string;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/typings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,3 +517,5 @@ export interface Seeder {

export abstract class PlainObject {
}

export type MaybePromise<T> = T | Promise<T>;
9 changes: 7 additions & 2 deletions packages/core/src/utils/Configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { inspect } from 'util';
import { NamingStrategy } from '../naming-strategy';
import { CacheAdapter, FileCacheAdapter, NullCacheAdapter } from '../cache';
import { EntityRepository } from '../entity';
import { AnyEntity, Constructor, Dictionary, EntityClass, EntityClassGroup, FilterDef, Highlighter, HydratorConstructor, IHydrator, IPrimaryKey, MigrationObject } from '../typings';
import { AnyEntity, Constructor, Dictionary, EntityClass, EntityClassGroup, FilterDef, Highlighter, HydratorConstructor, IHydrator, IPrimaryKey, MaybePromise, MigrationObject } from '../typings';
import { ObjectHydrator } from '../hydration';
import { NullHighlighter } from '../utils/NullHighlighter';
import { Logger, LoggerNamespace } from '../utils/Logger';
Expand Down Expand Up @@ -287,6 +287,11 @@ export class Configuration<D extends IDatabaseDriver = IDatabaseDriver> {

}

export interface DynamicPassword {
password: string;
expirationChecker?: () => boolean;
}

export interface ConnectionOptions {
dbName?: string;
schema?: string;
Expand All @@ -295,7 +300,7 @@ export interface ConnectionOptions {
host?: string;
port?: number;
user?: string;
password?: string;
password?: string | (() => MaybePromise<string> | MaybePromise<DynamicPassword>);
charset?: string;
collate?: string;
multipleStatements?: boolean; // for mysql driver
Expand Down
24 changes: 23 additions & 1 deletion packages/knex/src/AbstractSqlConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,33 @@ export abstract class AbstractSqlConnection extends Connection {
}

protected getKnexOptions(type: string): Knex.Config {
return Utils.merge({
const config = Utils.merge({
client: type,
connection: this.getConnectionOptions(),
pool: this.config.get('pool'),
}, this.config.get('driverOptions'));
const options = config.connection as ConnectionOptions;
const password = options.password;

if (!(password instanceof Function)) {
return config;
}

config.connection = async () => {
const pw = await password();

if (typeof pw === 'string') {
return { ...options, password: pw };
}

return {
...options,
password: pw.password,
expirationChecker: pw.expirationChecker,
};
};

return config;
}

private getSql(query: string, formatted: string): string {
Expand Down
2 changes: 1 addition & 1 deletion packages/mysql-base/src/MySqlConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export class MySqlConnection extends AbstractSqlConnection {
}

getConnectionOptions(): Knex.MySqlConnectionConfig {
const ret: Knex.MySqlConnectionConfig = super.getConnectionOptions();
const ret = super.getConnectionOptions() as Knex.MySqlConnectionConfig;

if (this.config.get('multipleStatements')) {
ret.multipleStatements = this.config.get('multipleStatements');
Expand Down
2 changes: 1 addition & 1 deletion packages/postgresql/src/PostgreSqlConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export class PostgreSqlConnection extends AbstractSqlConnection {
}

getConnectionOptions(): Knex.PgConnectionConfig {
const ret: Knex.PgConnectionConfig = super.getConnectionOptions();
const ret = super.getConnectionOptions() as Knex.PgConnectionConfig;
[1082].forEach(oid => types.setTypeParser(oid, str => str)); // date type

if (this.config.get('forceUtcTimezone')) {
Expand Down
137 changes: 101 additions & 36 deletions tests/MikroORM.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
const knex = jest.fn();
const raw = jest.fn();
const destroy = jest.fn();
knex.mockReturnValue({
on: jest.fn(() => ({ raw, destroy })),
});
jest.mock('knex', () => ({ knex }));

(global as any).process.env.FORCE_COLOR = 0;

import { Configuration, EntityManager, MikroORM, NullCacheAdapter } from '@mikro-orm/core';
Expand Down Expand Up @@ -39,19 +47,6 @@ describe('MikroORM', () => {
await expect(MikroORM.init({ type: 'mongo', dbName: 'test', baseDir: BASE_DIR, entities: ['entities-1', 'entities-2'] })).rejects.toThrowError('Duplicate entity names are not allowed: Dup1, Dup2');
});

test('should report connection failure', async () => {
const logger = jest.fn();
await MikroORM.init({
dbName: 'not-found',
baseDir: BASE_DIR,
type: 'mysql',
entities: [Car2, CarOwner2, User2, Sandwich],
debug: ['info'],
logger,
});
expect(logger.mock.calls[0][0]).toEqual('[info] MikroORM failed to connect to database not-found on mysql://root@127.0.0.1:3306');
});

test('should throw when only abstract entities were discovered', async () => {
const err = 'Only abstract entities were discovered, maybe you forgot to use @Entity() decorator?';
await expect(MikroORM.init({ type: 'mongo', dbName: 'test', baseDir: BASE_DIR, entities: [BaseEntity2] })).rejects.toThrowError(err);
Expand All @@ -73,29 +68,6 @@ describe('MikroORM', () => {
await orm.close();
});

test('orm.close() calls CacheAdapter.close()', async () => {
let closed = 0;

class Adapter extends NullCacheAdapter {

async close() {
closed++;
}

}

const orm = await MikroORM.init({
type: 'sqlite',
dbName: ':memory:',
entities: [Car2, CarOwner2, User2, Sandwich],
cache: { adapter: Adapter, enabled: true },
resultCache: { adapter: Adapter },
}, true);
expect(closed).toBe(0);
await orm.close();
expect(closed).toBe(2);
});

test('should use CLI config', async () => {
const options = {
entities: [Test],
Expand Down Expand Up @@ -147,4 +119,97 @@ describe('MikroORM', () => {
expect(Object.keys(orm.getMetadata().getAll()).sort()).toEqual(['Author4', 'Book4', 'BookTag4', 'FooBar4', 'FooBaz4', 'Publisher4', 'Test4', 'User4', 'publisher4_tests', 'tags_ordered', 'tags_unordered']);
});

test('should work with dynamic passwords/tokens', async () => {
const options = {
entities: [Test],
type: 'postgresql' as const,
dbName: 'mikro-orm-test',
};

await MikroORM.init({
...options,
password: () => 'pass1',
});
await expect(knex.mock.calls[0][0].connection()).resolves.toEqual({
host: '127.0.0.1',
port: 5432,
user: 'postgres',
password: 'pass1',
database: 'mikro-orm-test',
});

await MikroORM.init({
...options,
password: async () => 'pass2',
});
await expect(knex.mock.calls[1][0].connection()).resolves.toEqual({
host: '127.0.0.1',
port: 5432,
user: 'postgres',
password: 'pass2',
database: 'mikro-orm-test',
});

await MikroORM.init({
...options,
password: async () => ({ password: 'pass3' }),
});
await expect(knex.mock.calls[2][0].connection()).resolves.toEqual({
host: '127.0.0.1',
port: 5432,
user: 'postgres',
password: 'pass3',
database: 'mikro-orm-test',
});

await MikroORM.init({
...options,
password: async () => ({ password: 'pass4', expirationChecker: () => true }),
});
await expect(knex.mock.calls[3][0].connection()).resolves.toMatchObject({
host: '127.0.0.1',
port: 5432,
user: 'postgres',
password: 'pass4',
database: 'mikro-orm-test',
});
});

test('should report connection failure', async () => {
const logger = jest.fn();
raw.mockImplementationOnce(() => { throw new Error(); });
await MikroORM.init({
dbName: 'not-found',
baseDir: BASE_DIR,
type: 'mysql',
entities: [Car2, CarOwner2, User2, Sandwich],
debug: ['info'],
logger,
});
expect(logger.mock.calls[0][0]).toEqual('[info] MikroORM failed to connect to database not-found on mysql://root@127.0.0.1:3306');
});

test('orm.close() calls CacheAdapter.close()', async () => {
let closed = 0;

class Adapter extends NullCacheAdapter {

async close() {
closed++;
}

}

const orm = await MikroORM.init({
type: 'sqlite',
dbName: ':memory:',
entities: [Car2, CarOwner2, User2, Sandwich],
cache: { adapter: Adapter, enabled: true },
resultCache: { adapter: Adapter },
}, true);
expect(closed).toBe(0);
await orm.close();
expect(closed).toBe(2);
});

});

0 comments on commit 4499838

Please sign in to comment.