Skip to content

Commit 32df819

Browse files
feat: ecosystem member invitation and management API's (#1545)
* feat/add script to add platform admin keycloak and role Signed-off-by: sujitaw <sujit.sutar@ayanworks.com> * fix/eslint issue Signed-off-by: sujitaw <sujit.sutar@ayanworks.com> * fix/coderabbit comments Signed-off-by: sujitaw <sujit.sutar@ayanworks.com> * feat: ecosystem service and create ecosystem invitation API route Signed-off-by: pranalidhanavade <pranali.dhanavade@ayanworks.com> * fix: coderabbit issues Signed-off-by: pranalidhanavade <pranali.dhanavade@ayanworks.com> * fix: coderabbit warnings Signed-off-by: pranalidhanavade <pranali.dhanavade@ayanworks.com> * feat: get all invitations api Signed-off-by: pranalidhanavade <pranali.dhanavade@ayanworks.com> * wip Signed-off-by: sujitaw <sujit.sutar@ayanworks.com> * wip completed invite member and update status for invitation Signed-off-by: sujitaw <sujit.sutar@ayanworks.com> * wip Signed-off-by: sujitaw <sujit.sutar@ayanworks.com> * wip Signed-off-by: sujitaw <sujit.sutar@ayanworks.com> * feat/added ecosystem invitation workflow apis Signed-off-by: sujitaw <sujit.sutar@ayanworks.com> * fix/code rabbit comments Signed-off-by: sujitaw <sujit.sutar@ayanworks.com> * fix/minor typo issue Signed-off-by: sujitaw <sujit.sutar@ayanworks.com> * fix/ pr comments Signed-off-by: sujitaw <sujit.sutar@ayanworks.com> * fix/pr comments Signed-off-by: sujitaw <sujit.sutar@ayanworks.com> --------- Signed-off-by: sujitaw <sujit.sutar@ayanworks.com> Signed-off-by: pranalidhanavade <pranali.dhanavade@ayanworks.com> Co-authored-by: pranalidhanavade <pranali.dhanavade@ayanworks.com> Signed-off-by: pranalidhanavade <pranali.dhanavade@ayanworks.com>
1 parent 3b6e01b commit 32df819

24 files changed

Lines changed: 1711 additions & 189 deletions

File tree

apps/api-gateway/src/authz/authz.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { CommonConstants } from '@credebl/common/common.constant';
88
import { CommonModule } from '../../../../libs/common/src/common.module';
99
import { CommonService } from '../../../../libs/common/src/common.service';
1010
import { ConnectionService } from '../connection/connection.service';
11+
import { EcosystemModule } from '../ecosystem/ecosystem.module';
1112
import { HttpModule } from '@nestjs/axios';
1213
import { JwtStrategy } from './jwt.strategy';
1314
import { MobileJwtStrategy } from './mobile-jwt.strategy';
@@ -25,6 +26,7 @@ import { getNatsOptions } from '@credebl/common/nats.config';
2526

