Skip to content
Merged
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
4 changes: 4 additions & 0 deletions src/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ ADMIN_TOKEN_HEADER_NAME='admin-auth-token'
# The header name used to pass the organization ID in HTTP requests. This helps identify the organization associated with the request.
ORG_ID_HEADER_NAME='organization-id'

# Header names used by admins to override the target org/tenant when calling APIs on behalf of another organization or tenant.
ORG_CODE_HEADER_NAME='x-org-code'
TENANT_CODE_HEADER_NAME='x-tenant-code'

# Flag to enable/disable chat chapabilities
ENABLE_CHAT=true

Expand Down
3 changes: 3 additions & 0 deletions src/constants/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -317,4 +317,7 @@ module.exports = {
ASCENDING: 'ASC',
DESCENDING: 'DESC',
},
TENANT_ADMIN_ROLE: 'tenant_admin',
ORG_CODE_HEADER: (process.env.ORG_CODE_HEADER_NAME || 'x-org-code').toLowerCase(),
TENANT_CODE_HEADER: (process.env.TENANT_CODE_HEADER_NAME || 'x-tenant-code').toLowerCase(),
}
8 changes: 0 additions & 8 deletions src/controllers/v1/notification.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,6 @@ module.exports = class NotificationTemplate {

async template(req) {
try {
if (!utilsHelper.validateRoleAccess(req.decodedToken.roles, [common.ADMIN_ROLE, common.ORG_ADMIN_ROLE])) {
throw responses.failureResponse({
message: 'USER_IS_NOT_A_ADMIN',
statusCode: httpStatusCode.bad_request,
responseCode: 'CLIENT_ERROR',
})
}

if (req.method === common.PATCH_METHOD) {
const updatedTemplate = await notificationService.update(
req.params.id,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
'use strict'

/** @type {import('sequelize-cli').Migration} */
module.exports = {
up: async (queryInterface) => {
const transaction = await queryInterface.sequelize.transaction()

try {
// Step 1: Get all tenants from organization_extension restricted to the default org
const defaultOrgCode = process.env.DEFAULT_ORGANISATION_CODE
if (!defaultOrgCode) {
throw new Error('DEFAULT_ORGANISATION_CODE env variable is not set')
}

const [defaultOrgPerTenants] = await queryInterface.sequelize.query(
`SELECT DISTINCT oe.tenant_code, oe.organization_id, oe.organization_code
FROM organization_extension oe
INNER JOIN tenants t ON t.code = oe.tenant_code AND t.deleted_at IS NULL
WHERE oe.deleted_at IS NULL
AND oe.organization_code = :defaultOrgCode
ORDER BY oe.tenant_code, oe.organization_id`,
{ transaction, replacements: { defaultOrgCode } }
)

if (defaultOrgPerTenants.length === 0) {
console.log(
'No active tenant-org combinations found in organization_extension. Skipping role_extensions insert.'
)
} else {
// Step 2: Insert tenant_admin into role_extensions for each tenant+org combination
const roleExtensionInserts = defaultOrgPerTenants.map((row) => ({
title: 'tenant_admin',
label: 'Tenant Admin',
status: 'ACTIVE',
scope: 'ALL',
organization_id: row.organization_id,
organization_code: row.organization_code,
tenant_code: row.tenant_code,
created_at: new Date(),
updated_at: new Date(),
deleted_at: null,
}))

await queryInterface.bulkInsert('role_extensions', roleExtensionInserts, {
transaction,
ignoreDuplicates: true,
})

console.log(`Inserted tenant_admin role for ${defaultOrgPerTenants.length} active tenants`)
}

// Step 3: Copy all admin permissions except the admin module
const [adminPermissions] = await queryInterface.sequelize.query(
`SELECT DISTINCT
permission_id,
module,
request_type,
api_path,
created_by
FROM role_permission_mapping
WHERE role_title = 'admin'
AND module != 'admin'
ORDER BY permission_id`,
{ transaction }
)

console.log(`Found ${adminPermissions.length} permissions to copy for tenant_admin`)

if (adminPermissions.length > 0) {
const permissionInserts = adminPermissions.map((perm) => ({
role_title: 'tenant_admin',
permission_id: perm.permission_id,
module: perm.module,
request_type: perm.request_type,
api_path: perm.api_path,
created_at: new Date(),
updated_at: new Date(),
created_by: perm.created_by,
}))

await queryInterface.bulkInsert('role_permission_mapping', permissionInserts, {
transaction,
ignoreDuplicates: true,
})

console.log(`Inserted ${permissionInserts.length} permissions for tenant_admin`)
}

await transaction.commit()
console.log('Migration completed successfully')
} catch (error) {
await transaction.rollback()
console.error('Migration failed, rolled back:', error)
throw error
}
},

down: async (queryInterface) => {
const transaction = await queryInterface.sequelize.transaction()

try {
await queryInterface.sequelize.query(
`DELETE FROM role_permission_mapping WHERE role_title = 'tenant_admin'`,
{ transaction }
)

await queryInterface.sequelize.query(`DELETE FROM role_extensions WHERE title = 'tenant_admin'`, {
transaction,
})

await transaction.commit()
console.log('Rollback completed successfully')
} catch (error) {
await transaction.rollback()
console.error('Rollback failed:', error)
throw error
}
},
}
10 changes: 10 additions & 0 deletions src/envVariables.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ let enviromentVariables = {
optional: true,
default: 'organization-id',
},
ORG_CODE_HEADER_NAME: {
message: 'Required organization code header name for admin org override',
optional: true,
default: 'x-org-code',
},
TENANT_CODE_HEADER_NAME: {
message: 'Required tenant code header name for admin tenant override',
optional: true,
default: 'x-tenant-code',
},
IS_AUTH_TOKEN_BEARER: {
message: 'Required specification: If auth token is bearer or not',
optional: true,
Expand Down
4 changes: 4 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,10 @@
"PROFILE_NOT_UPDATED": "Please update your profile with all the mandatory fields",
"BULK_SESSIONS_REMOVED": "Bulk session removal process successfully executed.",
"ADD_ORG_HEADER": "{{orgIdHeader}} is mandatory when {{adminHeader}} is passed",
"ADD_ORG_OR_TENANT_HEADER": "Please provide all required organization headers: {{orgCodeHeader}}, and {{tenantCodeHeader}} for admin override.",

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 | 🟡 Minor | ⚡ Quick win

Minor wording issue in ADD_ORG_OR_TENANT_HEADER.

Two small issues:

  • The phrase "all required organization headers" is misleading since {{tenantCodeHeader}} is a tenant header, not an organization header.
  • The Oxford comma before "and" with only two items reads awkwardly when interpolated (e.g., x-org-code, and x-tenant-code).
✏️ Proposed wording fix
-	"ADD_ORG_OR_TENANT_HEADER": "Please provide all required organization headers: {{orgCodeHeader}}, and {{tenantCodeHeader}} for admin override.",
+	"ADD_ORG_OR_TENANT_HEADER": "Please provide both {{orgCodeHeader}} and {{tenantCodeHeader}} headers for admin override.",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"ADD_ORG_OR_TENANT_HEADER": "Please provide all required organization headers: {{orgCodeHeader}}, and {{tenantCodeHeader}} for admin override.",
"ADD_ORG_OR_TENANT_HEADER": "Please provide both {{orgCodeHeader}} and {{tenantCodeHeader}} headers for admin override.",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/locales/en.json` at line 191, Update the locale string for key
ADD_ORG_OR_TENANT_HEADER to correctly describe the two headers and remove the
awkward comma; replace the current text with something like: "Please provide the
required organization and tenant headers: {{orgCodeHeader}} and
{{tenantCodeHeader}} for admin override." Ensure you edit the value for
ADD_ORG_OR_TENANT_HEADER only and preserve the interpolation tokens
{{orgCodeHeader}} and {{tenantCodeHeader}} exactly.

"ORG_CODE_REQUIRED_FOR_TENANT_ADMIN": "Please provide header {{orgCodeHeader}} for admin override.",
"INVALID_ORG_OR_TENANT_CODE": "The value provided in headers '{{orgCodeHeader}}' or '{{tenantCodeHeader}}' is invalid or does not match any active organization.",
"INVALID_ORG_CODE_FOR_TENANT": "The value provided in header '{{orgCodeHeader}}' is invalid or does not match any active organization for the current tenant.",
"UNAUTHORIZED_REQUEST": "You are not authorized to access this resource. Please ensure that you have the correct permissions or authentication credentials.",
"NO_RESULTS_FOUND": "No results found",
"ENTITY_TYPE_AND_ENTITES_DELETED_SUCCESSFULLY": "Entity types and Entities deleted successfully.",
Expand Down
89 changes: 89 additions & 0 deletions src/middlewares/authenticator.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const MenteeExtensionQueries = require('@database/queries/userExtension')
const utils = require('@generics/utils')
const path = require('path')
const cacheHelper = require('@generics/cacheHelper')
const OrganizationExtensionQueries = require('@database/queries/organisationExtension')

module.exports = async function (req, res, next) {
try {
Expand Down Expand Up @@ -152,6 +153,93 @@ module.exports = async function (req, res, next) {
}
req.decodedToken.token = authHeader

// Check for admin and tenant admin roles — allow overriding tenant/org via headers
const roles = req.decodedToken.roles || []
const isAdmin = roles.some((role) => role.title === common.ADMIN_ROLE)
const isTenantAdmin = roles.some((role) => role.title === common.TENANT_ADMIN_ROLE)

if (isAdmin || isTenantAdmin) {
const orgCodeHeaderName = common.ORG_CODE_HEADER
const tenantCodeHeaderName = common.TENANT_CODE_HEADER

const orgCode = (req.headers[orgCodeHeaderName] || '').trim()
const tenantCode = (req.headers[tenantCodeHeaderName] || '').trim()

const hasAnyOverrideHeader = orgCode || tenantCode

if (hasAnyOverrideHeader) {
if (isTenantAdmin && !isAdmin) {
const effectiveTenantCode = req.decodedToken.tenant_code
if (!orgCode) {
throw responses.failureResponse({
message: {
key: 'ORG_CODE_REQUIRED_FOR_TENANT_ADMIN',
interpolation: { orgCodeHeader: orgCodeHeaderName },
},
statusCode: httpStatusCode.bad_request,
responseCode: 'CLIENT_ERROR',
})
}

const overrideOrg = await OrganizationExtensionQueries.findOne(
{ organization_code: orgCode },
effectiveTenantCode
)

if (!overrideOrg) {
throw responses.failureResponse({
message: {
key: 'INVALID_ORG_CODE_FOR_TENANT',
interpolation: { orgCodeHeader: orgCodeHeaderName },
},
statusCode: httpStatusCode.bad_request,
responseCode: 'CLIENT_ERROR',
})
}

req.decodedToken.organization_id = overrideOrg.organization_id
req.decodedToken.organization_code = orgCode
Comment on lines +171 to +201

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 | 🏗️ Heavy lift

Privilege escalation: tenant_admin can resolve orgs across tenants when token lacks tenant_code.

effectiveTenantCode is taken from req.decodedToken.tenant_code and passed straight to OrganizationExtensionQueries.findOne. Per its implementation in src/database/queries/organisationExtension.js (lines 95–117), when tenantCode is falsy the query is executed without the tenant_code filter:

if (tenantCode) {
    filter.tenant_code = tenantCode
}

If a tenant_admin's decoded token does not carry a tenant_code (e.g., older tokens, alternate auth providers, partial migrations), the lookup degenerates to WHERE organization_code = :orgCode, allowing the tenant_admin to bind their session to any organization with that code in any tenant. The tenant_admin branch is the security-sensitive path that is explicitly meant to be tenant-scoped, so this guard must be enforced in the middleware itself rather than relying on the query helper.

🔒 Suggested guard before the lookup
 				if (isTenantAdmin && !isAdmin) {
 					const effectiveTenantCode = req.decodedToken.tenant_code
+					if (!effectiveTenantCode) {
+						throw createUnauthorizedResponse()
+					}
 					if (!orgCode) {
 						throw responses.failureResponse({
 							message: {
 								key: 'ORG_CODE_REQUIRED_FOR_TENANT_ADMIN',
 								interpolation: { orgCodeHeader: orgCodeHeaderName },
 							},
 							statusCode: httpStatusCode.bad_request,
 							responseCode: 'CLIENT_ERROR',
 						})
 					}

 					const overrideOrg = await OrganizationExtensionQueries.findOne(
 						{ organization_code: orgCode },
 						effectiveTenantCode
 					)
As per coding guidelines: "This is security-sensitive middleware. Scrutinize for potential vulnerabilities."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/middlewares/authenticator.js` around lines 171 - 198, The tenant_admin
branch currently uses effectiveTenantCode = req.decodedToken.tenant_code and
calls OrganizationExtensionQueries.findOne without enforcing a tenant scope,
allowing cross-tenant org resolution when tenant_code is missing; fix this by
explicitly validating that req.decodedToken.tenant_code is present and non-empty
before calling OrganizationExtensionQueries.findOne (when isTenantAdmin &&
!isAdmin), and if missing throw the same kind of failureResponse (e.g.,
CLIENT_ERROR / bad_request) rather than proceeding, so the lookup always happens
with a guaranteed tenant_code and req.decodedToken.organization_id /
organization_code are only set after a tenant-scoped findOne succeeds.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

tenantCode header is silently ignored for tenant_admin overrides.

When isTenantAdmin && !isAdmin, a caller-provided x-tenant-code is read at line 166 but never consulted — the tenant is implicitly forced to req.decodedToken.tenant_code. A tenant_admin who passes x-tenant-code expecting to switch tenants will silently get a successful response scoped to their own tenant, which is confusing and hides what is effectively a denied authorization attempt.

Either reject the request with a clear 400 when a tenant_admin sends x-tenant-code, or document that the header is ignored for that role. Rejecting is preferred for an admin-debug feature.

🔒 Suggested explicit rejection
 				if (isTenantAdmin && !isAdmin) {
+					if (tenantCode) {
+						throw responses.failureResponse({
+							message: 'INVALID_ORG_CODE_FOR_TENANT',
+							statusCode: httpStatusCode.bad_request,
+							responseCode: 'CLIENT_ERROR',
+						})
+					}
 					const effectiveTenantCode = req.decodedToken.tenant_code
As per coding guidelines: "This is security-sensitive middleware. Scrutinize for potential vulnerabilities."
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (isTenantAdmin && !isAdmin) {
const effectiveTenantCode = req.decodedToken.tenant_code
if (!orgCode) {
throw responses.failureResponse({
message: {
key: 'ORG_CODE_REQUIRED_FOR_TENANT_ADMIN',
interpolation: { orgCodeHeader: orgCodeHeaderName },
},
statusCode: httpStatusCode.bad_request,
responseCode: 'CLIENT_ERROR',
})
}
const overrideOrg = await OrganizationExtensionQueries.findOne(
{ organization_code: orgCode },
effectiveTenantCode
)
if (!overrideOrg) {
throw responses.failureResponse({
message: 'INVALID_ORG_CODE_FOR_TENANT',
statusCode: httpStatusCode.bad_request,
responseCode: 'CLIENT_ERROR',
})
}
req.decodedToken.organization_id = overrideOrg.organization_id
req.decodedToken.organization_code = orgCode
if (isTenantAdmin && !isAdmin) {
if (tenantCode) {
throw responses.failureResponse({
message: 'INVALID_ORG_CODE_FOR_TENANT',
statusCode: httpStatusCode.bad_request,
responseCode: 'CLIENT_ERROR',
})
}
const effectiveTenantCode = req.decodedToken.tenant_code
if (!orgCode) {
throw responses.failureResponse({
message: {
key: 'ORG_CODE_REQUIRED_FOR_TENANT_ADMIN',
interpolation: { orgCodeHeader: orgCodeHeaderName },
},
statusCode: httpStatusCode.bad_request,
responseCode: 'CLIENT_ERROR',
})
}
const overrideOrg = await OrganizationExtensionQueries.findOne(
{ organization_code: orgCode },
effectiveTenantCode
)
if (!overrideOrg) {
throw responses.failureResponse({
message: 'INVALID_ORG_CODE_FOR_TENANT',
statusCode: httpStatusCode.bad_request,
responseCode: 'CLIENT_ERROR',
})
}
req.decodedToken.organization_id = overrideOrg.organization_id
req.decodedToken.organization_code = orgCode
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/middlewares/authenticator.js` around lines 171 - 198, When handling the
branch for isTenantAdmin && !isAdmin, explicitly reject requests that include a
caller-provided tenant header instead of silently ignoring it: check the parsed
tenant header variable (the value read into tenantCode / the x-tenant-code
header) and if it is present throw responses.failureResponse with a clear client
error (use httpStatusCode.bad_request and a distinct message/key such as
'TENANT_CODE_NOT_ALLOWED_FOR_TENANT_ADMIN' or similar), keeping the existing
overrideOrg lookup and assignment to req.decodedToken.organization_id and
organization_code unchanged for cases where no tenant header is provided.

} else if (isAdmin) {
if (!orgCode || !tenantCode) {
throw responses.failureResponse({
message: {
key: 'ADD_ORG_OR_TENANT_HEADER',
interpolation: {
orgCodeHeader: orgCodeHeaderName,
tenantCodeHeader: tenantCodeHeaderName,
},
},
statusCode: httpStatusCode.bad_request,
responseCode: 'CLIENT_ERROR',
})
}

const overrideOrg = await OrganizationExtensionQueries.findOne(
{ organization_code: orgCode },
tenantCode
)

if (!overrideOrg) {
throw responses.failureResponse({
message: {
key: 'INVALID_ORG_OR_TENANT_CODE',
interpolation: {
orgCodeHeader: orgCodeHeaderName,
tenantCodeHeader: tenantCodeHeaderName,
},
},
statusCode: httpStatusCode.bad_request,
responseCode: 'CLIENT_ERROR',
})
}

req.decodedToken.tenant_code = tenantCode
req.decodedToken.organization_id = overrideOrg.organization_id
req.decodedToken.organization_code = orgCode
Comment on lines +200 to +238

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 | 🟠 Major | ⚡ Quick win

Type inconsistency on organization_id after override.

A few lines above (146-149), req.decodedToken.organization_id is explicitly normalized to a string when numeric. After the override at lines 197 and 228, overrideOrg.organization_id is assigned directly from a Sequelize raw: true query and is typically returned as a number for INTEGER columns. Downstream code that was working against the normalized string (e.g., string equality comparisons, header propagation, cache keys) can silently misbehave when it now receives a number.

The existing admin-token branch at line 251 (organizationId.toString()) already guards against this — apply the same coercion here.

🛠 Proposed fix
 					req.decodedToken.organization_id = overrideOrg.organization_id
 					req.decodedToken.organization_code = orgCode
-				} else if (isAdmin) {
+				} else if (isAdmin) {
@@
 					req.decodedToken.tenant_code = tenantCode
 					req.decodedToken.organization_id = overrideOrg.organization_id
 					req.decodedToken.organization_code = orgCode

Replace both assignments to use string coercion:

-					req.decodedToken.organization_id = overrideOrg.organization_id
+					req.decodedToken.organization_id = overrideOrg.organization_id?.toString()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/middlewares/authenticator.js` around lines 197 - 229, The override path
assigns overrideOrg.organization_id (from OrganizationExtensionQueries.findOne)
directly to req.decodedToken.organization_id which can be a number; coerce it to
a string (like the earlier normalization and the admin-token branch that uses
organizationId.toString()) so downstream string comparisons/caching remain
correct—update both places where overrideOrg.organization_id is assigned to
req.decodedToken.organization_id to convert the value to a string before
assignment.

}
}
}

if (adminHeader) {
if (adminHeader != process.env.ADMIN_ACCESS_TOKEN) throw createUnauthorizedResponse()
const organizationId = req.get(process.env.ORG_ID_HEADER_NAME)
Expand Down Expand Up @@ -433,6 +521,7 @@ const validRoles = new Set([
common.ORG_ADMIN_ROLE,
common.ADMIN_ROLE,
common.SESSION_MANAGER_ROLE,
common.TENANT_ADMIN_ROLE,
])

async function keycloakPublicKeyAuthentication(token) {
Expand Down
1 change: 1 addition & 0 deletions src/requests/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ const validRoles = new Set([
common.ORG_ADMIN_ROLE,
common.ADMIN_ROLE,
common.SESSION_MANAGER_ROLE,
common.TENANT_ADMIN_ROLE,
])

/**
Expand Down