diff --git a/.github/workflows/deploy-manually.yml b/.github/workflows/deploy-manually.yml
index dea0b1d..cd277dd 100644
--- a/.github/workflows/deploy-manually.yml
+++ b/.github/workflows/deploy-manually.yml
@@ -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
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 6d51f77..8f71d05 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -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()
diff --git a/.npmrc b/.npmrc
deleted file mode 100644
index bd3327a..0000000
--- a/.npmrc
+++ /dev/null
@@ -1 +0,0 @@
-//registry.npmjs.org/:_authToken=${NPM_TOKEN}
\ No newline at end of file
diff --git a/.releaserc.json b/.releaserc.json
index 4f6d6b7..e526302 100644
--- a/.releaserc.json
+++ b/.releaserc.json
@@ -26,7 +26,6 @@
"preset": "eslint"
}
],
- "@semantic-release/npm",
"@semantic-release/github"
]
}
diff --git a/Dockerfile b/Dockerfile
index 0c77cb1..ba242b6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
\ No newline at end of file
+ENTRYPOINT npm run start
diff --git a/README.md b/README.md
index 890cecc..12972d2 100644
--- a/README.md
+++ b/README.md
@@ -1,50 +1,301 @@
-# Maestro
- Maestro
-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
+- 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
\ No newline at end of file
+
+### `@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: `[optional scope]: `
+
+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).
diff --git a/package-lock.json b/package-lock.json
index 2073a97..e149c54 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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"
@@ -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",
diff --git a/package.json b/package.json
index 78c597c..a8030ea 100644
--- a/package.json
+++ b/package.json
@@ -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": "1.1.1",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.5.5",
@@ -107,4 +107,4 @@
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
-}
\ No newline at end of file
+}
diff --git a/src/authentication/authentication.decorator.ts b/src/authentication/authentication.decorator.ts
index e840f54..6c86f13 100644
--- a/src/authentication/authentication.decorator.ts
+++ b/src/authentication/authentication.decorator.ts
@@ -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);
}
diff --git a/src/authentication/authentication.guard.spec.ts b/src/authentication/authentication.guard.spec.ts
new file mode 100644
index 0000000..347a549
--- /dev/null
+++ b/src/authentication/authentication.guard.spec.ts
@@ -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 '../clients/auth/client.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']);
+});
diff --git a/src/authentication/authentication.guard.ts b/src/authentication/authentication.guard.ts
index 6236408..399ca9f 100644
--- a/src/authentication/authentication.guard.ts
+++ b/src/authentication/authentication.guard.ts
@@ -11,13 +11,11 @@ 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 {
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(
- PERMISSIONS_KEY,
- [ctx.getHandler(), ctx.getClass()],
- );
- const customAuthenticationFunction =
- this.reflector.getAllAndOverride(
- CUSTOM_AUTHENTICATION_FUNCTION_KEY,
- [ctx.getHandler(), ctx.getClass()],
- );
- const mustBeAuthenticated = this.reflector.getAllAndOverride(
- 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),
- );
- }
}
diff --git a/src/authentication/user.decorator.spec.ts b/src/authentication/user.decorator.spec.ts
new file mode 100644
index 0000000..a8fa989
--- /dev/null
+++ b/src/authentication/user.decorator.spec.ts
@@ -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 '../clients/auth/client.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);
+});
diff --git a/src/authentication/user.decorator.ts b/src/authentication/user.decorator.ts
index d5fe984..31041ff 100644
--- a/src/authentication/user.decorator.ts
+++ b/src/authentication/user.decorator.ts
@@ -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;
+ });
diff --git a/src/error/http-exception.filter.spec.ts b/src/error/http-exception.filter.spec.ts
deleted file mode 100644
index 8f016dd..0000000
--- a/src/error/http-exception.filter.spec.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { HttpExceptionFilter } from './http-exception.filter';
-
-describe('HttpExceptionFilter', () => {
- it('should be defined', () => {
- expect(new HttpExceptionFilter()).toBeDefined();
- });
-});
diff --git a/src/modules/health/health.service.spec.ts b/src/modules/health/health.service.spec.ts
deleted file mode 100644
index 89e1958..0000000
--- a/src/modules/health/health.service.spec.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-describe('PipelinesGrpcServerService', () => {
- it('should be defined', () => {
- expect(2 + 2).toBe(4);
- });
-});
diff --git a/src/modules/inputs/inputs.service.spec.ts b/src/modules/inputs/inputs.service.spec.ts
deleted file mode 100644
index 89e1958..0000000
--- a/src/modules/inputs/inputs.service.spec.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-describe('PipelinesGrpcServerService', () => {
- it('should be defined', () => {
- expect(2 + 2).toBe(4);
- });
-});
diff --git a/src/modules/outputs/outputs.service.spec.ts b/src/modules/outputs/outputs.service.spec.ts
deleted file mode 100644
index 89e1958..0000000
--- a/src/modules/outputs/outputs.service.spec.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-describe('PipelinesGrpcServerService', () => {
- it('should be defined', () => {
- expect(2 + 2).toBe(4);
- });
-});
diff --git a/src/modules/pipelines/pipelines.service.spec.ts b/src/modules/pipelines/pipelines.service.spec.ts
deleted file mode 100644
index 89e1958..0000000
--- a/src/modules/pipelines/pipelines.service.spec.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-describe('PipelinesGrpcServerService', () => {
- it('should be defined', () => {
- expect(2 + 2).toBe(4);
- });
-});
diff --git a/src/modules/transformations/transformations.service.spec.ts b/src/modules/transformations/transformations.service.spec.ts
deleted file mode 100644
index 89e1958..0000000
--- a/src/modules/transformations/transformations.service.spec.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-describe('PipelinesGrpcServerService', () => {
- it('should be defined', () => {
- expect(2 + 2).toBe(4);
- });
-});