Compare commits

...
16 Commits
Author SHA1 Message Date
arthur simas cc3b34d87e Merge pull request #119 from dadosfera/fix/error-handling
FIX: added exception filter to catch unhandled errors
2022-07-07 20:28:44 -03:00
Arthur Simas 88bc7fe625 FIX: added exception filter to catch unhandled errors 2022-07-07 20:24:27 -03:00
Gabriel Amorim 137f129e13 Merge pull request #118 from dadosfera/conditional-apm-import
FIX: Conditional apm import
2022-07-07 20:22:55 -03:00
Gabriel Rosa f49c5ac1c1 FIX: conditionally importing apm 2022-07-07 20:04:42 -03:00
Gabriel Rosa d288a4f357 FIX: conditionally importing apm 2022-07-07 18:18:53 -03:00
Gabriel Rosa 9f009309cb FIX: conditionally importing apm 2022-07-07 17:51:28 -03:00
arthur simas 366987399e Merge pull request #117 from dadosfera/refactor/protospack-v2
FEAT: protospack-v2 updated
2022-07-07 16:05:00 -03:00
Arthur Simas 4c49dbc0ea FEAT: protospack-v2 updated 2022-07-07 15:00:59 -03:00
arthur simas 59ffb24985 Merge pull request #116 from dadosfera/refactor/auth-module
FEAT: auth module refactored
2022-07-06 17:47:54 -03:00
Arthur Simas 1cd191e970 FEAT: auth module refactored 2022-07-06 17:20:00 -03:00
arthur simas 7e92ca2c36 Merge pull request #113 from dadosfera/refactor/auth
auth unit tests (+ readme updated)
2022-07-06 14:10:13 -03:00
Arthur Simas 12af510d36 DOCS: readme improved 2022-07-06 11:55:19 -03:00
Arthur Simas 61871958aa DOCS: readme improved 2022-07-06 11:55:19 -03:00
Arthur Simas 64d84f9148 FEAT: refactor authentication and user decorators + unit tests 2022-07-06 11:55:19 -03:00
Arthur Simas 2c099ba491 CI: removing npm token on build 2022-07-06 11:55:19 -03:00
Arthur Simas 65b2578f15 FIX: removing useless tests 2022-07-06 11:55:19 -03:00
31 changed files with 1160 additions and 312 deletions
+1
View File
@@ -6,6 +6,7 @@ module.exports = {
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
+1 -5
View File
@@ -35,9 +35,7 @@ jobs:
semantic_release:
runs-on: ubuntu-latest
outputs:
#new_release_published: ${{ steps.semantic.outputs.last_release_version != steps.semantic.outputs.new_release_version }}
new_release_published: ${{ steps.semantic.outputs.new_release_published }}
#new_release_version: ${{ steps.semantic.outputs.new_release_version }}
new_release_version: ${{ (steps.semantic.outputs.new_release_published == 'true' && steps.semantic.outputs.new_release_version) || (github.event_name == 'workflow_dispatch' && '0.0.0') }}
steps:
- name: Checkout
@@ -57,7 +55,6 @@ jobs:
]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
deploy:
if: ${{ github.event_name == 'workflow_dispatch' || needs.semantic_release.outputs.new_release_published == 'true' }}
@@ -107,9 +104,8 @@ jobs:
ENV: ${{ needs.extract_environment.outputs.environment }}
IMAGE_TAG: ${{ needs.semantic_release.outputs.new_release_version }}
ACCOUNT_ID: ${{ steps.aws.outputs.aws-account-id }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
docker-compose -f build.docker-compose.yml build --build-arg NPM_TOKEN=${NPM_TOKEN}
docker-compose -f build.docker-compose.yml build
docker-compose -f build.docker-compose.yml push
- name: Create ZIP file to Deploy AWS Beanstalk
+3 -6
View File
@@ -2,8 +2,6 @@ name: Test
on:
pull_request:
branches:
- alpha
- beta
- main
jobs:
@@ -21,23 +19,22 @@ jobs:
env:
ENV: test
IMAGE_TAG: test
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
export ENV=test
export IMAGE_TAG=test
export ACCOUNT_ID=test
docker-compose -f build.docker-compose.yml build --build-arg NPM_TOKEN=${NPM_TOKEN}
docker-compose -f build.docker-compose.yml build
- name: Run Test
env:
ENV: test
IMAGE_TAG: test
ACCOUNT_ID: test
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
APP_NAME: ${{ github.event.repository.name }}
run: |
export ENV=test
export IMAGE_TAG=test
docker-compose -f build.docker-compose.yml run -e NPM_TOKEN=${NPM_TOKEN} --rm --entrypoint="npm run test" maestro
docker-compose -f build.docker-compose.yml run --rm --entrypoint="npm run test" $APP_NAME
- name: Remove Docker's Trash
if: always()
-1
View File
@@ -1 +0,0 @@
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
-1
View File
@@ -26,7 +26,6 @@
"preset": "eslint"
}
],
"@semantic-release/npm",
"@semantic-release/github"
]
}
+6 -13
View File
@@ -1,23 +1,16 @@
FROM node:18.3.0-alpine3.15 as packages
WORKDIR /packages
ARG NPM_TOKEN
COPY package.json .
COPY package-lock.json .
COPY .npmrc .
RUN apk update \
&& apk add --no-cache aws-cli \
&& aws codeartifact login --tool npm --repository dadosfera-npm --domain dadosfera --domain-owner 611330257153 --region us-east-1 \
&& npm install
RUN apk update && apk add curl unzip
RUN apk add --no-cache aws-cli
RUN aws codeartifact login --tool npm --repository dadosfera-npm --domain dadosfera --domain-owner 611330257153 --region us-east-1
RUN npm install
RUN rm -f ./.npmrc
FROM node:14.15.4-alpine3.12
FROM node:18.3.0-alpine3.15
WORKDIR /app
COPY . /app/
ARG NPM_TOKEN
COPY --from=packages /packages/node_modules /app/node_modules
RUN npm run build
EXPOSE 3333
ENTRYPOINT npm run start
ENTRYPOINT npm run start
+282 -31
View File
@@ -1,50 +1,301 @@
# Maestro
<p align="center">
<image src="./assets/maestro.svg" style="width:10rem">
<h1 align="center">Maestro</h1>
</p>
This is the Dadosfera´s gateway repository, it´s responsable for the communication between frontend application and Dadosfera´s mirosservices.
## 💻 Requirements
# Maestro
Maestro is the Dadosfera's gateway, it's responsible for the communication between the frontend application and Dadosfera's microservices.
Before you start, make sure you have done the following steps:
## 🚀 Starting
These instructions will allow you to get a working copy of the project on your local machine for development and testing purposes.
* Installed Nodejs version 16.14.2
* Installed latest NPM version
### 📋 Requirements
- [NodeJS v18.3.0 LTS / NPM v8.11](https://nodejs.org/pt-br/download/) (you can opt to use [NVM](https://github.com/nvm-sh/nvm) to easily manage node versions)
- Request access to AWS Console dev account for **all services** (avoid gradually asking for each needed service. it will slow down your development cycle)
- Create your Access Key on the "Security credentials" menu
- Set the Access Key on your local development machine
- Request access to the dev, stg and prd VPNs
### 🔧 Installation<a id="installation"></a>
- Clone the repository
- SSH
```
git clone git@github.com:dadosfera/maestro.git
```
or
- HTTPS
```
git clone https://github.com/dadosfera/maestro.git
```
## 🚀 Installing Maestro
- Select the correct node version (optional, only if using [NVM](https://github.com/nvm-sh/nvm)):
```sh
nvm use
```
First of all clone the repository:
* SSH:
```
git clone git@github.com:dadosfera/maestro.git
```
* HTTPS:
```
git clone git@github.com:dadosfera/maestro.git
- Install the project dependencies:
```sh
npm i
```
- Setup the following enviroment variables:
```
ENV=
DUC_URL=
INFACTORY_URL=
OTFACTORY_URL=
PIFACTORY_URL=
SM_OAUTH_PATH=
```
- Start the server:
```sh
# dev mode
npm run start:dev
# or in debug mode
npm run start:debug
```
The service should start successfully.
## Authentication decorators
Maestro have utilities to ease the user authentication on every controller and route. The following decorators are available:
### `@Authenticated`
If the user must be authenticated to make request, we can use the `@Authenticated` decorator in the controller or route, as needed.
```ts
import { Authenticated } from '../../authentication/authentication.decorator';
@Controller('foo')
@Authenticated()
class FooController {
/* ... */
@Get('bar')
async getBar() {
this.logger.info('user is authenticated!');
return { authenticated: true };
}
}
```
## Enviroment variables
```ts
import { Authenticated } from '../../authentication/authentication.decorator';
Here is a list of enviroment variables needed in order to run the application correctly.
@Controller('foo')
class FooController {
/* ... */
```
ENV=
DUC_URL=
INFACTORY_URL=
TRFACTORY_URL=
OTFACTORY_URL=
PIFACTORY_URL=
JWT_PRIVATE_KEY=
AWS_IDENTITY_POOL_ID=
@Get('bar')
@Authenticated()
async getBar() {
this.logger.info('user is authenticated!');
return { authenticated: true };
}
@Post('bar')
async postBar() {
this.logger.info('user is NOT authenticated!');
return { authenticated: false };
}
}
```
## Running Maestro
### `@RequireAllPermissions`
This decorator requires that **all permissions** listed are granted to the requesting user.
In order to run Maestro just run the following command:
```ts
import { RequireAllPermissions } from '../../authentication/authentication.decorator';
import { Permissions } from '../../authentication/permissions.enum';
@Controller('foo')
class FooController {
/* ... */
@Get('bar')
@RequireAllPermissions(Permissions.BAR.MANAGE, Permissions.BAR.CREATE)
async getBar() {
/* ... */
}
}
```
npm run start:dev
### `@RequireSomePermission`
In the following case, the user is required to have **at least one** listed permission.
```ts
import { RequireSomePermission } from '../../authentication/authentication.decorator';
import { Permissions } from '../../authentication/permissions.enum';
@Controller('foo')
@RequireSomePermission(Permissions.FOO.MANAGE, Permissions.FOO.CREATE)
class FooController {
/* ... */
@Get('bar')
async getBar() {
/* ... */
}
}
```
If everything is fine the Maestro will start and be ready to receive HTTP requests
### `@AuthenticateCondition`
If a more complicated authentication check needs to be done, we can use the `@AuthenticateCondition` decorator to define it. The custom function must return `true` to authenticate the request.
In the following example:
- all routes on the `FooController` controller can only be requested from localhost
- `POST /foo/bar` can only be requested from localhost **and** by users from customer id `111...eef`
```ts
import { AuthenticateCondition } from '../../authentication/authentication.decorator';
@Controller('foo')
// allow requests only from localhost
@AuthenticateCondition((request: Request) => request.ip === '::ffff:127.0.0.1')
class FooController {
/* ... */
@Get('bar')
async getBar() {
/* ... */
}
@Post('bar')
// allow requests only from a specific customer
@AuthenticateCondition((request: Request, user: RequestUser) => user.customer_id === '1113e943-2187-4fdd-9c2c-54338fedaeef')
async postBar() {
/* ... */
}
}
```
### Note on authentication decorators
- The old authentication method placed the user data in the `request.body.info` field, this imposes certain issues regarding the request body because this data should be from the frontend without any modification by Maestro. Now this usage is ⚠️ **DEPRECATED** ⚠️. We are working to migrate to the `@User` parameter decorator. The old method is working while the migration is in progress.
- Authentication decorators can be used together and all of them **must** pass to the request be authenticated, but in the general case you don't need to (*and wouldn't like to...*) use all of them together, as you can code all of the authentication logic in the `@AuthenticateCondition` decorator.
```ts
import {
RequireAllPermissions,
RequireSomePermission,
AuthenticateCondition,
} from '../../authentication/authentication.decorator';
import { Permissions } from '../../authentication/permissions.enum';
import { Waa, Baz } from './authenticationFunctions'
@Controller('foo')
@RequireAllPermissions(Permissions.FOO.USE, Permissions.FOO.REQUEST)
@RequireSomePermission(Permissions.FOO.MANAGE, Permissions.FOO.ADMIN)
@AuthenticateCondition(Waa)
@AuthenticateCondition(Baz)
class FooController {
/* ... */
@Get('bar')
async getBar() {
/* ... */
}
@Post('bar')
@RequireAllPermissions(Permissions.BAR.MANAGE, Permissions.BAR.USE, Permissions.BAR.REQUEST)
async postBar() {
/* ... */
}
}
```
- If `@RequireAllPermissions` and `@RequireSomePermission` are used with **only a single permission**, they present the **exactly same behavior**.
```ts
// same behavior
@RequireAllPermissions(Permissions.BAR.MANAGE)
@RequireSomePermission(Permissions.BAR.MANAGE)
```
## `@User` parameter decorator
The requesting user data can be obtained using the @User parameter decorator, like in the following snippet:
```ts
import { User, RequestUser } from '../../authentication/user.decorator';
@Controller('foo')
class FooController {
/* ... */
@Get('bar')
async getBar(@User() user: RequestUser) {
this.logger.info(user);
return { user };
}
}
```
If the user is required to be logged in, set `required` to `true`, as you would want in the `change-password` operation:
```ts
import { User, RequestUser } from '../../authentication/user.decorator';
@Controller('user')
class UserController {
/* ... */
@Post('change-password')
@HttpCode(HttpStatus.OK)
async changePassword(
@User({ required: true }) user: RequestUser,
@Body() body: AuthChangePasswordRequest,
) {
const { oldPassword, newPassword } = body;
return this.authClient.changePassword({
userId: user.user_id,
oldPassword,
newPassword,
});
}
}
```
## 📦 Development
### ⌨️ Coding Style
By default, we use [ESLint](https://eslint.org/) + [Prettier](https://prettier.io/) with default settings.
**We recommend using Visual Studio Code and installing the recommended extensions to ease the development process.**
### Commits pattern
Our workflow pipeline follows the conventional commits specs ([cheat sheets](https://cheatography.com/albelop/cheat-sheets/conventional-commits/)) to release versions accordingly.
Format: `<type>[optional scope]: <description>`
Example: `FIX: ensure Range headers adhere more closely to RFC 2616`
### Branching naming convention
- **Feature**: Any code changes for a new module or use case should be done on a feature branch. This branch is created based on the `main` branch. When all changes are done, a Pull Request/Merge Request is needed to put all of these changes back to the `main` branch. Examples: `feature/integrate-swagger`, `feature/JIRA-1234`, `feature/JIRA-1234_support-dark-theme`.
**It is recommended to use all lower caps letters and hyphen (-) to separate words unless it is a specific item name or ID. Underscore (_) could be used to separate the ID and description.**
- **Bug Fix**: If the code changes made from the feature branch were rejected after a release, sprint or demo, any necessary fixes after that should be done on the bugfix branch. Examples: `bugfix/more-gray-shades`, `bugfix/JIRA-1444_gray-on-blur-fix`.
- **Hot Fix**: If there is a need to fix a blocker, do a temporary patch, apply a critical framework or configuration change that should be handled immediately, it should be created as a Hotfix. Examples: `hotfix/disable-endpoint-zero-day-exploit`, `hotfix/increase-scaling-threshold`.
- **Experimental**: A branch for playing around. Any new feature or idea that is not part of a release or a sprint. Example: `experimental/dark-theme-support`.
### Making a Pull Request
1. Commit your changes
2. Open the Pull Request on GitHub
3. Send Pull Request link in microsfera Google Chat Group for review and possible approval
## 🛠️ Built with
Some technologies used in this project:
- [NestJS](https://docs.nestjs.com) - Framework for building efficient and scalable NodeJS server-side applications
## ⚙️ Back-end Architecture
The architecture can be found at [this link](https://sites.google.com/dadosfera.ai/wikidoproduto/time/back-end).
+11 -8
View File
@@ -36,7 +36,7 @@
"passport-hubspot-oauth2": "^1.0.3",
"passport-mailchimp": "^1.1.0",
"protospack": "2.5.1",
"protospack-v2": "1.1.1",
"protospack-v2": "3.0.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.5.5",
@@ -8997,9 +8997,9 @@
}
},
"node_modules/protospack-v2": {
"version": "1.1.1",
"resolved": "https://dadosfera-611330257153.d.codeartifact.us-east-1.amazonaws.com:443/npm/dadosfera-npm/protospack-v2/-/protospack-v2-1.1.1.tgz",
"integrity": "sha512-BqIJSORaqauAT9kF4/cP/uJNCyoT3KxkVkamd7SWARMxnywFB/mpYJg/VDGcyvVOAU4IOLyjnXkrvRuylE0Q2w==",
"version": "3.0.0",
"resolved": "https://dadosfera-611330257153.d.codeartifact.us-east-1.amazonaws.com:443/npm/dadosfera-npm/protospack-v2/-/protospack-v2-3.0.0.tgz",
"integrity": "sha512-GEFbf8+vVaq/lA28K4nFf7bOSlghQJn/aeVnrYzh4YJUaa1+ccNz3uQs9p5OGbfjt2u/fFE5rHGEBUtueAA8KA==",
"dependencies": {
"rxjs": "^7.5.5",
"ts-proto": "^1.112.2"
@@ -9868,8 +9868,9 @@
},
"node_modules/supertest": {
"version": "6.2.3",
"resolved": "https://dadosfera-611330257153.d.codeartifact.us-east-1.amazonaws.com:443/npm/dadosfera-npm/supertest/-/supertest-6.2.3.tgz",
"integrity": "sha512-3GSdMYTMItzsSYjnIcljxMVZKPW1J9kYHZY+7yLfD0wpPwww97GeImZC1oOk0S5+wYl2niJwuFusBJqwLqYM3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"methods": "^1.1.2",
"superagent": "^7.1.3"
@@ -17197,9 +17198,9 @@
}
},
"protospack-v2": {
"version": "1.1.1",
"resolved": "https://dadosfera-611330257153.d.codeartifact.us-east-1.amazonaws.com:443/npm/dadosfera-npm/protospack-v2/-/protospack-v2-1.1.1.tgz",
"integrity": "sha512-BqIJSORaqauAT9kF4/cP/uJNCyoT3KxkVkamd7SWARMxnywFB/mpYJg/VDGcyvVOAU4IOLyjnXkrvRuylE0Q2w==",
"version": "3.0.0",
"resolved": "https://dadosfera-611330257153.d.codeartifact.us-east-1.amazonaws.com:443/npm/dadosfera-npm/protospack-v2/-/protospack-v2-3.0.0.tgz",
"integrity": "sha512-GEFbf8+vVaq/lA28K4nFf7bOSlghQJn/aeVnrYzh4YJUaa1+ccNz3uQs9p5OGbfjt2u/fFE5rHGEBUtueAA8KA==",
"requires": {
"rxjs": "^7.5.5",
"ts-proto": "^1.112.2"
@@ -17790,6 +17791,8 @@
},
"supertest": {
"version": "6.2.3",
"resolved": "https://dadosfera-611330257153.d.codeartifact.us-east-1.amazonaws.com:443/npm/dadosfera-npm/supertest/-/supertest-6.2.3.tgz",
"integrity": "sha512-3GSdMYTMItzsSYjnIcljxMVZKPW1J9kYHZY+7yLfD0wpPwww97GeImZC1oOk0S5+wYl2niJwuFusBJqwLqYM3g==",
"dev": true,
"requires": {
"methods": "^1.1.2",
+4 -4
View File
@@ -36,13 +36,11 @@
"@nestjs/platform-express": "^8.4.7",
"@nestjs/schedule": "^1.1.0",
"@nestjs/swagger": "^5.2.1",
"protospack": "2.5.1",
"protospack-v2": "1.1.1",
"axios": "^0.25.0",
"cron-parser": "^4.4.0",
"dadosfera-logs": "^1.0.0-alpha.10",
"elastic-apm-node": "^3.36.0",
"dotenv": "^14.3.2",
"elastic-apm-node": "^3.36.0",
"helmet": "^5.1.0",
"jsonwebtoken": "^8.5.1",
"jwk-to-pem": "^2.0.5",
@@ -52,6 +50,8 @@
"passport-google-oauth20": "^2.0.0",
"passport-hubspot-oauth2": "^1.0.3",
"passport-mailchimp": "^1.1.0",
"protospack": "2.5.1",
"protospack-v2": "3.0.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.5.5",
@@ -107,4 +107,4 @@
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
}
+4 -12
View File
@@ -7,7 +7,6 @@ import { InputsController } from './modules/inputs/inputs.controller';
import { TransformationsController } from './modules/transformations/transformations.controller';
import { OutputsController } from './modules/outputs/outputs.controllers';
import { PipelinesController } from './modules/pipelines/pipelines.controller';
import { AuthController } from './modules/auth/auth.controller';
import { HealthController } from './modules/health/health.controller';
import { InputsService } from './modules/inputs/inputs.service';
@@ -16,16 +15,15 @@ import { OutputsService } from './modules/outputs/outputs.service';
import { PipelinesService } from './modules/pipelines/pipelines.service';
import { HealthService } from './modules/health/health.service';
import { AuthClientService } from './clients/auth/client.service';
import { AuthModule } from './modules/auth/auth.module';
import { InputsClientService } from './clients/inputs/client.service';
import { TransformationsClientService } from './clients/transformations/client.service';
import { OutputsClientService } from './clients/outputs/client.service';
import { PipelinesClientService } from './clients/pipelines/client.service';
import { PermissionsClientService } from './clients/permissions/client.service';
import { PermissionsModule } from './modules/permissions/permissions.module';
import { OutputsClientConfiguration } from './clients/outputs/client.config';
import { TransformationsClientConfiguration } from './clients/transformations/client.config';
import { DucClient } from './clients/duc/client.config';
import { InputsClientConfiguration } from './clients/inputs/client.config';
import { PipelinesClientConfiguration } from './clients/pipelines/client.config';
import { CatalogController } from './modules/catalog/catalog.controller';
@@ -43,7 +41,6 @@ import { ConnectorClientConfiguration } from './clients/connector/client.config'
import { ConnectorController } from './modules/connector/connector.controller';
import { ConnectorClientService } from './clients/connector/client.service';
const ducClient = new DucClient();
const inputClient = new InputsClientConfiguration();
const outputClient = new OutputsClientConfiguration();
const pipelineClient = new PipelinesClientConfiguration();
@@ -56,7 +53,6 @@ const connectorClient = new ConnectorClientConfiguration();
TransformationsController,
OutputsController,
PipelinesController,
AuthController,
HealthController,
CatalogController,
OauthController,
@@ -75,8 +71,6 @@ const connectorClient = new ConnectorClientConfiguration();
TransformationsClientService,
OutputsClientService,
PipelinesClientService,
AuthClientService,
PermissionsClientService,
CatalogService,
HubspotStrategy,
FacebookStrategy,
@@ -92,6 +86,8 @@ const connectorClient = new ConnectorClientConfiguration();
ConfigModule.forRoot({
isGlobal: true,
}),
AuthModule,
PermissionsModule,
ClientsModule.register([
{
@@ -106,10 +102,6 @@ const connectorClient = new ConnectorClientConfiguration();
name: 'OUTPUTS_PACKAGE',
...outputClient.config(),
},
{
name: 'DUC_PACKAGE',
...ducClient.config(),
},
{
name: 'PIPELINES_PACKAGE',
...pipelineClient.config(),
+38 -14
View File
@@ -1,28 +1,52 @@
import { SetMetadata, applyDecorators } from '@nestjs/common';
import { Request } from 'express';
import { CustomDecorator } from '@nestjs/common';
import { Permission } from './permissions.enum';
import { RequestUser } from './user.decorator';
export const PERMISSIONS_KEY = '__PERMISSIONS__';
export const MUST_BE_AUTHENTICATED_KEY = '__MUST_BE_AUTHENTICATED__';
export const CUSTOM_AUTHENTICATION_FUNCTION_KEY =
'__CUSTOM_AUTHENTICATION_FUNCTION__';
export const AUTH_FUNCTION_KEY = '__AUTH_FUNCTION__';
export type AuthenticationFunction = (req: Request, user: any) => boolean;
export function RequirePermissions(...permissions: Permission[]) {
return applyDecorators(
SetMetadata(PERMISSIONS_KEY, permissions),
SetMetadata(MUST_BE_AUTHENTICATED_KEY, true),
// implementation copied from SetMetadata, but tweaked to get existing values
// of the metadataKey and accumulate it with the new metadataValue
function SetMultipleMetadata(metadataKey, metadataValue): CustomDecorator {
const decoratorFactory = (target, key, descriptor) => {
// .start: tweak
// descriptor?.value = function decorator; target = class decorator
const accumulatedVal =
Reflect.getMetadata(metadataKey, descriptor?.value ?? target) ?? [];
accumulatedVal.push(metadataValue);
// .end: tweak
if (descriptor) {
Reflect.defineMetadata(metadataKey, accumulatedVal, descriptor.value);
return descriptor;
}
Reflect.defineMetadata(metadataKey, accumulatedVal, target);
return target;
};
decoratorFactory.KEY = metadataKey;
return decoratorFactory as any;
}
export function RequireAllPermissions(...permissions: Permission[]) {
return SetMultipleMetadata(AUTH_FUNCTION_KEY, (req, user: RequestUser) =>
permissions.every(({ seqid }) => user.permissions.includes(seqid)),
);
}
export function RequireSomePermission(...permissions: Permission[]) {
return SetMultipleMetadata(AUTH_FUNCTION_KEY, (req, user: RequestUser) =>
permissions.some(({ seqid }) => user.permissions.includes(seqid)),
);
}
export function AuthenticateCondition(func: AuthenticationFunction) {
return applyDecorators(
SetMetadata(CUSTOM_AUTHENTICATION_FUNCTION_KEY, func),
SetMetadata(MUST_BE_AUTHENTICATED_KEY, true),
);
return SetMultipleMetadata(AUTH_FUNCTION_KEY, func);
}
export function Authenticated() {
return SetMetadata(MUST_BE_AUTHENTICATED_KEY, true);
return SetMultipleMetadata(AUTH_FUNCTION_KEY, () => true);
}
@@ -0,0 +1,447 @@
import request from 'supertest';
import { Test } from '@nestjs/testing';
import { HttpStatus, INestApplication } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import jwt from 'jsonwebtoken';
import { Body, Controller, Get } from '@nestjs/common';
import { DadosferaLogger } from 'dadosfera-logs';
import {
AuthenticateCondition,
Authenticated,
RequireAllPermissions,
RequireSomePermission,
} from './authentication.decorator';
import { AuthenticationGuard } from './authentication.guard';
import { Permissions } from './permissions.enum';
import { AuthClientService } from '../modules/auth/auth.service';
import ErrorCodes from '../utils/errorCodes';
@Controller('no-class-auth')
class NoClassAuthController {
@Get('body')
async getBody(@Body() body) {
return { body };
}
@Get('authenticated')
@Authenticated()
async mustBeAuthenticated(@Body() body) {
return { body };
}
@Get('required-permission')
@RequireSomePermission(Permissions.ZENDESK.OPEN)
async requiredPermission(@Body() body) {
return { body };
}
@Get('has-all-permissions')
@RequireAllPermissions(Permissions.ZENDESK.OPEN, Permissions.METABASE.OPEN)
async hasAllPermissions(@Body() body) {
return { body };
}
@Get('has-some-permission')
@RequireSomePermission(Permissions.ZENDESK.OPEN, Permissions.METABASE.OPEN)
async hasSomePermission(@Body() body) {
return { body };
}
}
@Controller('class-auth-condition')
@AuthenticateCondition((req) => req.get('x-on-class') === 'ok')
@RequireSomePermission(Permissions.METABASE.OPEN)
class ClassAuthConditionController {
@Get('body')
async getBody(@Body() body) {
return { body };
}
@Get('authenticated')
@Authenticated()
async mustBeAuthenticated(@Body() body) {
return { body };
}
@Get('required-permission')
@AuthenticateCondition((req) => req.get('x-on-route') === 'ok')
@RequireSomePermission(Permissions.ZENDESK.OPEN)
async requiredPermission(@Body() body) {
return { body };
}
}
describe('authentication.guard', () => {
let app: INestApplication;
const jwtSecretA = {
kid: 'token-a-testing-shared-key',
pem: '$tr0ng-SH4Red-secr3t!!!~gl0ba1~]',
};
const jwtSecretB = {
kid: 'token-b',
pem: 'another-shared-token',
};
const fakeUserPayload = {
user_id: 'd50d33c7-6c2b-463c-861f-e21667e7c125',
username: 'super.admin',
customer_id: '9d18e8ae-24b9-41a3-9e8f-a25ce57555b11',
customer_name: 'dadosfera',
customer_tier: 'BASIC',
};
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
{
provide: AuthClientService,
useValue: {
getPublicKeys: async () => ({
keys: [jwtSecretA, jwtSecretB],
}),
},
},
{
provide: DadosferaLogger,
useValue: { logger: console },
},
{
provide: APP_GUARD,
useClass: AuthenticationGuard,
},
],
controllers: [NoClassAuthController, ClassAuthConditionController],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
function AssertBodyNoAuth(res: request.Response) {
expect(res.statusCode).toBe(HttpStatus.OK);
expect(res.body).toStrictEqual({ body: {} });
}
function AssertBodyWithAuth(res: request.Response) {
expect(res.statusCode).toBe(HttpStatus.OK);
expect(res.body).toStrictEqual({
body: {
info: {
customer: 'dadosfera',
customer_id: '9d18e8ae-24b9-41a3-9e8f-a25ce57555b11',
customer_tier: 'BASIC',
user_id: 'd50d33c7-6c2b-463c-861f-e21667e7c125',
},
},
});
}
function AssertUnauthorized(res: request.Response) {
expect(res.statusCode).toBe(HttpStatus.UNAUTHORIZED);
expect(res.body).toStrictEqual({
code: ErrorCodes.AUTH.UNAUTHORIZED,
error: 'Não autenticado',
message: 'É necessário estar logado para realizar essa operação',
statusCode: HttpStatus.UNAUTHORIZED,
});
}
function AssertForbidden(res: request.Response) {
expect(res.statusCode).toBe(HttpStatus.FORBIDDEN);
expect(res.body).toStrictEqual({
code: ErrorCodes.AUTH.FORBIDDEN,
error: 'Não autorizado',
message:
'Você não tem permissões suficientes para realizar essa operação',
statusCode: HttpStatus.FORBIDDEN,
});
}
function NoClassAuthTest(accessToken: string, tokenName: string[]) {
const route = '/no-class-auth';
describe(`${route}, ${tokenName?.length ? tokenName : 'none'} auth`, () => {
it('should GET /body with user data (if available)', async () => {
const res = await request(app.getHttpServer())
.get(`${route}/body`)
.set({ Authorization: accessToken });
if (tokenName?.length) {
AssertBodyWithAuth(res);
} else {
AssertBodyNoAuth(res);
}
});
it('should GET /authenticated if authenticated', async () => {
const res = await request(app.getHttpServer())
.get(`${route}/authenticated`)
.set({ Authorization: accessToken });
if (tokenName?.length) {
AssertBodyWithAuth(res);
} else {
AssertUnauthorized(res);
}
});
it('should GET /required-permission if authenticated', async () => {
const res = await request(app.getHttpServer())
.get(`${route}/required-permission`)
.set({ Authorization: accessToken });
// zendesk required
if (tokenName?.includes('zendesk')) {
AssertBodyWithAuth(res);
} else if (tokenName?.length) {
AssertForbidden(res);
} else {
AssertUnauthorized(res);
}
});
it('should GET /has-all-permissions if authenticated', async () => {
const res = await request(app.getHttpServer())
.get(`${route}/has-all-permissions`)
.set({ Authorization: accessToken });
// zendesk and metabase
if (tokenName?.includes('zendesk') && tokenName?.includes('metabase')) {
AssertBodyWithAuth(res);
} else if (tokenName?.length) {
AssertForbidden(res);
} else {
AssertUnauthorized(res);
}
});
it('should GET /has-some-permission if authenticated', async () => {
const res = await request(app.getHttpServer())
.get(`${route}/has-some-permission`)
.set({ Authorization: accessToken });
// zendesk or metabase
if (tokenName?.includes('zendesk') || tokenName?.includes('metabase')) {
AssertBodyWithAuth(res);
} else if (tokenName?.length) {
AssertForbidden(res);
} else {
AssertUnauthorized(res);
}
});
});
}
function ClassAuthConditionTest(accessToken: string, tokenName: string[]) {
const route = '/class-auth-condition';
describe(`${route}, ${tokenName?.length ? tokenName : 'none'} auth`, () => {
describe('GET /body', () => {
it('should GET /body if authenticated and with correct headers', async () => {
const res = await request(app.getHttpServer())
.get(`${route}/body`)
.set({
'x-on-class': 'ok',
Authorization: accessToken,
});
// metabase required
if (tokenName?.includes('metabase')) {
AssertBodyWithAuth(res);
} else if (tokenName?.length) {
AssertForbidden(res);
} else {
AssertUnauthorized(res);
}
});
it('should not GET /body without correct headers', async () => {
const res = await request(app.getHttpServer())
.get(`${route}/body`)
.set({ Authorization: accessToken });
if (tokenName?.length) {
AssertForbidden(res);
} else {
AssertUnauthorized(res);
}
});
});
describe('GET /authenticated', () => {
it('should GET /authenticated if authenticated and with correct headers', async () => {
const res = await request(app.getHttpServer())
.get(`${route}/authenticated`)
.set({
'x-on-class': 'ok',
Authorization: accessToken,
});
// metabase required
if (tokenName?.includes('metabase')) {
AssertBodyWithAuth(res);
} else if (tokenName?.length) {
AssertForbidden(res);
} else {
AssertUnauthorized(res);
}
});
it('should not GET /authenticated without correct class headers', async () => {
const res = await request(app.getHttpServer())
.get(`${route}/authenticated`)
.set({ Authorization: accessToken });
if (tokenName?.length) {
AssertForbidden(res);
} else {
AssertUnauthorized(res);
}
});
});
describe('GET /required-permission', () => {
it('should GET /required-permission if authenticated and with correct headers', async () => {
const res = await request(app.getHttpServer())
.get(`${route}/required-permission`)
.set({
'x-on-class': 'ok',
'x-on-route': 'ok',
Authorization: accessToken,
});
// zendesk and metabase
if (
tokenName?.includes('zendesk') &&
tokenName?.includes('metabase')
) {
AssertBodyWithAuth(res);
} else if (tokenName?.length) {
AssertForbidden(res);
} else {
AssertUnauthorized(res);
}
});
it('should not GET /required-permission without correct route headers', async () => {
const res = await request(app.getHttpServer())
.get(`${route}/required-permission`)
.set({
'x-on-class': 'ok',
Authorization: accessToken,
});
if (tokenName?.length) {
AssertForbidden(res);
} else {
AssertUnauthorized(res);
}
});
it('should not GET /required-permission without correct class headers', async () => {
const res = await request(app.getHttpServer())
.get(`${route}/required-permission`)
.set({
'x-on-route': 'ok',
Authorization: accessToken,
});
if (tokenName?.length) {
AssertForbidden(res);
} else {
AssertUnauthorized(res);
}
});
});
});
}
describe('token validation', () => {
it('should accept token kid A', async () => {
const Authorization = CreateToken([], {
jwt: {
kid: jwtSecretA.kid,
pem: jwtSecretA.pem,
},
});
const res = await request(app.getHttpServer())
.get('/no-class-auth/authenticated')
.set({
Authorization,
});
AssertBodyWithAuth(res);
});
it('should accept token kid B', async () => {
const Authorization = CreateToken([], {
jwt: {
kid: jwtSecretB.kid,
pem: jwtSecretB.pem,
},
});
const res = await request(app.getHttpServer())
.get('/no-class-auth/authenticated')
.set({
Authorization,
});
AssertBodyWithAuth(res);
});
it('should not accept unknown kid', async () => {
const Authorization = CreateToken([], {
jwt: {
kid: 'unknown-key-id',
pem: jwtSecretA.pem,
},
});
const res = await request(app.getHttpServer())
.get('/no-class-auth/authenticated')
.set({
Authorization,
});
AssertUnauthorized(res);
});
});
function CreateToken(permissions, overrides?: { user?: any; jwt?: any }) {
const tokenPayload = {
...fakeUserPayload,
permissions: permissions.map(({ seqid }) => seqid),
token_use: 'access',
...overrides?.user,
};
return jwt.sign(tokenPayload, overrides?.jwt?.pem ?? jwtSecretA.pem, {
keyid: overrides?.jwt?.kid ?? jwtSecretA.kid,
});
}
NoClassAuthTest(null, null);
ClassAuthConditionTest(null, null);
const tokenZ = CreateToken([Permissions.ZENDESK.OPEN]);
NoClassAuthTest(tokenZ, ['zendesk']);
ClassAuthConditionTest(tokenZ, ['zendesk']);
const tokenM = CreateToken([Permissions.METABASE.OPEN]);
NoClassAuthTest(tokenM, ['metabase']);
ClassAuthConditionTest(tokenM, ['metabase']);
const tokenZM = CreateToken([
Permissions.ZENDESK.OPEN,
Permissions.METABASE.OPEN,
]);
NoClassAuthTest(tokenZM, ['zendesk', 'metabase']);
ClassAuthConditionTest(tokenZM, ['zendesk', 'metabase']);
});
+21 -51
View File
@@ -10,14 +10,12 @@ import { Reflector } from '@nestjs/core';
import assert from 'assert';
import jwt from 'jsonwebtoken';
import { DadosferaLogger } from 'dadosfera-logs';
import { AuthClientService } from '../clients/auth/client.service';
import { Permission } from './permissions.enum';
import { AuthClientService } from '../modules/auth/auth.service';
import {
AuthenticationFunction,
PERMISSIONS_KEY,
CUSTOM_AUTHENTICATION_FUNCTION_KEY,
MUST_BE_AUTHENTICATED_KEY,
AUTH_FUNCTION_KEY,
} from './authentication.decorator';
import { RequestUser } from './user.decorator';
import ErrorBuilder from '../utils/ErrorBuilder';
import ErrorCodes from '../utils/errorCodes';
@@ -43,7 +41,7 @@ export class AuthenticationGuard
return this.loadDucJWKS();
}
async loadDucJWKS() {
private async loadDucJWKS() {
const { keys } = await this.authClient.getPublicKeys();
keys.forEach((key) => {
@@ -52,57 +50,38 @@ export class AuthenticationGuard
}
canActivate(ctx: ExecutionContext): boolean {
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_KEY,
[ctx.getHandler(), ctx.getClass()],
);
const customAuthenticationFunction =
this.reflector.getAllAndOverride<AuthenticationFunction>(
CUSTOM_AUTHENTICATION_FUNCTION_KEY,
[ctx.getHandler(), ctx.getClass()],
);
const mustBeAuthenticated = this.reflector.getAllAndOverride<boolean>(
MUST_BE_AUTHENTICATED_KEY,
[ctx.getHandler(), ctx.getClass()],
);
const authFunctions = this.reflector.getAllAndMerge<
AuthenticationFunction[]
>(AUTH_FUNCTION_KEY, [ctx.getClass(), ctx.getHandler()]);
const mustBeAuthenticated = authFunctions.length > 0;
const request = ctx.switchToHttp().getRequest();
const accessToken = this.verifyToken(request, mustBeAuthenticated);
const accessToken = this.validateToken(request, mustBeAuthenticated);
if (!mustBeAuthenticated) {
// no need to be authenticated
return true;
}
if (!accessToken) {
// couldn't load valid token
if (!mustBeAuthenticated) {
// no need to be authenticated
return true;
}
throw new ErrorBuilder(ErrorCodes.AUTH.UNAUTHORIZED);
}
if (
typeof customAuthenticationFunction === 'function' &&
!customAuthenticationFunction(request, request.user)
) {
// custom authentication function forbidden this request
throw new ErrorBuilder(ErrorCodes.AUTH.FORBIDDEN);
}
if (
Array.isArray(requiredPermissions) &&
requiredPermissions.length > 0 &&
!this.matchPermissions(requiredPermissions, accessToken.permissions)
) {
// couldn't match permissions
// every authentication function must return true to authenticate
if (!authFunctions.every((func) => func(request, accessToken))) {
throw new ErrorBuilder(ErrorCodes.AUTH.FORBIDDEN);
}
return true;
}
private verifyToken(request, mustBeAuthenticated: boolean) {
private validateToken(
request,
mustBeAuthenticated: boolean,
): RequestUser | false {
const accessToken = request.get('Authorization');
let accessTokenPayload;
let accessTokenPayload: RequestUser;
// If the user isn't authenticated, an error will occurr anywhere here.
// Fancy error avoidance isn't performed by purpose, such as avoiding to access null values.
@@ -154,13 +133,4 @@ export class AuthenticationGuard
return accessTokenPayload;
}
private matchPermissions(
requiredPermissions: Permission[],
userPermissions: number[],
) {
return requiredPermissions.some((permission) =>
userPermissions.includes(permission.seqid),
);
}
}
+174
View File
@@ -0,0 +1,174 @@
import request from 'supertest';
import { Test } from '@nestjs/testing';
import { HttpStatus, INestApplication } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import jwt from 'jsonwebtoken';
import { Controller, Get } from '@nestjs/common';
import { DadosferaLogger } from 'dadosfera-logs';
import { AuthenticationGuard } from './authentication.guard';
import { AuthClientService } from '../modules/auth/auth.service';
import ErrorCodes from '../utils/errorCodes';
import { User } from './user.decorator';
import { Permissions } from './permissions.enum';
@Controller('user')
class UserController {
@Get()
public getUser(@User() user) {
return { user };
}
@Get('options')
public getUserWithOptions(@User({}) user) {
return { user };
}
@Get('not-required')
public getUserNotRequired(@User({ required: false }) user) {
return { user };
}
@Get('required')
public getUserRequired(@User({ required: true }) user) {
return { user };
}
}
describe('user.decorator', () => {
let app: INestApplication;
const jwt_secret = {
kid: 'token-testing-shared-key',
pem: '$tr0ng-SH4Red-secr3t!!!~gl0ba1~]',
};
const fakeUserPayload = {
user_id: 'd50d33c7-6c2b-463c-861f-e21667e7c125',
username: 'super.admin',
permissions: [Permissions.METABASE.OPEN].map(({ seqid }) => seqid),
customer_id: '9d18e8ae-24b9-41a3-9e8f-a25ce57555b11',
customer_name: 'dadosfera',
customer_tier: 'BASIC',
};
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
{
provide: AuthClientService,
useValue: {
getPublicKeys: async () => ({
keys: [jwt_secret],
}),
},
},
{
provide: DadosferaLogger,
useValue: { logger: console },
},
{
provide: APP_GUARD,
useClass: AuthenticationGuard,
},
],
controllers: [UserController],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
function AssertNoAuth(res: request.Response) {
expect(res.statusCode).toBe(HttpStatus.OK);
expect(res.body).toStrictEqual({});
}
function AssertWithAuth(res: request.Response) {
expect(res.statusCode).toBe(HttpStatus.OK);
expect(res.body).toStrictEqual({ user: fakeUserPayload });
}
function AssertRequiredNoAuth(res: request.Response) {
expect(res.statusCode).toBe(HttpStatus.UNAUTHORIZED);
expect(res.body).toStrictEqual({
code: ErrorCodes.AUTH.UNAUTHORIZED,
error: 'Não autenticado',
message: 'É necessário estar logado para realizar essa operação',
statusCode: HttpStatus.UNAUTHORIZED,
});
}
function UserTest(accessToken: string) {
describe(`with${accessToken ? '' : 'out'} token`, () => {
it('should GET /user', async () => {
const res = await request(app.getHttpServer())
.get('/user')
.set({ Authorization: accessToken });
if (accessToken) {
AssertWithAuth(res);
} else {
AssertNoAuth(res);
}
});
it('should GET /user/options', async () => {
const res = await request(app.getHttpServer())
.get('/user/options')
.set({ Authorization: accessToken });
if (accessToken) {
AssertWithAuth(res);
} else {
AssertNoAuth(res);
}
});
it('should GET /user/not-required', async () => {
const res = await request(app.getHttpServer())
.get('/user/not-required')
.set({ Authorization: accessToken });
if (accessToken) {
AssertWithAuth(res);
} else {
AssertNoAuth(res);
}
});
it('should GET /user/required if logged', async () => {
const res = await request(app.getHttpServer())
.get('/user/required')
.set({ Authorization: accessToken });
if (accessToken) {
AssertWithAuth(res);
} else {
AssertRequiredNoAuth(res);
}
});
});
}
function CreateToken(overrides?) {
const tokenPayload = {
...fakeUserPayload,
token_use: 'access',
...overrides?.user,
};
return jwt.sign(tokenPayload, jwt_secret.pem, {
keyid: jwt_secret.kid,
...overrides?.jwt,
});
}
UserTest(null);
const token = CreateToken();
UserTest(token);
});
+9 -8
View File
@@ -6,18 +6,19 @@ import ErrorCodes from '../utils/errorCodes';
export interface RequestUser {
user_id: string;
username: string;
permissions: string;
permissions: number[];
customer_id: string;
customer_name: string;
customer_tier: string;
}
export const User = createParamDecorator((data: any, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
export const User: (options?: { required?: boolean }) => ParameterDecorator =
createParamDecorator((options: any, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
if (!request.user && data?.required) {
throw new ErrorBuilder(ErrorCodes.AUTH.UNAUTHORIZED);
}
if (!request.user && options?.required) {
throw new ErrorBuilder(ErrorCodes.AUTH.UNAUTHORIZED);
}
return request.user;
});
return request.user;
});
-39
View File
@@ -1,39 +0,0 @@
import { OnModuleInit, Inject } from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices';
import { ProtoServices } from 'protospack-v2/dist/lib/Duc';
import { PermissionsProtoService as PermissionsServiceInterface } from 'protospack-v2/dist/lib/Duc/interfaces/write-service';
import {
Empty,
InjectPermissionsRequest,
} from 'protospack-v2/dist/lib/Duc/interfaces/messages';
import { DadosferaLogger } from 'dadosfera-logs';
import grpcHandler from '../../utils/grpcHandler';
export class PermissionsClientService implements OnModuleInit {
logger: any;
private permissionsService: PermissionsServiceInterface;
constructor(
@Inject(DadosferaLogger)
dadosferaLogger: DadosferaLogger,
@Inject('DUC_PACKAGE') private readonly grpcClient: ClientGrpc,
) {
this.logger = dadosferaLogger.logger;
}
onModuleInit() {
this.permissionsService =
this.grpcClient.getService<PermissionsServiceInterface>(
ProtoServices.PermissionsProtoService,
);
}
async injectPermissions({ permissions }: InjectPermissionsRequest) {
this.logger.info('InjectPermissions');
return grpcHandler<Empty>(
this.permissionsService.InjectPermissions({ permissions }),
);
}
}
@@ -0,0 +1,22 @@
import { ExceptionFilter, Catch, ArgumentsHost } from '@nestjs/common';
import { Response } from 'express';
import ErrorBuilder from '../utils/ErrorBuilder';
@Catch(Error)
export class GrpcToHttpExceptionFilter implements ExceptionFilter {
catch(exception: any, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
// return this exception directly if is already of type ErrorBuilder,
// else transform it using the ErrorBuilder
const err =
exception.constructor.name === ErrorBuilder.name
? exception
: new ErrorBuilder(exception.details);
const { statusCode } = err.response;
return response.status(statusCode).json(err.response);
}
}
-7
View File
@@ -1,7 +0,0 @@
import { HttpExceptionFilter } from './http-exception.filter';
describe('HttpExceptionFilter', () => {
it('should be defined', () => {
expect(new HttpExceptionFilter()).toBeDefined();
});
});
+1 -4
View File
@@ -1,20 +1,17 @@
import 'elastic-apm-node/start';
if (process.env.ENV !== 'local') require('elastic-apm-node/start');
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { writeFileSync } from 'fs';
import helmet from 'helmet';
import { DadosferaLogger } from 'dadosfera-logs';
import documentEmpty from '../swagger_empty.json';
import { AppModule } from './app.module';
async function bootstrap() {
DadosferaLogger.setupLogger({
serviceName: 'maestro',
serviceEnvironment: process.env.ENV,
});
const logger = new DadosferaLogger().logger;
const orginalWinstonLog = logger.log.bind(logger);
+5 -31
View File
@@ -5,8 +5,8 @@ import {
Post,
HttpCode,
HttpStatus,
OnApplicationBootstrap,
Inject,
UseFilters,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
@@ -21,50 +21,24 @@ import {
AuthVerifyTotpMfaRequest,
} from 'protospack-v2/dist/lib/Duc/interfaces/messages';
import { AuthClientService } from '../../clients/auth/client.service';
import { PermissionsClientService } from '../../clients/permissions/client.service';
import { Permissions } from '../../authentication/permissions.enum';
import { AuthClientService } from './auth.service';
import { DadosferaLogger } from 'dadosfera-logs';
import ErrorBuilder from '../../utils/ErrorBuilder';
import { GrpcToHttpExceptionFilter } from '../../error/grpc-to-http-exception.filter';
@ApiTags('Auth')
@UseFilters(new GrpcToHttpExceptionFilter())
@Controller('auth')
export class AuthController implements OnApplicationBootstrap {
export class AuthController {
logger: any;
constructor(
@Inject(DadosferaLogger)
dadosferaLogger: DadosferaLogger,
private authClient: AuthClientService,
private permissionsClient: PermissionsClientService,
) {
this.logger = dadosferaLogger.logger;
}
// internally used to send permissions to duc on microservice startup
async onApplicationBootstrap() {
this.logger.info('sending permissions to DUC...');
const permissions = Object.values(Permissions).flatMap((namespace) =>
Object.values(namespace),
);
return this.permissionsClient
.injectPermissions({ permissions })
.catch((err: ErrorBuilder) => {
if (
err.code === 'No connection established' &&
process.env.LOCAL_ENV === 'true'
) {
return this.logger.info(
"couldn't connect to DUC. suppresing in local env",
);
}
throw err;
});
}
@Post('sign-in')
@HttpCode(HttpStatus.OK)
async signIn(@Body() { username, password, totp }: AuthSignInRequest) {
+24
View File
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { ClientsModule } from '@nestjs/microservices';
import { DadosferaLogger } from 'dadosfera-logs';
import { AuthController } from './auth.controller';
import { AuthClientService } from './auth.service';
import { DucClient } from '../../clients/duc/client.config';
const ducClient = new DucClient();
@Module({
imports: [
ClientsModule.register([
{
name: 'DUC_PACKAGE',
...ducClient.config(),
},
]),
],
controllers: [AuthController],
providers: [AuthClientService, DadosferaLogger],
exports: [AuthClientService],
})
export class AuthModule {}
@@ -1,35 +1,24 @@
import { OnModuleInit, Inject } from '@nestjs/common';
import { OnModuleInit, Inject, Injectable } from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices';
import { DadosferaLogger } from 'dadosfera-logs';
import { lastValueFrom } from 'rxjs';
import { ProtoServices } from 'protospack-v2/dist/lib/Duc';
import { AuthProtoService as AuthServiceInterface } from 'protospack-v2/dist/lib/Duc/interfaces/write-service';
import {
AuthGetPublicKeysResponse,
AuthSignInRequest,
AuthSignInResponse,
AuthRefreshAccessTokenRequest,
AuthRefreshAccessTokenResponse,
AuthEnableTotpMfaRequest,
AuthEnableTotpMfaResponse,
AuthDisableTotpMfaRequest,
AuthDisableTotpMfaResponse,
AuthDismissTotpMfaRequest,
AuthDismissTotpMfaResponse,
AuthVerifyTotpMfaRequest,
AuthVerifyTotpMfaResponse,
AuthChangePasswordRequest,
AuthChangePasswordResponse,
AuthResetPasswordRequest,
AuthResetPasswordResponse,
AuthVerifyResetPasswordCodeRequest,
AuthVerifyResetPasswordCodeResponse,
AuthConfirmResetPasswordRequest,
AuthConfirmResetPasswordResponse,
} from 'protospack-v2/dist/lib/Duc/interfaces/messages';
import grpcHandler from '../../utils/grpcHandler';
@Injectable()
export class AuthClientService implements OnModuleInit {
logger: any;
@@ -51,15 +40,13 @@ export class AuthClientService implements OnModuleInit {
async getPublicKeys() {
this.logger.info('GetPublicKeys');
return grpcHandler<AuthGetPublicKeysResponse>(
this.authService.AuthGetPublicKeys({}),
);
return lastValueFrom(this.authService.AuthGetPublicKeys({}));
}
async signIn({ username, password, totp }: AuthSignInRequest) {
this.logger.info('SignIn');
return grpcHandler<AuthSignInResponse>(
return lastValueFrom(
this.authService.AuthSignIn({ username, password, totp }),
);
}
@@ -67,7 +54,7 @@ export class AuthClientService implements OnModuleInit {
async refreshAccessToken({ refreshToken }: AuthRefreshAccessTokenRequest) {
this.logger.info('RefreshAccessToken');
return grpcHandler<AuthRefreshAccessTokenResponse>(
return lastValueFrom(
this.authService.AuthRefreshAccessToken({ refreshToken }),
);
}
@@ -79,7 +66,7 @@ export class AuthClientService implements OnModuleInit {
}: AuthChangePasswordRequest) {
this.logger.info('ChangePassword');
return grpcHandler<AuthChangePasswordResponse>(
return lastValueFrom(
this.authService.AuthChangePassword({
accessToken,
oldPassword,
@@ -91,9 +78,7 @@ export class AuthClientService implements OnModuleInit {
async resetPassword({ username }: AuthResetPasswordRequest) {
this.logger.info('resetPassword');
return grpcHandler<AuthResetPasswordResponse>(
this.authService.AuthResetPassword({ username }),
);
return lastValueFrom(this.authService.AuthResetPassword({ username }));
}
async verifyResetPasswordCode({
@@ -102,7 +87,7 @@ export class AuthClientService implements OnModuleInit {
}: AuthVerifyResetPasswordCodeRequest) {
this.logger.info('verifyResetPasswordCode');
return grpcHandler<AuthVerifyResetPasswordCodeResponse>(
return lastValueFrom(
this.authService.AuthVerifyResetPasswordCode({ username, code }),
);
}
@@ -114,7 +99,7 @@ export class AuthClientService implements OnModuleInit {
}: AuthConfirmResetPasswordRequest) {
this.logger.info('confirmResetPassword');
return grpcHandler<AuthConfirmResetPasswordResponse>(
return lastValueFrom(
this.authService.AuthConfirmResetPassword({
username,
code,
@@ -126,7 +111,7 @@ export class AuthClientService implements OnModuleInit {
async enableTotpMFA({ accessToken, password }: AuthEnableTotpMfaRequest) {
this.logger.info('enableTotpMFA');
return grpcHandler<AuthEnableTotpMfaResponse>(
return lastValueFrom(
this.authService.AuthEnableTotpMfa({ accessToken, password }),
);
}
@@ -134,7 +119,7 @@ export class AuthClientService implements OnModuleInit {
async disableTotpMFA({ accessToken, password }: AuthDisableTotpMfaRequest) {
this.logger.info('disableTotpMFA');
return grpcHandler<AuthDisableTotpMfaResponse>(
return lastValueFrom(
this.authService.AuthDisableTotpMfa({ accessToken, password }),
);
}
@@ -142,15 +127,13 @@ export class AuthClientService implements OnModuleInit {
async dismissTotpMFA({ accessToken }: AuthDismissTotpMfaRequest) {
this.logger.info('dismissTotpMFA');
return grpcHandler<AuthDismissTotpMfaResponse>(
this.authService.AuthDismissTotpMfa({ accessToken }),
);
return lastValueFrom(this.authService.AuthDismissTotpMfa({ accessToken }));
}
async verifyTotp({ accessToken, totp }: AuthVerifyTotpMfaRequest) {
this.logger.info('disableTotpMFA');
return grpcHandler<AuthVerifyTotpMfaResponse>(
return lastValueFrom(
this.authService.AuthVerifyTotpMfa({ accessToken, totp }),
);
}
@@ -1,5 +0,0 @@
describe('PipelinesGrpcServerService', () => {
it('should be defined', () => {
expect(2 + 2).toBe(4);
});
});
@@ -1,5 +0,0 @@
describe('PipelinesGrpcServerService', () => {
it('should be defined', () => {
expect(2 + 2).toBe(4);
});
});
@@ -1,5 +0,0 @@
describe('PipelinesGrpcServerService', () => {
it('should be defined', () => {
expect(2 + 2).toBe(4);
});
});
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { ClientsModule } from '@nestjs/microservices';
import { DadosferaLogger } from 'dadosfera-logs';
import { PermissionsClientService } from './permissions.service';
import { DucClient } from '../../clients/duc/client.config';
const ducClient = new DucClient();
@Module({
imports: [
ClientsModule.register([
{
name: 'DUC_PACKAGE',
...ducClient.config(),
},
]),
],
providers: [PermissionsClientService, DadosferaLogger],
})
export class PermissionsModule {}
@@ -0,0 +1,70 @@
import {
OnApplicationBootstrap,
OnModuleInit,
Inject,
Injectable,
} from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices';
import { lastValueFrom } from 'rxjs';
import { ProtoServices } from 'protospack-v2/dist/lib/Duc';
import { PermissionsProtoService as PermissionsServiceInterface } from 'protospack-v2/dist/lib/Duc/interfaces/write-service';
import { InjectPermissionsRequest } from 'protospack-v2/dist/lib/Duc/interfaces/messages';
import { DadosferaLogger } from 'dadosfera-logs';
import { Permissions } from '../../authentication/permissions.enum';
import ErrorBuilder from '../../utils/ErrorBuilder';
@Injectable()
export class PermissionsClientService
implements OnModuleInit, OnApplicationBootstrap
{
logger: any;
private permissionsService: PermissionsServiceInterface;
constructor(
@Inject(DadosferaLogger)
dadosferaLogger: DadosferaLogger,
@Inject('DUC_PACKAGE') private readonly grpcClient: ClientGrpc,
) {
this.logger = dadosferaLogger.logger;
}
onModuleInit() {
this.permissionsService =
this.grpcClient.getService<PermissionsServiceInterface>(
ProtoServices.PermissionsProtoService,
);
}
// internally used to send permissions to duc on microservice startup
async onApplicationBootstrap() {
this.logger.info('sending permissions to DUC...');
const permissions = Object.values(Permissions).flatMap((namespace) =>
Object.values(namespace),
);
return this.injectPermissions({
permissions,
}).catch((err: ErrorBuilder) => {
if (
err.code === 'No connection established' &&
process.env.LOCAL_ENV === 'true'
) {
return this.logger.info(
"couldn't connect to DUC. suppresing in local env",
);
}
throw err;
});
}
async injectPermissions({ permissions }: InjectPermissionsRequest) {
this.logger.info('InjectPermissions');
return lastValueFrom(
this.permissionsService.InjectPermissions({ permissions }),
);
}
}
@@ -1,5 +0,0 @@
describe('PipelinesGrpcServerService', () => {
it('should be defined', () => {
expect(2 + 2).toBe(4);
});
});
@@ -1,5 +0,0 @@
describe('PipelinesGrpcServerService', () => {
it('should be defined', () => {
expect(2 + 2).toBe(4);
});
});
+2 -2
View File
@@ -3,7 +3,7 @@ import { RpcException } from '@nestjs/microservices';
import ErrorCodes from './errorCodes';
function enrichErrorCode(code: string) {
export function EnrichErrorCode(code: string) {
switch (code) {
case ErrorCodes.AUTH.WRONG_CREDENTIALS:
return {
@@ -138,7 +138,7 @@ export default class ErrorBuilder extends HttpException {
logger.log(code);
}
const { statusCode, message, error, code: rCode } = enrichErrorCode(code);
const { statusCode, message, error, code: rCode } = EnrichErrorCode(code);
super({ statusCode, message, error, code: rCode }, statusCode);
this.code = code;
}
-19
View File
@@ -1,19 +0,0 @@
import { Logger } from '@nestjs/common';
import { RpcException } from '@nestjs/microservices';
import { from } from 'rxjs';
import ErrorBuilder from './ErrorBuilder';
const logger = new Logger();
export default async function grpcHandler<T>(method: Promise<T>) {
return new Promise<T>((resolve, reject) => {
from(method).subscribe({
next: resolve,
error: reject,
complete: () => logger.log('done'),
});
}).catch((err: RpcException) => {
throw new ErrorBuilder(err);
});
}