feat(auth): bootstrap protected tenant owner on SSO

This commit is contained in:
Inamul-hasan-tec
2026-09-02 15:10:58 +05:30
parent 4c30f329ab
commit 1c35706a5e
5 changed files with 116 additions and 33 deletions
@@ -47,6 +47,9 @@ export class RoleService {
// Generate role code from name: Admin Editor -> ADMIN_EDITOR
const role_code = role_name.toUpperCase().replace(/[^A-Z0-9]/g, '_');
if (role_code === 'TENANT_OWNER') {
throw new ApiError(403, 'Tenant Owner cannot be created or assigned through normal role management');
}
return await repository.create({
role_name,
@@ -3,6 +3,7 @@ import jwt from 'jsonwebtoken';
import { models } from '../../../shared/database/models.js';
import { generateToken, generateRefreshToken } from '../../../utils/helpers/jwt.utils.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import sequelize from '../../../shared/database/connection.js';
const MODULE_ID = 'pim';
@@ -34,6 +35,97 @@ export function formatSaasPermissions(values = []) {
return result;
}
async function ensureLocalSaasRole(user, tenant) {
const transaction = await sequelize.transaction();
try {
// Serialize first-user bootstrapping per tenant to prevent two simultaneous
// first logins from both becoming owner.
await models.Tenant.findByPk(tenant.id, { transaction, lock: transaction.LOCK.UPDATE });
const existingAssignments = await models.UserRole.findAll({
where: { user_id: user.id, status: true },
transaction
});
if (existingAssignments.length) {
await transaction.commit();
return existingAssignments.map(item => item.role_id);
}
const saasUserCount = await models.User.count({
where: { tenant_id: tenant.id, is_saas_user: true },
transaction
});
if (saasUserCount !== 1) {
await transaction.commit();
return [];
}
let ownerRole = await models.Role.findOne({
where: { tenant_id: tenant.id, role_code: 'TENANT_OWNER' },
transaction
});
if (!ownerRole) {
ownerRole = await models.Role.create({
tenant_id: tenant.id,
role_code: 'TENANT_OWNER',
role_name: 'Tenant Owner',
description: 'Non-delegable tenant owner with full PIM access.',
role_type: 'tenant',
is_system_role: true,
status: true
}, { transaction });
}
const nodes = await models.PermissionNode.findAll({ transaction });
await models.RolePermission.destroy({ where: { role_id: ownerRole.id }, transaction });
if (nodes.length) {
await models.RolePermission.bulkCreate(nodes.map(node => ({
role_id: ownerRole.id,
node_id: node.id,
can_view: Boolean(node.can_view),
can_create: Boolean(node.can_create),
can_edit: Boolean(node.can_edit),
can_delete: Boolean(node.can_delete),
can_alter: Boolean(node.can_alter),
can_import: Boolean(node.can_import),
can_export: Boolean(node.can_export)
})), { transaction });
}
await models.UserRole.create({ user_id: user.id, role_id: ownerRole.id, status: true }, { transaction });
await transaction.commit();
return [ownerRole.id];
} catch (error) {
await transaction.rollback();
throw error;
}
}
async function loadLocalAccess(roleIds) {
if (!roleIds.length) return { roles: [], permissions: {} };
const roles = await models.Role.findAll({
where: { id: roleIds, status: true },
include: [{
model: models.PermissionNode,
as: 'permissions',
through: { attributes: ['can_view', 'can_create', 'can_edit', 'can_delete', 'can_alter', 'can_import', 'can_export'] }
}]
});
const permissions = {};
for (const role of roles) {
for (const node of role.permissions || []) {
const grant = node.RolePermission;
permissions[node.node_code] ||= { view: false, create: false, edit: false, delete: false, alter: false, import: false, export: false };
for (const action of ['view', 'create', 'edit', 'delete', 'alter', 'import', 'export']) {
permissions[node.node_code][action] ||= Boolean(grant?.[`can_${action}`]);
}
}
}
return {
roles: roles.map(role => ({ id: role.id, name: role.role_name, code: role.role_code })),
permissions
};
}
export function verifySaasModuleToken(token, publicKey) {
const key = publicKey.replaceAll('\\n', '\n');
const claims = jwt.verify(token, key, { algorithms: ['RS256'], audience: MODULE_ID });
@@ -101,14 +193,14 @@ export async function exchangeSaasGrant(grantCode, { fetchImpl = fetch } = {}) {
}
if (!user.status) throw new ApiError(403, 'PIM user is disabled');
const permissionClaims = Array.isArray(claims.permissions) ? claims.permissions : [];
const roleIds = await ensureLocalSaasRole(user, tenant);
const localAccess = await loadLocalAccess(roleIds);
const localPayload = {
user_id: user.id,
tenant_id: tenant.id,
canonical_tenant_id: tenant.canonical_tenant_id,
user_type: 'tenant',
role_ids: [],
permissions: permissionClaims,
role_ids: roleIds,
auth_source: 'saas'
};
user.last_login_at = new Date();
@@ -120,11 +212,11 @@ export async function exchangeSaasGrant(grantCode, { fetchImpl = fetch } = {}) {
email: user.email,
type: 'tenant',
auth_source: 'saas',
roles: (Array.isArray(claims.roles) ? claims.roles : []).map((name, index) => ({ id: `saas-${index}`, name })),
roles: localAccess.roles,
tenant: { id: tenant.id, name: tenant.tenant_name }
},
accessToken: generateToken(localPayload),
refreshToken: generateRefreshToken(localPayload),
permissions: formatSaasPermissions(permissionClaims)
permissions: localAccess.permissions
};
}
@@ -37,6 +37,12 @@ export class UserService {
if (roleCount !== role_ids.length) {
throw new ApiError(403, 'Forbidden: One or more roles belong to another tenant workspace');
}
const ownerRoleCount = await models.Role.count({
where: { id: role_ids, tenant_id: userContext.tenantId, role_code: 'TENANT_OWNER' }
});
if (ownerRoleCount) {
throw new ApiError(403, 'Tenant Owner cannot be assigned through normal user management');
}
}
// Get the name of the first role for the email template
@@ -83,6 +89,14 @@ export class UserService {
async update(id, data, context = {}) {
const { user_name, phone, status, role_ids } = data;
const current = await repository.findById(id, {}, context);
if (current?.roles?.some(role => role.role_code === 'TENANT_OWNER') && role_ids !== undefined) {
throw new ApiError(403, 'Tenant Owner role cannot be changed through normal user management');
}
if (Array.isArray(role_ids) && role_ids.length) {
const ownerRoleCount = await models.Role.count({ where: { id: role_ids, role_code: 'TENANT_OWNER' } });
if (ownerRoleCount) throw new ApiError(403, 'Tenant Owner cannot be assigned through normal user management');
}
const userData = {};
if (user_name !== undefined) userData.user_name = user_name;
if (phone !== undefined) userData.phone = phone;
@@ -28,19 +28,6 @@ export const authorize = (requiredPermissions = [], specificAction = null) => {
const permissionsList = Array.isArray(requiredPermissions) ? requiredPermissions : [requiredPermissions];
// SaaS is the authority for SSO sessions. These users intentionally do
// not need duplicate local PIM roles; authorize only from the signed,
// module-scoped permission claims issued during the grant exchange.
if (req.user.auth_source === 'saas' && Array.isArray(req.user.permissions)) {
const allowed = req.user.permissions.includes('*') || permissionsList.some(code =>
req.user.permissions.includes(code) || req.user.permissions.includes(`${code}.${action}`) ||
(action === 'view' && req.user.permissions.includes(`${code}.read`)) ||
(action === 'edit' && req.user.permissions.includes(`${code}.update`))
);
if (allowed) return next();
return res.status(403).json({ success: false, message: `Forbidden: Insufficient SaaS permission for action: ${action}` });
}
const { role_ids } = req.user;
if (!role_ids || role_ids.length === 0) {
return res.status(403).json({ success: false, message: 'Forbidden: No roles assigned' });
@@ -21,7 +21,7 @@ function runAuthorization({ method = 'GET', user, required = ['products.items']
});
}
test('SaaS users are authorized from signed permissions without local roles', async () => {
test('SaaS users without local PIM roles are denied even when legacy claims exist', async () => {
const result = await runAuthorization({
user: {
auth_source: 'saas',
@@ -30,22 +30,9 @@ test('SaaS users are authorized from signed permissions without local roles', as
}
});
assert.equal(result.passed, true);
});
test('SaaS permissions remain action-scoped', async () => {
const result = await runAuthorization({
method: 'POST',
user: {
auth_source: 'saas',
role_ids: [],
permissions: ['products.items.read']
}
});
assert.equal(result.passed, false);
assert.equal(result.status, 403);
assert.match(result.body.message, /Insufficient SaaS permission/);
assert.equal(result.body.message, 'Forbidden: No roles assigned');
});
test('native PIM users without roles are still denied', async () => {