From 1dd9c3efb360fee40d5325aba549d53d1acfd770 Mon Sep 17 00:00:00 2001 From: Surbhi Sharma Date: Thu, 12 Dec 2024 15:47:00 +0530 Subject: [PATCH] feat(user-tenant-service): add idp controller to manage users add idp controller to manage users GH-38 --- facades/tenant-mgmt-facade/.env.example | 4 +- .../src/controllers/tenant-user.controller.ts | 174 +++++ .../src/datasources/proxies/index.ts | 1 + .../proxies/user-tenant-service.datasource.ts | 102 +++ facades/tenant-mgmt-facade/src/enum/index.ts | 3 + .../src/enum/permission-key.enum.ts | 15 + .../src/enum/status-codes.enum.ts | 68 ++ .../src/enum/user-config-key.enum.ts | 3 + .../src/models/idp-details-dto.model.ts | 19 + .../src/models/user-dto.model.ts | 20 + .../src/models/user-view.model.ts | 157 +++++ .../src/models/user.model.ts | 145 ++++ facades/tenant-mgmt-facade/src/openapi.json | 628 +++++++++++++++++- .../src/services/proxies/index.ts | 1 + .../services/proxies/user-tenant.service.ts | 44 ++ .../src/services/tenant-helper.service.ts | 74 ++- facades/tenant-mgmt-facade/src/types.ts | 14 + .../user-tenant-service/src/application.ts | 6 +- .../src/controllers/idp.controller.ts | 64 ++ services/user-tenant-service/src/keys.ts | 12 + .../src/models/idp-details-dto.model.ts | 20 + .../user-tenant-service/src/models/index.ts | 1 + .../src/models/user-dto.model.ts | 21 + services/user-tenant-service/src/openapi.json | 137 ++++ .../providers/idp/idp-keycloak.provider.ts | 391 +++++++++++ .../src/providers/idp/index.ts | 3 + .../src/providers/idp/types.ts | 4 + .../src/providers/types.ts | 24 + 28 files changed, 2124 insertions(+), 31 deletions(-) create mode 100644 facades/tenant-mgmt-facade/src/controllers/tenant-user.controller.ts create mode 100644 facades/tenant-mgmt-facade/src/datasources/proxies/user-tenant-service.datasource.ts create mode 100644 facades/tenant-mgmt-facade/src/enum/permission-key.enum.ts create mode 100644 facades/tenant-mgmt-facade/src/enum/status-codes.enum.ts create mode 100644 facades/tenant-mgmt-facade/src/enum/user-config-key.enum.ts create mode 100644 facades/tenant-mgmt-facade/src/models/idp-details-dto.model.ts create mode 100644 facades/tenant-mgmt-facade/src/models/user-dto.model.ts create mode 100644 facades/tenant-mgmt-facade/src/models/user-view.model.ts create mode 100644 facades/tenant-mgmt-facade/src/models/user.model.ts create mode 100644 facades/tenant-mgmt-facade/src/services/proxies/user-tenant.service.ts create mode 100644 services/user-tenant-service/src/controllers/idp.controller.ts create mode 100644 services/user-tenant-service/src/keys.ts create mode 100644 services/user-tenant-service/src/models/idp-details-dto.model.ts create mode 100644 services/user-tenant-service/src/models/index.ts create mode 100644 services/user-tenant-service/src/models/user-dto.model.ts create mode 100644 services/user-tenant-service/src/providers/idp/idp-keycloak.provider.ts create mode 100644 services/user-tenant-service/src/providers/idp/index.ts create mode 100644 services/user-tenant-service/src/providers/idp/types.ts create mode 100644 services/user-tenant-service/src/providers/types.ts diff --git a/facades/tenant-mgmt-facade/.env.example b/facades/tenant-mgmt-facade/.env.example index ffc1c16..9679d31 100644 --- a/facades/tenant-mgmt-facade/.env.example +++ b/facades/tenant-mgmt-facade/.env.example @@ -34,4 +34,6 @@ AUTH0_CLIENT_SECRET= # payment gateway id of payment gateway used for payment GATEWAY_ACCOUNT_ID= WEBHOOK_USERNAME= -WEBHOOK_PASSWORD= \ No newline at end of file +WEBHOOK_PASSWORD= + +USER_TENANT_SERVICE_URL= \ No newline at end of file diff --git a/facades/tenant-mgmt-facade/src/controllers/tenant-user.controller.ts b/facades/tenant-mgmt-facade/src/controllers/tenant-user.controller.ts new file mode 100644 index 0000000..24b400e --- /dev/null +++ b/facades/tenant-mgmt-facade/src/controllers/tenant-user.controller.ts @@ -0,0 +1,174 @@ +import { + RestBindings, + Request, + get, + getModelSchemaRef, + getFilterSchemaFor, + param, + del, + post, + requestBody, + patch, + getWhereSchemaFor, +} from '@loopback/rest'; + +import {inject} from '@loopback/context'; +import {authenticate, STRATEGY} from 'loopback4-authentication'; +import {authorize} from 'loopback4-authorization'; + +import { + CONTENT_TYPE, + ErrorCodes, + OPERATION_SECURITY_SPEC, + STATUS_CODE, +} from '@sourceloop/core'; +import {AnyObject, Filter, Where} from '@loopback/repository'; + +import { + TenantMgmtProxyService, + UserTenantServiceProxy, +} from '../services/proxies'; +import {PermissionKey} from '../enum/permission-key.enum'; + +import {UserView} from '../models/user-view.model'; +import {UserDto} from '../models/user-dto.model'; +import {User} from '../models/user.model'; +import {IdpDetailsDTO} from '../models/idp-details-dto.model'; +import {TenantHelperService} from '../services'; +import {service} from '@loopback/core'; + +const basePath = '/tenants/{id}/users'; +export class TenantUserController { + private readonly currentUserToken; + constructor( + @inject(RestBindings.Http.REQUEST) + private readonly request: Request, + @inject('services.TenantMgmtProxyService') + private readonly tenantMgmtProxyService: TenantMgmtProxyService, + @inject('services.UserTenantServiceProxy') + private readonly utService: UserTenantServiceProxy, + @service(TenantHelperService) + private readonly tenantHelper: TenantHelperService, + ) { + this.currentUserToken = this.request.headers.authorization; + } + + @authenticate(STRATEGY.BEARER, { + passReqToCallback: true, + }) + @authorize({ + permissions: [ + PermissionKey.ViewTenantUser, + PermissionKey.ViewTenantUserNum, + ], + }) + @get(basePath, { + security: OPERATION_SECURITY_SPEC, + responses: { + ...ErrorCodes, + [STATUS_CODE.OK]: { + description: 'Array of Tenant has many Users', + content: { + [CONTENT_TYPE.JSON]: { + schema: {type: 'array', items: getModelSchemaRef(User)}, + }, + }, + }, + }, + }) + async find( + @param.path.string('id') id: string, + @param.query.object('filter', getFilterSchemaFor(UserView)) + filter?: Filter, + ): Promise { + return this.utService.findTenantUser( + id, + this.currentUserToken, + JSON.stringify(filter), + ); + } + + @authenticate(STRATEGY.BEARER, { + passReqToCallback: true, + }) + @authorize({ + permissions: [ + PermissionKey.ViewTenant, + PermissionKey.CreateTenantUser, + PermissionKey.CreateTenantUserNum, + ], + }) + @post(basePath, { + security: OPERATION_SECURITY_SPEC, + responses: { + ...ErrorCodes, + [STATUS_CODE.OK]: { + description: 'tenant user model instance', + content: { + [CONTENT_TYPE.JSON]: {schema:Object}, + }, + }, + }, + }) + async create( + @param.path.string('id') id: string, + @requestBody({ + [CONTENT_TYPE.JSON]: { + schema: getModelSchemaRef(IdpDetailsDTO, { + title: 'NewUserInTenant', + }), + }, + }) + userData: IdpDetailsDTO, + ): Promise { + return this.tenantHelper.createTenantUser(id,userData,this.currentUserToken); + const authId=this.utService.configureIdpDetails(userData,this.currentUserToken) + // const userDataPayload = { + // firstName: userData.firstName, + // middleName: userData.middleName, + // lastname: userData.last_name, + // username: userData.username, + // email: userData.email, + // designation: userData.designation, + // phone: userData.phone, + // authClientIds: userData.authClientIds, + // photoUrl: userData.photoUrl, + // gender: userData.gender, + // dob: userData.dob, + // roleId: userData.roleId , + // locale: userData.locale, + // } as Partial; + + // this.utService.createTenantUser(id, userDataPayload, this.currentUserToken); + // return {}; + } + + @authenticate(STRATEGY.BEARER, { + passReqToCallback: true, + }) + @authorize({ + permissions: [ + PermissionKey.DeleteTenantUser, + PermissionKey.DeleteTenantUserNum, + ], + }) + @del(`${basePath}/{userId}`, { + security: OPERATION_SECURITY_SPEC, + responses: { + [STATUS_CODE.NO_CONTENT]: { + description: 'User DELETE success', + }, + }, + }) + async deleteById( + @param.path.string('id') id: string, + @param.path.string('userId') userId: string, + ): Promise { + return this.utService.deleteTenantUserById( + id, + userId, + this.currentUserToken, + ); + } + +} diff --git a/facades/tenant-mgmt-facade/src/datasources/proxies/index.ts b/facades/tenant-mgmt-facade/src/datasources/proxies/index.ts index 61c47dd..022fddc 100644 --- a/facades/tenant-mgmt-facade/src/datasources/proxies/index.ts +++ b/facades/tenant-mgmt-facade/src/datasources/proxies/index.ts @@ -1,3 +1,4 @@ export * from './subscription-proxy.datasource'; export * from './tenant-mgmt-proxy.datasource'; export * from './notification-proxy.datasource'; +export * from './user-tenant-service.datasource' \ No newline at end of file diff --git a/facades/tenant-mgmt-facade/src/datasources/proxies/user-tenant-service.datasource.ts b/facades/tenant-mgmt-facade/src/datasources/proxies/user-tenant-service.datasource.ts new file mode 100644 index 0000000..496ea6d --- /dev/null +++ b/facades/tenant-mgmt-facade/src/datasources/proxies/user-tenant-service.datasource.ts @@ -0,0 +1,102 @@ +import {inject, lifeCycleObserver, LifeCycleObserver} from '@loopback/core'; +import {juggler} from '@loopback/repository'; +import {CONTENT_TYPE} from '@sourceloop/core'; + +const tokenKey = '{token}'; +const config = { + name: 'UserTenantService', + connector: 'rest', + baseUrl: process.env.USER_TENANT_SERVICE_URL as string, + crud: true, + options: { + baseUrl: process.env.USER_TENANT_SERVICE_URL as string, + headers: { + accept: CONTENT_TYPE.JSON, + ['content-type']: CONTENT_TYPE.JSON, + }, + }, + operations: [ + { + template: { + method: 'POST', + url: '/idp/users', + headers: { + Authorization: '{token}', + }, + body: '{body}', + }, + functions: { + configureIdpDetails: [ 'body', 'token'], + }, + }, + { + template: { + method: 'POST', + url: '/tenants/{id}/users', + headers: { + Authorization: '{token}', + }, + body: '{body}', + }, + functions: { + createTenantUser: ['id', 'body', 'token'], + }, + }, + { + template: { + method: 'DELETE', + url: `/tenants/{id}/users/{userId}`, + headers: { + Authorization: '{token}', + }, + }, + functions: { + deleteTenantUserById: ['id', 'userId', 'token'], + }, + }, + { + template: { + method: 'GET', + url: '/tenants/{id}/users', + headers: { + Authorization: tokenKey, + }, + query: { + filter: '{filter}', + }, + options: { + useQuerystring: true, + }, + }, + functions: { + findTenantUser: ['id', 'token', 'filter'], + }, + }, + ], +}; + +@lifeCycleObserver('datasource') +export class UserTenantServiceDataSource + extends juggler.DataSource + implements LifeCycleObserver +{ + static dataSourceName = 'UserTenantService'; + static readonly defaultConfig = config; + + constructor( + @inject('datasources.config.UserTenantService', {optional: true}) + dsConfig: object = config, + ) { + const dsConfigJson = { + ...dsConfig, + options: { + baseUrl: process.env.USER_SERVICE_URL, + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + }, + }; + super(dsConfigJson); + } +} diff --git a/facades/tenant-mgmt-facade/src/enum/index.ts b/facades/tenant-mgmt-facade/src/enum/index.ts index 3b6659c..7ed78e3 100644 --- a/facades/tenant-mgmt-facade/src/enum/index.ts +++ b/facades/tenant-mgmt-facade/src/enum/index.ts @@ -3,3 +3,6 @@ export * from './invoice-status.enum'; export * from './webhook-types.enum'; export * from './subscription-status.enum'; export * from './notification-type.enum'; +export * from './status-codes.enum'; +export * from './permission-key.enum'; +export * from './user-config-key.enum'; diff --git a/facades/tenant-mgmt-facade/src/enum/permission-key.enum.ts b/facades/tenant-mgmt-facade/src/enum/permission-key.enum.ts new file mode 100644 index 0000000..cb3ab56 --- /dev/null +++ b/facades/tenant-mgmt-facade/src/enum/permission-key.enum.ts @@ -0,0 +1,15 @@ +export const enum PermissionKey { + ViewTenantUser = 'ViewTenantUser', + CreateTenantUser = 'CreateTenantUser', + UpdateTenantUser = 'UpdateTenantUser', + UpdateTenantUserRestricted = 'UpdateTenantUserRestricted', + DeleteTenantUser = 'DeleteTenantUser', + CreateTenant = 'CreateTenant', + ViewTenant = 'ViewTenant', + ViewTenantUserNum = '12', + CreateTenantUserNum = '13', + UpdateTenantUserNum = '14', + DeleteTenantUserNum = '15', + CreateTenantNum = '16', + ViewTenantNum = '17', +} diff --git a/facades/tenant-mgmt-facade/src/enum/status-codes.enum.ts b/facades/tenant-mgmt-facade/src/enum/status-codes.enum.ts new file mode 100644 index 0000000..6544d69 --- /dev/null +++ b/facades/tenant-mgmt-facade/src/enum/status-codes.enum.ts @@ -0,0 +1,68 @@ +/* eslint-disable-next-line @typescript-eslint/naming-convention */ +export const enum STATUS_CODE { + // sonarignore:start + CONTINUE = 100, + SWITCHING_PROTOCOLS = 101, + PROCESSING = 102, + EARLYHINTS = 103, + OK = 200, + CREATED = 201, + ACCEPTED = 202, + NON_AUTHORITATIVE_INFORMATION = 203, + NO_CONTENT = 204, + RESET_CONTENT = 205, + PARTIAL_CONTENT = 206, + AMBIGUOUS = 300, + MOVED_PERMANENTLY = 301, + FOUND = 302, + SEE_OTHER = 303, + NOT_MODIFIED = 304, + TEMPORARY_REDIRECT = 307, + PERMANENT_REDIRECT = 308, + BAD_REQUEST = 400, + UNAUTHORISED = 401, + PAYMENT_REQUIRED = 402, + FORBIDDEN = 403, + NOT_FOUND = 404, + METHOD_NOT_ALLOWED = 405, + NOT_ACCEPTABLE = 406, + PROXY_AUTHENTICATION_REQUIRED = 407, + REQUEST_TIMEOUT = 408, + CONFLICT = 409, + GONE = 410, + LENGTH_REQUIRED = 411, + PRECONDITION_FAILED = 412, + PAYLOAD_TOO_LARGE = 413, + URI_TOO_LONG = 414, + UNSUPPORTED_MEDIA_TYPE = 415, + REQUESTED_RANGE_NOT_SATISFIABLE = 416, + EXPECTATION_FAILED = 417, + I_AM_A_TEAPOT = 418, + MISDIRECTED = 421, + UNPROCESSED_ENTITY = 422, + FAILED_DEPENDENCY = 424, + PRECONDITION_REQUIRED = 428, + TOO_MANY_REQUESTS = 429, + INTERNAL_SERVER_ERROR = 500, + NOT_IMPLEMENTED = 501, + BAD_GATEWAY = 502, + SERVICE_UNAVAILABLE = 503, + GATEWAY_TIMEOUT = 504, + HTTP_VERSION_NOT_SUPPORTED = 505, + // sonarignore:end +} + +export const ErrorCodes = { + [STATUS_CODE.UNAUTHORISED]: { + description: 'Invalid Credentials.', + }, + [STATUS_CODE.BAD_REQUEST]: { + description: 'The syntax of the request entity is incorrect.', + }, + [STATUS_CODE.UNPROCESSED_ENTITY]: { + description: 'The syntax of the request entity is incorrect', + }, + [STATUS_CODE.NOT_FOUND]: { + description: 'The entity requested does not exist.', + }, +}; diff --git a/facades/tenant-mgmt-facade/src/enum/user-config-key.enum.ts b/facades/tenant-mgmt-facade/src/enum/user-config-key.enum.ts new file mode 100644 index 0000000..8e07980 --- /dev/null +++ b/facades/tenant-mgmt-facade/src/enum/user-config-key.enum.ts @@ -0,0 +1,3 @@ +export const enum UserConfigKey { + LastAccessedUrl = 'last-accessed-url', +} diff --git a/facades/tenant-mgmt-facade/src/models/idp-details-dto.model.ts b/facades/tenant-mgmt-facade/src/models/idp-details-dto.model.ts new file mode 100644 index 0000000..a873908 --- /dev/null +++ b/facades/tenant-mgmt-facade/src/models/idp-details-dto.model.ts @@ -0,0 +1,19 @@ +import {getJsonSchema} from '@loopback/openapi-v3'; +import {AnyObject, Model, model, property} from '@loopback/repository'; +import { UserDto } from './user-dto.model'; + +@model({ + description: 'model describing payload for IDP controller', +}) +export class IdpDetailsDTO extends UserDto { + @property({ + type: 'object', + description: 'Tenat object', + jsonSchema: getJsonSchema(Object), + }) + tenant: AnyObject; + + constructor(data?: Partial) { + super(data); + } +} diff --git a/facades/tenant-mgmt-facade/src/models/user-dto.model.ts b/facades/tenant-mgmt-facade/src/models/user-dto.model.ts new file mode 100644 index 0000000..b88d27e --- /dev/null +++ b/facades/tenant-mgmt-facade/src/models/user-dto.model.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2023 Sourcefuse Technologies +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT +import {model, property} from '@loopback/repository'; +import {User} from './user.model'; + +@model() +export class UserDto extends User { + @property({ + type: 'string', + required: true, + }) + roleId: string; + + @property({ + type: 'string', + }) + locale?: string; +} diff --git a/facades/tenant-mgmt-facade/src/models/user-view.model.ts b/facades/tenant-mgmt-facade/src/models/user-view.model.ts new file mode 100644 index 0000000..f8ab087 --- /dev/null +++ b/facades/tenant-mgmt-facade/src/models/user-view.model.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2023 Sourcefuse Technologies +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT +import {DataObject, Model, model, property} from '@loopback/repository'; +import {Gender, UserModifiableEntity, UserStatus} from '@sourceloop/core'; + +@model({ + name: 'v_users', + description: 'User details view in DB', + settings: { + defaultIdSort: false, + }, +}) +export class UserView> extends UserModifiableEntity< + T & UserView +> { + @property({ + type: 'string', + id: true, + generated: false, + }) + id?: string; + + @property({ + type: 'string', + required: true, + name: 'first_name', + }) + firstName: string; + + @property({ + type: 'string', + name: 'last_name', + }) + lastName: string; + + @property({ + type: 'string', + name: 'middle_name', + }) + middleName?: string; + + @property({ + type: 'string', + required: true, + }) + username: string; + + @property({ + type: 'string', + required: true, + }) + email: string; + + @property({ + type: 'string', + }) + designation?: string; + + @property({ + type: 'string', + }) + phone?: string; + + @property({ + type: 'string', + name: 'auth_client_ids', + }) + authClientIds: string; + + @property({ + name: 'last_login', + type: 'string', + }) + lastLogin?: string; + + @property({ + name: 'photo_url', + type: 'string', + }) + photoUrl?: string; + + @property({ + type: 'string', + description: `This field takes a single character as input in database. + 'M' for male and 'F' for female.`, + jsonSchema: { + enum: ['M', 'F', 'O'], + }, + }) + gender?: Gender; + + @property({ + type: 'date', + jsonSchema: { + nullable: true, + }, + }) + dob?: Date; + + @property({ + type: 'string', + name: 'default_tenant_id', + required: true, + }) + defaultTenantId: string; + + @property({ + type: 'number', + jsonSchema: { + maximum: 11, + minimum: 0, + }, + }) + status?: UserStatus; + + @property({ + type: 'string', + name: 'tenant_id', + required: true, + }) + tenantId: string; + + @property({ + type: 'string', + name: 'role_id', + required: true, + }) + roleId: string; + + @property({ + name: 'name', + type: 'string', + required: true, + }) + tenantName: string; + + @property({ + name: 'key', + type: 'string', + }) + tenantKey?: string; + + @property({ + name: 'rolename', + type: 'string', + }) + roleName?: string; + + @property({ + name: 'user_tenant_id', + type: 'string', + required: true, + }) + userTenantId: string; +} diff --git a/facades/tenant-mgmt-facade/src/models/user.model.ts b/facades/tenant-mgmt-facade/src/models/user.model.ts new file mode 100644 index 0000000..a86d4b8 --- /dev/null +++ b/facades/tenant-mgmt-facade/src/models/user.model.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2023 Sourcefuse Technologies +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT +import { + DataObject, + Model, + belongsTo, + hasMany, + hasOne, + model, + property, +} from '@loopback/repository'; +import {Gender, UserModifiableEntity} from '@sourceloop/core'; +import {IAuthUser} from 'loopback4-authentication'; +import { + Tenant, + TenantWithRelations, + +} from '../models'; +import { UserCredentials, UserCredentialsWithRelations, UserTenant, UserTenantWithRelations } from '@sourceloop/authentication-service'; + +@model({ + name: 'users', + description: 'This is signature for user model.', +}) +export class User> + extends UserModifiableEntity + implements IAuthUser +{ + @property({ + type: 'string', + id: true, + generated: true, + }) + id: string; + + @property({ + type: 'string', + required: true, + name: 'first_name', + }) + firstName: string; + + @property({ + type: 'string', + name: 'last_name', + }) + lastName: string; + + @property({ + type: 'string', + name: 'middle_name', + }) + middleName?: string; + + @property({ + type: 'string', + required: true, + }) + username: string; + + @property({ + type: 'string', + required: true, + }) + email: string; + + @property({ + type: 'string', + }) + designation?: string; + + @property({ + type: 'string', + jsonSchema: { + pattern: `^\\+?[1-9]\\d{1,14}$`, + }, + }) + phone?: string; + + @property({ + type: 'string', + name: 'auth_client_ids', + }) + authClientIds?: string; + + @property({ + type: 'date', + name: 'last_login', + postgresql: { + column: 'last_login', + }, + }) + lastLogin?: Date; + + @property({ + name: 'photo_url', + type: 'string', + }) + photoUrl?: string; + + @property({ + type: 'string', + description: `This field takes a single character as input in database. + 'M' for male and 'F' for female.`, + jsonSchema: { + enum: ['M', 'F', 'O'], + }, + }) + gender?: Gender; + + @property({ + type: 'date', + }) + dob?: Date; + + //Indexer property to allow additional data + //eslint-disable-next-line @typescript-eslint/no-explicit-any + [prop: string]: any; //NOSONAR + + @belongsTo( + () => Tenant, + {keyFrom: 'defaultTenantId', name: 'defaultTenant'}, + { + name: 'default_tenant_id', + required: false, + }, + ) + defaultTenantId?: string; + + @hasOne(() => UserCredentials, {keyTo: 'userId'}) + credentials: UserCredentials; + + @hasMany(() => UserTenant, {keyTo: 'userId'}) + userTenants: UserTenant[]; +} + +export interface UserRelations { + defaultTenant: TenantWithRelations; + credentials: UserCredentialsWithRelations; + userTenants: UserTenantWithRelations[]; +} + +export type UserWithRelations = User & UserRelations; diff --git a/facades/tenant-mgmt-facade/src/openapi.json b/facades/tenant-mgmt-facade/src/openapi.json index 5404d29..eabd9fc 100644 --- a/facades/tenant-mgmt-facade/src/openapi.json +++ b/facades/tenant-mgmt-facade/src/openapi.json @@ -695,6 +695,173 @@ "operationId": "TenantMgmtConfigController.find" } }, + "/tenants/{id}/users/{userId}": { + "delete": { + "x-controller-name": "TenantUserController", + "x-operation-name": "deleteById", + "tags": [ + "TenantUserController" + ], + "security": [ + { + "HTTPBearer": [] + } + ], + "responses": { + "204": { + "description": "User DELETE success" + } + }, + "description": "\n\n| Permissions |\n| ------- |\n| DeleteTenantUser |\n| 15 |\n", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "userId", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "operationId": "TenantUserController.deleteById" + } + }, + "/tenants/{id}/users": { + "post": { + "x-controller-name": "TenantUserController", + "x-operation-name": "create", + "tags": [ + "TenantUserController" + ], + "security": [ + { + "HTTPBearer": [] + } + ], + "responses": { + "200": { + "description": "tenant user model instance", + "content": { + "application/json": {} + } + }, + "400": { + "description": "The syntax of the request entity is incorrect." + }, + "401": { + "description": "Invalid Credentials." + }, + "404": { + "description": "The entity requested does not exist." + }, + "422": { + "description": "The syntax of the request entity is incorrect" + } + }, + "description": "\n\n| Permissions |\n| ------- |\n| ViewTenant |\n| CreateTenantUser |\n| 13 |\n", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IdpDetailsDTO" + } + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUserInTenant", + "definitions": { + "NewUserInTenant": { + "$ref": "#/components/schemas/NewUserInTenant" + } + } + } + }, + "x-parameter-index": 1 + }, + "operationId": "TenantUserController.create" + }, + "get": { + "x-controller-name": "TenantUserController", + "x-operation-name": "find", + "tags": [ + "TenantUserController" + ], + "security": [ + { + "HTTPBearer": [] + } + ], + "responses": { + "200": { + "description": "Array of Tenant has many Users", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "400": { + "description": "The syntax of the request entity is incorrect." + }, + "401": { + "description": "Invalid Credentials." + }, + "404": { + "description": "The entity requested does not exist." + }, + "422": { + "description": "The syntax of the request entity is incorrect" + } + }, + "description": "\n\n| Permissions |\n| ------- |\n| ViewTenantUser |\n| 12 |\n", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "filter", + "in": "query", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/v_users.Filter" + } + } + } + } + ], + "operationId": "TenantUserController.find" + } + }, "/tenants": { "post": { "x-controller-name": "TenantController", @@ -889,17 +1056,103 @@ "IdpDetailsDTO": { "title": "IdpDetailsDTO", "type": "object", - "description": "model describing payload for IDP controller (tsType: IdpDetailsDTO, schemaOptions: { title: 'IdpDetailsDTO' })", + "description": "model describing payload for IDP controller", "properties": { + "deleted": { + "type": "boolean" + }, + "deletedOn": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "deletedBy": { + "type": "string", + "nullable": true + }, + "createdOn": { + "type": "string", + "format": "date-time" + }, + "modifiedOn": { + "type": "string", + "format": "date-time" + }, + "createdBy": { + "type": "string" + }, + "modifiedBy": { + "type": "string" + }, + "id": { + "readOnly": true, + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "middleName": { + "type": "string" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "designation": { + "type": "string" + }, + "phone": { + "type": "string", + "pattern": "^\\+?[1-9]\\d{1,14}$" + }, + "authClientIds": { + "type": "string" + }, + "lastLogin": { + "type": "string", + "format": "date-time" + }, + "photoUrl": { + "type": "string" + }, + "gender": { + "type": "string", + "description": "This field takes a single character as input in database.\n 'M' for male and 'F' for female.", + "enum": [ + "M", + "F", + "O" + ] + }, + "dob": { + "type": "string", + "format": "date-time" + }, + "defaultTenantId": { + "type": "string" + }, + "roleId": { + "type": "string" + }, + "locale": { + "type": "string" + }, "tenant": { "type": "object", "description": "Tenat object" - }, - "plan": { - "type": "object", - "description": "plan object" } }, + "required": [ + "firstName", + "username", + "email", + "roleId" + ], "additionalProperties": false }, "TenantMgmtConfig": { @@ -1936,6 +2189,97 @@ ], "additionalProperties": false }, + "User": { + "title": "User", + "type": "object", + "description": "This is signature for user model.", + "properties": { + "deleted": { + "type": "boolean" + }, + "deletedOn": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "deletedBy": { + "type": "string", + "nullable": true + }, + "createdOn": { + "type": "string", + "format": "date-time" + }, + "modifiedOn": { + "type": "string", + "format": "date-time" + }, + "createdBy": { + "type": "string" + }, + "modifiedBy": { + "type": "string" + }, + "id": { + "readOnly": true, + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "middleName": { + "type": "string" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "designation": { + "type": "string" + }, + "phone": { + "type": "string", + "pattern": "^\\+?[1-9]\\d{1,14}$" + }, + "authClientIds": { + "type": "string" + }, + "lastLogin": { + "type": "string", + "format": "date-time" + }, + "photoUrl": { + "type": "string" + }, + "gender": { + "type": "string", + "description": "This field takes a single character as input in database.\n 'M' for male and 'F' for female.", + "enum": [ + "M", + "F", + "O" + ] + }, + "dob": { + "type": "string", + "format": "date-time" + }, + "defaultTenantId": { + "type": "string" + } + }, + "required": [ + "firstName", + "username", + "email" + ], + "additionalProperties": false + }, "CheckBillingSubscriptionsDTO": { "title": "CheckBillingSubscriptionsDTO", "type": "object", @@ -2518,6 +2862,280 @@ "additionalProperties": false, "x-typescript-type": "@loopback/repository#Filter" }, + "NewUserInTenant": { + "title": "NewUserInTenant", + "type": "object", + "description": "model describing payload for IDP controller (tsType: IdpDetailsDTO, schemaOptions: { title: 'NewUserInTenant' })", + "properties": { + "deleted": { + "type": "boolean" + }, + "deletedOn": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "deletedBy": { + "type": "string", + "nullable": true + }, + "createdOn": { + "type": "string", + "format": "date-time" + }, + "modifiedOn": { + "type": "string", + "format": "date-time" + }, + "createdBy": { + "type": "string" + }, + "modifiedBy": { + "type": "string" + }, + "id": { + "readOnly": true, + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "middleName": { + "type": "string" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "designation": { + "type": "string" + }, + "phone": { + "type": "string", + "pattern": "^\\+?[1-9]\\d{1,14}$" + }, + "authClientIds": { + "type": "string" + }, + "lastLogin": { + "type": "string", + "format": "date-time" + }, + "photoUrl": { + "type": "string" + }, + "gender": { + "type": "string", + "description": "This field takes a single character as input in database.\n 'M' for male and 'F' for female.", + "enum": [ + "M", + "F", + "O" + ] + }, + "dob": { + "type": "string", + "format": "date-time" + }, + "defaultTenantId": { + "type": "string" + }, + "roleId": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "tenant": { + "type": "object", + "description": "Tenat object" + } + }, + "required": [ + "firstName", + "username", + "email", + "roleId" + ], + "additionalProperties": false + }, + "v_users.Filter": { + "type": "object", + "title": "v_users.Filter", + "properties": { + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "example": 100 + }, + "skip": { + "type": "integer", + "minimum": 0 + }, + "order": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "where": { + "title": "v_users.WhereFilter", + "type": "object", + "additionalProperties": true + }, + "fields": { + "oneOf": [ + { + "type": "object", + "properties": { + "deleted": { + "type": "boolean" + }, + "deletedOn": { + "type": "boolean" + }, + "deletedBy": { + "type": "boolean" + }, + "createdOn": { + "type": "boolean" + }, + "modifiedOn": { + "type": "boolean" + }, + "createdBy": { + "type": "boolean" + }, + "modifiedBy": { + "type": "boolean" + }, + "id": { + "type": "boolean" + }, + "firstName": { + "type": "boolean" + }, + "lastName": { + "type": "boolean" + }, + "middleName": { + "type": "boolean" + }, + "username": { + "type": "boolean" + }, + "email": { + "type": "boolean" + }, + "designation": { + "type": "boolean" + }, + "phone": { + "type": "boolean" + }, + "authClientIds": { + "type": "boolean" + }, + "lastLogin": { + "type": "boolean" + }, + "photoUrl": { + "type": "boolean" + }, + "gender": { + "type": "boolean" + }, + "dob": { + "type": "boolean" + }, + "defaultTenantId": { + "type": "boolean" + }, + "status": { + "type": "boolean" + }, + "tenantId": { + "type": "boolean" + }, + "roleId": { + "type": "boolean" + }, + "tenantName": { + "type": "boolean" + }, + "tenantKey": { + "type": "boolean" + }, + "roleName": { + "type": "boolean" + }, + "userTenantId": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "deleted", + "deletedOn", + "deletedBy", + "createdOn", + "modifiedOn", + "createdBy", + "modifiedBy", + "id", + "firstName", + "lastName", + "middleName", + "username", + "email", + "designation", + "phone", + "authClientIds", + "lastLogin", + "photoUrl", + "gender", + "dob", + "defaultTenantId", + "status", + "tenantId", + "roleId", + "tenantName", + "tenantKey", + "roleName", + "userTenantId" + ], + "example": "deleted" + }, + "uniqueItems": true + } + ], + "title": "v_users.Fields" + } + }, + "additionalProperties": false, + "x-typescript-type": "@loopback/repository#Filter" + }, "tenants.ScopeFilter": { "type": "object", "properties": { diff --git a/facades/tenant-mgmt-facade/src/services/proxies/index.ts b/facades/tenant-mgmt-facade/src/services/proxies/index.ts index 80cc5a9..7e9f679 100644 --- a/facades/tenant-mgmt-facade/src/services/proxies/index.ts +++ b/facades/tenant-mgmt-facade/src/services/proxies/index.ts @@ -1,4 +1,5 @@ export * from './subscription-proxy.provider'; export * from './tenant-mgmt-proxy.provider'; export * from './types'; +export * from './user-tenant.service' export * from './notification-proxy.provider'; diff --git a/facades/tenant-mgmt-facade/src/services/proxies/user-tenant.service.ts b/facades/tenant-mgmt-facade/src/services/proxies/user-tenant.service.ts new file mode 100644 index 0000000..ffa56e7 --- /dev/null +++ b/facades/tenant-mgmt-facade/src/services/proxies/user-tenant.service.ts @@ -0,0 +1,44 @@ +import {Filter, FilterExcludingWhere} from '@loopback/repository'; +import { + +} from '../../models'; +import {inject, Provider} from '@loopback/context'; +import {UserTenantServiceDataSource} from '../../datasources'; +import {getService} from '@loopback/service-proxy'; +import { UserView } from '../../models/user-view.model'; +import { UserDto } from '../../models/user-dto.model'; +import { User } from '../../models/user.model'; +import { IdpDetailsDTO } from '../../models/idp-details-dto.model'; +import { IdpResp } from '../../types'; + + +export interface UserTenantServiceProxy { + ping(): Promise; + + findTenantUser( + id: string, + token?: string, + filter?: string, + ): Promise; + createTenantUser(id: string, body: Partial, token?: string): Promise; + + deleteTenantUserById( + id: string, + userId: string, + token?: string, + ): Promise; + configureIdpDetails(payload: IdpDetailsDTO, token?: string): Promise; +} + +export class UserTenantServiceProxyProvider + implements Provider +{ + constructor( + @inject('datasources.UserTenantService') + protected dataSource: UserTenantServiceDataSource = new UserTenantServiceDataSource(), + ) {} + + value(): Promise { + return getService(this.dataSource); + } +} diff --git a/facades/tenant-mgmt-facade/src/services/tenant-helper.service.ts b/facades/tenant-mgmt-facade/src/services/tenant-helper.service.ts index e3defb2..fcd964a 100644 --- a/facades/tenant-mgmt-facade/src/services/tenant-helper.service.ts +++ b/facades/tenant-mgmt-facade/src/services/tenant-helper.service.ts @@ -11,6 +11,7 @@ import { PaymentSourceDtoType, SubscriptionProxyService, TenantMgmtProxyService, + UserTenantServiceProxy, } from './proxies'; import { CheckBillingSubscriptionsDTO, @@ -23,6 +24,8 @@ import {NotificationService} from './notifications/notification.service'; import {CryptoHelperService} from '@sourceloop/ctrl-plane-tenant-management-service'; import {Filter} from '@loopback/repository'; import {TenantMgmtConfig} from '../models/dtos/tenant-mgmt-config.model'; +import {UserDto} from '../models/user-dto.model'; +import {IdpDetailsDTO} from '../models/idp-details-dto.model'; const SECONDS_IN_ONE_HOUR = 60 * 60; @injectable({scope: BindingScope.TRANSIENT}) export class TenantHelperService { @@ -35,6 +38,8 @@ export class TenantHelperService { private readonly tenantMgmtProxyService: TenantMgmtProxyService, @inject('services.BillingHelperService') private readonly billingHelperService: BillingHelperService, + @inject('services.UserTenantServiceProxy') + private readonly utService: UserTenantServiceProxy, @service(NotificationService) private notificationService: NotificationService, @inject(LOGGER.LOGGER_INJECT) @@ -127,7 +132,11 @@ export class TenantHelperService { ); return tenantConfig; } - async createTenant(dto: CreateTenantWithPlanDTO,sourceOfOrigin:string, token?: string) { + async createTenant( + dto: CreateTenantWithPlanDTO, + sourceOfOrigin: string, + token?: string, + ) { token = token ?? this.request.headers.authorization; if (!token) { throw new HttpErrors.Unauthorized( @@ -158,32 +167,30 @@ export class TenantHelperService { new TenantOnboardDTO(dto), ); - if(sourceOfOrigin!=='market_place'){ - - /**for market place we assume IdP will always be cognito - * if we call the tenant config creation api make sure to add its permission - * in the token - */ - const config = new TenantMgmtConfig({ - configKey: 'auth0', - configValue: { - password: 'test123@123', - connection: 'Username-Password-Authentication', - display_name: 'corporatidonw', - verify_email: true, - page_background: '#000000', - primary_color: '#0059d6', - }, - tenantId: tenant.id, - }); + if (sourceOfOrigin !== 'market_place') { + /**for market place we assume IdP will always be cognito + * if we call the tenant config creation api make sure to add its permission + * in the token + */ + const config = new TenantMgmtConfig({ + configKey: 'auth0', + configValue: { + password: 'test123@123', + connection: 'Username-Password-Authentication', + display_name: 'corporatidonw', + verify_email: true, + page_background: '#000000', + primary_color: '#0059d6', + }, + tenantId: tenant.id, + }); - await this.tenantMgmtProxyService.createTenantConfig(token, config); - console.log('step 2'); - }else{ + await this.tenantMgmtProxyService.createTenantConfig(token, config); + console.log('step 2'); + } else { // DO NOTHING } - const customer: CustomerDtoType = { firstName: tenant.contacts[0].firstName, lastName: tenant.contacts[0].lastName, @@ -390,7 +397,6 @@ export class TenantHelperService { }); return tenant; } - async getTenantBills(userId: string): Promise { const token = this.cryptoHelperService.generateTempToken({ id: userId, @@ -819,4 +825,24 @@ export class TenantHelperService { return tenantDetails; } + async createTenantUser(id: string, userData: IdpDetailsDTO, token?: string) { + const authId = this.utService.configureIdpDetails(userData, token); + const userDataPayload = { + firstName: userData.firstName, + middleName: userData.middleName, + lastname: userData.last_name, + username: userData.username, + email: userData.email, + designation: userData.designation, + phone: userData.phone, + authClientIds: userData.authClientIds, + photoUrl: userData.photoUrl, + gender: userData.gender, + dob: userData.dob, + roleId: userData.roleId, + locale: userData.locale, + } as Partial; + this.utService.createTenantUser(id, userDataPayload, token); + return {id: authId}; + } } diff --git a/facades/tenant-mgmt-facade/src/types.ts b/facades/tenant-mgmt-facade/src/types.ts index aeb1d60..3ca9ec6 100644 --- a/facades/tenant-mgmt-facade/src/types.ts +++ b/facades/tenant-mgmt-facade/src/types.ts @@ -11,6 +11,7 @@ export type LeadUserWithToken = { token: string; } & LeadUser; +import { AnyObject } from '@loopback/repository'; import {IServiceConfig} from '@sourceloop/core'; export interface ISubscription { @@ -133,3 +134,16 @@ export interface IValue { name: string; value: number | string | boolean; } +export type ConfigureIdpFunc = (payload: IdpDetails) => Promise; + +export interface IdpDetails { + tenant: AnyObject; +} +export interface IdpResp { + authId: string; +} +export enum IdPKey { + AUTH0 = 'auth0', + COGNITO = 'cognito', + KEYCLOAK = 'keycloak', +} diff --git a/services/user-tenant-service/src/application.ts b/services/user-tenant-service/src/application.ts index 28eeab5..cb4e070 100644 --- a/services/user-tenant-service/src/application.ts +++ b/services/user-tenant-service/src/application.ts @@ -26,6 +26,8 @@ import {RestApplication} from '@loopback/rest'; import {ServiceMixin} from '@loopback/service-proxy'; import path from 'path'; import * as openapi from './openapi.json'; +import { KeycloakIdpProvider } from './providers/idp'; +import { UserTenantServiceBindings } from './keys'; export {ApplicationConfig}; @@ -78,7 +80,9 @@ export class UserTenantService extends BootMixin( this.component(AuthenticationComponent); this.component(UserTenantServiceComponent); - + this.bind(UserTenantServiceBindings.IDP_KEYCLOAK).toProvider( + KeycloakIdpProvider, + ), // Add bearer verifier component this.bind(BearerVerifierBindings.Config).to({ type: BearerVerifierType.service, diff --git a/services/user-tenant-service/src/controllers/idp.controller.ts b/services/user-tenant-service/src/controllers/idp.controller.ts new file mode 100644 index 0000000..222aa42 --- /dev/null +++ b/services/user-tenant-service/src/controllers/idp.controller.ts @@ -0,0 +1,64 @@ +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 {ConfigureIdpFunc, IdPKey, IdpResp} from '../providers/types'; +import { UserTenantServiceBindings } from '../keys'; +import { IdpDetailsDTO } from '../models'; +import { authenticate, STRATEGY } from 'loopback4-authentication'; + +const basePath = '/idp/users'; +export class IdpController { + constructor( + @inject(UserTenantServiceBindings.IDP_KEYCLOAK) + private readonly idpKeycloakProvider: ConfigureIdpFunc, + ) {} + @authenticate(STRATEGY.BEARER, { + passReqToCallback: true, + }) + @authorize({ + permissions: ['*'], + }) + @post(`${basePath}`, { + security: OPERATION_SECURITY_SPEC, + responses: { + [STATUS_CODE.NO_CONTENT]: { + description: 'Creating User', + }, + }, + }) + 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.COGNITO: + break; + case IdPKey.KEYCLOAK: + res = await this.idpKeycloakProvider(payload); + break; + default: + break; + } + return res; + } +} diff --git a/services/user-tenant-service/src/keys.ts b/services/user-tenant-service/src/keys.ts new file mode 100644 index 0000000..a9c1e27 --- /dev/null +++ b/services/user-tenant-service/src/keys.ts @@ -0,0 +1,12 @@ +import { BindingKey } from "@loopback/context"; +import { BINDING_PREFIX } from "@sourceloop/core"; +import { ConfigureIdpFunc, IdpResp } from "./providers/types"; + +export namespace UserTenantServiceBindings { + /** + * Binding key for the Idp keycloak provider. + */ + export const IDP_KEYCLOAK = BindingKey.create>( + 'sf.user.idp.keycloak', + ); + } \ No newline at end of file diff --git a/services/user-tenant-service/src/models/idp-details-dto.model.ts b/services/user-tenant-service/src/models/idp-details-dto.model.ts new file mode 100644 index 0000000..755c1ba --- /dev/null +++ b/services/user-tenant-service/src/models/idp-details-dto.model.ts @@ -0,0 +1,20 @@ +import {getJsonSchema} from '@loopback/openapi-v3'; +import {AnyObject, Model, model, property} from '@loopback/repository'; +import { User } from '@sourceloop/user-tenant-service'; +import { UserDto } from './user-dto.model'; + +@model({ + description: 'model describing payload for IDP controller', +}) +export class IdpDetailsDTO extends UserDto { + @property({ + type: 'object', + description: 'Tenat object', + jsonSchema: getJsonSchema(Object), + }) + tenant: AnyObject; + + constructor(data?: Partial) { + super(data); + } +} diff --git a/services/user-tenant-service/src/models/index.ts b/services/user-tenant-service/src/models/index.ts new file mode 100644 index 0000000..1c7383f --- /dev/null +++ b/services/user-tenant-service/src/models/index.ts @@ -0,0 +1 @@ +export * from './idp-details-dto.model' \ No newline at end of file diff --git a/services/user-tenant-service/src/models/user-dto.model.ts b/services/user-tenant-service/src/models/user-dto.model.ts new file mode 100644 index 0000000..9749e6d --- /dev/null +++ b/services/user-tenant-service/src/models/user-dto.model.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2023 Sourcefuse Technologies +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT +import {model, property} from '@loopback/repository'; +import { User } from '@sourceloop/user-tenant-service'; + + +@model() +export class UserDto extends User { + @property({ + type: 'string', + required: true, + }) + roleId: string; + + @property({ + type: 'string', + }) + locale?: string; +} diff --git a/services/user-tenant-service/src/openapi.json b/services/user-tenant-service/src/openapi.json index 3aa1e2e..f5261fa 100644 --- a/services/user-tenant-service/src/openapi.json +++ b/services/user-tenant-service/src/openapi.json @@ -216,6 +216,36 @@ "operationId": "GroupUserController.find" } }, + "/idp/users": { + "post": { + "x-controller-name": "IdpController", + "x-operation-name": "idpConfigure", + "tags": [ + "IdpController" + ], + "security": [ + { + "HTTPBearer": [] + } + ], + "responses": { + "204": { + "description": "Creating User" + } + }, + "description": "", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IdpDetailsDTO" + } + } + } + }, + "operationId": "IdpController.idpConfigure" + } + }, "/ping": { "get": { "x-controller-name": "PingController", @@ -2479,6 +2509,7 @@ "type": "string" }, "id": { + "readOnly": true, "type": "string" }, "firstName": { @@ -2836,6 +2867,7 @@ "type": "string" }, "id": { + "readOnly": true, "type": "string" }, "groupId": { @@ -3207,6 +3239,7 @@ "type": "string" }, "id": { + "readOnly": true, "type": "string" }, "groupId": { @@ -3533,6 +3566,7 @@ "type": "string" }, "id": { + "readOnly": true, "type": "string" }, "firstName": { @@ -3714,6 +3748,7 @@ "type": "string" }, "id": { + "readOnly": true, "type": "string" }, "firstName": { @@ -4156,6 +4191,108 @@ "additionalProperties": false, "x-typescript-type": "Partial" }, + "IdpDetailsDTO": { + "title": "IdpDetailsDTO", + "type": "object", + "description": "model describing payload for IDP controller (tsType: IdpDetailsDTO, schemaOptions: { title: 'IdpDetailsDTO' })", + "properties": { + "deleted": { + "type": "boolean" + }, + "deletedOn": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "deletedBy": { + "type": "string", + "nullable": true + }, + "createdOn": { + "type": "string", + "format": "date-time" + }, + "modifiedOn": { + "type": "string", + "format": "date-time" + }, + "createdBy": { + "type": "string" + }, + "modifiedBy": { + "type": "string" + }, + "id": { + "readOnly": true, + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "middleName": { + "type": "string" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "designation": { + "type": "string" + }, + "phone": { + "type": "string", + "pattern": "^\\+?[1-9]\\d{1,14}$" + }, + "authClientIds": { + "type": "string" + }, + "lastLogin": { + "type": "string", + "format": "date-time" + }, + "photoUrl": { + "type": "string" + }, + "gender": { + "type": "string", + "description": "This field takes a single character as input in database.\n 'M' for male and 'F' for female.", + "enum": [ + "M", + "F", + "O" + ] + }, + "dob": { + "type": "string", + "format": "date-time" + }, + "defaultTenantId": { + "type": "string" + }, + "roleId": { + "type": "string" + }, + "locale": { + "type": "string" + }, + "tenant": { + "type": "object", + "description": "Tenat object" + } + }, + "required": [ + "firstName", + "username", + "email", + "roleId" + ], + "additionalProperties": false + }, "PingResponse": { "type": "object", "title": "PingResponse", diff --git a/services/user-tenant-service/src/providers/idp/idp-keycloak.provider.ts b/services/user-tenant-service/src/providers/idp/idp-keycloak.provider.ts new file mode 100644 index 0000000..a6189e0 --- /dev/null +++ b/services/user-tenant-service/src/providers/idp/idp-keycloak.provider.ts @@ -0,0 +1,391 @@ +import {Provider} from '@loopback/context'; +import axios from 'axios'; +import qs from 'qs'; + +import AWS from 'aws-sdk'; +import {randomBytes} from 'crypto'; +import { ConfigureIdpFunc, IdpResp, IdpDetails } from '../types'; + +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} = payload; + + try { + const token = await this.authenticateAdmin(); + + // Fetch the clientId, clientSecret, and realmName from AWS SSM + const clientId = process.env.KEYCLOAK_CLIENT_ID as string; + const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET as string; + const realmName = process.env.KEYCLOAK_REALM_NAME as string; + 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 = payload.email as string; + const passwordLength = 20; + const adminPassword = this.generateStrongPassword(passwordLength); + const {firstName, lastName, email} = payload; + + 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}`, + ); + } + } + 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 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 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/user-tenant-service/src/providers/idp/index.ts b/services/user-tenant-service/src/providers/idp/index.ts new file mode 100644 index 0000000..da3db4d --- /dev/null +++ b/services/user-tenant-service/src/providers/idp/index.ts @@ -0,0 +1,3 @@ + +export * from './idp-keycloak.provider'; +export * from './types'; diff --git a/services/user-tenant-service/src/providers/idp/types.ts b/services/user-tenant-service/src/providers/idp/types.ts new file mode 100644 index 0000000..1f80e7d --- /dev/null +++ b/services/user-tenant-service/src/providers/idp/types.ts @@ -0,0 +1,4 @@ +export type Auth0Response = { + organizationId: string; + userId: string; +}; diff --git a/services/user-tenant-service/src/providers/types.ts b/services/user-tenant-service/src/providers/types.ts new file mode 100644 index 0000000..5b2ac03 --- /dev/null +++ b/services/user-tenant-service/src/providers/types.ts @@ -0,0 +1,24 @@ +import { AnyObject } from "@loopback/repository"; +import { UserDto } from "../models/user-dto.model"; + +export type ConfigureIdpFunc = (payload: IdpDetails) => Promise; + +export interface IdpDetails { + tenant: AnyObject; + firstName: string; + lastName: string; + roleId: string; + username: string; + email: string; + designation?: string; + middleName?: string; + locale?: string; +} +export interface IdpResp { + authId: string; +} +export enum IdPKey { + AUTH0 = 'auth0', + COGNITO = 'cognito', + KEYCLOAK = 'keycloak', +} \ No newline at end of file