feat(auth): bootstrap protected tenant owner on SSO
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user