-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(example-multi-tenancy): add multi-tenancy action and strategies
- Loading branch information
1 parent
e486b75
commit 0c99edb
Showing
29 changed files
with
1,013 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,4 +3,190 @@ | |
An example application to demonstrate how to implement multi-tenancy with | ||
LoopBack 4. | ||
|
||
[![LoopBack](<https://github.com/strongloop/loopback-next/raw/master/docs/site/imgs/branding/Powered-by-LoopBack-Badge-(blue)[email protected]>)](http://loopback.io/) | ||
## Key artifacts | ||
|
||
### MultiTenancyStrategy | ||
|
||
This interface defines the contract for multi-tenancy strategies to implement | ||
the logic to identify a tenant and bind tenant specific resources to the request | ||
context. | ||
|
||
```ts | ||
/** | ||
* Interface for a multi-tenancy strategy to implement | ||
*/ | ||
export interface MultiTenancyStrategy { | ||
/** | ||
* Name of the strategy | ||
*/ | ||
name: string; | ||
/** | ||
* Identify the tenant for a given http request | ||
* @param requestContext - Http request | ||
*/ | ||
identifyTenant( | ||
requestContext: RequestContext, | ||
): ValueOrPromise<Tenant | undefined>; | ||
|
||
/** | ||
* Bind tenant-specific resources for downstream artifacts with dependency | ||
* injection | ||
* @param requestContext - Request context | ||
*/ | ||
bindResources( | ||
requestContext: RequestContext, | ||
tenant: Tenant, | ||
): ValueOrPromise<void>; | ||
} | ||
``` | ||
|
||
### MultiTenancyActionProvider | ||
|
||
`MultiTenancyActionProvider` serves two purposes: | ||
|
||
- Provides an action (`MultiTenancyAction`) for the REST sequence to enforce | ||
multi-tenancy | ||
- Exposes an extension point to plug in multi-tenancy strategies | ||
|
||
### Implement MultiTenancyStrategy | ||
|
||
The example includes a few simple implementations of `MultiTenancyStrategy`: | ||
|
||
#### Identify tenant id for a given http request | ||
|
||
- JWTStrategy - use JWT token from `Authorization` header | ||
- HeaderStrategy - use `x-tenant-id` header | ||
- QueryStrategy - use `tenant-id` query parameter | ||
- HostStrategy - use `host` header | ||
|
||
#### Bind tenant specific resources to the request context | ||
|
||
We simply rebind `datasources.db` to a tenant specific datasource to select the | ||
right datasource for `UserRepository`. | ||
|
||
```ts | ||
bindResources( | ||
requestContext: RequestContext, | ||
tenant: Tenant, | ||
): ValueOrPromise<void> { | ||
requestContext | ||
.bind('datasources.db') | ||
.toAlias(`datasources.db.${tenant.id}`); | ||
} | ||
``` | ||
### Register multi-tenancy strategies | ||
Multi-tenancy strategies are registered to the extension point using | ||
`extensionFor` template: | ||
```ts | ||
app.add( | ||
createBindingFromClass(JWTStrategy).apply( | ||
extensionFor(MULTI_TENANCY_STRATEGIES), | ||
), | ||
); | ||
``` | ||
We group multiple registrations in `src/multi-tenancy/component.ts` using the | ||
`MultiTenancyComponent`: | ||
```ts | ||
export class MultiTenancyComponent implements Component { | ||
bindings = [ | ||
createBindingFromClass(MultiTenancyActionProvider), | ||
...MultiTenancyComponent.createStrategyBindings(), | ||
]; | ||
|
||
static createStrategyBindings() { | ||
return [ | ||
createBindingFromClass(JWTStrategy).apply( | ||
extensionFor(MULTI_TENANCY_STRATEGIES), | ||
), | ||
createBindingFromClass(HeaderStrategy).apply( | ||
extensionFor(MULTI_TENANCY_STRATEGIES), | ||
), | ||
createBindingFromClass(QueryStrategy).apply( | ||
extensionFor(MULTI_TENANCY_STRATEGIES), | ||
), | ||
createBindingFromClass(HostStrategy).apply( | ||
extensionFor(MULTI_TENANCY_STRATEGIES), | ||
), | ||
]; | ||
} | ||
} | ||
``` | ||
### Configure what strategies to be used | ||
The `MultiTenancyBindings.STRATEGIES` binding configures what strategies are | ||
checked in order. | ||
```ts | ||
app.bind(MultiTenancyBindings.STRATEGIES).to(['jwt', 'header']); | ||
``` | ||
### Register MultiTenancyAction | ||
`MultiTenancyAction` is added to `src/sequence.ts` so that REST requests will be | ||
intercepted to enforce multiple tenancy before other actions. | ||
```ts | ||
export class MySequence implements SequenceHandler { | ||
constructor( | ||
// ... | ||
@inject(MultiTenancyBindings.ACTION) | ||
public multiTenancy: MultiTenancyAction, | ||
) {} | ||
|
||
async handle(context: RequestContext) { | ||
try { | ||
const {request, response} = context; | ||
await this.multiTenancy(context); | ||
// ... | ||
} catch (err) { | ||
this.reject(context, err); | ||
} | ||
} | ||
} | ||
``` | ||
|
||
## Use | ||
|
||
```sh | ||
npm start | ||
``` | ||
|
||
The strategies expect clients to set tenant id for REST API requests. | ||
|
||
- `jwt`: set `Authorization` header as | ||
`Authorization: Bearer <signed-jwt-token>` | ||
- `header`: set `x-tenant-id` header as `x-tenant-id: <tenant-id>` | ||
- `query`: set `tenant-id` query parameter, such as: `?tenant-id=<tenant-id>` | ||
|
||
Check out acceptance tests to understand how to pass tenant id using different | ||
strategies: | ||
|
||
- src/tests/acceptance/user.controller.header.acceptance.ts | ||
- src/tests/acceptance/user.controller.jwt.acceptance.ts | ||
|
||
You can use environment variable `DEBUG=loopback:multi-tenancy:*` to print out | ||
information about the multi-tenancy actions. | ||
|
||
## Contributions | ||
|
||
- [Guidelines](https://github.com/strongloop/loopback-next/blob/master/docs/CONTRIBUTING.md) | ||
- [Join the team](https://github.com/strongloop/loopback-next/issues/110) | ||
|
||
## Tests | ||
|
||
Run `npm test` from the root folder. | ||
|
||
## Contributors | ||
|
||
See | ||
[all contributors](https://github.com/strongloop/loopback-next/graphs/contributors). | ||
|
||
## License | ||
|
||
MIT |
53 changes: 53 additions & 0 deletions
53
examples/multi-tenancy/src/__tests__/acceptance/user.controller.header.acceptance.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
// Copyright IBM Corp. 2020. All Rights Reserved. | ||
// Node module: @loopback/example-multi-tenancy | ||
// This file is licensed under the MIT License. | ||
// License text available at https://opensource.org/licenses/MIT | ||
|
||
import {Client, expect} from '@loopback/testlab'; | ||
import {ExampleMultiTenancyApplication} from '../..'; | ||
import {MultiTenancyBindings} from '../../multi-tenancy'; | ||
import {setupApplication} from './test-helper'; | ||
|
||
describe('UserController with header-based multi-tenancy', () => { | ||
let app: ExampleMultiTenancyApplication; | ||
let client: Client; | ||
|
||
before('setupApplication', async () => { | ||
({app, client} = await setupApplication()); | ||
app.bind(MultiTenancyBindings.STRATEGIES).to(['jwt', 'header', 'query']); | ||
}); | ||
|
||
before('create users', async () => { | ||
// Tenant abc | ||
await client.post('/users').set('x-tenant-id', 'abc').send({name: 'John'}); | ||
// Tenant xyz | ||
await client.post('/users').set('x-tenant-id', 'xyz').send({name: 'Mary'}); | ||
// No tenant | ||
await client.post('/users').send({tenantId: '', name: 'Jane'}); | ||
}); | ||
|
||
after(async () => { | ||
await app.stop(); | ||
}); | ||
|
||
it('Get users by tenantId - abc', async () => { | ||
const res = await client | ||
.get('/users') | ||
.set('x-tenant-id', 'abc') | ||
.expect(200); | ||
expect(res.body).to.eql([{tenantId: 'abc', id: '1', name: 'John'}]); | ||
}); | ||
|
||
it('Get users by tenantId - xyz', async () => { | ||
const res = await client | ||
.get('/users') | ||
.set('x-tenant-id', 'xyz') | ||
.expect(200); | ||
expect(res.body).to.eql([{tenantId: 'xyz', id: '1', name: 'Mary'}]); | ||
}); | ||
|
||
it('Get users by tenantId - none', async () => { | ||
const res = await client.get('/users').expect(200); | ||
expect(res.body).to.eql([{tenantId: '', id: '1', name: 'Jane'}]); | ||
}); | ||
}); |
57 changes: 57 additions & 0 deletions
57
examples/multi-tenancy/src/__tests__/acceptance/user.controller.jwt.acceptance.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
// Copyright IBM Corp. 2020. All Rights Reserved. | ||
// Node module: @loopback/example-multi-tenancy | ||
// This file is licensed under the MIT License. | ||
// License text available at https://opensource.org/licenses/MIT | ||
|
||
import {Client, expect, supertest} from '@loopback/testlab'; | ||
import {sign} from 'jsonwebtoken'; | ||
import {ExampleMultiTenancyApplication} from '../..'; | ||
import {MultiTenancyBindings} from '../../multi-tenancy'; | ||
import {setupApplication} from './test-helper'; | ||
|
||
describe('UserController with jwt-based multi-tenancy', () => { | ||
let app: ExampleMultiTenancyApplication; | ||
let client: Client; | ||
|
||
before('setupApplication', async () => { | ||
({app, client} = await setupApplication()); | ||
app.bind(MultiTenancyBindings.STRATEGIES).to(['jwt', 'header', 'query']); | ||
}); | ||
|
||
before('create users', async () => { | ||
// Tenant abc | ||
await addJWT(client.post('/users'), 'abc').send({name: 'John'}).expect(200); | ||
// Tenant xyz | ||
await addJWT(client.post('/users'), 'xyz').send({name: 'Mary'}).expect(200); | ||
// No tenant | ||
await client.post('/users').send({name: 'Jane'}); | ||
}); | ||
|
||
after(async () => { | ||
await app.stop(); | ||
}); | ||
|
||
it('Get users by tenantId - abc', async () => { | ||
const res = await addJWT(client.get('/users'), 'abc').expect(200); | ||
expect(res.body).to.eql([{tenantId: 'abc', id: '1', name: 'John'}]); | ||
}); | ||
|
||
it('Get users by tenantId - xyz', async () => { | ||
const res = await addJWT(client.get('/users'), 'xyz').expect(200); | ||
expect(res.body).to.eql([{tenantId: 'xyz', id: '1', name: 'Mary'}]); | ||
}); | ||
|
||
it('Get users by tenantId - none', async () => { | ||
const res = await client.get('/users').expect(200); | ||
expect(res.body).to.eql([{tenantId: '', id: '1', name: 'Jane'}]); | ||
}); | ||
|
||
function addJWT(test: supertest.Test, tenantId: string) { | ||
const tenant = { | ||
tenantId, | ||
}; | ||
const jwt = sign(tenant, 'my-secret'); | ||
const token = `Bearer ${jwt}`; | ||
return test.set('authorization', token); | ||
} | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.