Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion apps/api-gateway/src/authz/guards/ecosystem-roles.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import { Reflector } from '@nestjs/core';
import { ResponseMessages } from '@credebl/common/response-messages';
import { validate as isValidUUID } from 'uuid';

interface EcosystemRoleGroup {
ecosystem_role?: {
lead?: string[];
member?: string[];
};
}

@Injectable()
export class EcosystemRolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {} // eslint-disable-next-line array-callback-return
Expand Down Expand Up @@ -45,7 +52,56 @@ export class EcosystemRolesGuard implements CanActivate {

const isPlatformAdmin = user.email === process.env.PLATFORM_ADMIN_EMAIL;

if (user?.ecosystemRoles && requiredRolesNames.some((role: string) => user.ecosystemRoles.includes(role))) {
let ecosystemId = '';

const ecosystemIdExists =
'undefined' !== typeof reqData.params?.ecosystemId ||
'undefined' !== typeof reqData.query?.ecosystemId ||
'undefined' !== typeof reqData.body?.ecosystemId;

switch (true) {
case 'string' === typeof reqData.params?.ecosystemId:
ecosystemId = reqData.params.ecosystemId.trim();
break;
case 'string' === typeof reqData.query?.ecosystemId:
ecosystemId = reqData.query.ecosystemId.trim();
break;
case 'string' === typeof reqData.body?.ecosystemId:
ecosystemId = reqData.body.ecosystemId.trim();
break;
default:
ecosystemId = '';
}

if (ecosystemIdExists) {
if (!ecosystemId) {
throw new BadRequestException(ResponseMessages.ecosystem.error.ecosystemIdIsRequired);
}
if (!isValidUUID(ecosystemId)) {
throw new BadRequestException(ResponseMessages.ecosystem?.error?.invalidEcosystemId || 'Invalid ecosystem id');
}

const ecosystemAccess = user?.ecosystem_access;

if (!ecosystemAccess) {
throw new ForbiddenException(
ResponseMessages.ecosystem?.error?.ecosystemNotFound || 'User does not have ecosystem access'
);
}

const hasAccess = Object.values(ecosystemAccess).some((entry: EcosystemRoleGroup) => {
const leadList = entry?.ecosystem_role?.lead ?? [];
const memberList = entry?.ecosystem_role?.member ?? [];
return leadList.includes(ecosystemId) || memberList.includes(ecosystemId);
});

if (!hasAccess) {
throw new ForbiddenException(
ResponseMessages.ecosystem?.error?.ecosystemNotFound || 'User does not have access to this ecosystem'
);
}

user.selectedEcosystem = ecosystemId;
return true;
Comment on lines +92 to 105
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical auth bypass in ecosystem path (return true ignores required roles).

After hasAccess succeeds, the guard returns true without enforcing requiredRoles. This lets any user with ecosystem membership pass endpoints that require stronger org roles, and it also treats lead/member as equivalent.

🔧 Suggested fix
-      const hasAccess = Object.values(ecosystemAccess).some((entry: EcosystemRoleGroup) => {
-        const leadList = entry?.ecosystem_role?.lead ?? [];
-        const memberList = entry?.ecosystem_role?.member ?? [];
-        return leadList.includes(ecosystemId) || memberList.includes(ecosystemId);
-      });
+      const roleMatch = Object.values(ecosystemAccess).some((entry: EcosystemRoleGroup) => {
+        const leadList = entry?.ecosystem_role?.lead ?? [];
+        const memberList = entry?.ecosystem_role?.member ?? [];
+
+        const leadOk =
+          !requiredRolesNames.includes(OrgRoles.ECOSYSTEM_LEAD) || leadList.includes(ecosystemId);
+        const memberOk =
+          !requiredRolesNames.includes(OrgRoles.ECOSYSTEM_MEMBER) ||
+          leadList.includes(ecosystemId) ||
+          memberList.includes(ecosystemId);
+
+        return leadOk && memberOk;
+      });
 
-      if (!hasAccess) {
+      if (!roleMatch) {
         throw new ForbiddenException(
           ResponseMessages.ecosystem?.error?.ecosystemNotFound || 'User does not have access to this ecosystem'
         );
       }
 
       user.selectedEcosystem = ecosystemId;
-      return true;
+      return true;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api-gateway/src/authz/guards/ecosystem-roles.guard.ts` around lines 92 -
105, The guard currently stops at membership check (using ecosystemAccess and
hasAccess) and unconditionally returns true after setting
user.selectedEcosystem, which bypasses enforcement of requiredRoles; update the
logic in the guard (the block around hasAccess, user.selectedEcosystem and the
final return) to, after verifying membership, evaluate the requiredRoles for the
request and ensure the user has one of those roles (distinguish lead vs member
using entry.ecosystem_role.lead and .member from EcosystemRoleGroup), throwing
ForbiddenException if none match, and only return true when both membership and
requiredRoles checks pass.

}

Expand Down Expand Up @@ -77,6 +133,7 @@ export class EcosystemRolesGuard implements CanActivate {
description: ResponseMessages.errorMessages.forbidden
});
}

return roleAccess;
}

Expand Down
16 changes: 16 additions & 0 deletions apps/api-gateway/src/authz/jwt-payload.interface.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
export interface ResourceAccess {
roles: string[];
}

export interface EcosystemRole {
lead?: string[];
member?: string[];
}

export interface EcosystemAccess {
ecosystem_role: EcosystemRole;
}

export interface JwtPayload {
iss: string;
sub: string;
Expand All @@ -10,4 +23,7 @@ export interface JwtPayload {
permissions: string[];
email?: string;
sid: string;

resource_access?: Record<string, ResourceAccess>;
ecosystem_access?: Record<string, EcosystemAccess>;
}
28 changes: 5 additions & 23 deletions apps/api-gateway/src/authz/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
/* eslint-disable camelcase */
import * as dotenv from 'dotenv';
import * as jwt from 'jsonwebtoken';

import { CommonConstants, uuidRegex } from '@credebl/common/common.constant';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common';

import { AuthzService } from './authz.service';
import { CommonConstants, uuidRegex } from '@credebl/common/common.constant';
import { EcosystemService } from '../ecosystem/ecosystem.service';
import { IOrganization } from '@credebl/common/interfaces/organization.interface';
import { JwtPayload } from './jwt-payload.interface';
Expand All @@ -24,8 +25,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly usersService: UserService,
private readonly organizationService: OrganizationService,
private readonly authzService: AuthzService,
private readonly ecosystemService: EcosystemService
private readonly authzService: AuthzService
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
Expand Down Expand Up @@ -74,20 +74,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
if (payload?.email) {
userInfo = await this.usersService.getUserByUserIdInKeycloak(payload?.email);
}
let ecosystemRole = null;
if (userInfo?.id) {
try {
const user = await this.ecosystemService.getUserByKeycloakId(userInfo.id);
if (user?.id) {
const ecosystem = await this.ecosystemService.getEcosystemDetailsByUserId(user.id);
if (ecosystem?.id) {
ecosystemRole = await this.ecosystemService.getEcosystemOrgDetailsByUserId(user.id, ecosystem.id);
}
}
} catch (error) {
this.logger.warn('Failed to fetch ecosystem roles', JSON.stringify(error));
}
}

if (payload.hasOwnProperty('client_id') && uuidRegex.test(payload['client_id'])) {
const orgDetails: IOrganization = await this.organizationService.findOrganizationOwner(payload['client_id']);
Expand Down Expand Up @@ -122,13 +108,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
userDetails['userRole'] = userInfo?.['attributes']?.userRole;
}

if (Array.isArray(ecosystemRole) && 0 < ecosystemRole.length) {
const ecosystemRoleList = [
...new Set(ecosystemRole.map((record: { ecosystemRole: { name: string } }) => record.ecosystemRole.name))
];
userDetails.ecosystemRoles = ecosystemRoleList;
if (payload?.ecosystem_access) {
userDetails.ecosystem_access = payload.ecosystem_access;
}

return {
...userDetails,
...payload
Expand Down
10 changes: 6 additions & 4 deletions apps/api-gateway/src/ecosystem/ecosystem.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export class EcosystemController {
return res.status(HttpStatus.CREATED).json(finalResponse);
}

@Post('/invitation/status')
@Put('/invitation/status')
@ApiOperation({
summary: 'Update invitation status',
description: 'Updates the status of an existing ecosystem invitation (accept or reject).'
Expand All @@ -108,7 +108,7 @@ export class EcosystemController {
name: 'status',
enum: [Invitation.REJECTED, Invitation.ACCEPTED]
})
@UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), EcosystemRolesGuard)
Comment thread
pranalidhanavade marked this conversation as resolved.
@ApiBearerAuth()
async updateEcosystemInvitationStatus(
@Body() updateInvitation: UpdateEcosystemInvitationDto,
Expand Down Expand Up @@ -207,17 +207,19 @@ export class EcosystemController {
})
@ApiQuery({
name: 'orgId',
required: true,
//Need to check this once
required: false,
type: String
})
@Roles(OrgRoles.PLATFORM_ADMIN, OrgRoles.ECOSYSTEM_LEAD)
@Roles(OrgRoles.OWNER, OrgRoles.ECOSYSTEM_LEAD)
async getEcosystems(
@User() reqUser: user,
@Res() res: Response,
@Query() paginationDto: PaginationGetAllEcosystem,
@Query(
'orgId',
new ParseUUIDPipe({
optional: true,
exceptionFactory: (): Error => {
throw new BadRequestException(ResponseMessages.ecosystem.error.invalidOrgId);
}
Expand Down
1 change: 1 addition & 0 deletions apps/api-gateway/src/ecosystem/intent/intent.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export class IntentController {
@Body() createIntentDto: CreateIntentDto,
@Param(
'ecosystemId',
TrimStringParamPipe,
new ParseUUIDPipe({
exceptionFactory: (): Error => {
throw new BadRequestException(ResponseMessages.ecosystem.error.invalidFormatOfEcosystemId);
Expand Down
4 changes: 0 additions & 4 deletions apps/ecosystem/src/ecosystem.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,10 +210,6 @@ export class EcosystemService {
throw new Error('Error fetching user');
}

// if (!invitation) {
// throw new ForbiddenException(ResponseMessages.ecosystem.error.invitationRequired);
// }

const ecosystem = await this.prisma.$transaction(async (tx) => {
const newEcosystem = await this.ecosystemRepository.createNewEcosystem(createEcosystemDto, tx);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import { ClientTokenDto } from './dtos/client-token.dto';
import { CommonConstants } from '@credebl/common/common.constant';
import { CommonService } from '@credebl/common';
import { CreateUserDto } from './dtos/create-user.dto';
import { EcosystemServiceRole } from '@credebl/enum/enum';
import { IClientRoles } from './interfaces/client.interface';
import { IFormattedResponse } from '@credebl/common/interfaces/interface';
import { JwtService } from '@nestjs/jwt';
import { EcosystemServiceRole } from '@credebl/enum/enum';
import { KeycloakUrlService } from '@credebl/keycloak-url';
import { KeycloakUserRegistrationDto } from 'apps/user/dtos/keycloak-register.dto';
import { ResponseMessages } from '@credebl/common/response-messages';
Expand Down
3 changes: 2 additions & 1 deletion libs/keycloak-config/src/keycloak-config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@

import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ProtocolMapperResult, UnmanagedAttributePolicy } from '@credebl/enum/enum';

import { ClientRegistrationService } from '@credebl/client-registration';
import { CommonService } from '@credebl/common';
import { KeycloakUrlService } from '@credebl/keycloak-url';
import { ClientRegistrationService } from '@credebl/client-registration';

@Injectable()
export class KeycloakConfigService implements OnModuleInit {
Expand Down
3 changes: 1 addition & 2 deletions libs/prisma-service/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,14 +691,13 @@ export async function createKeycloakUser(): Promise<void> {
'Missing required environment variables for either PLATFORM_ADMIN_USER_PASSWORD or KEYCLOAK_DOMAIN or KEYCLOAK_REALM or PLATFORM_ADMIN_KEYCLOAK_ID or PLATFORM_ADMIN_KEYCLOAK_SECRET or CRYPTO_PRIVATE_KEY'
);
}
const decryptedPassword = CryptoJS.AES.decrypt(platformAdminData.password, CRYPTO_PRIVATE_KEY);
const token = await getKeycloakToken();
const user = {
username: cachedConfig.platformEmail,
email: cachedConfig.platformEmail,
firstName: cachedConfig.platformName,
lastName: cachedConfig.platformName,
password: decryptedPassword.toString(CryptoJS.enc.Utf8)
password: 'Password@1'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Remove hardcoded Keycloak admin password immediately.

Line 700 hardcodes a predictable credential ('Password@1') for a privileged account. This is a release-blocking security issue. Load it from environment/runtime secret instead.

🔐 Proposed fix
 export async function createKeycloakUser(): Promise<void> {
   logger.log(`✅ Creating keycloak user for platform admin`);
   const { platformAdminData } = JSON.parse(configData);
-  if (!platformAdminData?.password) {
-    throw new Error('platformAdminData password is missing from credebl-master-table.json');
-  }
   if (!cachedConfig) {
     throw new Error('failed to load platform config data from db');
   }

   const {
     KEYCLOAK_DOMAIN,
     KEYCLOAK_REALM,
     PLATFORM_ADMIN_KEYCLOAK_ID,
     PLATFORM_ADMIN_KEYCLOAK_SECRET,
-    CRYPTO_PRIVATE_KEY
+    CRYPTO_PRIVATE_KEY,
+    PLATFORM_ADMIN_USER_PASSWORD
   } = process.env;

   if (
     !KEYCLOAK_DOMAIN ||
     !KEYCLOAK_REALM ||
     !PLATFORM_ADMIN_KEYCLOAK_ID ||
     !PLATFORM_ADMIN_KEYCLOAK_SECRET ||
-    !CRYPTO_PRIVATE_KEY
+    !CRYPTO_PRIVATE_KEY ||
+    !PLATFORM_ADMIN_USER_PASSWORD
   ) {
     throw new Error(
-      'Missing required environment variables for either PLATFORM_ADMIN_USER_PASSWORD or KEYCLOAK_DOMAIN or KEYCLOAK_REALM or PLATFORM_ADMIN_KEYCLOAK_ID or PLATFORM_ADMIN_KEYCLOAK_SECRET or CRYPTO_PRIVATE_KEY'
+      'Missing required environment variables for PLATFORM_ADMIN_USER_PASSWORD, KEYCLOAK_DOMAIN, KEYCLOAK_REALM, PLATFORM_ADMIN_KEYCLOAK_ID, PLATFORM_ADMIN_KEYCLOAK_SECRET, or CRYPTO_PRIVATE_KEY'
     );
   }
   const token = await getKeycloakToken();
   const user = {
     username: cachedConfig.platformEmail,
     email: cachedConfig.platformEmail,
     firstName: cachedConfig.platformName,
     lastName: cachedConfig.platformName,
-    password: 'Password@1'
+    password: PLATFORM_ADMIN_USER_PASSWORD
   };

Based on learnings: seed data keeps sensitive fields empty and populates them from .env at runtime.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/prisma-service/prisma/seed.ts` at line 700, Remove the hardcoded
Keycloak admin password value ('Password@1') used in the seeding object and
instead read the credential from a runtime secret/environment variable (e.g.,
process.env.KEYCLOAK_ADMIN_PASSWORD); update the seeding logic that assigns the
password property (the object with password: 'Password@1') to either throw or
abort if the env var is missing, or set it to an empty/placeholder value so no
real secret is checked into source control, and ensure any helper function that
creates the admin (e.g., seedKeycloakAdmin or the admin object in the seed
routine) uses the env var rather than the literal.

};
const res = await fetch(`${KEYCLOAK_DOMAIN}admin/realms/${KEYCLOAK_REALM}/users`, {
method: 'POST',
Expand Down