From 1a999c089d3da8d123fcdd334a06aa239542dc81 Mon Sep 17 00:00:00 2001 From: msftsettiy <63882775+msftsettiy@users.noreply.github.com> Date: Wed, 11 Oct 2023 09:23:58 -0700 Subject: [PATCH] Decentralized authorization app (#240) * Decentralized authorization app * Fixed the readme.md * Added role and user tables * Added role and user tables * Updated the route to check if a user is allowed an action * Fixed review comments from Amaury * Updated README.md --------- Co-authored-by: Yagnesh Setti --- decentralize-rbac-app/README.md | 48 ++++ decentralize-rbac-app/babel.config.json | 13 ++ decentralize-rbac-app/build_bundle.js | 67 ++++++ decentralize-rbac-app/jest.config.js | 57 +++++ decentralize-rbac-app/package.json | 66 ++++++ decentralize-rbac-app/rollup.config.js | 14 ++ .../certificate/member-cert-validation.ts | 67 ++++++ .../certificate/user-cert-validation.ts | 64 ++++++ .../auth/validator/jwt/demo-jwt-provider.ts | 23 ++ .../src/auth/validator/jwt/jwt-validation.ts | 44 ++++ .../auth/validator/jwt/ms-aad-jwt-provider.ts | 61 +++++ .../src/auth/validator/validation-service.ts | 11 + decentralize-rbac-app/src/endpoints/all.ts | 6 + decentralize-rbac-app/src/endpoints/app.json | 118 ++++++++++ decentralize-rbac-app/src/endpoints/authz.ts | 39 ++++ decentralize-rbac-app/src/endpoints/roles.ts | 37 ++++ decentralize-rbac-app/src/endpoints/users.ts | 31 +++ .../src/repositories/kv-repository.ts | 209 ++++++++++++++++++ .../src/services/authentication-service.ts | 85 +++++++ .../src/services/authz-service.ts | 57 +++++ .../src/services/role-service.ts | 32 +++ .../src/services/user-service.ts | 33 +++ decentralize-rbac-app/src/utils/api-result.ts | 110 +++++++++ decentralize-rbac-app/src/utils/config.ts | 39 ++++ decentralize-rbac-app/src/utils/constants.ts | 1 + .../src/utils/service-result.ts | 44 ++++ decentralize-rbac-app/tsconfig.json | 16 ++ 27 files changed, 1392 insertions(+) create mode 100755 decentralize-rbac-app/README.md create mode 100755 decentralize-rbac-app/babel.config.json create mode 100755 decentralize-rbac-app/build_bundle.js create mode 100755 decentralize-rbac-app/jest.config.js create mode 100755 decentralize-rbac-app/package.json create mode 100755 decentralize-rbac-app/rollup.config.js create mode 100644 decentralize-rbac-app/src/auth/validator/certificate/member-cert-validation.ts create mode 100644 decentralize-rbac-app/src/auth/validator/certificate/user-cert-validation.ts create mode 100644 decentralize-rbac-app/src/auth/validator/jwt/demo-jwt-provider.ts create mode 100644 decentralize-rbac-app/src/auth/validator/jwt/jwt-validation.ts create mode 100644 decentralize-rbac-app/src/auth/validator/jwt/ms-aad-jwt-provider.ts create mode 100644 decentralize-rbac-app/src/auth/validator/validation-service.ts create mode 100755 decentralize-rbac-app/src/endpoints/all.ts create mode 100755 decentralize-rbac-app/src/endpoints/app.json create mode 100644 decentralize-rbac-app/src/endpoints/authz.ts create mode 100644 decentralize-rbac-app/src/endpoints/roles.ts create mode 100644 decentralize-rbac-app/src/endpoints/users.ts create mode 100755 decentralize-rbac-app/src/repositories/kv-repository.ts create mode 100755 decentralize-rbac-app/src/services/authentication-service.ts create mode 100644 decentralize-rbac-app/src/services/authz-service.ts create mode 100644 decentralize-rbac-app/src/services/role-service.ts create mode 100644 decentralize-rbac-app/src/services/user-service.ts create mode 100755 decentralize-rbac-app/src/utils/api-result.ts create mode 100755 decentralize-rbac-app/src/utils/config.ts create mode 100755 decentralize-rbac-app/src/utils/constants.ts create mode 100755 decentralize-rbac-app/src/utils/service-result.ts create mode 100755 decentralize-rbac-app/tsconfig.json diff --git a/decentralize-rbac-app/README.md b/decentralize-rbac-app/README.md new file mode 100755 index 000000000..dcfe0f8e9 --- /dev/null +++ b/decentralize-rbac-app/README.md @@ -0,0 +1,48 @@ +# Decentralized AuthZ application + +This is the _CCF Decentralized AuthZ app - sample_ in typescript. + +## Overview + +The CCF network will be used to host a decentralized RBAC application where a consortium of members from different organizations would manage the roles, allowed action for a role and users. A user would have a specific role that would determine the allowed action. + +A service could use the decentralized RBAC application to determine if an action is allowed for a logged-in user. + +## Architecture + +The application consists of two parts: Role and User Management, Authorization. + +- Role and User Management + - API Endpoint: allow members to add a role and action allowed for a role. + - API Endpoint: allow members to add a user and their role. +- Authorization + - Check if a user exist and an action is allowed. + +### Repository Layout + +```text +📂 +└── src Application source code +| └── auth Member and User cert Authentication +│ └── endpoints Application endpoints +│ └── repositories Data repositories +│ └── services Domain services +│ └── utils utility classes + +``` + +## Getting Started + +To get started and run the application locally, start with setting up the environment. + +```bash +# setup the environment +git clone https://github.com/microsoft/ccf-app-samples # Clone the samples repository +code ccf-app-samples # open samples repository in Visual studio code + +# In the VScode terminal window +cd decentralized-authz-app # Navigate to app folder +npm run build # Build and create the application deployment bundle +``` + +## Local Deployment diff --git a/decentralize-rbac-app/babel.config.json b/decentralize-rbac-app/babel.config.json new file mode 100755 index 000000000..b05fea502 --- /dev/null +++ b/decentralize-rbac-app/babel.config.json @@ -0,0 +1,13 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "node": "current" + } + } + ], + "@babel/preset-typescript" + ] +} diff --git a/decentralize-rbac-app/build_bundle.js b/decentralize-rbac-app/build_bundle.js new file mode 100755 index 000000000..d9e3058b7 --- /dev/null +++ b/decentralize-rbac-app/build_bundle.js @@ -0,0 +1,67 @@ +import { readdirSync, statSync, readFileSync, writeFileSync } from "fs"; +import { join, posix, sep } from "path"; + +const args = process.argv.slice(2); + +const getAllFiles = function (dirPath, arrayOfFiles) { + arrayOfFiles = arrayOfFiles || []; + + const files = readdirSync(dirPath); + for (const file of files) { + const filePath = join(dirPath, file); + if (statSync(filePath).isDirectory()) { + arrayOfFiles = getAllFiles(filePath, arrayOfFiles); + } else { + arrayOfFiles.push(filePath); + } + } + + return arrayOfFiles; +}; + +const removePrefix = function (s, prefix) { + return s.substr(prefix.length).split(sep).join(posix.sep); +}; + +const rootDir = args[0]; + +const metadataPath = join(rootDir, "app.json"); +const metadata = JSON.parse(readFileSync(metadataPath, "utf-8")); + +const srcDir = join(rootDir, "src"); +const allFiles = getAllFiles(srcDir); + +// The trailing / is included so that it is trimmed in removePrefix. +// This produces "foo/bar.js" rather than "/foo/bar.js" +const toTrim = srcDir + "/"; + +const modules = allFiles.map(function (filePath) { + return { + name: removePrefix(filePath, toTrim), + module: readFileSync(filePath, "utf-8"), + }; +}); + +const bundlePath = join(args[0], "bundle.json"); +const appRegPath = join(args[0], "set_js_app.json"); +const bundle = { + metadata: metadata, + modules: modules, +}; +const app_reg = { + actions: [ + { + name: "set_js_app", + args: { + bundle: bundle, + disable_bytecode_cache: false, + }, + }, + ], +}; + +console.log( + `Writing bundle containing ${modules.length} modules to ${bundlePath}` +); +writeFileSync(bundlePath, JSON.stringify(bundle)); +writeFileSync(appRegPath, JSON.stringify(app_reg)); diff --git a/decentralize-rbac-app/jest.config.js b/decentralize-rbac-app/jest.config.js new file mode 100755 index 000000000..695ba0060 --- /dev/null +++ b/decentralize-rbac-app/jest.config.js @@ -0,0 +1,57 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + * https://jestjs.io/docs/ecmascript-modules + * https://microsoft.github.io/CCF/main/js/ccf-app/modules/polyfill.html + * To Run: Add to package.json > "scripts": {"unit-test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"} + */ + +export default { + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + collectCoverageFrom: ["./src/**/*.ts"], + + // The directory where Jest should output its coverage files + coverageDirectory: "test/coverage", + + // An array of file extensions your modules use + moduleFileExtensions: [ + "js", + "mjs", + "cjs", + "jsx", + "ts", + "tsx", + "json", + "node", + ], + + // A preset that is used as a base for Jest's configuration + // preset: undefined, + // preset: 'ts-jest', + + // A list of paths to directories that Jest should use to search for files in + roots: ["./"], + + // The test environment that will be used for testing + testEnvironment: "node", + + testMatch: ["**/test/unit-test/**/*.test.(ts|js|mjs)"], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + testPathIgnorePatterns: ["/node_modules/", "/lib/"], + + // This option allows use of a custom test runner + // testRunner: "jest-circus/runner", + + // A map from regular expressions to paths to transformers + // transform: undefined, + transform: { "^.+\\.[t|j]sx?$": "babel-jest" }, + + extensionsToTreatAsEsm: [".ts"], + + // Indicates whether each individual test should be reported during the run + verbose: true, +}; diff --git a/decentralize-rbac-app/package.json b/decentralize-rbac-app/package.json new file mode 100755 index 000000000..6ffffebd5 --- /dev/null +++ b/decentralize-rbac-app/package.json @@ -0,0 +1,66 @@ +{ + "private": true, + "scripts": { + "build": "del-cli -f dist/ && rollup --config && cp src/endpoints/app.json dist/ && node build_bundle.js dist/", + "bundle": "node build_bundle.js dist", + "unit-test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", + "create-jwt-config": "ts-node --esm ./test/utils/jwt-config-generator.ts", + "e2e-test": "ts-node --esm ./test/e2e-test/src/index.ts" + }, + "type": "module", + "engines": { + "node": ">=14" + }, + "dependencies": { + "@microsoft/ccf-app": "^4.0.7", + "axios": "^1.2.4", + "crypto-js": "^3.1.9-1", + "inquirer": "9.1.4", + "js-base64": "^3.5.2", + "jsonwebtoken": "^9.0.0", + "jsrsasign": "^10.0.4", + "jsrsasign-util": "^1.0.2", + "jwt-decode": "^3.0.0", + "lodash-es": "^4.17.15", + "node-forge": "^1.3.1", + "protobufjs": "^7.2.4" + }, + "devDependencies": { + "@babel/core": "^7.20.5", + "@babel/preset-env": "^7.20.2", + "@babel/preset-typescript": "^7.18.6", + "@jest/globals": "^29.3.1", + "@rollup/plugin-commonjs": "^17.1.0", + "@rollup/plugin-node-resolve": "^11.2.0", + "@rollup/plugin-typescript": "^8.2.0", + "@types/inquirer": "^9.0.3", + "@types/jasmine": "^4.3.0", + "@types/jest": "^29.2.4", + "@types/jsrsasign": "^8.0.7", + "@types/lodash-es": "^4.17.3", + "@types/mocha": "^10.0.0", + "@types/node": "^18.11.9", + "axios": "^1.2.2", + "babel-jest": "^29.3.1", + "del-cli": "^3.0.1", + "http-server": "^0.13.0", + "jest": "^29.3.1", + "rollup": "^2.41.0", + "ts-jest": "^29.0.3", + "ts-node": "^10.9.1", + "tslib": "^2.0.1", + "typescript": "^4.9.4", + "@typescript-eslint/eslint-plugin": "^5.48.1", + "@typescript-eslint/parser": "^5.48.1", + "eslint": "^8.28.0", + "eslint-config-prettier": "^8.5.0", + "eslint-config-standard-with-typescript": "^4.0.0", + "eslint-plugin-import": "^2.27.4", + "eslint-plugin-n": "^15.5.1", + "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-unused-imports": "^2.0.0", + "prettier": "^2.8.0", + "rimraf": "^3.0.2" + } +} diff --git a/decentralize-rbac-app/rollup.config.js b/decentralize-rbac-app/rollup.config.js new file mode 100755 index 000000000..332d8df0d --- /dev/null +++ b/decentralize-rbac-app/rollup.config.js @@ -0,0 +1,14 @@ +import { nodeResolve } from "@rollup/plugin-node-resolve"; +import commonjs from "@rollup/plugin-commonjs"; +import typescript from "@rollup/plugin-typescript"; + +export default { + input: "src/endpoints/all.ts", + output: { + dir: "dist/src", + format: "es", + preserveModules: true, + preserveModulesRoot: "src", + }, + plugins: [nodeResolve(), typescript(), commonjs()], +}; diff --git a/decentralize-rbac-app/src/auth/validator/certificate/member-cert-validation.ts b/decentralize-rbac-app/src/auth/validator/certificate/member-cert-validation.ts new file mode 100644 index 000000000..6ab22191e --- /dev/null +++ b/decentralize-rbac-app/src/auth/validator/certificate/member-cert-validation.ts @@ -0,0 +1,67 @@ +import * as ccfapp from "@microsoft/ccf-app"; +import { ccf } from "@microsoft/ccf-app/global"; +import { ServiceResult } from "../../../utils/service-result"; +import { IValidatorService } from "../validation-service"; +import { UserMemberAuthnIdentity } from "./user-cert-validation"; + +/** + * CCF member information + * https://microsoft.github.io/CCF/main/audit/builtin_maps.html#members-info + */ +enum MemberStatus{ + ACCEPTED = "Accepted", + ACTIVE = "Active" +} + +interface CCFMember { + status: MemberStatus; +} + +export class MemberCertValidator implements IValidatorService { + validate(request: ccfapp.Request): ServiceResult { + const memberCaller = request.caller as unknown as UserMemberAuthnIdentity; + const identityId = memberCaller.id; + const isValid = this.isActiveMember(identityId); + if (isValid.success && isValid.content) { + return ServiceResult.Succeeded(identityId); + } + return ServiceResult.Failed({ + errorMessage: "Error: invalid caller identity", + errorType: "AuthenticationError", + }); + } + + /** + * Checks if a member exists and active + * @see https://microsoft.github.io/CCF/main/audit/builtin_maps.html#members-info + * @param {string} memberId memberId to check if it exists and active + * @returns {ServiceResult} + */ + public isActiveMember(memberId: string): ServiceResult { + const membersCerts = ccfapp.typedKv( + "public:ccf.gov.members.certs", + ccfapp.string, + ccfapp.arrayBuffer + ); + + const isMember = membersCerts.has(memberId); + + const membersInfo = ccfapp.typedKv( + "public:ccf.gov.members.info", + ccfapp.string, + ccfapp.json() + ); + + const memberInfo = membersInfo.get(memberId); + + const isActiveMember = memberInfo && memberInfo.status === MemberStatus.ACTIVE; + + return ServiceResult.Succeeded(isActiveMember && isMember); + } +} + +/** + * Export the member cert validator + */ +const memberCertValidator: IValidatorService = new MemberCertValidator(); +export default memberCertValidator; diff --git a/decentralize-rbac-app/src/auth/validator/certificate/user-cert-validation.ts b/decentralize-rbac-app/src/auth/validator/certificate/user-cert-validation.ts new file mode 100644 index 000000000..419b88ae5 --- /dev/null +++ b/decentralize-rbac-app/src/auth/validator/certificate/user-cert-validation.ts @@ -0,0 +1,64 @@ +import * as ccfapp from "@microsoft/ccf-app"; +import { ccf } from "@microsoft/ccf-app/global"; +import { ServiceResult } from "../../../utils/service-result"; +import { IValidatorService } from "../validation-service"; +/** + * CCF user and member authentication identity + */ +export interface UserMemberAuthnIdentity extends ccfapp.AuthnIdentityCommon { + /** + * User/member ID. + */ + id: string; + /** + * User/member data object. + */ + data: any; + /** + * PEM-encoded user/member certificate. + */ + cert: string; + /** + * A string indicating which policy accepted this request, + * for use when multiple policies are listed in the endpoint + * configuration of ``app.json``. + */ + policy: string; +} + +export class UserCertValidator implements IValidatorService { + validate(request: ccfapp.Request): ServiceResult { + const userCaller = request.caller as unknown as UserMemberAuthnIdentity; + const identityId = userCaller.id; + const isValid = this.isUser(identityId); + if (isValid.success && isValid.content) { + return ServiceResult.Succeeded(identityId); + } + return ServiceResult.Failed({ + errorMessage: "Error: invalid caller identity", + errorType: "AuthenticationError", + }); + } + + /** + * Checks if a user exists + * @see https://microsoft.github.io/CCF/main/audit/builtin_maps.html#users-info + * @param {string} userId userId to check if it exists + * @returns {ServiceResult} + */ + public isUser(userId: string): ServiceResult { + const usersCerts = ccfapp.typedKv( + "public:ccf.gov.users.certs", + ccfapp.string, + ccfapp.arrayBuffer + ); + const result = usersCerts.has(userId); + return ServiceResult.Succeeded(result); + } +} + +/** + * Export the user cert validator + */ +const userCertValidator: IValidatorService = new UserCertValidator(); +export default userCertValidator; diff --git a/decentralize-rbac-app/src/auth/validator/jwt/demo-jwt-provider.ts b/decentralize-rbac-app/src/auth/validator/jwt/demo-jwt-provider.ts new file mode 100644 index 000000000..aaa5eaaea --- /dev/null +++ b/decentralize-rbac-app/src/auth/validator/jwt/demo-jwt-provider.ts @@ -0,0 +1,23 @@ +import * as ccfapp from "@microsoft/ccf-app"; +import { ServiceResult } from "../../../utils/service-result"; +import { IJwtIdentityProvider } from "./jwt-validation"; + +export class DemoJwtProvider implements IJwtIdentityProvider { + /** + * Check if caller's access token is valid + * @param {JwtAuthnIdentity} identity JwtAuthnIdentity object + * @returns {ServiceResult} + */ + public isValidJwtToken( + identity: ccfapp.JwtAuthnIdentity + ): ServiceResult { + const identityId = identity?.jwt?.payload?.sub; + return ServiceResult.Succeeded(identityId); + } +} + +/** + * Export jwt validator + */ +const demoJwtProvider: IJwtIdentityProvider = new DemoJwtProvider(); +export default demoJwtProvider; diff --git a/decentralize-rbac-app/src/auth/validator/jwt/jwt-validation.ts b/decentralize-rbac-app/src/auth/validator/jwt/jwt-validation.ts new file mode 100644 index 000000000..5ea409b76 --- /dev/null +++ b/decentralize-rbac-app/src/auth/validator/jwt/jwt-validation.ts @@ -0,0 +1,44 @@ +import * as ccfapp from "@microsoft/ccf-app"; +import { ServiceResult } from "../../../utils/service-result"; +import { IValidatorService } from "../validation-service"; +import msJwtProvider from "./ms-aad-jwt-provider"; +import testJwtProvider from "./demo-jwt-provider"; + +/** + * JWT Identity Providers + */ +export enum JwtIdentityProviderEnum { + MS_AAD = "https://login.microsoftonline.com/common/v2.0", + Demo = "https://demo", +} +type identityId = string; + +export interface IJwtIdentityProvider { + isValidJwtToken(identity: ccfapp.JwtAuthnIdentity): ServiceResult; +} + +export class JwtValidator implements IValidatorService { + private readonly identityProviders = new Map< + JwtIdentityProviderEnum, + IJwtIdentityProvider + >(); + + constructor() { + this.identityProviders.set(JwtIdentityProviderEnum.MS_AAD, msJwtProvider); + this.identityProviders.set(JwtIdentityProviderEnum.Demo, testJwtProvider); + } + + validate(request: ccfapp.Request): ServiceResult { + const jwtCaller = request.caller as unknown as ccfapp.JwtAuthnIdentity; + const provider = this.identityProviders.get( + jwtCaller.jwt.keyIssuer + ); + return provider.isValidJwtToken(jwtCaller); + } +} + +/** + * Export jwt validator + */ +const jwtValidator: IValidatorService = new JwtValidator(); +export default jwtValidator; diff --git a/decentralize-rbac-app/src/auth/validator/jwt/ms-aad-jwt-provider.ts b/decentralize-rbac-app/src/auth/validator/jwt/ms-aad-jwt-provider.ts new file mode 100644 index 000000000..ace1283be --- /dev/null +++ b/decentralize-rbac-app/src/auth/validator/jwt/ms-aad-jwt-provider.ts @@ -0,0 +1,61 @@ +import * as ccfapp from "@microsoft/ccf-app"; +import { MS_AAD_CONFIG } from "../../../utils/config"; +import { ServiceResult } from "../../../utils/service-result"; +import { IJwtIdentityProvider } from "./jwt-validation"; + +/** + * MS Access Token + */ +export interface MSAccessToken { + sub: string; + iss: string; + aud: string; + appid: string; + ver: string; +} + +export class MsJwtProvider implements IJwtIdentityProvider { + /** + * Check if caller's access token is valid + * @param {JwtAuthnIdentity} identity JwtAuthnIdentity object + * @returns {ServiceResult} + */ + public isValidJwtToken( + identity: ccfapp.JwtAuthnIdentity + ): ServiceResult { + const msClaims = identity.jwt.payload as MSAccessToken; + + // check if token has the right version + if (msClaims.ver !== "1.0") { + return ServiceResult.Failed({ + errorMessage: "Error: unsupported access token version, must be 1.0", + errorType: "AuthenticationError", + }); + } + + // check if token is for this app + if (msClaims.appid !== MS_AAD_CONFIG.ClientApplicationId) { + return ServiceResult.Failed({ + errorMessage: "Error: jwt validation failed: appid mismatch", + errorType: "AuthenticationError", + }); + } + + // check if token audience is for this app + if (msClaims.aud !== MS_AAD_CONFIG.ApiIdentifierUri) { + return ServiceResult.Failed({ + errorMessage: + "Error: jwt validation failed: aud mismatch (incorrect scope requested?)", + errorType: "AuthenticationError", + }); + } + const identityId = identity?.jwt?.payload?.sub; + return ServiceResult.Succeeded(identityId); + } +} + +/** + * Export jwt validator + */ +const msJwtProvider: IJwtIdentityProvider = new MsJwtProvider(); +export default msJwtProvider; diff --git a/decentralize-rbac-app/src/auth/validator/validation-service.ts b/decentralize-rbac-app/src/auth/validator/validation-service.ts new file mode 100644 index 000000000..894f8b31f --- /dev/null +++ b/decentralize-rbac-app/src/auth/validator/validation-service.ts @@ -0,0 +1,11 @@ +import * as ccfapp from "@microsoft/ccf-app"; +import { ServiceResult } from "../../utils/service-result"; + +/** + * Validator Service Interface + */ + +type identityId = string; +export interface IValidatorService { + validate(request: ccfapp.Request): ServiceResult; +} diff --git a/decentralize-rbac-app/src/endpoints/all.ts b/decentralize-rbac-app/src/endpoints/all.ts new file mode 100755 index 000000000..ffd7d84c2 --- /dev/null +++ b/decentralize-rbac-app/src/endpoints/all.ts @@ -0,0 +1,6 @@ +/** + * Exports all endpoints + */ +export * from "./authz"; +export * from "./roles"; +export * from "./users"; \ No newline at end of file diff --git a/decentralize-rbac-app/src/endpoints/app.json b/decentralize-rbac-app/src/endpoints/app.json new file mode 100755 index 000000000..b8a87a252 --- /dev/null +++ b/decentralize-rbac-app/src/endpoints/app.json @@ -0,0 +1,118 @@ +{ + "endpoints": { + "/{user_id}/action/{actionName}": { + "get": { + "js_module": "endpoints/authz.js", + "js_function": "authorize", + "forwarding_required": "never", + "authn_policies": [ + "user_cert" + ], + "mode": "readonly", + "openapi": { + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "allowed": { + "type": "boolean" + } + } + } + } + } + } + } + } + } + }, + "/{role}/roles/{action}": { + "put": { + "js_module": "endpoints/roles.js", + "js_function": "add_role", + "forwarding_required": "always", + "authn_policies": [ + "member_cert" + ], + "mode": "readwrite", + "openapi": { + "responses": { + "200": { + "description": "Ok" + }, + "400": { + "description": "Failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { "type": "object" }, + "error": { + "type": "object", + "properties": { + "errorMessage": { "type": "string" }, + "errorType": { "type": "string" } + } + }, + "success": { "type": "boolean" }, + "failure": { "type": "boolean" }, + "statusCode": { "type": "number" }, + "status": { "type": "string" } + } + } + } + } + } + } + } + } + }, + "/{user_id}/users/{role}": { + "put": { + "js_module": "endpoints/users.js", + "js_function": "add_user", + "forwarding_required": "always", + "authn_policies": [ + "member_cert" + ], + "mode": "readwrite", + "openapi": { + "responses": { + "200": { + "description": "Ok" + }, + "400": { + "description": "Failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { "type": "object" }, + "error": { + "type": "object", + "properties": { + "errorMessage": { "type": "string" }, + "errorType": { "type": "string" } + } + }, + "success": { "type": "boolean" }, + "failure": { "type": "boolean" }, + "statusCode": { "type": "number" }, + "status": { "type": "string" } + } + } + } + } + } + } + } + } + } + } + } \ No newline at end of file diff --git a/decentralize-rbac-app/src/endpoints/authz.ts b/decentralize-rbac-app/src/endpoints/authz.ts new file mode 100644 index 000000000..83179ef77 --- /dev/null +++ b/decentralize-rbac-app/src/endpoints/authz.ts @@ -0,0 +1,39 @@ +import * as ccfapp from "@microsoft/ccf-app"; +import { ApiResult, CCFResponse } from "../utils/api-result"; +import authenticationService from "../services/authentication-service"; +import authzService from "../services/authz-service"; +import { ServiceResult } from "../utils/service-result"; +import { Service } from "protobufjs"; + +/** + * HTTP GET Handler for checking if a user exists + * @param {ccfapp.Request} request - mTLS request with userId and CSV file for ingestion + * @returns {ServiceResult} - data has been ingested successfully + */ +export function authorize(request: ccfapp.Request): ccfapp.Response { + // check if caller has a valid identity + const isValidIdentity = authenticationService.isAuthenticated(request); + if (isValidIdentity.failure) + return ApiResult.AuthFailure(); + + const userId = request.params.user_id; + const action = request.params.actionName; + + if (!userId || !action){ + return ApiResult.Failed(ServiceResult.Failed({ + errorMessage: "userId and action are required", + errorType: "InvalidData" + }, 400)); + } + + // check if the user exist + const response = authzService.authorize(userId, action); + if (response.success) + { + return ApiResult.ActionAllowed(response); + } + else + { + return ApiResult.ActionDisallowed(response); + } +} diff --git a/decentralize-rbac-app/src/endpoints/roles.ts b/decentralize-rbac-app/src/endpoints/roles.ts new file mode 100644 index 000000000..bec926eb9 --- /dev/null +++ b/decentralize-rbac-app/src/endpoints/roles.ts @@ -0,0 +1,37 @@ +import * as ccfapp from "@microsoft/ccf-app"; +import { ApiResult, CCFResponse } from "../utils/api-result"; +import authenticationService from "../services/authentication-service"; +import roleService from "../services/role-service"; +import { ServiceResult } from "../utils/service-result"; + +/** + * HTTP GET Handler for checking if a user exists + * @param {ccfapp.Request} request - mTLS request with role and allowed action + * @returns {string} - role has been added successfully + */ +export function add_role(request: ccfapp.Request): ccfapp.Response { + // check if caller has a valid identity + const isValidIdentity = authenticationService.isAuthenticated(request); + if (isValidIdentity.failure) + return ApiResult.AuthFailure(); + + const role = request.params.role; + const action = request.params.action; + + if (!role || !action){ + return { + statusCode: 400, + }; + } + + const response = roleService.add_role(role, action); + + if (response.success){ + return ApiResult.Html("Ok"); + } else { + return ApiResult.Failed(ServiceResult.Failed({ + errorMessage: "role add failed", + errorType: "Invalid data" + })); + } +} diff --git a/decentralize-rbac-app/src/endpoints/users.ts b/decentralize-rbac-app/src/endpoints/users.ts new file mode 100644 index 000000000..494ff745c --- /dev/null +++ b/decentralize-rbac-app/src/endpoints/users.ts @@ -0,0 +1,31 @@ +import * as ccfapp from "@microsoft/ccf-app"; +import { ApiResult, CCFResponse } from "../utils/api-result"; +import authenticationService from "../services/authentication-service"; +import userService from "../services/user-service"; +import { ServiceResult } from "../utils/service-result"; + +/** + * HTTP GET Handler for checking if a user exists + * @param {ccfapp.Request} request - mTLS request with userid and role + * @returns {string} - userid has been added successfully + */ +export function add_user(request: ccfapp.Request): ccfapp.Response { + // check if caller has a valid identity + const isValidIdentity = authenticationService.isAuthenticated(request); + if (isValidIdentity.failure) + return ApiResult.AuthFailure(); + + const userId = request.params.user_id; + const role = request.params.role; + + // check if the user exist + const response = userService.add_user(userId, role); + if (response.success){ + return ApiResult.Html("Ok"); + } else { + return ApiResult.Failed(ServiceResult.Failed({ + errorMessage: "user add failed", + errorType: "Invalid data" + })); + } +} diff --git a/decentralize-rbac-app/src/repositories/kv-repository.ts b/decentralize-rbac-app/src/repositories/kv-repository.ts new file mode 100755 index 000000000..d0ec9329f --- /dev/null +++ b/decentralize-rbac-app/src/repositories/kv-repository.ts @@ -0,0 +1,209 @@ +import { ccf } from "@microsoft/ccf-app/global"; +import * as ccfapp from "@microsoft/ccf-app"; +import { ServiceResult } from "../utils/service-result"; + +/** + * Generic Key-Value implementation wrapping CCF TypedKvMap storage engine + */ +export interface IRepository { + /** + * Store {T} in CFF TypedKvMap storage by key + * @param {string} key + * @param {T} value + */ + set(key: string, value: T): ServiceResult; + + /** + * Retrive {T} in CFF TypedKvMap storage by key + * @param {string} key + * @param {T} value + */ + get(key: string): ServiceResult; + + /** + * Check if {T} exists in CFF TypedKvMap storage by key + * @param {string} key + * @param {T} value + */ + has(key: string): ServiceResult; + + /** + * Retrieve all keys in CFF TypedKvMap storage + */ + keys(): ServiceResult; + + /** + * Retrieve all values in CFF TypedKvMap storage + */ + values(): ServiceResult; + + /** + * Get size of CFF TypedKvMap storage + * @returns {ServiceResult} + */ + get size(): ServiceResult; + + /** + * Iterate through CFF TypedKvMap storage by key + * @param callback + */ + forEach(callback: (key: string, value: T) => void): ServiceResult; + + /** + * Clears CFF TypedKvMap storage + */ + clear(): ServiceResult + + /** + * Remove a key from the TypedKvMap storage + */ + delete(key: string): ServiceResult; +} + +export class KeyValueRepository implements IRepository { + private kvStore: ccfapp.TypedKvMap; + + public constructor(kvStore: ccfapp.TypedKvMap) { + this.kvStore = kvStore; + } + + public set(key: string, value: T): ServiceResult { + try { + this.kvStore.set(key, value); + return ServiceResult.Succeeded(value); + } catch (ex) { + console.log(`Exception in kvstore.set: ${ex}`); + return ServiceResult.Failed({ + errorMessage: "Error: unable to set value to the kvstore", + errorType: "KeyValueStoreError", + }); + } + } + + public get(key: string): ServiceResult { + try { + const value: any = this.kvStore.get(key); + + if (value === undefined) { + return ServiceResult.Failed({ + errorMessage: "Error: key does not exist", + errorType: "KeyValueStoreError", + }); + } + + const data = value as T; + + return ServiceResult.Succeeded(data); + } catch (ex) { + console.log(`Exception in kvstore.get: ${ex}`); + return ServiceResult.Failed({ + errorMessage: "Error: unable to read value from the kvstore", + errorType: "KeyValueStoreError", + }); + } + } + + public has(key: string): ServiceResult { + try { + return ServiceResult.Succeeded(this.kvStore.has(key)); + } catch (ex) { + console.log(`Exception in kvstore.has: ${ex}`); + return ServiceResult.Failed({ + errorMessage: "Error: unable to check if key exists in the kvstore", + errorType: "KeyValueStoreError", + }); + } + } + + public keys(): ServiceResult { + try { + const keys: string[] = []; + this.kvStore.forEach((val, key) => { + keys.push(key); + }); + return ServiceResult.Succeeded(keys); + } catch (ex) { + console.log(`Exception in kvstore.keys: ${ex}`); + return ServiceResult.Failed({ + errorMessage: "Error: unable to get kvstore all keys", + errorType: "KeyValueStoreError", + }); + } + } + + public values(): ServiceResult { + try { + const values: T[] = []; + this.kvStore.forEach((val, key) => { + values.push(val); + }); + return ServiceResult.Succeeded(values); + } catch (ex) { + console.log(`Exception in kvstore.values: ${ex}`); + return ServiceResult.Failed({ + errorMessage: "Error: unable to get kvstore all values", + errorType: "KeyValueStoreError", + }); + } + } + + public clear(): ServiceResult { + try { + return ServiceResult.Succeeded(this.kvStore.clear()); + } catch (ex) { + console.log(`Exception in kvstore.clear: ${ex}`); + return ServiceResult.Failed({ + errorMessage: "Error: unable to clear kvstore values", + errorType: "KeyValueStoreError", + }); + } + } + + public delete(key: string): ServiceResult { + try { + return ServiceResult.Succeeded(this.kvStore.delete(key)); + } catch (ex) { + console.log(`Exception in kvstore.delete: ${ex}`); + return ServiceResult.Failed({ + errorMessage: "Error: unable to remove a key", + errorType: "KeyValueStoreError", + }); + } + } + + // + public forEach(callback: (key: string, value: T) => void): ServiceResult { + try { + this.kvStore.forEach((val, key) => { + callback(key, val); + }); + + return ServiceResult.Succeeded(""); + } catch (ex) { + console.log(`Exception in kvstore.foreach: ${ex}`); + return ServiceResult.Failed({ + errorMessage: "Error: user callback function failed ", + errorType: "UnexpectedError" + }); + } + } + + public get size(): ServiceResult { + try { + return ServiceResult.Succeeded(this.kvStore.size); + } catch (ex) { + console.log(`Exception in kvstore.size: ${ex}`); + return ServiceResult.Failed({ + errorMessage: "Error: unable to get the kvstore size", + errorType: "KeyValueStoreError", + }); + } + } +} + +const kvRoleActionStore = ccfapp.typedKv("public:rbac.roles",ccfapp.string,ccfapp.string); +export const keyValueRoleActionRepository: IRepository = new KeyValueRepository(kvRoleActionStore); + +const kvUserRoleStore = ccfapp.typedKv("public:rbac.users",ccfapp.string,ccfapp.string); +export const keyValueUserRoleRepository: IRepository = new KeyValueRepository(kvUserRoleStore); + diff --git a/decentralize-rbac-app/src/services/authentication-service.ts b/decentralize-rbac-app/src/services/authentication-service.ts new file mode 100755 index 000000000..a7cd7d77c --- /dev/null +++ b/decentralize-rbac-app/src/services/authentication-service.ts @@ -0,0 +1,85 @@ +import * as ccfapp from "@microsoft/ccf-app"; +import { ServiceResult } from "../utils/service-result"; +import { IValidatorService } from "../auth/validator/validation-service"; +import jwtValidator from "../auth/validator/jwt/jwt-validation"; +import userCertValidator from "../auth/validator/certificate/user-cert-validation"; +import memberCertValidator from "../auth/validator/certificate/member-cert-validation"; + +/** + * CCF authentication policies + */ +export enum CcfAuthenticationPolicyEnum { + User_cert = "user_cert", + User_signature = "user_signature", + Member_cert = "member_cert", + Member_signature = "member_signature", + Jwt = "jwt", +} + +/** + * Authentication Service Interface + */ +type identityId = string; +export interface IAuthenticationService { + /** + * Checks if caller is an active member or a registered user or has a valid JWT token + * @param {string} identityId userId extracted from mTLS certificate + * @returns {ServiceResult} + */ + isAuthenticated(request: ccfapp.Request): ServiceResult; +} + +/** + * Authentication Service Implementation + */ +export class AuthenticationService implements IAuthenticationService { + private readonly validators = new Map< + CcfAuthenticationPolicyEnum, + IValidatorService + >(); + + constructor() { + this.validators.set(CcfAuthenticationPolicyEnum.Jwt, jwtValidator); + this.validators.set( + CcfAuthenticationPolicyEnum.User_cert, + userCertValidator + ); + this.validators.set( + CcfAuthenticationPolicyEnum.User_signature, + userCertValidator + ); + this.validators.set( + CcfAuthenticationPolicyEnum.Member_cert, + memberCertValidator + ); + this.validators.set( + CcfAuthenticationPolicyEnum.Member_signature, + memberCertValidator + ); + } + + /* + * Check if caller is a valid identity (user or member or access token) + */ + public isAuthenticated(request: ccfapp.Request): ServiceResult { + try { + const caller = request.caller as unknown as ccfapp.AuthnIdentityCommon; + const validator = this.validators.get( + caller.policy + ); + return validator.validate(request); + } catch (ex) { + return ServiceResult.Failed({ + errorMessage: "Error: invalid caller identity", + errorType: "AuthenticationError", + }); + } + } +} + +/** + * Export the authentication service + */ +const authenticationService: IAuthenticationService = + new AuthenticationService(); +export default authenticationService; diff --git a/decentralize-rbac-app/src/services/authz-service.ts b/decentralize-rbac-app/src/services/authz-service.ts new file mode 100644 index 000000000..b66ee6c6e --- /dev/null +++ b/decentralize-rbac-app/src/services/authz-service.ts @@ -0,0 +1,57 @@ +import { ServiceResult } from "../utils/service-result"; +import { keyValueUserRoleRepository, keyValueRoleActionRepository, IRepository } from "../repositories/kv-repository"; +import { StatusCode } from "../utils/api-result"; + +export interface IAuthZService { + /** + * Map and Store UserRecord in KV-Store + * @param {string} userId - UserID + */ + authorize(userId: string, action: string): ServiceResult; +} + +export class AuthZService implements IAuthZService { + constructor(private readonly userRoleKeyValueRepo: IRepository, private readonly roleActionKeyVauleRepo: IRepository) {} + + public authorize(userId: string, action: string): ServiceResult { + console.log(`Check if ${userId} is allowed action ${action}`) + + const userData = this.userRoleKeyValueRepo.get(userId); + + if (!userData.success){ + console.log(`User lookup failed. Ex:${userData.error.details}`) + return ServiceResult.Failed({ + errorMessage: "Error: user not found", + errorType: "InvalidInputData", + }); + } + + const role = userData.content.toLowerCase(); + + console.log(`Role of ${userId} is ${role}`) + + // Check if the role exist and retrieve the allowed action + const allowedAction = this.roleActionKeyVauleRepo.get(role); + + if (!allowedAction.success){ + return ServiceResult.Failed({ + errorMessage: "Error: role does not exist", + errorType: "InvalidInputData", + }); + } + + console.log(`Allowed action for role ${role} is ${allowedAction.content}`) + + if (allowedAction.content.toLowerCase() != action.toLowerCase()){ + return ServiceResult.Failed({ + errorMessage: "Error: action not allowed", + errorType: "AuthorizationFailed", + }, 403); + } + + return ServiceResult.Succeeded(true); + } +} + +const authzService: IAuthZService = new AuthZService(keyValueUserRoleRepository, keyValueRoleActionRepository); +export default authzService; diff --git a/decentralize-rbac-app/src/services/role-service.ts b/decentralize-rbac-app/src/services/role-service.ts new file mode 100644 index 000000000..2d9eaac1f --- /dev/null +++ b/decentralize-rbac-app/src/services/role-service.ts @@ -0,0 +1,32 @@ +import { ServiceResult } from "../utils/service-result"; +import { keyValueRoleActionRepository, IRepository } from "../repositories/kv-repository"; + +export interface IRoleService { + /** + * Map and Store Role and actions in KV-Store + * @param {string} role - role being added + */ + add_role(role: string, action: string): ServiceResult; +} + +export class RoleService implements IRoleService { + constructor(private readonly keyValueRepo: IRepository) {} + + public add_role(role: string, action:string): ServiceResult { + const saveRoleRecord = this.keyValueRepo.set(role.toLowerCase(), action.toLowerCase()); + + if (!saveRoleRecord.success){ + return ServiceResult.Failed({ + errorMessage: "Error: role could not be added", + errorType: "InvalidInputData", + }); + } + + console.log(`Added role ${role} with action ${action}`) + + return saveRoleRecord; + } +} + +const roleService: IRoleService = new RoleService(keyValueRoleActionRepository); +export default roleService; diff --git a/decentralize-rbac-app/src/services/user-service.ts b/decentralize-rbac-app/src/services/user-service.ts new file mode 100644 index 000000000..08ffc0e0c --- /dev/null +++ b/decentralize-rbac-app/src/services/user-service.ts @@ -0,0 +1,33 @@ +import { ServiceResult } from "../utils/service-result"; +import { keyValueUserRoleRepository, keyValueRoleActionRepository, IRepository } from "../repositories/kv-repository"; + +export interface IUserService { + /** + * Map and Store Role and actions in KV-Store + * @param {string} userId - userId being added + * @param {string} role - role being added + */ + add_user(userId: string, role: string): ServiceResult; +} + +export class UserService implements IUserService { + constructor(private readonly keyValueRepo: IRepository, private readonly roleActionKVRepo: IRepository) {} + + public add_user(userId: string, role:string): ServiceResult { + const saveUserRecord = this.keyValueRepo.set(userId, role); + + if (!saveUserRecord.success){ + return ServiceResult.Failed({ + errorMessage: "Error: user add failed", + errorType: "InvalidInputData", + }); + } + + console.log(`Added user ${userId} with role ${role}`) + + return saveUserRecord; + } +} + +const userService: IUserService = new UserService(keyValueUserRoleRepository, keyValueRoleActionRepository); +export default userService; diff --git a/decentralize-rbac-app/src/utils/api-result.ts b/decentralize-rbac-app/src/utils/api-result.ts new file mode 100755 index 000000000..2041885a1 --- /dev/null +++ b/decentralize-rbac-app/src/utils/api-result.ts @@ -0,0 +1,110 @@ +import { ServiceResult } from "./service-result"; + +/** + * HTTP Status Code + */ +export enum StatusCode { + OK = 200, + BAD_REQUEST = 400, + UNAUTHORIZED = 401, +} + +/** + * Status code for CFF network conventions + */ +export interface CCFResponse { + statusCode: number; + body: any; + headers?: { [key: string]: string; } +} + +/** + * Utility class for wrapping the response with CFF network conventions + */ +export class ApiResult { + + /** + * Successful HTTP API operation + * @param result Result of the service operation + * @returns + */ + public static ActionAllowed(result: ServiceResult): CCFResponse { + const response: CCFResponse = { + statusCode: 200, + body: { + allowed: true, + }, + }; + + return response; + } + + public static ActionDisallowed(result: ServiceResult): CCFResponse { + const response: CCFResponse = { + statusCode: 200, + body: { + allowed: false, + }, + }; + + return response; + } + + /** + * Failed HTTP API operation + * @param result Result of the service operation + * @returns + */ + public static Failed(result: ServiceResult): CCFResponse { + const response: CCFResponse = { + statusCode: result.statusCode, + body: result, + }; + return response; + } + + /** + * mTLS Authentication failure + */ + public static AuthFailure(): CCFResponse { + const response: CCFResponse = { + statusCode: StatusCode.UNAUTHORIZED, + body: ServiceResult.Failed( + { + errorMessage: "Unauthorized", + errorType: "Unauthorized", + }, + StatusCode.UNAUTHORIZED + ), + }; + return response; + } + + /** + * Successful HTTP API operation + * @param result Result of the service operation + * @returns Html response + */ + public static Html(result: string, statusCode: StatusCode = StatusCode.OK): CCFResponse { + const response: CCFResponse = { + statusCode: StatusCode.OK, + headers: { "content-type": "text/html" }, + body: result, + }; + return response; + } + + /** + * Successful HTTP API operation + * @param result Result of the service operation + * @returns Json response + */ + public static Json(result: any, statusCode: StatusCode = StatusCode.OK): CCFResponse { + const response: CCFResponse = { + statusCode: statusCode, + headers: { "content-type": "application/json" }, + body: result, + }; + return response; + } +} diff --git a/decentralize-rbac-app/src/utils/config.ts b/decentralize-rbac-app/src/utils/config.ts new file mode 100755 index 000000000..06cde2b63 --- /dev/null +++ b/decentralize-rbac-app/src/utils/config.ts @@ -0,0 +1,39 @@ +/** + * To generate Microsoft Azure Active Directory config + * Run `make deploy-ms-idp` command to create the required application registrations. + * Update the following variables with the ones from ./aad.env + */ + +export const MS_AAD_CONFIG = { + + /** + * Application registrations Directory (tenant) ID + */ + TenantId: "16b3c013-d300-468d-ac64-7eda0820b6d3", + + /** + * Client Application registration Id + */ + ClientApplicationId: "ee48548a-7d69-4b8e-b2d4-805e8bac7f01", + + /** + * API Application registration Id + */ + ApiApplicationId: "b8dbd573-a015-424b-b111-2d5fa11cee3c", + + /** + * API Application registration "Application ID URI" + * The globally unique URI used to identify this web API. + * It is the prefix for scopes and in access tokens, + * it is the value of the audience claim. Also referred to as an identifier URI. + */ + ApiIdentifierUri: "api://b8dbd573-a015-424b-b111-2d5fa11cee3c", + + /** + * API Scopes used for user authorization + */ + ApiScopes: { "api://b8dbd573-a015-424b-b111-2d5fa11cee3c/user_impersonation": "User Impersonation" } + +}; + + diff --git a/decentralize-rbac-app/src/utils/constants.ts b/decentralize-rbac-app/src/utils/constants.ts new file mode 100755 index 000000000..df1365227 --- /dev/null +++ b/decentralize-rbac-app/src/utils/constants.ts @@ -0,0 +1 @@ +export const MINIMUM_VOTES_THRESHOLD = 3; diff --git a/decentralize-rbac-app/src/utils/service-result.ts b/decentralize-rbac-app/src/utils/service-result.ts new file mode 100755 index 000000000..dd95286b8 --- /dev/null +++ b/decentralize-rbac-app/src/utils/service-result.ts @@ -0,0 +1,44 @@ +export interface ErrorResponse { + errorMessage: string; + errorType: string; + details?: unknown; +} + +/** + * A generic result pattern implementation. + * Instead of returning the result directly, which can be an error or data itself, + * we wrap them with a meaningful state: Success or Failure + */ +export class ServiceResult { + public readonly success: boolean; + public readonly failure: boolean; + public readonly statusCode: number; + public readonly status: string; + public readonly content: T | null; + public readonly error: ErrorResponse | null; + + private constructor( + content: T | null, + error: ErrorResponse | null, + success: boolean, + statusCode: number + ) { + this.content = content; + this.error = error; + this.success = success; + this.failure = !success; + this.statusCode = statusCode; + this.status = success ? "Success" : "Error"; + } + + public static Succeeded(content: T): ServiceResult { + return new ServiceResult(content, null, true, 200); + } + + public static Failed( + error: ErrorResponse, + statusCode: number = 400 + ): ServiceResult { + return new ServiceResult(null, error, false, statusCode); + } +} diff --git a/decentralize-rbac-app/tsconfig.json b/decentralize-rbac-app/tsconfig.json new file mode 100755 index 000000000..9d29b046f --- /dev/null +++ b/decentralize-rbac-app/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "esnext", + "lib": ["ES2020"], + "esModuleInterop": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "noImplicitAny": false, + "removeComments": true, + "preserveConstEnums": true, + "sourceMap": false + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["node_modules"] +}