From 67f73574cdac477de70fc53fced23a17ff7025e7 Mon Sep 17 00:00:00 2001 From: Tyagi-Sunny Date: Fri, 13 Dec 2024 18:12:23 +0530 Subject: [PATCH 1/2] feat(tenant-management): integrate multiple idps integrate multiple idps 47 --- package-lock.json | 334 ++++++++++++--- ...20240925102459-add-table-tenant-configs.js | 59 +++ ...25102459-add-table-tenant-configs-down.sql | 1 + ...0925102459-add-table-tenant-configs-up.sql | 33 ++ .../tenant-management-service/package.json | 4 + .../webhook.controller.acceptance.ts | 2 +- .../src/component.ts | 37 +- .../src/controllers/idp.controller.ts | 69 +++ .../src/controllers/index.ts | 3 + .../tenant-mgmt-config-tenant.controller.ts | 40 ++ .../tenant-mgmt-config.controller.ts | 241 +++++++++++ .../callback-verifier.interceptor.ts | 100 +++++ .../src/interceptors/index.ts | 1 + .../webhook-verifier.interceptor.ts | 6 +- .../tenant-management-service/src/keys.ts | 37 +- .../src/models/dtos/idp-details-dto.model.ts | 23 + .../src/models/dtos/tenant-dto.model.ts | 33 ++ .../src/models/index.ts | 1 + .../src/models/tenant-config.model.ts | 46 ++ .../src/models/tenant-mgmt-config.model.ts | 47 ++ .../src/permissions.ts | 4 + .../src/providers/idp/idp-auth0.provider.ts | 181 ++++++++ .../providers/idp/idp-keycloak.provider.ts | 404 ++++++++++++++++++ .../src/providers/idp/index.ts | 3 + .../src/providers/idp/types.ts | 4 + .../src/providers/index.ts | 1 + .../src/repositories/index.ts | 1 + .../tenant-mgmt-config.repository.ts | 37 ++ .../src/types/i-idp.interface.ts | 18 + .../src/types/index.ts | 1 + .../src/webhook.component.ts | 47 +- 31 files changed, 1718 insertions(+), 100 deletions(-) create mode 100644 services/tenant-management-service/migrations/pg/migrations/20240925102459-add-table-tenant-configs.js create mode 100644 services/tenant-management-service/migrations/pg/migrations/sqls/20240925102459-add-table-tenant-configs-down.sql create mode 100644 services/tenant-management-service/migrations/pg/migrations/sqls/20240925102459-add-table-tenant-configs-up.sql create mode 100644 services/tenant-management-service/src/controllers/idp.controller.ts create mode 100644 services/tenant-management-service/src/controllers/tenant-mgmt-config-tenant.controller.ts create mode 100644 services/tenant-management-service/src/controllers/tenant-mgmt-config.controller.ts create mode 100644 services/tenant-management-service/src/interceptors/callback-verifier.interceptor.ts create mode 100644 services/tenant-management-service/src/models/dtos/idp-details-dto.model.ts create mode 100644 services/tenant-management-service/src/models/dtos/tenant-dto.model.ts create mode 100644 services/tenant-management-service/src/models/tenant-config.model.ts create mode 100644 services/tenant-management-service/src/models/tenant-mgmt-config.model.ts create mode 100644 services/tenant-management-service/src/providers/idp/idp-auth0.provider.ts create mode 100644 services/tenant-management-service/src/providers/idp/idp-keycloak.provider.ts create mode 100644 services/tenant-management-service/src/providers/idp/index.ts create mode 100644 services/tenant-management-service/src/providers/idp/types.ts create mode 100644 services/tenant-management-service/src/repositories/tenant-mgmt-config.repository.ts create mode 100644 services/tenant-management-service/src/types/i-idp.interface.ts diff --git a/package-lock.json b/package-lock.json index 9b334fd..389b295 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3887,6 +3887,12 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/@types/auth0": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/@types/auth0/-/auth0-3.3.10.tgz", + "integrity": "sha512-9tS0Y2igWxw+Dx5uCHkIUCu6tG0oRkwpE322dOJPwZMLXQMx49n/gDmUz7YJSe1iVjrWW+ffVYmlPShVIEwjkg==", + "dev": true + }, "node_modules/@types/aws-lambda": { "version": "8.10.146", "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.146.tgz", @@ -4864,6 +4870,33 @@ "node": ">= 4.0.0" } }, + "node_modules/auth0": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/auth0/-/auth0-4.15.0.tgz", + "integrity": "sha512-GPJJcDxk0sMnpbTZf9QWHXJrdPoSTG9eG5q9Un7X+eNKhZVPEzr3sSK3/EToRP4fNIPKbCWjXa2+HM3GnY3F4g==", + "dev": true, + "dependencies": { + "jose": "^4.13.2", + "undici-types": "^6.15.0", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/auth0/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/auto-parse": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/auto-parse/-/auto-parse-1.8.0.tgz", @@ -4891,6 +4924,35 @@ "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==" }, + "node_modules/aws-sdk": { + "version": "2.1692.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1692.0.tgz", + "integrity": "sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw==", + "hasInstallScript": true, + "dependencies": { + "buffer": "4.9.2", + "events": "1.1.1", + "ieee754": "1.1.13", + "jmespath": "0.16.0", + "querystring": "0.2.0", + "sax": "1.2.1", + "url": "0.10.3", + "util": "^0.12.4", + "uuid": "8.0.0", + "xml2js": "0.6.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aws-sdk/node_modules/uuid": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", + "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", @@ -5171,26 +5233,13 @@ } }, "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" } }, "node_modules/buffer-equal-constant-time": { @@ -5625,6 +5674,25 @@ "ieee754": "^1.2.1" } }, + "node_modules/casbin/node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/casbin/node_modules/minimatch": { "version": "7.4.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.6.tgz", @@ -8069,6 +8137,14 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "dev": true }, + "node_modules/events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -9717,6 +9793,29 @@ "uuid-parse": "^1.1.0" } }, + "node_modules/hyperid/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/hyperid/node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -9756,23 +9855,9 @@ } }, "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", + "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" }, "node_modules/ignore": { "version": "5.3.2", @@ -10087,12 +10172,12 @@ } }, "node_modules/is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -10164,11 +10249,11 @@ } }, "node_modules/is-boolean-object": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.0.tgz", - "integrity": "sha512-kR5g0+dXf/+kXnqI+lu0URKYPKgICtHGGNCDSB10AaUFj3o/HkB3u7WfpRBJGFopxxY0oH3ux7ZsDjLtK7xqvw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.1.tgz", + "integrity": "sha512-l9qO6eFlUETHtuihLcYOaLKByJ1f+N4kthcU9YjHy3N+B3hWv0y/2Nd0mu/7lTFnRQHTrSdXF50HQ3bl5fEnng==", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" }, "engines": { @@ -10303,7 +10388,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", - "peer": true, "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -10509,13 +10593,13 @@ } }, "node_modules/is-symbol": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.0.tgz", - "integrity": "sha512-qS8KkNNXUZ/I+nX6QT8ZS1/Yx0A444yhzdTKxCzKkNjQ9sHErBxJnJAgh+f5YhusYECEcjo4XcyH87hn6+ks0A==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "dependencies": { - "call-bind": "^1.0.7", - "has-symbols": "^1.0.3", - "safe-regex-test": "^1.0.3" + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -10540,7 +10624,6 @@ "version": "1.1.13", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", - "peer": true, "dependencies": { "which-typed-array": "^1.1.14" }, @@ -10955,6 +11038,23 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jmespath": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", + "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jpeg-exif": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz", @@ -15131,6 +15231,30 @@ "readable-stream": "^3.4.0" } }, + "node_modules/ora/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/os-homedir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", @@ -16487,6 +16611,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==", + "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -17362,6 +17495,11 @@ "node": ">=6" } }, + "node_modules/sax": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==" + }, "node_modules/semver": { "version": "7.3.7", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", @@ -18742,6 +18880,30 @@ "readable-stream": "^3.4.0" } }, + "node_modules/tar-stream/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/tar/node_modules/minipass": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", @@ -19743,6 +19905,15 @@ "punycode": "^2.1.0" } }, + "node_modules/url": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", + "integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==", + "dependencies": { + "punycode": "1.3.2", + "querystring": "0.2.0" + } + }, "node_modules/url-parse": { "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", @@ -19753,6 +19924,11 @@ "requires-port": "^1.0.0" } }, + "node_modules/url/node_modules/punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==" + }, "node_modules/user-home": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/user-home/-/user-home-2.0.0.tgz", @@ -19765,6 +19941,18 @@ "node": ">=0.10.0" } }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -19923,24 +20111,24 @@ } }, "node_modules/which-builtin-type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.0.tgz", - "integrity": "sha512-I+qLGQ/vucCby4tf5HsLmGueEla4ZhwTBSqaooS+Y0BuxN4Cp+okmGuV+8mXZ84KDI9BA+oklo+RzKg0ONdSUA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", "peer": true, "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", - "is-date-object": "^1.0.5", + "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", - "is-regex": "^1.1.4", + "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", - "which-boxed-primitive": "^1.0.2", + "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", - "which-typed-array": "^1.1.15" + "which-typed-array": "^1.1.16" }, "engines": { "node": ">= 0.4" @@ -20213,6 +20401,26 @@ "node": ">=6" } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlcreate": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz", @@ -20601,6 +20809,8 @@ "@opentelemetry/sdk-trace-base": "^1.15.0", "@opentelemetry/sdk-trace-node": "^1.15.0", "@sourceloop/core": "^14.1.0", + "aws-sdk": "^2.1691.0", + "axios": "^1.7.7", "dotenv": "^16.0.3", "dotenv-extended": "^2.9.0", "handlebars": "^4.7.8", @@ -20621,10 +20831,12 @@ "@loopback/build": "^11.0.2", "@loopback/eslint-config": "^15.0.2", "@loopback/testlab": "^7.0.2", + "@types/auth0": "^3.3.10", "@types/jsonwebtoken": "^9.0.5", "@types/moment": "^2.13.0", "@types/node": "^18.11.9", "@types/pdfkit": "^0.13.4", + "auth0": "^4.10.0", "eslint": "^8.57.0", "nodemon": "^2.0.21", "nyc": "^15.1.0", diff --git a/services/tenant-management-service/migrations/pg/migrations/20240925102459-add-table-tenant-configs.js b/services/tenant-management-service/migrations/pg/migrations/20240925102459-add-table-tenant-configs.js new file mode 100644 index 0000000..9e7d6e8 --- /dev/null +++ b/services/tenant-management-service/migrations/pg/migrations/20240925102459-add-table-tenant-configs.js @@ -0,0 +1,59 @@ +'use strict'; + +var dbm; +var type; +var seed; +var fs = require('fs'); +var path = require('path'); +var Promise; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; + Promise = options.Promise; +}; + +exports.up = function (db) { + var filePath = path.join( + __dirname, + 'sqls', + '20240925102459-add-table-tenant-configs-up.sql', + ); + return new Promise(function (resolve, reject) { + fs.readFile(filePath, {encoding: 'utf-8'}, function (err, data) { + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }).then(function (data) { + return db.runSql(data); + }); +}; + +exports.down = function (db) { + var filePath = path.join( + __dirname, + 'sqls', + '20240925102459-add-table-tenant-configs-down.sql', + ); + return new Promise(function (resolve, reject) { + fs.readFile(filePath, {encoding: 'utf-8'}, function (err, data) { + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }).then(function (data) { + return db.runSql(data); + }); +}; + +exports._meta = { + version: 1, +}; diff --git a/services/tenant-management-service/migrations/pg/migrations/sqls/20240925102459-add-table-tenant-configs-down.sql b/services/tenant-management-service/migrations/pg/migrations/sqls/20240925102459-add-table-tenant-configs-down.sql new file mode 100644 index 0000000..44c3c1c --- /dev/null +++ b/services/tenant-management-service/migrations/pg/migrations/sqls/20240925102459-add-table-tenant-configs-down.sql @@ -0,0 +1 @@ +drop table main.tenant_mgmt_configs; \ No newline at end of file diff --git a/services/tenant-management-service/migrations/pg/migrations/sqls/20240925102459-add-table-tenant-configs-up.sql b/services/tenant-management-service/migrations/pg/migrations/sqls/20240925102459-add-table-tenant-configs-up.sql new file mode 100644 index 0000000..8db7e26 --- /dev/null +++ b/services/tenant-management-service/migrations/pg/migrations/sqls/20240925102459-add-table-tenant-configs-up.sql @@ -0,0 +1,33 @@ +CREATE TABLE IF NOT EXISTS main.tenant_mgmt_configs +( + id uuid NOT NULL DEFAULT (md5(((random())::text || (clock_timestamp())::text)))::uuid, + config_key varchar(100) NOT NULL, + config_value jsonb NOT NULL, + created_on timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + modified_on timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + created_by uuid, + modified_by uuid, + deleted boolean DEFAULT FALSE NOT NULL, + deleted_by uuid, + deleted_on timestamptz, + tenant_id uuid NOT NULL, + CONSTRAINT pk_tenant_configs_id PRIMARY KEY (id), + CONSTRAINT fk_tenant_configs_tenants FOREIGN KEY (tenant_id) + REFERENCES main.tenants(id) +); + + +CREATE OR REPLACE FUNCTION main.moddatetime() + RETURNS TRIGGER + LANGUAGE plpgsql + AS $function$ +BEGIN + NEW.modified_on = now(); + RETURN NEW; +END; +$function$; + +CREATE TRIGGER mdt_tenant_configs + BEFORE UPDATE ON main.tenant_mgmt_configs + FOR EACH ROW + EXECUTE FUNCTION main.moddatetime('modified_on'); diff --git a/services/tenant-management-service/package.json b/services/tenant-management-service/package.json index 62c7912..51d4d7b 100644 --- a/services/tenant-management-service/package.json +++ b/services/tenant-management-service/package.json @@ -81,6 +81,8 @@ "@opentelemetry/sdk-trace-base": "^1.15.0", "@opentelemetry/sdk-trace-node": "^1.15.0", "@sourceloop/core": "^14.1.0", + "aws-sdk": "^2.1691.0", + "axios": "^1.7.7", "dotenv": "^16.0.3", "dotenv-extended": "^2.9.0", "handlebars": "^4.7.8", @@ -101,10 +103,12 @@ "@loopback/build": "^11.0.2", "@loopback/eslint-config": "^15.0.2", "@loopback/testlab": "^7.0.2", + "@types/auth0": "^3.3.10", "@types/jsonwebtoken": "^9.0.5", "@types/moment": "^2.13.0", "@types/node": "^18.11.9", "@types/pdfkit": "^0.13.4", + "auth0": "^4.10.0", "eslint": "^8.57.0", "nodemon": "^2.0.21", "nyc": "^15.1.0", diff --git a/services/tenant-management-service/src/__tests__/acceptance/webhook.controller.acceptance.ts b/services/tenant-management-service/src/__tests__/acceptance/webhook.controller.acceptance.ts index ba793b6..9affaac 100644 --- a/services/tenant-management-service/src/__tests__/acceptance/webhook.controller.acceptance.ts +++ b/services/tenant-management-service/src/__tests__/acceptance/webhook.controller.acceptance.ts @@ -115,7 +115,7 @@ describe('WebhookController', () => { }); it('should return 401 status for a webhook call with an expired timestamp', async () => { - const sixSeconds = 6000; + const sixSeconds = 20000; // generate token that was set 6 seconds ago const headers = await buildHeaders( webhookPayload, diff --git a/services/tenant-management-service/src/component.ts b/services/tenant-management-service/src/component.ts index 33fc487..0bfaf44 100644 --- a/services/tenant-management-service/src/component.ts +++ b/services/tenant-management-service/src/component.ts @@ -30,48 +30,51 @@ import { AuthorizationBindings, AuthorizationComponent, } from 'loopback4-authorization'; -import { - EventConnectorBinding, - LEAD_TOKEN_VERIFIER, - SYSTEM_USER, - TenantManagementServiceBindings, -} from './keys'; -import {ITenantManagementServiceConfig} from './types'; -import {InvoiceController} from './controllers/invoice.controller'; import { ContactController, HomePageController, - LeadTenantController, LeadController, + LeadTenantController, PingController, + TenantMgmtConfigController, + TenantMgmtConfigTenantController, TenantController, } from './controllers'; +import {InvoiceController} from './controllers/invoice.controller'; +import { + EventConnectorBinding, + LEAD_TOKEN_VERIFIER, + SYSTEM_USER, + TenantManagementServiceBindings, +} from './keys'; import { Address, Contact, + CreateLeadDTO, Invoice, Lead, LeadToken, + ProvisioningDTO, Resource, Tenant, - WebhookSecret, - CreateLeadDTO, - ProvisioningDTO, + TenantMgmtConfig, TenantOnboardDTO, VerifyLeadResponseDTO, WebhookDTO, + WebhookSecret, } from './models'; +import {LeadTokenVerifierProvider, SystemUserProvider} from './providers'; import { AddressRepository, ContactRepository, InvoiceRepository, - LeadTokenRepository, LeadRepository, + LeadTokenRepository, ResourceRepository, + TenantMgmtConfigRepository, TenantRepository, WebhookSecretRepository, } from './repositories'; -import {LeadTokenVerifierProvider, SystemUserProvider} from './providers'; import { CryptoHelperService, EventConnector, @@ -81,6 +84,8 @@ import { OnboardingService, ProvisioningService, } from './services'; +import {ITenantManagementServiceConfig} from './types'; + export class TenantManagementServiceComponent implements Component { constructor( @inject(CoreBindings.APPLICATION_INSTANCE) @@ -120,6 +125,7 @@ export class TenantManagementServiceComponent implements Component { ResourceRepository, TenantRepository, WebhookSecretRepository, + TenantMgmtConfigRepository, ]; this.models = [ @@ -136,6 +142,7 @@ export class TenantManagementServiceComponent implements Component { TenantOnboardDTO, VerifyLeadResponseDTO, WebhookDTO, + TenantMgmtConfig, ]; this.controllers = [ @@ -146,6 +153,8 @@ export class TenantManagementServiceComponent implements Component { LeadController, PingController, TenantController, + TenantMgmtConfigController, + TenantMgmtConfigTenantController, ]; this.bindings = [ diff --git a/services/tenant-management-service/src/controllers/idp.controller.ts b/services/tenant-management-service/src/controllers/idp.controller.ts new file mode 100644 index 0000000..55b84c0 --- /dev/null +++ b/services/tenant-management-service/src/controllers/idp.controller.ts @@ -0,0 +1,69 @@ +import {inject, intercept} from '@loopback/core'; +import {getModelSchemaRef, post, requestBody} from '@loopback/rest'; +import { + CONTENT_TYPE, + OPERATION_SECURITY_SPEC, + rateLimitKeyGenPublic, + STATUS_CODE, +} from '@sourceloop/core'; +import {authorize} from 'loopback4-authorization'; +import {ratelimit} from 'loopback4-ratelimiter'; +import {TenantManagementServiceBindings, CALLABCK_VERIFIER} from '../keys'; +import {IdpDetailsDTO} from '../models/dtos/idp-details-dto.model'; +import {ConfigureIdpFunc, IdPKey, IdpResp} from '../types'; + +const basePath = '/manage/users'; +export class IdpController { + constructor( + @inject(TenantManagementServiceBindings.IDP_KEYCLOAK) + private readonly idpKeycloakProvider: ConfigureIdpFunc, + @inject(TenantManagementServiceBindings.IDP_AUTH0) + private readonly idpAuth0Provider: ConfigureIdpFunc, + ) {} + + @intercept(CALLABCK_VERIFIER) + @ratelimit(true, { + max: parseInt(process.env.WEBHOOK_API_MAX_ATTEMPTS ?? '10'), + keyGenerator: rateLimitKeyGenPublic, + }) + @authorize({ + permissions: ['*'], + }) + @post(`${basePath}`, { + security: OPERATION_SECURITY_SPEC, + responses: { + [STATUS_CODE.NO_CONTENT]: { + description: 'Webhook success', + }, + }, + }) + async idpConfigure( + @requestBody({ + content: { + [CONTENT_TYPE.JSON]: { + schema: getModelSchemaRef(IdpDetailsDTO, { + title: 'IdpDetailsDTO', + }), + }, + }, + }) + payload: IdpDetailsDTO, + ): Promise { + let res: IdpResp = { + authId: '', + }; + switch (payload.tenant.identityProvider) { + case IdPKey.AUTH0: + res = await this.idpAuth0Provider(payload); + break; + case IdPKey.COGNITO: + break; + case IdPKey.KEYCLOAK: + res = await this.idpKeycloakProvider(payload); + break; + default: + break; + } + return res; + } +} diff --git a/services/tenant-management-service/src/controllers/index.ts b/services/tenant-management-service/src/controllers/index.ts index 8406aab..6aa19b5 100644 --- a/services/tenant-management-service/src/controllers/index.ts +++ b/services/tenant-management-service/src/controllers/index.ts @@ -6,3 +6,6 @@ export * from './lead-tenant.controller'; export * from './tenant.controller'; export * from './webhook.controller'; export * from './invoice.controller'; +export * from './tenant-mgmt-config.controller'; +export * from './tenant-mgmt-config-tenant.controller'; +export * from './idp.controller'; diff --git a/services/tenant-management-service/src/controllers/tenant-mgmt-config-tenant.controller.ts b/services/tenant-management-service/src/controllers/tenant-mgmt-config-tenant.controller.ts new file mode 100644 index 0000000..158a183 --- /dev/null +++ b/services/tenant-management-service/src/controllers/tenant-mgmt-config-tenant.controller.ts @@ -0,0 +1,40 @@ +import {repository} from '@loopback/repository'; +import {param, get, getModelSchemaRef} from '@loopback/rest'; +import {TenantMgmtConfig, Tenant} from '../models'; +import {TenantMgmtConfigRepository} from '../repositories'; +import {authenticate, STRATEGY} from 'loopback4-authentication'; +import {authorize} from 'loopback4-authorization'; +import {PermissionKey} from '../permissions'; +import {OPERATION_SECURITY_SPEC, STATUS_CODE} from '@sourceloop/core'; + +const basePath = '/tenant-configs/{id}/tenant'; +export class TenantMgmtConfigTenantController { + constructor( + @repository(TenantMgmtConfigRepository) + public tenantConfigRepository: TenantMgmtConfigRepository, + ) {} + @authorize({ + permissions: [PermissionKey.ViewTenantConfig], + }) + @authenticate(STRATEGY.BEARER, { + passReqToCallback: true, + }) + @get(`${basePath}`, { + security: OPERATION_SECURITY_SPEC, + responses: { + [STATUS_CODE.OK]: { + description: 'Tenant belonging to TenantConfig', + content: { + 'application/json': { + schema: getModelSchemaRef(Tenant), + }, + }, + }, + }, + }) + async getTenant( + @param.path.string('id') id: typeof TenantMgmtConfig.prototype.id, + ): Promise { + return this.tenantConfigRepository.tenant(id); + } +} diff --git a/services/tenant-management-service/src/controllers/tenant-mgmt-config.controller.ts b/services/tenant-management-service/src/controllers/tenant-mgmt-config.controller.ts new file mode 100644 index 0000000..c254d80 --- /dev/null +++ b/services/tenant-management-service/src/controllers/tenant-mgmt-config.controller.ts @@ -0,0 +1,241 @@ +import { + Count, + CountSchema, + Filter, + FilterExcludingWhere, + repository, + Where, +} from '@loopback/repository'; +import { + post, + param, + get, + getModelSchemaRef, + patch, + put, + del, + requestBody, +} from '@loopback/rest'; +import {TenantMgmtConfig} from '../models'; +import {TenantMgmtConfigRepository} from '../repositories'; +import {authenticate, STRATEGY} from 'loopback4-authentication'; +import {authorize} from 'loopback4-authorization'; +import {PermissionKey} from '../permissions'; +import { + CONTENT_TYPE, + OPERATION_SECURITY_SPEC, + STATUS_CODE, +} from '@sourceloop/core'; +const basePath = '/tenant-configs'; +export class TenantMgmtConfigController { + constructor( + @repository(TenantMgmtConfigRepository) + public tenantConfigRepository: TenantMgmtConfigRepository, + ) {} + @authorize({ + permissions: [PermissionKey.CreateTenantConfig], + }) + @authenticate(STRATEGY.BEARER, { + passReqToCallback: true, + }) + @post(basePath, { + security: OPERATION_SECURITY_SPEC, + responses: { + [STATUS_CODE.OK]: { + description: 'Tenant Config model instance', + content: { + [CONTENT_TYPE.JSON]: {schema: getModelSchemaRef(TenantMgmtConfig)}, + }, + }, + }, + }) + async create( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(TenantMgmtConfig, { + title: 'NewTenantConfig', + exclude: ['id'], + }), + }, + }, + }) + tenantConfig: Omit, + ): Promise { + return this.tenantConfigRepository.create(tenantConfig); + } + @authorize({ + permissions: [PermissionKey.ViewTenantConfig], + }) + @authenticate(STRATEGY.BEARER, { + passReqToCallback: true, + }) + @get(`${basePath}/count`, { + security: OPERATION_SECURITY_SPEC, + responses: { + [STATUS_CODE.OK]: { + description: 'Tenant Config model count', + content: {[CONTENT_TYPE.JSON]: {schema: CountSchema}}, + }, + }, + }) + async count( + @param.where(TenantMgmtConfig) where?: Where, + ): Promise { + return this.tenantConfigRepository.count(where); + } + @authorize({ + permissions: [PermissionKey.ViewTenantConfig], + }) + @authenticate(STRATEGY.BEARER, { + passReqToCallback: true, + }) + @get(basePath, { + security: OPERATION_SECURITY_SPEC, + responses: { + [STATUS_CODE.OK]: { + description: 'Array of TenantConfig model instances', + content: { + [CONTENT_TYPE.JSON]: { + schema: { + type: 'array', + items: getModelSchemaRef(TenantMgmtConfig, { + includeRelations: true, + }), + }, + }, + }, + }, + }, + }) + async find( + @param.filter(TenantMgmtConfig) filter?: Filter, + ): Promise { + return this.tenantConfigRepository.find(filter); + } + @authorize({ + permissions: [PermissionKey.UpdateTenantConfig], + }) + @authenticate(STRATEGY.BEARER, { + passReqToCallback: true, + }) + @patch(basePath, { + security: OPERATION_SECURITY_SPEC, + responses: { + [STATUS_CODE.OK]: { + description: 'Tenant Config PATCH success', + content: { + [CONTENT_TYPE.JSON]: { + schema: getModelSchemaRef(TenantMgmtConfig), + }, + }, + }, + }, + }) + async updateAll( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(TenantMgmtConfig, {partial: true}), + }, + }, + }) + tenantConfig: TenantMgmtConfig, + @param.where(TenantMgmtConfig) where?: Where, + ): Promise { + return this.tenantConfigRepository.updateAll(tenantConfig, where); + } + @authorize({ + permissions: [PermissionKey.ViewTenantConfig], + }) + @authenticate(STRATEGY.BEARER, { + passReqToCallback: true, + }) + @get(`${basePath}/{id}`, { + security: OPERATION_SECURITY_SPEC, + responses: { + [STATUS_CODE.OK]: { + description: 'Tenant Config model instance', + content: { + [CONTENT_TYPE.JSON]: {schema: getModelSchemaRef(TenantMgmtConfig)}, + }, + }, + }, + }) + async findById( + @param.path.string('id') id: string, + @param.filter(TenantMgmtConfig, {exclude: 'where'}) + filter?: FilterExcludingWhere, + ): Promise { + return this.tenantConfigRepository.findById(id, filter); + } + @authorize({ + permissions: [PermissionKey.UpdateTenantConfig], + }) + @authenticate(STRATEGY.BEARER, { + passReqToCallback: true, + }) + @patch(`${basePath}/{id}`, { + security: OPERATION_SECURITY_SPEC, + responses: { + [STATUS_CODE.NO_CONTENT]: { + description: 'Tenant Config PATCH success', + content: { + [CONTENT_TYPE.JSON]: { + schema: getModelSchemaRef(TenantMgmtConfig), + }, + }, + }, + }, + }) + async updateById( + @param.path.string('id') id: string, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(TenantMgmtConfig, {partial: true}), + }, + }, + }) + tenantConfig: TenantMgmtConfig, + ): Promise { + await this.tenantConfigRepository.updateById(id, tenantConfig); + } + @authorize({ + permissions: [PermissionKey.UpdateTenantConfig], + }) + @authenticate(STRATEGY.BEARER, { + passReqToCallback: true, + }) + @put(`${basePath}/{id}`, { + security: OPERATION_SECURITY_SPEC, + responses: { + [STATUS_CODE.NO_CONTENT]: { + description: 'Tenant Config PUT success', + }, + }, + }) + async replaceById( + @param.path.string('id') id: string, + @requestBody() tenantConfig: TenantMgmtConfig, + ): Promise { + await this.tenantConfigRepository.replaceById(id, tenantConfig); + } + @authorize({ + permissions: [PermissionKey.DeleteTenantConfig], + }) + @authenticate(STRATEGY.BEARER, { + passReqToCallback: true, + }) + @del(`${basePath}/{id}`, { + security: OPERATION_SECURITY_SPEC, + responses: { + [STATUS_CODE.NO_CONTENT]: { + description: 'Tenant DELETE success', + }, + }, + }) + async deleteById(@param.path.string('id') id: string): Promise { + await this.tenantConfigRepository.deleteById(id); + } +} diff --git a/services/tenant-management-service/src/interceptors/callback-verifier.interceptor.ts b/services/tenant-management-service/src/interceptors/callback-verifier.interceptor.ts new file mode 100644 index 0000000..f034873 --- /dev/null +++ b/services/tenant-management-service/src/interceptors/callback-verifier.interceptor.ts @@ -0,0 +1,100 @@ +import { + Interceptor, + InvocationContext, + Provider, + Setter, + ValueOrPromise, + inject, +} from '@loopback/core'; +import {AnyObject, repository} from '@loopback/repository'; +import {HttpErrors, RequestContext} from '@loopback/rest'; +import {ILogger, LOGGER} from '@sourceloop/core'; +import {createHmac, timingSafeEqual} from 'crypto'; +import {AuthenticationBindings, IAuthUser} from 'loopback4-authentication'; +import {SYSTEM_USER} from '../keys'; +import {WebhookSecretRepository} from '../repositories'; + +const DEFAULT_TIME_TOLERANCE = 20000; + +export class CallbackVerifierProvider implements Provider { + constructor( + @inject(LOGGER.LOGGER_INJECT) + private readonly logger: ILogger, + @repository(WebhookSecretRepository) + private readonly webhookSecretRepo: WebhookSecretRepository, + @inject.setter(AuthenticationBindings.CURRENT_USER) + private readonly setCurrentUser: Setter, + @inject(SYSTEM_USER) + private readonly systemUser: IAuthUser, + ) {} + + value() { + return this.intercept.bind(this); + } + + async intercept( + invocationCtx: InvocationContext, + next: () => ValueOrPromise, + ) { + const {request} = invocationCtx.parent as RequestContext; + const value: AnyObject = request.body; + const TIMESTAMP_TOLERANCE = +DEFAULT_TIME_TOLERANCE; + const timestamp = Number(request.headers['x-timestamp']); + if (isNaN(timestamp)) { + this.logger.error('Invalid timestamp'); + throw new HttpErrors.Unauthorized(); + } + + const signature = request.headers['x-signature']; + if (!signature || typeof signature !== 'string') { + this.logger.error('Missing signature string'); + throw new HttpErrors.Unauthorized(); + } + + const tenantId = value.tenant?.id; + if (!tenantId) { + this.logger.error('Missing secret'); + throw new HttpErrors.Unauthorized(); + } + + const secretInfo = await this.webhookSecretRepo.get(tenantId); + if (!secretInfo) { + this.logger.error('No secret found for this initiator'); + throw new HttpErrors.Unauthorized(); + } + + const expectedSignature = createHmac('sha256', secretInfo.secret) + .update(`${JSON.stringify(value)}${timestamp}`) + .digest('hex'); + + try { + // actual signature should be equal to expected signature + // timing safe equal is used to prevent timing attacks + if ( + !timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature)) + ) { + this.logger.error('Invalid signature'); + throw new HttpErrors.Unauthorized(); + } + + const hh = Math.abs(timestamp - Date.now()); + // timestamp should be within 20 seconds + if (hh > TIMESTAMP_TOLERANCE) { + this.logger.error('Timestamp out of tolerance'); + throw new HttpErrors.Unauthorized(); + } + } catch (e) { + this.logger.error(e); + throw new HttpErrors.Unauthorized(); + } + + this.setCurrentUser(this.systemUser); + return next(); + } +} + +export type TempUser = { + userTenantId: string; + tenantType: string; + tenantId?: string; +} & IAuthUser; diff --git a/services/tenant-management-service/src/interceptors/index.ts b/services/tenant-management-service/src/interceptors/index.ts index dc511f6..1f71b4f 100644 --- a/services/tenant-management-service/src/interceptors/index.ts +++ b/services/tenant-management-service/src/interceptors/index.ts @@ -1 +1,2 @@ export * from './webhook-verifier.interceptor'; +export * from './callback-verifier.interceptor'; diff --git a/services/tenant-management-service/src/interceptors/webhook-verifier.interceptor.ts b/services/tenant-management-service/src/interceptors/webhook-verifier.interceptor.ts index e4dd56c..e740f7a 100644 --- a/services/tenant-management-service/src/interceptors/webhook-verifier.interceptor.ts +++ b/services/tenant-management-service/src/interceptors/webhook-verifier.interceptor.ts @@ -79,10 +79,8 @@ export class WebhookVerifierProvider implements Provider { throw new HttpErrors.Unauthorized(); } - // timestamp should be within 5 seconds - if ( - Math.abs(timestamp - Date.now()) > this.webhookConfig.timestampTolerance - ) { + // timestamp should be within 5-20 seconds + if (Math.abs(timestamp - Date.now()) > 20000) { this.logger.error('Timestamp out of tolerance'); throw new HttpErrors.Unauthorized(); } diff --git a/services/tenant-management-service/src/keys.ts b/services/tenant-management-service/src/keys.ts index 655a903..bc25edc 100644 --- a/services/tenant-management-service/src/keys.ts +++ b/services/tenant-management-service/src/keys.ts @@ -1,22 +1,24 @@ +import { + BindingKey, + BindingTemplate, + extensionFor, + Interceptor, +} from '@loopback/core'; +import {AnyObject} from '@loopback/repository'; +import {BINDING_PREFIX} from '@sourceloop/core'; import {VerifyFunction} from 'loopback4-authentication'; +import {IAuthUser} from 'loopback4-authorization'; +import {WebhookController} from './controllers'; import { IPostWebhookHandlerService, + ConfigureIdpFunc, + IdpResp, ITenantManagementServiceConfig, LeadUser, ResourceProvisionedWebhookPayload, WebhookConfig, WebhookNotificationServiceType, } from './types'; -import {IAuthUser} from 'loopback4-authorization'; -import {AnyObject} from '@loopback/repository'; -import {WebhookController} from './controllers'; -import { - BindingKey, - BindingTemplate, - Interceptor, - extensionFor, -} from '@loopback/core'; -import {BINDING_PREFIX} from '@sourceloop/core'; import {IEventConnector} from './types/i-event-connector.interface'; export namespace TenantManagementServiceBindings { @@ -24,6 +26,17 @@ export namespace TenantManagementServiceBindings { BindingKey.create( `${BINDING_PREFIX}.chat.config`, ); + /** + * Binding key for the Idp keycloak provider. + */ + export const IDP_KEYCLOAK = BindingKey.create>( + 'sf.user.idp.keycloak', + ); + /** + * Binding key for the Idp Auth0 provider. + */ + export const IDP_AUTH0 = + BindingKey.create>('sf.user.idp.auth0'); } /** @@ -53,6 +66,10 @@ export const WEBHOOK_VERIFIER = BindingKey.create( 'sf.webhook.verifier', ); +export const CALLABCK_VERIFIER = BindingKey.create( + 'sf.callback.verifier', +); + /** * Binding key for the webhook handler extension point. */ diff --git a/services/tenant-management-service/src/models/dtos/idp-details-dto.model.ts b/services/tenant-management-service/src/models/dtos/idp-details-dto.model.ts new file mode 100644 index 0000000..744e620 --- /dev/null +++ b/services/tenant-management-service/src/models/dtos/idp-details-dto.model.ts @@ -0,0 +1,23 @@ +import {getJsonSchema} from '@loopback/openapi-v3'; +import {AnyObject, Model, model, property} from '@loopback/repository'; + +@model({ + description: 'model describing payload for IDP controller', +}) +export class IdpDetailsDTO extends Model { + @property({ + type: 'object', + description: 'Tenat object', + jsonSchema: getJsonSchema(Object), + }) + tenant: AnyObject; + @property({ + type: 'object', + description: 'plan object', + jsonSchema: getJsonSchema(Object), + }) + plan: AnyObject; + constructor(data?: Partial) { + super(data); + } +} diff --git a/services/tenant-management-service/src/models/dtos/tenant-dto.model.ts b/services/tenant-management-service/src/models/dtos/tenant-dto.model.ts new file mode 100644 index 0000000..0acdc8b --- /dev/null +++ b/services/tenant-management-service/src/models/dtos/tenant-dto.model.ts @@ -0,0 +1,33 @@ +import {getJsonSchema} from '@loopback/openapi-v3'; +import {model, property} from '@loopback/repository'; +import {Address} from '../address.model'; + +import {Tenant} from '../tenant.model'; +import {Contact} from '../contact.model'; + +@model({ + description: 'model describing payload used to create a lead', +}) +export class TenantDto extends Tenant { + @property({ + type: 'object', + description: 'address object to be created for the lead', + jsonSchema: getJsonSchema(Address), + }) + address: Address; + + @property({ + type: 'array', + itemType: 'object', + description: 'Array of contact objects', + jsonSchema: { + type: 'object', + items: getJsonSchema(Contact), + }, + }) + contacts: Contact[]; + + constructor(data?: Partial) { + super(data); + } +} diff --git a/services/tenant-management-service/src/models/index.ts b/services/tenant-management-service/src/models/index.ts index 02b5768..4180144 100644 --- a/services/tenant-management-service/src/models/index.ts +++ b/services/tenant-management-service/src/models/index.ts @@ -7,3 +7,4 @@ export * from './resource.model'; export * from './invoice.model'; export * from './address.model'; export * from './lead-token.model'; +export * from './tenant-mgmt-config.model'; diff --git a/services/tenant-management-service/src/models/tenant-config.model.ts b/services/tenant-management-service/src/models/tenant-config.model.ts new file mode 100644 index 0000000..58c1b89 --- /dev/null +++ b/services/tenant-management-service/src/models/tenant-config.model.ts @@ -0,0 +1,46 @@ +import {belongsTo, model, property} from '@loopback/repository'; +import {UserModifiableEntity} from '@sourceloop/core'; +import {Tenant} from './tenant.model'; + +@model({ + name: 'tenant_configs', + description: 'tenant_configs to save any tenant specific data related to idP', +}) +export class TenantConfig extends UserModifiableEntity { + @property({ + type: 'string', + id: true, + generated: true, + }) + id: string; + + @property({ + type: 'string', + required: true, + name: 'config_key', + }) + configKey: string; + + @property({ + type: 'object', + required: true, + name: 'config_value', + }) + configValue: object; + + @belongsTo( + () => Tenant, + {keyTo: 'id'}, + { + type: 'string', + name: 'tenant_id', + description: 'id of the tenant this invoice is generated for', + required: true, + }, + ) + tenantId: string; + + constructor(data?: Partial) { + super(data); + } +} diff --git a/services/tenant-management-service/src/models/tenant-mgmt-config.model.ts b/services/tenant-management-service/src/models/tenant-mgmt-config.model.ts new file mode 100644 index 0000000..6a22340 --- /dev/null +++ b/services/tenant-management-service/src/models/tenant-mgmt-config.model.ts @@ -0,0 +1,47 @@ +import {model, property, belongsTo, AnyObject} from '@loopback/repository'; +import {UserModifiableEntity} from '@sourceloop/core'; +import {Tenant} from './tenant.model'; + +@model({ + name: 'tenant_mgmt_configs', + description: + 'tenant_mgmt_configs to save any tenant specific data related to idP', +}) +export class TenantMgmtConfig extends UserModifiableEntity { + @property({ + type: 'string', + id: true, + generated: true, + }) + id: string; + + @property({ + type: 'string', + required: true, + name: 'config_key', + }) + configKey: string; + + @property({ + type: 'object', + required: true, + name: 'config_value', + }) + configValue: AnyObject; + + @belongsTo( + () => Tenant, + {keyTo: 'id'}, + { + type: 'string', + name: 'tenant_id', + description: 'id of the tenant this invoice is generated for', + required: true, + }, + ) + tenantId: string; + + constructor(data?: Partial) { + super(data); + } +} diff --git a/services/tenant-management-service/src/permissions.ts b/services/tenant-management-service/src/permissions.ts index 4cf7dd9..2eface1 100644 --- a/services/tenant-management-service/src/permissions.ts +++ b/services/tenant-management-service/src/permissions.ts @@ -22,6 +22,10 @@ export const PermissionKey = { UpdateSubscription: '7002', ViewSubscription: '7004', ViewPlan: '7008', + CreateTenantConfig: '10220', + ViewTenantConfig: '10221', + UpdateTenantConfig: '10222', + DeleteTenantConfig: '10223', // notification service ViewNotificationTemplate: '8000', diff --git a/services/tenant-management-service/src/providers/idp/idp-auth0.provider.ts b/services/tenant-management-service/src/providers/idp/idp-auth0.provider.ts new file mode 100644 index 0000000..741fd3f --- /dev/null +++ b/services/tenant-management-service/src/providers/idp/idp-auth0.provider.ts @@ -0,0 +1,181 @@ +import {Provider} from '@loopback/context'; + +import {ConfigureIdpFunc, IdpDetails, IdPKey, IdpResp} from '../../types'; +import {ManagementClient, PostOrganizationsRequest, UserCreate} from 'auth0'; +import {repository} from '@loopback/repository'; +import {randomBytes} from 'crypto'; +import {HttpErrors} from '@loopback/rest'; +import {TenantMgmtConfigRepository} from '../../repositories'; + +const STATUS_OK = 200; +const STATUS_NOT_FOUND = 404; +export class Auth0IdpProvider implements Provider> { + management: ManagementClient; + + constructor( + @repository(TenantMgmtConfigRepository) + private readonly tenantConfigRepository: TenantMgmtConfigRepository, + ) {} + + value(): ConfigureIdpFunc { + return payload => this.configure(payload); + } + async configure(payload: IdpDetails): Promise { + this.management = new ManagementClient({ + domain: process.env.AUTH0_DOMAIN ?? '', + clientId: process.env.AUTH0_CLIENT_ID ?? '', + clientSecret: process.env.AUTH0_CLIENT_SECRET ?? '', + audience: process.env.AUTH0_AUDIENCE, + }); + const tenant = payload.tenant; + const planTier = payload.plan.tier; + const tenantConfig = await this.tenantConfigRepository.find({ + where: {tenantId: tenant.id, configKey: IdPKey.AUTH0}, + }); + if (!tenantConfig) { + throw new HttpErrors.NotFound( + `Tenant configuration not found for tenant: ${tenant.id}`, + ); + } + + const configValue = tenantConfig[0].configValue; + + /**Organization name for silo tenants will be its key + * whereas for pooled tenants it will be the plan tier + * all the pooled tenants will be under the same organization + */ + const orgName = + planTier === 'PREMIUM' ? tenant.key : planTier.toLowerCase(); + const organizationData: PostOrganizationsRequest = { + name: orgName, + // eslint-disable-next-line + display_name: orgName, + branding: { + // eslint-disable-next-line + logo_url: configValue.logo_url, + colors: { + primary: configValue.primary_color, + // eslint-disable-next-line + page_background: configValue.page_background, + }, + }, + // eslint-disable-next-line + enabled_connections: configValue.enabled_connections, + }; + function generateStrongPassword(length: number): string { + const regex = /[A-Za-z0-9!@#$%^&*()_+~`|}{[\]:;?><,./-=]/; //NOSONAR + const validChars: string[] = []; + + const ASCII_PRINTABLE_START = 33; + + const ASCII_PRINTABLE_END = 126; + + for (let i = ASCII_PRINTABLE_START; i <= ASCII_PRINTABLE_END; i++) { + const char = String.fromCharCode(i); + if (regex.test(char)) { + validChars.push(char); + } + } + const randomBytesArray = randomBytes(length); + const password = Array.from(randomBytesArray) + .map(byte => validChars[byte % validChars.length]) + .join(''); + return password; + } + + const passwordLength = 20; + const password = generateStrongPassword(passwordLength); + const userData: UserCreate = { + email: tenant.contacts[0].email, + + connection: configValue.connection, + /* saving a constant password for now + ** this will a random generated string that will be temporary password + ** the user will be forced to change it on first login + ** need to check actions in auth0 to see how we can achieve this + **/ + password: password, + // eslint-disable-next-line + verify_email: configValue.verify_email, + // eslint-disable-next-line + phone_number: configValue.phone_number, + // eslint-disable-next-line + user_metadata: configValue.user_metadata, + blocked: configValue.blocked, + // eslint-disable-next-line + email_verified: configValue.email_verified, + // eslint-disable-next-line + app_metadata: configValue.app_metadata, + // eslint-disable-next-line + given_name: configValue.given_name, + // eslint-disable-next-line + family_name: configValue.family_name, + nickname: configValue.nickname, + picture: configValue.picture, + // eslint-disable-next-line + user_id: configValue.user_id, + }; + + let organizationId!: string; + + if (planTier === 'PREMIUM') { + const organization = await this.createOrganization(organizationData); + organizationId = organization.data.id; + } else { + try { + const organizationResponse = + await this.management.organizations.getByName({name: orgName}); + + if (organizationResponse.status === STATUS_OK) { + organizationId = organizationResponse.data.id; + } + } catch (error) { + if (error.statusCode === STATUS_NOT_FOUND) { + const organization = await this.createOrganization(organizationData); + organizationId = organization.data.id; + } else { + throw new Error(`Error checking organization: ${error.message}`); + } + } + } + + if (!organizationId) { + throw new Error('Failed to retrieve or create organization ID.'); + } + + const user = await this.createUser(userData); + const userId = user.data.user_id; + + await this.addMemberToOrganization(organizationId, userId); + return { + authId: userId, + }; + } + async createOrganization(data: PostOrganizationsRequest) { + try { + return await this.management.organizations.create(data); + } catch (error) { + throw new Error(`Error creating organization: ${error.message}`); + } + } + async createUser(userData: UserCreate) { + try { + return await this.management.users.create(userData); + } catch (error) { + throw new Error(`Error creating user: ${error.message}`); + } + } + + async addMemberToOrganization(organizationId: string, userId: string) { + try { + return await this.management.organizations.addMembers( + {id: organizationId}, + { + members: [userId], + }, + ); + } catch (error) { + throw new Error(`Error adding member to organization: ${error.message}`); + } + } +} diff --git a/services/tenant-management-service/src/providers/idp/idp-keycloak.provider.ts b/services/tenant-management-service/src/providers/idp/idp-keycloak.provider.ts new file mode 100644 index 0000000..9209971 --- /dev/null +++ b/services/tenant-management-service/src/providers/idp/idp-keycloak.provider.ts @@ -0,0 +1,404 @@ +import {Provider} from '@loopback/context'; +import axios from 'axios'; +import qs from 'qs'; +import {ConfigureIdpFunc, IdpDetails, IdpResp} from '../../types'; +import AWS from 'aws-sdk'; +import {randomBytes} from 'crypto'; + +interface TokenResponse { + // eslint-disable-next-line + access_token: string; +} + +export class KeycloakIdpProvider + implements Provider> +{ + ssm: AWS.SSM; + + constructor() { + this.ssm = new AWS.SSM({region: process.env.AWS_REGION}); + } + + value(): ConfigureIdpFunc { + return payload => this.configure(payload); + } + + async configure(payload: IdpDetails): Promise { + const {tenant, plan} = payload; + + try { + const token = await this.authenticateAdmin(); + + // Fetch the clientId, clientSecret, and realmName from AWS SSM + const clientId = await this.getParameterFromSSM( + `/${process.env.NAMESPACE}/${process.env.ENVIRONMENT}/${plan.tier.toLowerCase()}/${tenant.key}/keycloak-client-id`, + ); + const clientSecret = await this.getParameterFromSSM( + `/${process.env.NAMESPACE}/${process.env.ENVIRONMENT}/${plan.tier.toLowerCase()}/${tenant.key}/keycloak-client-secret`, + ); + const realmName = await this.getParameterFromSSM( + `/${process.env.NAMESPACE}/${process.env.ENVIRONMENT}/${plan.tier.toLowerCase()}/${tenant.key}/keycloak-client-realm`, + ); + + // Handling the logic based on tenant tier + if (plan.tier === 'PREMIUM') { + // For PREMIUM: always create a new realm + await this.createRealm(realmName ?? tenant.key, token); + } else if (plan.tier === 'STANDARD' || plan.tier === 'BASIC') { + // For STANDARD or BASIC: check if the realm exists + const realmExists = await this.realmExists( + realmName ?? tenant.key, + token, + ); + if (!realmExists) { + // If the realm does not exist, create it + await this.createRealm(realmName ?? tenant.key, token); + } + } + + // Set up SMTP settings in the realm for AWS SES + await this.setupEmailSettings(realmName ?? tenant.key, token); + + // Create a new client within the realm + await this.createClient( + realmName ?? tenant.key, + clientId, + token, + clientSecret, + tenant.key, + ); + + // Create a new admin user for the tenant + const adminUsername = tenant.contacts[0].email; + const passwordLength = 20; + const adminPassword = this.generateStrongPassword(passwordLength); + const {firstName, lastName, email} = tenant.contacts[0]; + + const user = await this.createUser( + realmName ?? tenant.key, + adminUsername, + adminPassword, + firstName, + lastName, + email, + token, + ); + + return { + authId: user.id, + }; + } catch (error) { + throw new Error( + `Failed to configure Keycloak for tenant: ${tenant.name}`, + ); + } + } + + // Method to check if a realm exists + async realmExists(realmName: string, token: string): Promise { + try { + const response = await axios.get( + `${process.env.KEYCLOAK_HOST}/admin/realms/${realmName}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + // If the realm exists, a successful response is returned (status code 200) + return response.status === 200; + } catch (error) { + if (error.response && error.response.status === 404) { + // If a 404 is returned, it means the realm doesn't exist + return false; + } + // Rethrow any other errors + throw new Error(`Error checking realm existence: ${error.message}`); + } + } + + // Method to authenticate as Keycloak Admin + async authenticateAdmin(): Promise { + const response = await axios.post( + `${process.env.KEYCLOAK_HOST}/realms/master/protocol/openid-connect/token`, + qs.stringify({ + username: process.env.KEYCLOAK_ADMIN_USERNAME, + password: process.env.KEYCLOAK_ADMIN_PASSWORD, + // eslint-disable-next-line + grant_type: 'password', + // eslint-disable-next-line + client_id: 'admin-cli', + }), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ); + + return response.data.access_token; + } + + async createRealm(realmName: string, token: string): Promise { + try { + await axios.post( + `${process.env.KEYCLOAK_HOST}/admin/realms`, + { + realm: realmName, + enabled: true, + }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + console.log(`Realm '${realmName}' created successfully.`); + } catch (error) { + if (axios.isAxiosError(error)) { + // Axios-specific error handling + console.error( + `Error creating realm: ${error.response?.data || error.message}`, + ); + } else { + // Generic error handling + console.error(`An unexpected error occurred: ${error.message}`); + } + throw new Error( + `Failed to create realm '${realmName}': ${error.message}`, + ); + } + } + + // Method to set up AWS SES SMTP settings in the realm + async setupEmailSettings(realmName: string, token: string): Promise { + try { + await axios.put( + `${process.env.KEYCLOAK_HOST}/admin/realms/${realmName}`, + { + smtpServer: { + auth: true, + starttls: true, // Enables TLS + host: process.env.AWS_SES_SMTP_HOST, // Example: email-smtp.us-east-1.amazonaws.com + port: '587', // Use port 587 for TLS + user: process.env.AWS_SES_SMTP_USERNAME, // Your AWS SES SMTP username + password: process.env.AWS_SES_SMTP_PASSWORD, // Your AWS SES SMTP password + from: process.env.SMTP_FROM_EMAIL, // The "from" email address, e.g. 'no-reply@yourdomain.com' + fromDisplayName: process.env.SMTP_FROM_DISPLAY_NAME, // The display name, e.g. 'Your Company Name' + }, + }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + console.log(`SMTP settings updated for realm '${realmName}'.`); + } catch (error) { + if (axios.isAxiosError(error)) { + console.error( + `Error setting up email settings: ${error.response?.data || error.message}`, + ); + } else { + console.error( + `An unexpected error occurred while setting up email settings: ${error.message}`, + ); + } + throw new Error( + `Failed to set up email settings for realm '${realmName}': ${error.message}`, + ); + } + } + // Method to create a new Keycloak client + async createClient( + realmName: string, + clientId: string, + token: string, + clientSecret: string, + key: string, + ): Promise { + try { + const redirectUris = [ + 'http://localhost:3000/*', + `https://${key}.${process.env.DOMAIN_NAME}/authentication-service/*`, + ]; + + await axios.post( + `${process.env.KEYCLOAK_HOST}/admin/realms/${realmName}/clients`, + { + clientId: clientId, + publicClient: false, // Must be false for client authentication + secret: clientSecret, + directAccessGrantsEnabled: true, + protocol: 'openid-connect', + enabled: true, + redirectUris: redirectUris, + clientAuthenticatorType: 'client-secret', // Enable client authentication + }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + console.log( + `Client '${clientId}' created successfully in realm '${realmName}'.`, + ); + } catch (error) { + if (axios.isAxiosError(error)) { + // Handle Axios-specific error + console.error( + `Error creating client: ${error.response?.data || error.message}`, + ); + } else { + // Handle generic error + console.error(`An unexpected error occurred: ${error.message}`); + } + throw new Error( + `Failed to create client '${clientId}' in realm '${realmName}': ${error.message}`, + ); + } + } + + // Method to create a new Keycloak user + async createUser( + realmName: string, + username: string, + password: string, + firstName: string, + lastName: string, + email: string, + token: string, + ): Promise<{id: string}> { + try { + const createUserResponse = await axios.post( + `${process.env.KEYCLOAK_HOST}/admin/realms/${realmName}/users`, + { + username: username, + enabled: true, + firstName: firstName, + lastName: lastName, + email: email, + emailVerified: true, + credentials: [ + { + type: 'password', + value: password, + temporary: true, // Set password as temporary + }, + ], + }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + const locationHeader = createUserResponse.headers['location']; + if (!locationHeader) { + throw new Error( + "User creation failed, no 'Location' header in response.", + ); + } + + const userId = locationHeader.split('/').pop(); + if (!userId) { + throw new Error( + "User creation failed, could not extract user ID from 'Location' header.", + ); + } + + // Send the password reset email + await this.sendPasswordResetEmail(realmName, userId, token); + + console.log( + `User '${username}' created successfully with ID '${userId}'.`, + ); + return {id: userId}; + } catch (error) { + if (axios.isAxiosError(error)) { + // Handle Axios-specific error + console.error( + `Error creating user: ${error.response?.data || error.message}`, + ); + } else { + // Handle generic error + console.error(`An unexpected error occurred: ${error.message}`); + } + throw new Error( + `Failed to create user '${username}' in realm '${realmName}': ${error.message}`, + ); + } + } + + // Method to send a password reset email + async sendPasswordResetEmail( + realmName: string, + userId: string, + token: string, + ): Promise { + try { + await axios.put( + `${process.env.KEYCLOAK_HOST}/admin/realms/${realmName}/users/${userId}/execute-actions-email`, + ['UPDATE_PASSWORD'], + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + console.log( + `Password reset email sent to user '${userId}' in realm '${realmName}'.`, + ); + } catch (error) { + if (axios.isAxiosError(error)) { + // Handle Axios-specific error + console.error( + `Error sending password reset email: ${error.response?.data || error.message}`, + ); + } else { + // Handle generic error + console.error(`An unexpected error occurred: ${error.message}`); + } + throw new Error( + `Failed to send password reset email for user '${userId}' in realm '${realmName}': ${error.message}`, + ); + } + } + + // Helper function to fetch parameters from AWS SSM with error handling + async getParameterFromSSM(parameterName: string): Promise { + try { + const response = await this.ssm + .getParameter({Name: parameterName, WithDecryption: true}) + .promise(); + return response.Parameter?.Value ?? ''; + } catch (error) { + console.error(`Error fetching parameter ${parameterName}:`, error); + // Optionally, you can throw the error or return a default value + throw new Error(`Failed to fetch parameter ${parameterName}`); + } + } + + generateStrongPassword(length: number): string { + const regex = /[A-Za-z0-9!@#$%^&*()_+~`|}{[\]:;?><,./-=]/; //NOSONAR + const validChars: string[] = []; + + const ASCII_PRINTABLE_START = 33; + + const ASCII_PRINTABLE_END = 126; + + for (let i = ASCII_PRINTABLE_START; i <= ASCII_PRINTABLE_END; i++) { + const char = String.fromCharCode(i); + if (regex.test(char)) { + validChars.push(char); + } + } + const randomBytesArray = randomBytes(length); + const password = Array.from(randomBytesArray) + .map(byte => validChars[byte % validChars.length]) + .join(''); + return password; + } +} diff --git a/services/tenant-management-service/src/providers/idp/index.ts b/services/tenant-management-service/src/providers/idp/index.ts new file mode 100644 index 0000000..0f157fc --- /dev/null +++ b/services/tenant-management-service/src/providers/idp/index.ts @@ -0,0 +1,3 @@ +export * from './idp-auth0.provider'; +export * from './idp-keycloak.provider'; +export * from './types'; diff --git a/services/tenant-management-service/src/providers/idp/types.ts b/services/tenant-management-service/src/providers/idp/types.ts new file mode 100644 index 0000000..1f80e7d --- /dev/null +++ b/services/tenant-management-service/src/providers/idp/types.ts @@ -0,0 +1,4 @@ +export type Auth0Response = { + organizationId: string; + userId: string; +}; diff --git a/services/tenant-management-service/src/providers/index.ts b/services/tenant-management-service/src/providers/index.ts index ec52e1f..735b9aa 100644 --- a/services/tenant-management-service/src/providers/index.ts +++ b/services/tenant-management-service/src/providers/index.ts @@ -1,2 +1,3 @@ export * from './lead-token-verify.provider'; export * from './system-user.provider'; +export * from './idp'; diff --git a/services/tenant-management-service/src/repositories/index.ts b/services/tenant-management-service/src/repositories/index.ts index 296033f..c1266c1 100644 --- a/services/tenant-management-service/src/repositories/index.ts +++ b/services/tenant-management-service/src/repositories/index.ts @@ -6,4 +6,5 @@ export * from './resource.repository'; export * from './invoice.repository'; export * from './address.repository'; export * from './lead-token.repository'; +export * from './tenant-mgmt-config.repository'; export * from './saas-tenant.repository'; diff --git a/services/tenant-management-service/src/repositories/tenant-mgmt-config.repository.ts b/services/tenant-management-service/src/repositories/tenant-mgmt-config.repository.ts new file mode 100644 index 0000000..a3c4b6c --- /dev/null +++ b/services/tenant-management-service/src/repositories/tenant-mgmt-config.repository.ts @@ -0,0 +1,37 @@ +import {Getter, inject} from '@loopback/core'; +import {juggler, repository, BelongsToAccessor} from '@loopback/repository'; +import {Tenant, TenantMgmtConfig} from '../models'; +import { + DefaultUserModifyCrudRepository, + IAuthUserWithPermissions, +} from '@sourceloop/core'; +import {SYSTEM_USER} from '../keys'; +import {TenantManagementDbSourceName} from '../types'; +import {TenantRepository} from './tenant.repository'; + +export class TenantMgmtConfigRepository extends DefaultUserModifyCrudRepository< + TenantMgmtConfig, + typeof TenantMgmtConfig.prototype.id, + {} +> { + public readonly tenant: BelongsToAccessor< + Tenant, + typeof TenantMgmtConfig.prototype.id + >; + + constructor( + @inject.getter(SYSTEM_USER) + public readonly getCurrentUser: Getter, + @inject(`datasources.${TenantManagementDbSourceName}`) + dataSource: juggler.DataSource, + @repository.getter('TenantRepository') + protected tenantRepositoryGetter: Getter, + ) { + super(TenantMgmtConfig, dataSource, getCurrentUser); + this.tenant = this.createBelongsToAccessorFor( + 'tenant', + tenantRepositoryGetter, + ); + this.registerInclusionResolver('tenant', this.tenant.inclusionResolver); + } +} diff --git a/services/tenant-management-service/src/types/i-idp.interface.ts b/services/tenant-management-service/src/types/i-idp.interface.ts new file mode 100644 index 0000000..031386f --- /dev/null +++ b/services/tenant-management-service/src/types/i-idp.interface.ts @@ -0,0 +1,18 @@ +import {AnyObject} from '@loopback/repository'; + +export enum IdPKey { + AUTH0 = 'auth0', + COGNITO = 'cognito', + KEYCLOAK = 'keycloak', +} + +export type ConfigureIdpFunc = (payload: IdpDetails) => Promise; + +export interface IdpDetails { + tenant: AnyObject; + plan: AnyObject; +} + +export interface IdpResp { + authId: string; +} diff --git a/services/tenant-management-service/src/types/index.ts b/services/tenant-management-service/src/types/index.ts index 0b6a3bf..f40a550 100644 --- a/services/tenant-management-service/src/types/index.ts +++ b/services/tenant-management-service/src/types/index.ts @@ -39,3 +39,4 @@ export * from './i-provisioning-service.interface'; export * from './i-subscription.interface'; export * from './i-event-connector.interface'; export * from './i-post-webhook-handler-service.interface'; +export * from './i-idp.interface'; diff --git a/services/tenant-management-service/src/webhook.component.ts b/services/tenant-management-service/src/webhook.component.ts index 7dde2e4..49b7209 100644 --- a/services/tenant-management-service/src/webhook.component.ts +++ b/services/tenant-management-service/src/webhook.component.ts @@ -29,48 +29,60 @@ import { AuthorizationComponent, } from 'loopback4-authorization'; import { + IdpController, + TenantMgmtConfigController, + TenantMgmtConfigTenantController, + WebhookController, +} from './controllers'; +import { + CallbackVerifierProvider, + WebhookVerifierProvider, +} from './interceptors'; +import { + CALLABCK_VERIFIER, SYSTEM_USER, TenantManagementServiceBindings, WEBHOOK_CONFIG, WEBHOOK_VERIFIER, } from './keys'; -import {ITenantManagementServiceConfig} from './types'; -import {WebhookController} from './controllers'; import { Address, Contact, + CreateLeadDTO, Invoice, Lead, LeadToken, + ProvisioningDTO, Resource, Tenant, - WebhookSecret, - CreateLeadDTO, - ProvisioningDTO, + TenantMgmtConfig, TenantOnboardDTO, VerifyLeadResponseDTO, WebhookDTO, + WebhookSecret, } from './models'; +import {KeycloakIdpProvider, SystemUserProvider} from './providers'; +import {Auth0IdpProvider} from './providers/idp/idp-auth0.provider'; import { AddressRepository, ContactRepository, InvoiceRepository, - LeadTokenRepository, LeadRepository, + LeadTokenRepository, ResourceRepository, + TenantMgmtConfigRepository, TenantRepository, WebhookSecretRepository, SaasTenantRepository, } from './repositories'; -import {WebhookVerifierProvider} from './interceptors'; -import {SystemUserProvider} from './providers'; import {CryptoHelperService, NotificationService} from './services'; +import {ProvisioningWebhookHandler} from './services/webhook'; +import {ITenantManagementServiceConfig} from './types'; import { DEFAULT_SIGNATURE_HEADER, DEFAULT_TIMESTAMP_HEADER, DEFAULT_TIMESTAMP_TOLERANCE, } from './utils'; -import {ProvisioningWebhookHandler} from './services/webhook'; export class WebhookTenantManagementServiceComponent implements Component { constructor( @@ -112,6 +124,7 @@ export class WebhookTenantManagementServiceComponent implements Component { TenantRepository, SaasTenantRepository, WebhookSecretRepository, + TenantMgmtConfigRepository, ]; this.models = [ @@ -128,12 +141,26 @@ export class WebhookTenantManagementServiceComponent implements Component { TenantOnboardDTO, VerifyLeadResponseDTO, WebhookDTO, + TenantMgmtConfig, ]; - this.controllers = [WebhookController]; + this.controllers = [ + WebhookController, + IdpController, + TenantMgmtConfigController, + TenantMgmtConfigTenantController, + ]; this.bindings = [ Binding.bind(WEBHOOK_VERIFIER).toProvider(WebhookVerifierProvider), + Binding.bind(CALLABCK_VERIFIER).toProvider(CallbackVerifierProvider), + + Binding.bind(TenantManagementServiceBindings.IDP_KEYCLOAK).toProvider( + KeycloakIdpProvider, + ), + Binding.bind(TenantManagementServiceBindings.IDP_AUTH0).toProvider( + Auth0IdpProvider, + ), Binding.bind(SYSTEM_USER).toProvider(SystemUserProvider), Binding.bind(WEBHOOK_CONFIG).to({ signatureHeaderName: DEFAULT_SIGNATURE_HEADER, From d4531e987a3069d0994c4d104843cce0b02e312f Mon Sep 17 00:00:00 2001 From: Tyagi-Sunny Date: Fri, 13 Dec 2024 18:27:07 +0530 Subject: [PATCH 2/2] feat(tenant-management): remove sonar issues remove sonar issues 47 --- .../webhook.controller.acceptance.ts | 4 +- .../webhook-verifier.interceptor.ts | 4 +- .../providers/idp/idp-keycloak.provider.ts | 68 +++---------------- 3 files changed, 13 insertions(+), 63 deletions(-) diff --git a/services/tenant-management-service/src/__tests__/acceptance/webhook.controller.acceptance.ts b/services/tenant-management-service/src/__tests__/acceptance/webhook.controller.acceptance.ts index 9affaac..8b79d08 100644 --- a/services/tenant-management-service/src/__tests__/acceptance/webhook.controller.acceptance.ts +++ b/services/tenant-management-service/src/__tests__/acceptance/webhook.controller.acceptance.ts @@ -115,11 +115,11 @@ describe('WebhookController', () => { }); it('should return 401 status for a webhook call with an expired timestamp', async () => { - const sixSeconds = 20000; + const TIMESTAMP_TOLERANCE_MS = 20000; // 20 seconds // generate token that was set 6 seconds ago const headers = await buildHeaders( webhookPayload, - Date.now() - sixSeconds, + Date.now() - TIMESTAMP_TOLERANCE_MS, ); await client .post('/webhook') diff --git a/services/tenant-management-service/src/interceptors/webhook-verifier.interceptor.ts b/services/tenant-management-service/src/interceptors/webhook-verifier.interceptor.ts index e740f7a..eb2da92 100644 --- a/services/tenant-management-service/src/interceptors/webhook-verifier.interceptor.ts +++ b/services/tenant-management-service/src/interceptors/webhook-verifier.interceptor.ts @@ -78,9 +78,9 @@ export class WebhookVerifierProvider implements Provider { this.logger.error('Invalid signature'); throw new HttpErrors.Unauthorized(); } - + const TIMESTAMP_TOLERANCE_MS = 20000; // 20 seconds // timestamp should be within 5-20 seconds - if (Math.abs(timestamp - Date.now()) > 20000) { + if (Math.abs(timestamp - Date.now()) > TIMESTAMP_TOLERANCE_MS) { this.logger.error('Timestamp out of tolerance'); throw new HttpErrors.Unauthorized(); } diff --git a/services/tenant-management-service/src/providers/idp/idp-keycloak.provider.ts b/services/tenant-management-service/src/providers/idp/idp-keycloak.provider.ts index 9209971..76b2d51 100644 --- a/services/tenant-management-service/src/providers/idp/idp-keycloak.provider.ts +++ b/services/tenant-management-service/src/providers/idp/idp-keycloak.provider.ts @@ -5,6 +5,11 @@ import {ConfigureIdpFunc, IdpDetails, IdpResp} from '../../types'; import AWS from 'aws-sdk'; import {randomBytes} from 'crypto'; +export const STATUS = { + OK: 200, + NOT_FOUND: 404, +}; + interface TokenResponse { // eslint-disable-next-line access_token: string; @@ -54,6 +59,8 @@ export class KeycloakIdpProvider // If the realm does not exist, create it await this.createRealm(realmName ?? tenant.key, token); } + } else { + // DO NOTHING } // Set up SMTP settings in the realm for AWS SES @@ -106,9 +113,9 @@ export class KeycloakIdpProvider }, ); // If the realm exists, a successful response is returned (status code 200) - return response.status === 200; + return response.status === STATUS.OK; } catch (error) { - if (error.response && error.response.status === 404) { + if (error.response && error.response.status === STATUS.NOT_FOUND) { // If a 404 is returned, it means the realm doesn't exist return false; } @@ -153,17 +160,7 @@ export class KeycloakIdpProvider }, }, ); - console.log(`Realm '${realmName}' created successfully.`); } catch (error) { - if (axios.isAxiosError(error)) { - // Axios-specific error handling - console.error( - `Error creating realm: ${error.response?.data || error.message}`, - ); - } else { - // Generic error handling - console.error(`An unexpected error occurred: ${error.message}`); - } throw new Error( `Failed to create realm '${realmName}': ${error.message}`, ); @@ -193,17 +190,7 @@ export class KeycloakIdpProvider }, }, ); - console.log(`SMTP settings updated for realm '${realmName}'.`); } catch (error) { - if (axios.isAxiosError(error)) { - console.error( - `Error setting up email settings: ${error.response?.data || error.message}`, - ); - } else { - console.error( - `An unexpected error occurred while setting up email settings: ${error.message}`, - ); - } throw new Error( `Failed to set up email settings for realm '${realmName}': ${error.message}`, ); @@ -241,19 +228,7 @@ export class KeycloakIdpProvider }, }, ); - console.log( - `Client '${clientId}' created successfully in realm '${realmName}'.`, - ); } catch (error) { - if (axios.isAxiosError(error)) { - // Handle Axios-specific error - console.error( - `Error creating client: ${error.response?.data || error.message}`, - ); - } else { - // Handle generic error - console.error(`An unexpected error occurred: ${error.message}`); - } throw new Error( `Failed to create client '${clientId}' in realm '${realmName}': ${error.message}`, ); @@ -312,20 +287,8 @@ export class KeycloakIdpProvider // Send the password reset email await this.sendPasswordResetEmail(realmName, userId, token); - console.log( - `User '${username}' created successfully with ID '${userId}'.`, - ); return {id: userId}; } catch (error) { - if (axios.isAxiosError(error)) { - // Handle Axios-specific error - console.error( - `Error creating user: ${error.response?.data || error.message}`, - ); - } else { - // Handle generic error - console.error(`An unexpected error occurred: ${error.message}`); - } throw new Error( `Failed to create user '${username}' in realm '${realmName}': ${error.message}`, ); @@ -348,19 +311,7 @@ export class KeycloakIdpProvider }, }, ); - console.log( - `Password reset email sent to user '${userId}' in realm '${realmName}'.`, - ); } catch (error) { - if (axios.isAxiosError(error)) { - // Handle Axios-specific error - console.error( - `Error sending password reset email: ${error.response?.data || error.message}`, - ); - } else { - // Handle generic error - console.error(`An unexpected error occurred: ${error.message}`); - } throw new Error( `Failed to send password reset email for user '${userId}' in realm '${realmName}': ${error.message}`, ); @@ -375,7 +326,6 @@ export class KeycloakIdpProvider .promise(); return response.Parameter?.Value ?? ''; } catch (error) { - console.error(`Error fetching parameter ${parameterName}:`, error); // Optionally, you can throw the error or return a default value throw new Error(`Failed to fetch parameter ${parameterName}`); }