diff --git a/src/.env.sample b/src/.env.sample index 88d18dd39..2cfa85c92 100644 --- a/src/.env.sample +++ b/src/.env.sample @@ -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 diff --git a/src/constants/common.js b/src/constants/common.js index 6e935e887..3f92659ca 100644 --- a/src/constants/common.js +++ b/src/constants/common.js @@ -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(), } diff --git a/src/controllers/v1/notification.js b/src/controllers/v1/notification.js index 8a91b4982..9534ed2a4 100644 --- a/src/controllers/v1/notification.js +++ b/src/controllers/v1/notification.js @@ -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, diff --git a/src/database/migrations/20260511122947-add-tenant-admin-role-permissions.js b/src/database/migrations/20260511122947-add-tenant-admin-role-permissions.js new file mode 100644 index 000000000..7b6687790 --- /dev/null +++ b/src/database/migrations/20260511122947-add-tenant-admin-role-permissions.js @@ -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 + } + }, +} diff --git a/src/envVariables.js b/src/envVariables.js index 79904a636..0e0ed2913 100644 --- a/src/envVariables.js +++ b/src/envVariables.js @@ -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, diff --git a/src/locales/en.json b/src/locales/en.json index 70fa7add2..bb713a07f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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.", + "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.", diff --git a/src/middlewares/authenticator.js b/src/middlewares/authenticator.js index de38cdcfd..e6808e72a 100644 --- a/src/middlewares/authenticator.js +++ b/src/middlewares/authenticator.js @@ -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 { @@ -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 + } 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 + } + } + } + if (adminHeader) { if (adminHeader != process.env.ADMIN_ACCESS_TOKEN) throw createUnauthorizedResponse() const organizationId = req.get(process.env.ORG_ID_HEADER_NAME) @@ -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) { diff --git a/src/requests/user.js b/src/requests/user.js index d307f985a..6a3817dad 100644 --- a/src/requests/user.js +++ b/src/requests/user.js @@ -140,6 +140,7 @@ const validRoles = new Set([ common.ORG_ADMIN_ROLE, common.ADMIN_ROLE, common.SESSION_MANAGER_ROLE, + common.TENANT_ADMIN_ROLE, ]) /**