From 0ecf67f5b2f0f0a9cfaf71a4a093b2a24428817a Mon Sep 17 00:00:00 2001 From: Surbhi-sharma1 Date: Fri, 27 Sep 2024 18:03:29 +0530 Subject: [PATCH] feat(tenant-management): integrate auth0 integrate auth0 GH-47 --- package-lock.json | 44 +++++ ...20240925102459-add-table-tenant-configs.js | 40 ++-- .../tenant-management-service/package.json | 2 + .../src/component.ts | 25 ++- .../src/controllers/idp.controller.ts | 110 +++++------ .../tenant-config-tenant.controller.ts | 35 ++-- .../controllers/tenant-config.controller.ts | 183 +++++++++++++----- .../tenant-management-service/src/keys.ts | 35 ++-- .../src/models/dtos/idp-details-dto.model.ts | 12 +- .../src/models/dtos/tenant-dto.model.ts | 17 +- .../src/models/index.ts | 2 +- .../src/models/tenant-config.model.ts | 15 +- .../src/permissions.ts | 5 + .../src/providers/idp/idp-auth0.provider.ts | 164 ++++++++++++++++ .../providers/idp/idp-keycloak.provider.ts | 132 +++++++------ .../src/providers/idp/index.ts | 4 +- .../src/providers/idp/types.ts | 47 +++++ .../src/repositories/index.ts | 1 - .../repositories/tenant-config.repository.ts | 34 ++-- .../src/types/i-idp.interface.ts | 23 ++- .../src/types/index.ts | 2 +- .../src/webhook.component.ts | 29 ++- 22 files changed, 685 insertions(+), 276 deletions(-) create mode 100644 services/tenant-management-service/src/providers/idp/idp-auth0.provider.ts create mode 100644 services/tenant-management-service/src/providers/idp/types.ts diff --git a/package-lock.json b/package-lock.json index 091acf3..2508b31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3812,6 +3812,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.145", "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.145.tgz", @@ -4788,6 +4794,33 @@ "node": ">= 4.0.0" } }, + "node_modules/auth0": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/auth0/-/auth0-4.10.0.tgz", + "integrity": "sha512-xfNtSyL84w9z1DQXWV1GXgtq2Oi3OXeJe/r+pI29GKZHpfgspNb4rFqp/CqI8zKVir6L3Iq2KZgE2rDHRDtxfA==", + "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", @@ -10748,6 +10781,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.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", @@ -20234,10 +20276,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 index 2052c51..9e7d6e8 100644 --- 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 @@ -8,46 +8,52 @@ 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) { + * 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){ +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) { + }).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){ +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) { + }).then(function (data) { return db.runSql(data); }); }; exports._meta = { - "version": 1 + version: 1, }; diff --git a/services/tenant-management-service/package.json b/services/tenant-management-service/package.json index 62c7912..335b615 100644 --- a/services/tenant-management-service/package.json +++ b/services/tenant-management-service/package.json @@ -101,10 +101,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/component.ts b/services/tenant-management-service/src/component.ts index 404e149..17e53cd 100644 --- a/services/tenant-management-service/src/component.ts +++ b/services/tenant-management-service/src/component.ts @@ -45,6 +45,18 @@ import { SYSTEM_USER, TenantManagementServiceBindings, } from './keys'; +import {ITenantManagementServiceConfig} from './types'; +import {InvoiceController} from './controllers/invoice.controller'; +import { + ContactController, + HomePageController, + LeadTenantController, + LeadController, + PingController, + TenantController, + TenantConfigController, + TenantConfigTenantController, +} from './controllers'; import { Address, Contact, @@ -82,7 +94,8 @@ import { OnboardingService, ProvisioningService, } from './services'; -import { ITenantManagementServiceConfig } from './types'; +import {IdpController} from './controllers/idp.controller'; + export class TenantManagementServiceComponent implements Component { constructor( @inject(CoreBindings.APPLICATION_INSTANCE) @@ -122,7 +135,7 @@ export class TenantManagementServiceComponent implements Component { ResourceRepository, TenantRepository, WebhookSecretRepository, - TenantConfigRepository + TenantConfigRepository, ]; this.models = [ @@ -139,7 +152,7 @@ export class TenantManagementServiceComponent implements Component { TenantOnboardDTO, VerifyLeadResponseDTO, WebhookDTO, - TenantConfig + TenantConfig, ]; this.controllers = [ @@ -149,12 +162,16 @@ export class TenantManagementServiceComponent implements Component { LeadTenantController, LeadController, PingController, - TenantController + TenantController, + IdpController, + TenantConfigController, + TenantConfigTenantController, ]; this.bindings = [ Binding.bind(LEAD_TOKEN_VERIFIER).toProvider(LeadTokenVerifierProvider), Binding.bind(SYSTEM_USER).toProvider(SystemUserProvider), + createServiceBinding(ProvisioningService), createServiceBinding(OnboardingService), createServiceBinding(LeadAuthenticator), diff --git a/services/tenant-management-service/src/controllers/idp.controller.ts b/services/tenant-management-service/src/controllers/idp.controller.ts index 086b51c..15ee0e5 100644 --- a/services/tenant-management-service/src/controllers/idp.controller.ts +++ b/services/tenant-management-service/src/controllers/idp.controller.ts @@ -1,65 +1,65 @@ -import { inject, intercept } from '@loopback/core'; -import { getModelSchemaRef, post, requestBody } from '@loopback/rest'; +import {inject, intercept} from '@loopback/core'; +import {getModelSchemaRef, post, requestBody} from '@loopback/rest'; import { - CONTENT_TYPE, - OPERATION_SECURITY_SPEC, - rateLimitKeyGenPublic, - STATUS_CODE, + CONTENT_TYPE, + OPERATION_SECURITY_SPEC, + rateLimitKeyGenPublic, + STATUS_CODE, } from '@sourceloop/core'; -import { authorize } from 'loopback4-authorization'; -import { ratelimit } from 'loopback4-ratelimiter'; -import { TenantManagementServiceBindings, WEBHOOK_VERIFIER } from '../keys'; -import { IdpDetailsDTO } from '../models/dtos/idp-details-dto.model'; -import { ConfigureIdpFunc, IdPKey } from '../types'; +import {authorize} from 'loopback4-authorization'; +import {ratelimit} from 'loopback4-ratelimiter'; +import {TenantManagementServiceBindings, WEBHOOK_VERIFIER} from '../keys'; +import {IdpDetailsDTO} from '../models/dtos/idp-details-dto.model'; +import {ConfigureIdpFunc, IdPKey} from '../types'; const basePath = '/manage/users'; export class IdpController { - constructor( - @inject(TenantManagementServiceBindings.IDP_KEYCLOAK) - private readonly idpKeycloakProvider:ConfigureIdpFunc - ) { } - @intercept(WEBHOOK_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', - }, + constructor( + @inject(TenantManagementServiceBindings.IDP_KEYCLOAK) + private readonly idpKeycloakProvider: ConfigureIdpFunc, + @inject(TenantManagementServiceBindings.IDP_AUTH0) + private readonly idpAuth0Provider: ConfigureIdpFunc, + ) {} + @intercept(WEBHOOK_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', + }), }, + }, }) - async idpConfigure( - @requestBody({ - content: { - [CONTENT_TYPE.JSON]: { - schema: getModelSchemaRef(IdpDetailsDTO, { - title: 'IdpDetailsDTO', - }), - }, - }, - }) - payload: IdpDetailsDTO, - ): Promise { - switch (payload.identityProvider) { - case IdPKey.AUTH0: - - break; - case IdPKey.COGNITO: - - break; - case IdPKey.KEYCLOAK: - await this.idpKeycloakProvider(payload); - break; - - default: - break; - } + payload: IdpDetailsDTO, + ): Promise { + switch (payload.identityProvider) { + case IdPKey.AUTH0: + await this.idpAuth0Provider(payload); + break; + case IdPKey.COGNITO: + break; + case IdPKey.KEYCLOAK: + await this.idpKeycloakProvider(payload); + break; + default: + break; } + } } diff --git a/services/tenant-management-service/src/controllers/tenant-config-tenant.controller.ts b/services/tenant-management-service/src/controllers/tenant-config-tenant.controller.ts index fe79d87..5a3e99e 100644 --- a/services/tenant-management-service/src/controllers/tenant-config-tenant.controller.ts +++ b/services/tenant-management-service/src/controllers/tenant-config-tenant.controller.ts @@ -1,26 +1,27 @@ -import { - repository, -} from '@loopback/repository'; -import { - param, - get, - getModelSchemaRef, -} from '@loopback/rest'; -import { - TenantConfig, - Tenant, -} from '../models'; +import {repository} from '@loopback/repository'; +import {param, get, getModelSchemaRef} from '@loopback/rest'; +import {TenantConfig, Tenant} from '../models'; import {TenantConfigRepository} 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 TenantConfigTenantController { constructor( @repository(TenantConfigRepository) public tenantConfigRepository: TenantConfigRepository, - ) { } - - @get('/tenant-configs/{id}/tenant', { + ) {} + @authorize({ + permissions: [PermissionKey.ViewTenantConfig], + }) + @authenticate(STRATEGY.BEARER, { + passReqToCallback: true, + }) + @get(`${basePath}`, { + security: OPERATION_SECURITY_SPEC, responses: { - '200': { + [STATUS_CODE.OK]: { description: 'Tenant belonging to TenantConfig', content: { 'application/json': { diff --git a/services/tenant-management-service/src/controllers/tenant-config.controller.ts b/services/tenant-management-service/src/controllers/tenant-config.controller.ts index b1bbcdf..fc690cc 100644 --- a/services/tenant-management-service/src/controllers/tenant-config.controller.ts +++ b/services/tenant-management-service/src/controllers/tenant-config.controller.ts @@ -15,21 +15,39 @@ import { put, del, requestBody, - response, } from '@loopback/rest'; import {TenantConfig} from '../models'; import {TenantConfigRepository} 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 TenantConfigController { constructor( @repository(TenantConfigRepository) - public tenantConfigRepository : TenantConfigRepository, + public tenantConfigRepository: TenantConfigRepository, ) {} - - @post('/tenant-configs') - @response(200, { - description: 'TenantConfig model instance', - content: {'application/json': {schema: getModelSchemaRef(TenantConfig)}}, + @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(TenantConfig)}, + }, + }, + }, }) async create( @requestBody({ @@ -46,26 +64,44 @@ export class TenantConfigController { ): Promise { return this.tenantConfigRepository.create(tenantConfig); } - - @get('/tenant-configs/count') - @response(200, { - description: 'TenantConfig model count', - content: {'application/json': {schema: CountSchema}}, + @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(TenantConfig) where?: Where, ): Promise { return this.tenantConfigRepository.count(where); } - - @get('/tenant-configs') - @response(200, { - description: 'Array of TenantConfig model instances', - content: { - 'application/json': { - schema: { - type: 'array', - items: getModelSchemaRef(TenantConfig, {includeRelations: true}), + @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(TenantConfig, {includeRelations: true}), + }, + }, }, }, }, @@ -75,11 +111,24 @@ export class TenantConfigController { ): Promise { return this.tenantConfigRepository.find(filter); } - - @patch('/tenant-configs') - @response(200, { - description: 'TenantConfig PATCH success count', - content: {'application/json': {schema: CountSchema}}, + @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(TenantConfig), + }, + }, + }, + }, }) async updateAll( @requestBody({ @@ -94,26 +143,48 @@ export class TenantConfigController { ): Promise { return this.tenantConfigRepository.updateAll(tenantConfig, where); } - - @get('/tenant-configs/{id}') - @response(200, { - description: 'TenantConfig model instance', - content: { - 'application/json': { - schema: getModelSchemaRef(TenantConfig, {includeRelations: true}), + @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(TenantConfig)}, + }, }, }, }) async findById( @param.path.string('id') id: string, - @param.filter(TenantConfig, {exclude: 'where'}) filter?: FilterExcludingWhere + @param.filter(TenantConfig, {exclude: 'where'}) + filter?: FilterExcludingWhere, ): Promise { return this.tenantConfigRepository.findById(id, filter); } - - @patch('/tenant-configs/{id}') - @response(204, { - description: 'TenantConfig PATCH success', + @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(TenantConfig), + }, + }, + }, + }, }) async updateById( @param.path.string('id') id: string, @@ -128,10 +199,19 @@ export class TenantConfigController { ): Promise { await this.tenantConfigRepository.updateById(id, tenantConfig); } - - @put('/tenant-configs/{id}') - @response(204, { - description: 'TenantConfig PUT success', + @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, @@ -139,10 +219,19 @@ export class TenantConfigController { ): Promise { await this.tenantConfigRepository.replaceById(id, tenantConfig); } - - @del('/tenant-configs/{id}') - @response(204, { - description: 'TenantConfig DELETE success', + @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/keys.ts b/services/tenant-management-service/src/keys.ts index a1be917..a2f676a 100644 --- a/services/tenant-management-service/src/keys.ts +++ b/services/tenant-management-service/src/keys.ts @@ -1,4 +1,4 @@ -import { VerifyFunction } from 'loopback4-authentication'; +import {VerifyFunction} from 'loopback4-authentication'; import { ConfigureIdpFunc, ITenantManagementServiceConfig, @@ -6,18 +6,18 @@ import { WebhookConfig, WebhookNotificationServiceType, } from './types'; -import { IAuthUser } from 'loopback4-authorization'; -import { AnyObject } from '@loopback/repository'; -import { WebhookController } from './controllers'; +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'; -import { ValueOrPromise } from '@loopback/context'; +import {BINDING_PREFIX} from '@sourceloop/core'; +import {IEventConnector} from './types/i-event-connector.interface'; +import {Auth0Response} from './providers/idp'; export namespace TenantManagementServiceBindings { export const Config = @@ -25,11 +25,16 @@ export namespace TenantManagementServiceBindings { `${BINDING_PREFIX}.chat.config`, ); /** - * Binding key for the Idp keycloak provider. - */ - export const IDP_KEYCLOAK = BindingKey.create< - ConfigureIdpFunc - >('sf.user.idp.keycloak'); + * 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'); } /** @@ -39,10 +44,6 @@ export const LEAD_TOKEN_VERIFIER = BindingKey.create< VerifyFunction.BearerFn >('sf.user.lead.verifier'); - - - - /** * Binding key for the system user. */ @@ -75,7 +76,7 @@ export const WebhookHandlerEP = BindingKey.create>( */ export const asWebhookHandler: BindingTemplate = binding => { extensionFor(WebhookHandlerEP.key)(binding); - binding.tag({ namespace: WebhookHandlerEP.key }); + binding.tag({namespace: WebhookHandlerEP.key}); }; export const WebhookNotificationService = 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 index c12cac6..6f606d1 100644 --- 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 @@ -1,7 +1,7 @@ -import { getJsonSchema } from '@loopback/openapi-v3'; -import { Model, model, property } from '@loopback/repository'; -import { IdpDetails, IdPKey } from '../../types'; -import { TenantDto } from './tenant-dto.model'; +import {getJsonSchema} from '@loopback/openapi-v3'; +import {Model, model, property} from '@loopback/repository'; +import {IdpDetails, IdPKey} from '../../types'; +import {TenantDto} from './tenant-dto.model'; @model({ description: 'model describing payload for IDP controller', @@ -13,8 +13,8 @@ export class IdpDetailsDTO extends Model implements IdpDetails { required: true, default: IdPKey.AUTH0, jsonSchema: { - enum: Object.values(IdPKey), - }, + enum: Object.values(IdPKey), + }, }) identityProvider: IdPKey; 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 index 3bd36ab..c49b673 100644 --- a/services/tenant-management-service/src/models/dtos/tenant-dto.model.ts +++ b/services/tenant-management-service/src/models/dtos/tenant-dto.model.ts @@ -1,8 +1,9 @@ -import { getJsonSchema } from '@loopback/openapi-v3'; -import { model, property } from '@loopback/repository'; -import { Address } from '../address.model'; -import { Contact } from '../contact.model'; -import { Tenant } from '../tenant.model'; +import {getJsonSchema} from '@loopback/openapi-v3'; +import {AnyObject, 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', @@ -25,6 +26,12 @@ export class TenantDto extends Tenant { }, }) contacts: Contact[]; + @property({ + type: 'object', + description: 'plan details', + jsonSchema: getJsonSchema(Object), + }) + plan: AnyObject; 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 10cb796..d463d4d 100644 --- a/services/tenant-management-service/src/models/index.ts +++ b/services/tenant-management-service/src/models/index.ts @@ -7,4 +7,4 @@ export * from './resource.model'; export * from './invoice.model'; export * from './address.model'; export * from './lead-token.model'; -export * from './tenant-config.model'; \ No newline at end of file +export * from './tenant-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 index a700439..17c5945 100644 --- a/services/tenant-management-service/src/models/tenant-config.model.ts +++ b/services/tenant-management-service/src/models/tenant-config.model.ts @@ -1,10 +1,11 @@ -import { belongsTo, model, property } from '@loopback/repository'; -import { UserModifiableEntity } from '@sourceloop/core'; -import { Tenant } from './tenant.model'; +import {model, property, belongsTo} from '@loopback/repository'; +import {UserModifiableEntity} from '@sourceloop/core'; +import {Tenant} from './tenant.model'; +import {ConfigValue} from '../providers/idp'; @model({ name: 'tenant_configs', - description: 'tenant_configs to save any tenant specific data related to idP' + description: 'tenant_configs to save any tenant specific data related to idP', }) export class TenantConfig extends UserModifiableEntity { @property({ @@ -17,16 +18,16 @@ export class TenantConfig extends UserModifiableEntity { @property({ type: 'string', required: true, - name: 'config_key' + name: 'config_key', }) configKey: string; @property({ type: 'object', required: true, - name: 'config_value' + name: 'config_value', }) - configValue: object; + configValue: ConfigValue; @belongsTo( () => Tenant, diff --git a/services/tenant-management-service/src/permissions.ts b/services/tenant-management-service/src/permissions.ts index 4cf7dd9..c4953c2 100644 --- a/services/tenant-management-service/src/permissions.ts +++ b/services/tenant-management-service/src/permissions.ts @@ -17,6 +17,11 @@ export const PermissionKey = { DeleteInvoice: '10214', ViewInvoice: '10215', CreateNotification: '2', + CreateTenantConfig: '10216', + + UpdateTenantConfig: '10217', + DeleteTenantConfig: '10218', + ViewTenantConfig: '10219', CreateSubscription: '7001', UpdateSubscription: '7002', 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..b9219c1 --- /dev/null +++ b/services/tenant-management-service/src/providers/idp/idp-auth0.provider.ts @@ -0,0 +1,164 @@ +import {Provider} from '@loopback/context'; + +import {ConfigureIdpFunc, IdpDetails} from '../../types'; +import {ManagementClient} from 'auth0'; + +import {Auth0Response, ConfigValue, OrganizationData, UserData} from './types'; + +import {TenantConfigRepository} from '../../repositories/tenant-config.repository'; +import {repository} from '@loopback/repository'; + +import {HttpErrors} from '@loopback/rest'; +const STATUS_OK = 200; +const STATUS_NOT_FOUND = 404; +export class Auth0IdpProvider + implements Provider> +{ + management: ManagementClient; + + constructor( + @repository(TenantConfigRepository) + private readonly tenantConfigRepository: TenantConfigRepository, + ) {} + + 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; + const planTier = tenant.plan.tier; + const tenantConfig = await this.tenantConfigRepository.findOne({ + where: {tenantId: tenant.id}, + }); + if (!tenantConfig) { + throw new HttpErrors.NotFound( + `Tenant configuration not found for tenant: ${tenant.id}`, + ); + } + const configValue: ConfigValue = tenantConfig.configValue; + const organizationData: OrganizationData = { + name: tenant.name, + // eslint-disable-next-line + display_name: configValue.display_name, + // eslint-disable-next-line + logo_url: configValue.logo_url, + // eslint-disable-next-line + primary_color: configValue.primary_color, + // eslint-disable-next-line + page_background: configValue.page_background, + // eslint-disable-next-line + link_color: configValue.link_color, + }; + + const userData: UserData = { + email: tenant.contacts[0].email, + + connection: configValue.connection, + password: configValue.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: tenant.name}); + + 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 { + organizationId: organizationId, + userId: userId, + }; + } + async createOrganization(data: OrganizationData) { + try { + return await this.management.organizations.create({ + name: data.name, + // eslint-disable-next-line + display_name: data.display_name, + branding: { + // eslint-disable-next-line + logo_url: data.logo_url, + colors: { + primary: data.primary_color ?? '#007BFF', + // eslint-disable-next-line + page_background: data.page_background ?? '#007BFF', + }, + }, + // eslint-disable-next-line + enabled_connections: [], + }); + } catch (error) { + throw new Error(`Error creating organization: ${error.message}`); + } + } + async createUser(userData: UserData) { + 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 index 775752d..d583006 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 @@ -1,9 +1,10 @@ -import { Provider } from "@loopback/context"; +import {Provider} from '@loopback/context'; import axios from 'axios'; import qs from 'qs'; -import { ConfigureIdpFunc, IdpDetails } from "../../types"; +import {ConfigureIdpFunc, IdpDetails} from '../../types'; interface TokenResponse { + // eslint-disable-next-line access_token: string; } @@ -13,62 +14,60 @@ interface Credentials { temporary: boolean; } -export class KeycloakIdpProvider implements Provider>{ - constructor(){} +export class KeycloakIdpProvider implements Provider> { + constructor() {} - value(): ConfigureIdpFunc { - return (payload)=>this.configure(payload); - } - async configure(payload: IdpDetails): Promise { - const { tenant } = payload; - - try { - const token=await this.authenticateAdmin(); - // 1. Create a new realm using the tenant key - await this.createRealm(tenant.key,token); - - // 2. Create a new client within the realm - const clientId = `client-${tenant.key}`; // You can customize this as needed - await this.createClient(tenant.key, clientId,token); - - // 3. Create a new admin user for the tenant - const adminUsername = `${tenant.key}-admin`; // Customize this as needed - const adminPassword = 'your-secure-password'; // This can be dynamic or set in the environment - await this.createUser(tenant.key, adminUsername, adminPassword,token); - - console.log(`Successfully configured Keycloak for tenant: ${tenant.name}`); - } catch (error) { - console.error(`Error configuring Keycloak for tenant: ${tenant.name}`, error); - throw new Error(`Failed to configure Keycloak for tenant: ${tenant.name}`); - } - } - + value(): ConfigureIdpFunc { + return payload => this.configure(payload); + } + async configure(payload: IdpDetails): Promise { + const {tenant} = payload; + try { + const token = await this.authenticateAdmin(); + // 1. Create a new realm using the tenant key + await this.createRealm(tenant.key, token); + // 2. Create a new client within the realm + const clientId = `client-${tenant.key}`; // You can customize this as needed + await this.createClient(tenant.key, clientId, token); + + // 3. Create a new admin user for the tenant + const adminUsername = `${tenant.key}-admin`; // Customize this as needed + const adminPassword = 'your-secure-password'; // This can be dynamic or set in the environment + await this.createUser(tenant.key, adminUsername, adminPassword, token); + } catch (error) { + throw new Error( + `Failed to configure Keycloak for tenant: ${tenant.name}`, + ); + } + } - async authenticateAdmin(): Promise { + 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, - grant_type: 'password', - client_id: 'admin-cli', - }), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - } + `${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 { + async createRealm(realmName: string, token: string): Promise { // const token = await this.authenticateAdmin(); - - const response = await axios.post( + + await axios.post( `${process.env.KEYCLOAK_HOST}/admin/realms`, { realm: realmName, @@ -78,16 +77,18 @@ export class KeycloakIdpProvider implements Provider>{ headers: { Authorization: `Bearer ${token}`, }, - } + }, ); - - console.log('Realm created:', response.data); } - async createClient(realmName: string, clientId: string,token:string): Promise { + async createClient( + realmName: string, + clientId: string, + token: string, + ): Promise { // const token = await this.authenticateAdmin(); - - const response = await axios.post( + + await axios.post( `${process.env.KEYCLOAK_HOST}/admin/realms/${realmName}/clients`, { clientId: clientId, @@ -100,16 +101,19 @@ export class KeycloakIdpProvider implements Provider>{ headers: { Authorization: `Bearer ${token}`, }, - } + }, ); - - console.log('Client created:', response.data); } - async createUser(realmName: string, username: string, password: string,token:string): Promise { + async createUser( + realmName: string, + username: string, + password: string, + token: string, + ): Promise { // const token = await this.authenticateAdmin(); - - const response = await axios.post( + + await axios.post( `${process.env.KEYCLOAK_HOST}/admin/realms/${realmName}/users`, { username: username, @@ -126,13 +130,7 @@ export class KeycloakIdpProvider implements Provider>{ headers: { Authorization: `Bearer ${token}`, }, - } + }, ); - - console.log('User created:', response.data); } - - } - - diff --git a/services/tenant-management-service/src/providers/idp/index.ts b/services/tenant-management-service/src/providers/idp/index.ts index ff21cfb..0f157fc 100644 --- a/services/tenant-management-service/src/providers/idp/index.ts +++ b/services/tenant-management-service/src/providers/idp/index.ts @@ -1 +1,3 @@ -export * from './idp-keycloak.provider' \ No newline at end of file +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..d3b7c91 --- /dev/null +++ b/services/tenant-management-service/src/providers/idp/types.ts @@ -0,0 +1,47 @@ +import {PostInvitationsRequestAppMetadata} from 'auth0'; +export interface UserData { + email?: string; + // eslint-disable-next-line + phone_number?: string; + // eslint-disable-next-line + user_metadata?: {[key: string]: any}; //NOSONAR + blocked?: boolean; + // eslint-disable-next-line + email_verified?: boolean; + // eslint-disable-next-line + app_metadata?: PostInvitationsRequestAppMetadata; + // eslint-disable-next-line + given_name?: string; + // eslint-disable-next-line + family_name?: string; + name?: string; + nickname?: string; + picture?: string; + // eslint-disable-next-line + user_id?: string; + connection: string; + password?: string; + // eslint-disable-next-line + verify_email?: boolean; + username?: string; +} +export interface OrganizationData { + name: string; + // eslint-disable-next-line + display_name?: string; + // eslint-disable-next-line + logo_url?: string; + // eslint-disable-next-line + primary_color?: string; + // eslint-disable-next-line + page_background?: string; + // eslint-disable-next-line + link_color?: string; +} +export interface ConfigValue extends Omit, OrganizationData { + username: UserData['name']; +} +export type Auth0Response = { + organizationId: string; + userId: string; +}; diff --git a/services/tenant-management-service/src/repositories/index.ts b/services/tenant-management-service/src/repositories/index.ts index 3e7105b..7f7c28c 100644 --- a/services/tenant-management-service/src/repositories/index.ts +++ b/services/tenant-management-service/src/repositories/index.ts @@ -7,4 +7,3 @@ export * from './invoice.repository'; export * from './address.repository'; export * from './lead-token.repository'; export * from './tenant-config.repository'; -export * from './saas-tenant.repository'; \ No newline at end of file diff --git a/services/tenant-management-service/src/repositories/tenant-config.repository.ts b/services/tenant-management-service/src/repositories/tenant-config.repository.ts index 67941ab..752f325 100644 --- a/services/tenant-management-service/src/repositories/tenant-config.repository.ts +++ b/services/tenant-management-service/src/repositories/tenant-config.repository.ts @@ -1,27 +1,37 @@ -import { Getter, inject } from '@loopback/core'; -import { BelongsToAccessor, juggler, repository } from '@loopback/repository'; -import { DefaultUserModifyCrudRepository, IAuthUserWithPermissions } from '@sourceloop/core'; -import { SYSTEM_USER } from '../keys'; -import { Tenant, TenantConfig } from '../models'; -import { TenantManagementDbSourceName } from '../types'; -import { TenantRepository } from './tenant.repository'; +import {Getter, inject} from '@loopback/core'; +import {juggler, repository, BelongsToAccessor} from '@loopback/repository'; +import {TenantConfig, Tenant} from '../models'; +import { + DefaultUserModifyCrudRepository, + IAuthUserWithPermissions, +} from '@sourceloop/core'; +import {SYSTEM_USER} from '../keys'; +import {TenantManagementDbSourceName} from '../types'; +import {TenantRepository} from './tenant.repository'; export class TenantConfigRepository extends DefaultUserModifyCrudRepository< TenantConfig, typeof TenantConfig.prototype.id, {} > { - - public readonly tenant: BelongsToAccessor; + public readonly tenant: BelongsToAccessor< + Tenant, + typeof TenantConfig.prototype.id + >; constructor( @inject.getter(SYSTEM_USER) public readonly getCurrentUser: Getter, @inject(`datasources.${TenantManagementDbSourceName}`) - dataSource: juggler.DataSource, @repository.getter('TenantRepository') protected tenantRepositoryGetter: Getter, + dataSource: juggler.DataSource, + @repository.getter('TenantRepository') + protected tenantRepositoryGetter: Getter, ) { - super(TenantConfig, dataSource,getCurrentUser); - this.tenant = this.createBelongsToAccessorFor('tenant', tenantRepositoryGetter,); + super(TenantConfig, 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 index 1b0f3fe..e381dbf 100644 --- a/services/tenant-management-service/src/types/i-idp.interface.ts +++ b/services/tenant-management-service/src/types/i-idp.interface.ts @@ -1,15 +1,14 @@ -import { Tenant } from "../models"; +import {TenantDto} from '../models/dtos/tenant-dto.model'; export enum IdPKey { - AUTH0 = 'auth0', - COGNITO = 'cognito', - KEYCLOAK = 'keycloak', - } + AUTH0 = 'auth0', + COGNITO = 'cognito', + KEYCLOAK = 'keycloak', +} -export type ConfigureIdpFunc=(payload:IdpDetails)=>Promise; - - export interface IdpDetails { - identityProvider: IdPKey; - tenant: Tenant; - } - \ No newline at end of file +export type ConfigureIdpFunc = (payload: IdpDetails) => Promise; + +export interface IdpDetails { + identityProvider: IdPKey; + tenant: TenantDto; +} diff --git a/services/tenant-management-service/src/types/index.ts b/services/tenant-management-service/src/types/index.ts index 16f8603..3ac555b 100644 --- a/services/tenant-management-service/src/types/index.ts +++ b/services/tenant-management-service/src/types/index.ts @@ -38,4 +38,4 @@ export * from './resource.type'; export * from './i-provisioning-service.interface'; export * from './i-subscription.interface'; export * from './i-event-connector.interface'; -export * from './i-idp.interface'; \ No newline at end of file +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 39d5325..5fa722f 100644 --- a/services/tenant-management-service/src/webhook.component.ts +++ b/services/tenant-management-service/src/webhook.component.ts @@ -35,7 +35,11 @@ import { WEBHOOK_VERIFIER, } from './keys'; import {ITenantManagementServiceConfig} from './types'; -import {IdpController, TenantConfigController, TenantConfigTenantController, WebhookController} from './controllers'; +import { + TenantConfigController, + TenantConfigTenantController, + WebhookController, +} from './controllers'; import { Address, Contact, @@ -73,6 +77,9 @@ import { DEFAULT_TIMESTAMP_TOLERANCE, } from './utils'; import {ProvisioningWebhookHandler} from './services/webhook'; +import {KeycloakIdpProvider} from './providers/idp/idp-keycloak.provider'; +import {IdpController} from './controllers/idp.controller'; +import {Auth0IdpProvider} from './providers/idp'; export class WebhookTenantManagementServiceComponent implements Component { constructor( @@ -114,7 +121,7 @@ export class WebhookTenantManagementServiceComponent implements Component { TenantRepository, SaasTenantRepository, WebhookSecretRepository, - TenantConfigRepository + TenantConfigRepository, ]; this.models = [ @@ -131,14 +138,24 @@ export class WebhookTenantManagementServiceComponent implements Component { TenantOnboardDTO, VerifyLeadResponseDTO, WebhookDTO, - TenantConfig + TenantConfig, ]; - this.controllers = [WebhookController,IdpController,TenantConfigController,TenantConfigTenantController]; + this.controllers = [ + WebhookController, + IdpController, + TenantConfigController, + TenantConfigTenantController, + ]; this.bindings = [ - Binding.bind(WEBHOOK_VERIFIER).toProvider(WebhookVerifierProvider),Binding.bind(TenantManagementServiceBindings.IDP_KEYCLOAK).toProvider(KeycloakIdpProvider), - + Binding.bind(WEBHOOK_VERIFIER).toProvider(WebhookVerifierProvider), + 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,