/**
* Organization utilities for handling organization selection and root setting
*
* Key Concept: Link Propagation
* =============================
* Most objects (persons, groups, externs, jobs, guests) have their member links
* propagated up to their organization. However, LISTS are different:
*
* - Propagated objects: Person/Group/Extern/Job/Guest → member link → Organization
* - Non-propagated objects: List → memberA link → Group → member link → Organization
*
* This is why getOrganizationForList() needs a more complex query than getOrganizationForObject().
*
* @import {ExpressRequestAuthorized, ExpressResponse} from './../types.js'
*/
import { query, UUID2hex, HEX2uuid } from '@commtool/sql-query';
import { getConfig } from '../utils/compileTemplates.js';
import { getRedisClient } from '@commtool/shared-auth';
import { getSuperAdmin, getAllOrganizations } from '../Router/orga/service.js';
import { isAdmin, isAdminOrga, isBaseAdmin } from '../utils/authChecks.js';
import { wsFakeUser } from '../server.ws.js';
import { getUser, getFakeUser } from './userUtils.js';
/**
* Get the required database roles based on NODE_ENV
* Production: db-user, db-admin
* Development: db-dev, db-admin
* Demonstration: db-demo, db-admin
* @returns {string[]} Array of valid roles for current environment
*/
const getRequiredDbRoles = () => {
if (process.env.NODE_ENV === 'development') {
return ['db-dev', 'db-admin'];
}
if (process.env.NODE_ENV === 'demonstration') {
return ['db-demo', 'db-admin'];
}
// Production (default)
return ['db-user', 'db-admin'];
};
/**
* Get the organization UID for any PROPAGATED object UID (group, person, extern, job, guest)
* Works because these object links are propagated to their organization via member links.
*
* Link Propagation Flow:
* Propagated Object → member link → ... → Organization (Data.root=true)
*
* NOTE: Does NOT work for lists - use getOrganizationForList() instead
*
* @param {Buffer} objectUID - The object UID (Buffer or hex string)
* @returns {Promise<string|null>} - The organization UID or null if not found
*/
export const getOrganizationForObject = async (objectUID) => {
try {
const result = await query(`
SELECT org.UID AS UIDOrga
FROM ObjectBase AS obj
INNER JOIN Links AS objOrgaLink ON (objOrgaLink.UID = obj.UID AND objOrgaLink.Type IN ('member','memberSys', 'memberA','memberS','memberG'))
INNER JOIN ObjectBase AS org ON (org.UID = objOrgaLink.UIDTarget AND JSON_VALUE(org.Data, '$.root'))
WHERE obj.UID = ?
LIMIT 1
`, [objectUID], { cast: ['UUID'] });
return result.length > 0 ? result[0].UIDOrga : null;
} catch (error) {
console.error('Error getting organization for object:', error);
return null;
}
};
/**
* Get the organization UID for a given group UID
* @deprecated Use getOrganizationForObject instead (works for all object types due to link propagation)
* @param {Buffer} groupUID - The group UID (Buffer or hex string)
* @returns {Promise<string|null>} - The organization UID or null if not found
*/
export const getOrganizationForGroup = async (groupUID) => {
return getOrganizationForObject(groupUID);
};
/**
* Get the organization UID for a given list UID
* Lists are NOT propagated, so we need: List → memberA → Group → member → Organization
* Uses simplified organization detection with Data.root=true
* @param {Buffer} listUID - The list UID (Buffer or hex string)
* @returns {Promise<string|null>} - The organization UID or null if not found
*/
export const getOrganizationForList = async (listUID) => {
try {
const result = await query(`
SELECT (
SELECT org.UID
FROM Links AS groupOrgaLink
INNER JOIN ObjectBase AS org ON (org.UID = groupOrgaLink.UIDTarget AND JSON_VALUE(org.Data, '$.root'))
WHERE groupOrgaLink.UID = listGroupLink.UID
AND groupOrgaLink.Type IN ('member','memberSys', 'memberA','memberS','memberG')
LIMIT 1
) AS UIDOrga
FROM ObjectBase AS list
INNER JOIN Links AS listGroupLink ON (listGroupLink.UID = list.UID AND listGroupLink.Type = 'memberA')
WHERE list.UID = ?
LIMIT 1
`, [listUID]);
return result.length > 0 ? result[0].UIDOrga : null;
} catch (error) {
console.error('Error getting organization for list:', error);
return null;
}
};
/** this code is obsoleted, as we always get an organisation from keycloak login */
/**
* Alternative root finding logic when no specific organization is provided
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @returns {Promise<Object>} - {user, config} object
*/
export const alternativeRoot = async (req, res) => {
// Get authenticated user from session (set by sessionEnhancer)
const authUser = req.session.authUser;
// Try to find the orga the user has in his keycloak data
const orgas = await query(
`SELECT UID FROM Objects WHERE Objects.Type='group' AND Objects.UID=Objects.UIDBelongsTo AND JSON_VALUE(Objects.Data,'$.keycloakOrga')=?`,
[`UUID-${authUser.organization}`],
{ cast: ['UUID'] }
);
if (orgas.length > 0) {
return setOrgaRoot(orgas[0].UID, req);
}
// Try to find the first orga the user is member of
const orgas2 = await query(
`SELECT Links.UIDTarget AS UID FROM Links
INNER JOIN Links AS MLink ON (Links.UIDTarget=MLink.UID AND MLink.Type IN ('member','memberA','memberSys'))
WHERE Links.UID=? AND Links.Type='identifyer'
ORDER BY MLink.Type DESC LIMIT 1`,
[UUID2hex(`UUID-${authUser.id}`)],
{ cast: ['UUID'] }
);
if (orgas2.length > 0) {
return setOrgaRoot(orgas2[0].UID, req);
}
// If employee, try to find an orga which has no admin list
if (authUser.groups && authUser.groups.includes('employees')) {
const orgas3 = await query(
`SELECT Objects.UID FROM Objects
WHERE Objects.Type='group' AND Objects.UID=Objects.UIDBelongsTo
AND NOT EXISTS (SELECT 1 FROM Links WHERE Links.UIDTarget=Objects.UID AND Links.Type='admin')
ORDER BY Objects.UID LIMIT 1`,
[],
{ cast: ['UUID'] }
);
if (orgas3.length > 0) {
// Make the session an admin session
req.session.admin = true;
return setOrgaRoot(orgas3[0].UID, req);
}
}
return { user: null, config: null };
};
/**
* Set the organization root for the current session
* @param {string} UIDroot - Organization UUID
* @param {Object} req - Express request object
* @returns {Promise<Object>} - {user, config} object
*/
export const setOrgaRoot = async (UIDroot, req) => {
// Set both properties at once
Object.assign(req.session, {
root: UIDroot,
UIDroot: UIDroot,
app: req.params.app ? (req.params.app === 'root' ? 'db' : req.params.app) : (req.session.app ? req.session.app : 'db')
});
/**
* SECURITY-CRITICAL: Distinguish between service/bot and regular user
* Service accounts in Keycloak are assigned to 'app-bot' group
*/
const authUser = req.session.authUser;
// Handle groups as array or string (Keycloak can return either format)
const groups = Array.isArray(authUser?.groups) ? authUser.groups : (authUser?.groups ? [authUser.groups] : []);
const isServiceClient = groups.includes('app-bot') || req.session.superadmin;
let result;
if (isServiceClient) {
// Handle service client/bot requests - assign cached super admin user
try {
// Simplified super admin caching - auth library handles bot user identity
const superAdminKey = `superadmin-${UIDroot}`;
let superAdmin = await getRedisClient().get(superAdminKey);
if (!superAdmin) {
// Cache miss - fetch from database
superAdmin = await getSuperAdmin(UIDroot);
if (superAdmin) {
// Cache for 1 hour (3600 seconds)
await getRedisClient().setEx(superAdminKey, 3600, superAdmin);
}
}
if (superAdmin) {
// Assign super admin as bot user (auth library handles the detailed user object)
req.session.user = superAdmin;
req.session.botUser = !req.session.superadmin; // Keep for logging purposes only
req.session.baseUser = superAdmin;
let userOrgas=[]
if(req.session.superadmin)
{
// get all orgas for superadmin
const orgasResult = await query(`
SELECT orga.UID FROM ObjectBase as orga
INNER JOIN Links AS mslink ON (mslink.UID=orga.UID AND mslink.Type ='memberSys' AND mslink.UID=mslink.UIDTarget)
WHERE orga.Type='group'
`,
[UUID2hex(superAdmin)],
{ cast: ['UUID'] }
);
userOrgas=orgasResult.map(o=>o.UID)
}
const config = await getConfig(req.session.app, req);
const user= { UID: superAdmin, isAdmin: true, Data: { firstName: 'super', lastName: 'admin',userOrgas } };
const mergedConfig = { ...config, sysUserUID: superAdmin, orgaUser: user };
result = { user: user, config: mergedConfig };
} else {
console.warn(`[setOrgaRoot] No super admin found for org: ${UIDroot}`);
result = { user: null, config: null };
}
} catch (error) {
console.error(`[setOrgaRoot] Error assigning bot user: ${error.message}`);
result = { user: null, config: null };
}
} else {
// Handle regular user requests
const user = await getUser(req);
let fakeUser = null;
// Check for user impersonation via X-Fake-User header (admin only)
const fakeUserHeader = req.headers['x-fake-user'];
if (fakeUserHeader) {
// First check if requester is admin before allowing impersonation
req.session.user = user.UID;
req.session.baseUser = user.UID;
const isUserAdmin = await isAdmin(req.session);
if (isUserAdmin) {
fakeUser = await getFakeUser(fakeUserHeader, UIDroot);
} else {
console.warn(`[setOrgaRoot] Non-admin user ${user.UID} attempted to impersonate ${fakeUserHeader}`);
}
} const app = req.session.app === 'root' ? 'db' : req.session.app;
if (fakeUser) {
// Fake a user for admins (impersonation for testing/support)
req.session.user = fakeUser.UID;
req.session.fakeLogin = fakeUserHeader;
req.session.baseUser = user.UID;
const config = await getConfig(req.session.app, req);
const mergedConfig = { ...config, sysUserUID: req.session.sysUser, orgaUser: fakeUser };
req.session.admin = false; // Admin rights not transferred to fake user
req.session.sysUser = mergedConfig.sysUser;
req.session.app = app;
req.session.sysUser = mergedConfig.sysUser
result = { user: fakeUser, config: mergedConfig };
} else {
// Standard user settings
req.session.user = user.UID;
req.session.baseUser = user.UID;
// Get org roles from authenticated user (set by sessionEnhancer)
const authUser = req.session.authUser;
const config = await getConfig(req.session.app, req);
const mergedConfig = { ...config, sysUserUID: req.session.sysUser, orgaUser: user };
req.session.admin = await isAdmin(req.session);
req.session.app = app;
req.session.sysUser = mergedConfig.sysUser;
result = { user, config: mergedConfig };
}
}
return result;
};
/**
* Check and set the organization root for the current request
* Uses Keycloak organization as single source of truth
*
* @param {ExpressRequestAuthorized} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next function
*/
export const checkRoot = async (req, res, next) => {
try {
// Determine the organization from either:
// 1. X-Organization header (for bots and API calls)
// 2. Keycloak token organization claim (for logged-in users)
const orgaHeader = req.headers['x-organization'] || req.headers['x-orga'];
/** @type {any} */ (req.session).superadmin = req.headers['x-superadmin-uid'] === 'true';
let targetOrg=orgaHeader
// Validate organization format
if (!targetOrg.match(/^UUID-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/)) {
console.warn(`[checkRoot] Invalid organization format: ${targetOrg}`);
return res.status(400).json({
success: false,
missingOrga: true,
message: 'Invalid organization format'
});
}
/** @type {any} */ req.session.root=targetOrg;
// Set the new organization root
const { user, config } = await setOrgaRoot(targetOrg, req);
// Handle optional user override header (for testing/bots)
const userHeader = req.headers['x-user-uid'];
if (userHeader && userHeader.match(/^UUID-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/)) {
const { validateUserForOrganization } = await import('./userUtils.js');
const userValidation = await validateUserForOrganization(userHeader, targetOrg);
if (userValidation.valid) {
req.session.user = userHeader;
/** @type {any} */ (req.session).baseUser = userHeader;
} else {
console.warn(`[checkRoot] ❌ User ${userHeader} not valid for org ${targetOrg}`);
return res.status(403).json({
success: false,
message: 'User not authorized for organization'
});
}
}
// Handle super admin override header (for bots)
const superAdminHeader = req.headers['x-superadmin-uid'];
if (superAdminHeader && (!user || user.UID === null)) {
req.session.user = superAdminHeader;
/** @type {any} */ (req.session).botUser = true;
/** @type {any} */ (req.session).baseUser = superAdminHeader;
// Cache super admin UID
const superAdminKey = `superadmin-${targetOrg}`;
await getRedisClient().setEx(superAdminKey, 3600, superAdminHeader);
}
if (!config) {
console.error(`[checkRoot] Failed to load config for organization: ${targetOrg}`);
return res.status(500).json({
success: false,
message: 'Failed to load organization configuration'
});
}
// Note: With Bearer-only auth (v7.0), session data is stored in the virtual session object
// attached to req.session by authFactory. No need to persist to Redis.
next();
} catch (error) {
console.error(`[checkRoot] Error: ${error.message}`);
return res.status(500).json({
success: false,
message: 'Error processing organization context'
});
}
};