2627
@Module({
2728
imports: [
29+
EcosystemModule,
2830
HttpModule,
2931
PassportModule.register({
3032
defaultStrategy: 'jwt',
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { BadRequestException, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
2+
3+
import { Injectable } from '@nestjs/common';
4+
import { OrgRoles } from 'libs/org-roles/enums';
5+
import { ROLES_KEY } from '../decorators/roles.decorator';
6+
import { Reflector } from '@nestjs/core';
7+
import { ResponseMessages } from '@credebl/common/response-messages';
8+
import { validate as isValidUUID } from 'uuid';
9+
10+
@Injectable()
11+
export class EcosystemRolesGuard implements CanActivate {
12+
constructor(private readonly reflector: Reflector) {} // eslint-disable-next-line array-callback-return
13+
14+
async canActivate(context: ExecutionContext): Promise<boolean> {
15+
const requiredRoles = this.reflector.getAllAndOverride<OrgRoles[]>(ROLES_KEY, [
16+
context.getHandler(),
17+
context.getClass()
18+
]);
19+
20+
if (!requiredRoles || 0 === requiredRoles.length) {
21+
return true;
22+
}
23+
const requiredRolesNames = requiredRoles as string[];
24+
const reqData = context.switchToHttp().getRequest();
25+
const { user } = reqData;
26+
27+
let orgId = '';
28+
29+
switch (true) {
30+
case 'string' === typeof reqData.params?.orgId:
31+
orgId = reqData.params.orgId.trim();
32+
break;
33+
34+
case 'string' === typeof reqData.query?.orgId:
35+
orgId = reqData.query.orgId.trim();
36+
break;
37+
38+
case 'string' === typeof reqData.body?.orgId:
39+
orgId = reqData.body.orgId.trim();
40+
break;
41+
42+
default:
43+
orgId = '';
44+
}
45+
46+
const isPlatformAdmin = user.email === process.env.PLATFORM_ADMIN_EMAIL;
47+
48+
if (user?.ecosystemRoles && requiredRolesNames.some((role: string) => user.ecosystemRoles.includes(role))) {
49+
return true;
50+
}
51+
52+
if (isPlatformAdmin && requiredRolesNames.includes(OrgRoles.PLATFORM_ADMIN)) {
53+
// eslint-disable-next-line array-callback-return
54+
const isPlatformAdminFlag = user.userOrgRoles.find((orgDetails) => {
55+
if (orgDetails.orgRole.name === OrgRoles.PLATFORM_ADMIN) {
56+
return true;
57+
}
58+
});
59+
60+
if (isPlatformAdminFlag) {
61+
return true;
62+
}
63+
}
64+
65+
if (orgId) {
66+
if (!isValidUUID(orgId)) {
67+
throw new BadRequestException(ResponseMessages.organisation.error.invalidOrgId);
68+
}
69+
70+
if (user.hasOwnProperty('resource_access') && user.resource_access[orgId]) {
71+
const orgRoles: string[] = user.resource_access[orgId].roles;
72+
const roleAccess = requiredRoles.some((role) => orgRoles.includes(role));
73+
74+
if (!roleAccess) {
75+
throw new ForbiddenException(ResponseMessages.organisation.error.roleNotMatch, {
76+
cause: new Error('error'),
77+
description: ResponseMessages.errorMessages.forbidden
78+
});
79+
}
80+
return roleAccess;
81+
}
82+
83+
const specificOrg = user.userOrgRoles.find((orgDetails) => {
84+
if (!orgDetails.orgId) {
85+
return false;
86+
}
87+
return orgDetails.orgId.toString().trim() === orgId.toString().trim();
88+
});
89+
90+
if (!specificOrg) {
91+
throw new ForbiddenException(ResponseMessages.organisation.error.orgNotMatch, {
92+
cause: new Error('error'),
93+
description: ResponseMessages.errorMessages.forbidden
94+
});
95+
}
96+
97+
user.selectedOrg = specificOrg;
98+
// eslint-disable-next-line array-callback-return
99+
user.selectedOrg.orgRoles = user.userOrgRoles
100+
.filter((orgRoleItem) => orgRoleItem.orgId && orgRoleItem.orgId.toString().trim() === orgId.toString().trim())
101+
.map((orgRoleItem) => orgRoleItem.orgRole.name);
102+
} else {
103+
return false;
104+
}
105+
106+
// Sending user friendly message if a user attempts to access an API that is inaccessible to their role
107+
const roleAccess = requiredRoles.some((role) => user.selectedOrg?.orgRoles.includes(role));
108+
if (!roleAccess) {
109+
throw new ForbiddenException(ResponseMessages.organisation.error.roleNotMatch, {
110+
cause: new Error('error'),
111+
description: ResponseMessages.errorMessages.forbidden
112+
});
113+
}
114+
115+
return roleAccess;
116+
}
117+
}

apps/api-gateway/src/authz/jwt.strategy.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Injectable, Logger, NotFoundException, UnauthorizedException } from '@n
66

77
import { AuthzService } from './authz.service';
88
import { CommonConstants } from '@credebl/common/common.constant';
9+
import { EcosystemService } from '../ecosystem/ecosystem.service';
910
import { IOrganization } from '@credebl/common/interfaces/organization.interface';
1011
import { JwtPayload } from './jwt-payload.interface';
1112
import { OrganizationService } from '../organization/organization.service';
@@ -23,7 +24,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
2324
constructor(
2425
private readonly usersService: UserService,
2526
private readonly organizationService: OrganizationService,
26-
private readonly authzService: AuthzService
27+
private readonly authzService: AuthzService,
28+
private readonly ecosystemService: EcosystemService
2729
) {
2830
super({
2931
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
@@ -69,22 +71,33 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
6971
throw new UnauthorizedException(ResponseMessages.user.error.invalidAccessToken);
7072
}
7173
}
72-
7374
if (payload?.email) {
7475
userInfo = await this.usersService.getUserByUserIdInKeycloak(payload?.email);
7576
}
77+
let ecosystemRole = null;
78+
if (userInfo?.id) {
79+
try {
80+
const user = await this.ecosystemService.getUserByKeycloakId(userInfo.id);
81+
if (user?.id) {
82+
const ecosystem = await this.ecosystemService.getEcosystemDetailsByUserId(user.id);
83+
if (ecosystem?.id) {
84+
ecosystemRole = await this.ecosystemService.getEcosystemOrgDetailsByUserId(user.id, ecosystem.id);
85+
}
86+
}
87+
} catch (error) {
88+
this.logger.warn('Failed to fetch ecosystem roles', JSON.stringify(error));
89+
}
90+
}
7691

7792
if (payload.hasOwnProperty('client_id')) {
7893
const orgDetails: IOrganization = await this.organizationService.findOrganizationOwner(payload['client_id']);
79-
8094
this.logger.log('Organization details fetched');
8195
if (!orgDetails) {
8296
throw new NotFoundException(ResponseMessages.organisation.error.orgNotFound);
8397
}
8498

8599
// eslint-disable-next-line prefer-destructuring
86100
const userOrgDetails = 0 < orgDetails.userOrgRoles.length && orgDetails.userOrgRoles[0];
87-
88101
userDetails = userOrgDetails.user;
89102
userDetails.userOrgRoles = [];
90103
userDetails.userOrgRoles.push({
@@ -108,6 +121,13 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
108121
userDetails['userRole'] = userInfo?.['attributes']?.userRole;
109122
}
110123

124+
if (Array.isArray(ecosystemRole) && 0 < ecosystemRole.length) {
125+
const ecosystemRoleList = [
126+
...new Set(ecosystemRole.map((record: { ecosystemRole: { name: string } }) => record.ecosystemRole.name))
127+
];
128+
userDetails.ecosystemRoles = ecosystemRoleList;
129+
}
130+
111131
return {
112132
...userDetails,
113133
...payload
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, IsUUID } from 'class-validator';
2+
3+
import { ApiProperty } from '@nestjs/swagger';
4+
import { Transform } from 'class-transformer';
5+
6+
export class DeleteEcosystemOrgDto {
7+
@ApiProperty({
8+
example: [
9+
'6e672a9c-64f0-4d98-b312-f578f633800b',
10+
'2f1a5a3c-91a2-4c4b-9f7d-1b7e6a22a111'
11+
],
12+
isArray: true
13+
})
14+
@IsArray({ message: 'orgId must be an array' })
15+
@ArrayNotEmpty({ message: 'orgId cannot be empty' })
16+
@IsUUID('4', { each: true })
17+
@IsString({ each: true })
18+
@Transform(({ value }) => Array.isArray(value) ? value.map(v => v.trim()) : value
19+
)
20+
orgIds: string[];
21+
22+
@ApiProperty({ example: '61ec22e3-9158-409d-874d-345ad2fc51e4' })
23+
@IsUUID()
24+
@IsNotEmpty({ message: 'ecosystemId is required' })
25+
@IsString({ message: 'ecosystemId should be a string' })
26+
@Transform(({ value }) => value?.trim())
27+
ecosystemId: string;
28+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2+
import { ArrayNotEmpty, IsArray, IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
3+
4+
import { EcosystemInvitationRoles } from 'apps/ecosystem/interfaces/ecosystem.interfaces';
5+
import { EcosystemOrgStatus } from '@credebl/enum/enum';
6+
import { OrgRoles } from 'libs/org-roles/enums';
7+
import { Transform } from 'class-transformer';
8+
9+
export class UpdateEcosystemOrgStatusDto {
10+
@ApiProperty({
11+
example: ['ef93be23-d950-497c-a886-22fcd98370fe'],
12+
isArray: true
13+
})
14+
@IsArray({ message: 'orgId must be an array for updating organization status' })
15+
@ArrayNotEmpty({ message: 'orgId cannot be empty' })
16+
@IsUUID('4', { each: true })
17+
@IsString({ each: true })
18+
@Transform(({ value }) => (Array.isArray(value) ? value.map((v) => v.trim()) : value))
19+
orgIds: string[];
20+
21+
@ApiProperty({ example: 'c78046ba-c98a-4785-80c6-06ad5167e74c' })
22+
@IsUUID()
23+
@IsNotEmpty({ message: 'ecosystemId is required to update status of an organization' })
24+
@IsString({ message: 'ecosystemId should be a string to update status of an organization' })
25+
@Transform(({ value }) => value?.trim())
26+
ecosystemId: string;
27+
28+
@ApiProperty({ enum: EcosystemOrgStatus, example: EcosystemOrgStatus.INACTIVE })
29+
@IsEnum(EcosystemOrgStatus, { message: `Status must be one of: ${Object.values(EcosystemOrgStatus).join(', ')}` })
30+
@IsNotEmpty({ message: 'Status is required to update status of an organization' })
31+
status: EcosystemOrgStatus;
32+
}
33+
34+
export enum InvitationViewRole {
35+
ECOSYSTEM_MEMBER = OrgRoles.ECOSYSTEM_MEMBER,
36+
ECOSYSTEM_LEAD = OrgRoles.ECOSYSTEM_LEAD
37+
}
38+
39+
export class GetEcosystemInvitationsQueryDto {
40+
@IsEnum(InvitationViewRole)
41+
role: EcosystemInvitationRoles;
42+
43+
@ApiPropertyOptional({ format: 'uuid' })
44+
@IsOptional()
45+
@IsUUID()
46+
ecosystemId?: string;
47+
48+
@ApiPropertyOptional({ example: 'user@example.com' })
49+
@IsOptional()
50+
@IsEmail()
51+
email?: string;
52+
53+
@ApiPropertyOptional({ format: 'uuid' })
54+
@IsOptional()
55+
@IsUUID()
56+
userId?: string;
57+
}

apps/api-gateway/src/ecosystem/dtos/send-ecosystem-invitation.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
1+
import { IsEmail, IsEnum, IsNotEmpty, IsString, IsUUID } from 'class-validator';
22

33
import { ApiProperty } from '@nestjs/swagger';
4+
import { Invitation } from '@credebl/enum/enum';
45
import { Transform } from 'class-transformer';
56

67
export class CreateEcosystemInvitationDto {
@@ -11,3 +12,39 @@ export class CreateEcosystemInvitationDto {
1112
@Transform(({ value }) => value?.trim())
1213
email: string;
1314
}
15+
16+
export class InviteMemberToEcosystemDto {
17+
@ApiProperty({ example: '6e672a9c-64f0-4d98-b312-f578f633800b' })
18+
@IsUUID()
19+
@IsNotEmpty({ message: 'OrgId is required' })
20+
@IsString({ message: 'OrgId should be a string' })
21+
@Transform(({ value }) => value?.trim())
22+
orgId: string;
23+
24+
@ApiProperty({ example: '61ec22e3-9158-409d-874d-345ad2fc51e4' })
25+
@IsUUID()
26+
@IsNotEmpty({ message: 'ecosystemId is required' })
27+
@IsString({ message: 'ecosystemId should be a string' })
28+
@Transform(({ value }) => value?.trim())
29+
ecosystemId: string;
30+
}
31+
32+
export class OrgIdParam {
33+
@IsUUID() // or @IsString()
34+
orgId: string;
35+
}
36+
37+
export class UpdateEcosystemInvitationDto {
38+
@ApiProperty({ enum: Invitation, example: Invitation.ACCEPTED })
39+
@Transform(({ value }) => ('string' === typeof value ? value.toLowerCase() : value))
40+
@IsEnum(Invitation, { message: `Status must be one of: ${Object.values(Invitation).join(', ')}` })
41+
@IsNotEmpty({ message: 'Status is required' })
42+
status: Invitation;
43+
44+
@ApiProperty({ example: '61ec22e3-9158-409d-874d-345ad2fc51e4' })
45+
@IsUUID()
46+
@IsNotEmpty({ message: 'ecosystemId is required' })
47+
@IsString({ message: 'ecosystemId should be a string' })
48+
@Transform(({ value }) => value?.trim())
49+
ecosystemId: string;
50+
}

0 commit comments

Comments
 (0)