/**
* Organization utilities for handling organization selection and root setting
*
* Key Concept: Definition of the organization
* ===========================================
* The organization is derived over the object links, never stored on the object
* itself: the chain runs from the object to its group and up to the root group —
* the group that marks itself as top level (`memberSys` self-link / `Data.root`).
*
* This works uniformly for all object types (person, group, extern, job, guest,
* list, dlist, email, project, repositoryShare, …). Two depths are covered:
*
* - Propagated objects: one hop already reaches the organization
* (Person/Group/Extern/Job/Guest → member → Organisation)
* - Objects that only carry their group link (list, project): one further hop
* (List/Projekt → memberA → Gruppe → member → Organisation)
*
* A share has no organization link at all: its organization is the one of its
* project (see getOrganizationForShare).
*
* @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'];
};
/**
* SQL fragment: is `${alias}` a top-level group (an organization)?
*
* Two equivalent markers exist in the data, and both are accepted:
* - `Data.root = true` (the flag set by the org setup), and
* - a `memberSys` self-link (`UID = UIDTarget = <group>`), which marks the top
* level of the group tree.
*
* Test fixtures use the `memberSys` self-link; checking only `Data.root` would
* miss organizations that carry just that marker.
*
* @param {string} alias - the ObjectBase alias
* @returns {string}
*/
const isRootGroupSql = (alias) => `(
JSON_VALUE(${alias}.Data, '$.root')
OR EXISTS (
SELECT 1 FROM Links AS rootLink
WHERE rootLink.UID = ${alias}.UID AND rootLink.UIDTarget = ${alias}.UID AND rootLink.Type = 'memberSys'
)
)`;
/**
* 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)
*
* For NOT propagated objects (list, project) the single hop is not enough: they
* only link to their group. The second query below covers that case
* (object → group → organization), so this function answers for all object
* types except shares — a share's organization is the one of its project.
*
* @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 {
// Accept both a hex Buffer and a `UUID-…` string (the query parameters are
// not auto-cast, so a bare string would never match the binary(16) column).
objectUID = UUID2hex(objectUID);
// 1. Propagated objects: one hop reaches the organization directly.
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 ${isRootGroupSql('org')})
WHERE obj.UID = ?
LIMIT 1
`, [objectUID], { cast: ['UUID'] });
if (result.length > 0) return result[0].UIDOrga;
// 2. Not propagated objects (list, project): they carry only the link to
// their group, so one more hop is needed to reach the organization.
const viaGroup = await query(`
SELECT org.UID AS UIDOrga
FROM ObjectBase AS obj
INNER JOIN Links AS objGroupLink ON (objGroupLink.UID = obj.UID AND objGroupLink.Type IN ('member','memberA','memberS','memberG'))
INNER JOIN Links AS groupOrgaLink ON (groupOrgaLink.UID = objGroupLink.UIDTarget AND groupOrgaLink.Type IN ('member','memberA','memberS','memberG','memberSys'))
INNER JOIN ObjectBase AS org ON (org.UID = groupOrgaLink.UIDTarget AND ${isRootGroupSql('org')})
WHERE obj.UID = ?
LIMIT 1
`, [objectUID], { cast: ['UUID'] });
return viaGroup.length > 0 ? viaGroup[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 share (repositoryShare / directoryShare).
*
* A share carries **no** organization link: the organization is derived over its
* project (`Share → memberA → Projekt → Owner-Gruppe → Organisation`). While the
* old model is still in the database (project → share link, `UIDBelongsTo` = org),
* both link directions and the direct `UIDBelongsTo` fallback are tolerated, so the
* function answers before, during and after the migration.
*
* @param {Buffer|string} shareHex - the share UID (Buffer or hex string)
* @returns {Promise<string|null>} - the organization UID or null if not found
*/
export const getOrganizationForShare = async (shareHex) => {
try {
shareHex = UUID2hex(shareHex);
// 1. Target model: the share links to its project.
const [own] = await query(`
SELECT proj.UID AS project
FROM Links AS link
INNER JOIN ObjectBase AS proj ON (proj.UID = link.UIDTarget AND proj.Type = 'project')
WHERE link.UID = ? AND link.Type IN ('memberA','member')
LIMIT 1
`, [shareHex], { cast: ['UUID'] });
// 2. Old model: the project links to the share.
const [foreign] = own ? [null] : await query(`
SELECT proj.UID AS project
FROM Links AS link
INNER JOIN ObjectBase AS proj ON (proj.UID = link.UID AND proj.Type = 'project')
WHERE link.UIDTarget = ? AND link.Type IN ('memberA','member')
LIMIT 1
`, [shareHex], { cast: ['UUID'] });
const project = own?.project ?? foreign?.project ?? null;
if (project) return getOrganizationForObject(project);
// 3. Old model without a project link: `UIDBelongsTo` still holds the org.
const [direct] = await query(`
SELECT org.UID AS UIDOrga
FROM ObjectBase AS share
INNER JOIN ObjectBase AS org ON (org.UID = share.UIDBelongsTo AND ${isRootGroupSql('org')})
WHERE share.UID = ?
LIMIT 1
`, [shareHex], { cast: ['UUID'] });
return direct?.UIDOrga ?? null;
} catch (error) {
console.error('Error getting organization for share:', error);
return null;
}
};
/**
* Get the organization UID for a given list UID
* @deprecated Delegates to getOrganizationForObject, which covers the
* list/project case (object → group → organization) as its second step.
* @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) => {
return getOrganizationForObject(listUID);
};
/**
* Does this object belong to the given organization?
*
* Replaces the former `ObjectBase.UIDBelongsTo = <org>` filter, which no longer
* holds once objects carry their self-reference / parent there
* (project, share). Both sides are compared as canonical UUID strings.
*
* @param {Buffer|string} objectUID - the object UID (Buffer or hex string)
* @param {Buffer|string} orgUID - the organization UID (Buffer or hex string)
* @returns {Promise<boolean>}
*/
export const isObjectInOrg = async (objectUID, orgUID) => {
const org = await getOrganizationForObject(objectUID);
if (!org) return false;
return org === HEX2uuid(orgUID);
};
/**
* The same rule as `getOrganizationForObject`, but as a SQL predicate so a
* listing can scope many rows in one query instead of resolving each object.
*
* Bind the organization **twice** (the predicate uses two placeholders) — both
* times as the value you would pass as `orgUID`.
*
* @param {string} [alias] - the ObjectBase alias the predicate applies to
* @returns {string} an `EXISTS(...)` expression for a WHERE clause
*/
export const objectInOrgSql = (alias = 'ObjectBase') => `(
EXISTS (
SELECT 1 FROM Links AS l1
INNER JOIN ObjectBase AS o1 ON (o1.UID = l1.UIDTarget AND o1.UID = ? AND ${isRootGroupSql('o1')})
WHERE l1.UID = ${alias}.UID AND l1.Type IN ('member','memberSys','memberA','memberS','memberG')
)
OR EXISTS (
SELECT 1 FROM Links AS l1
INNER JOIN Links AS l2 ON (l2.UID = l1.UIDTarget AND l2.Type IN ('member','memberA','memberS','memberG','memberSys'))
INNER JOIN ObjectBase AS o2 ON (o2.UID = l2.UIDTarget AND o2.UID = ? AND ${isRootGroupSql('o2')})
WHERE l1.UID = ${alias}.UID AND l1.Type IN ('member','memberA','memberS','memberG')
)
)`;
/** The number of `?` placeholders `objectInOrgSql` expects. */
export const OBJECT_IN_ORG_PARAMS = 2;
/** 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 obfuserject
* @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
*
* Answers the request itself (does not call `next`) when the organization context is unusable:
* - `400` with `missingOrga: true` - `x-organization`/`x-orga` missing or not a UUID
* - `403` - `x-user-uid` was sent, but that user is not valid for the organization
* - `500` - the organization config could not be loaded
*
* @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 {
// Organisation context comes from the `x-organization` header (alias `x-orga`),
// which the frontend service worker and the bot/API clients set. There is no
// token-claim fallback here - only the public portal routes resolve it themselves.
const targetOrgHeader = req.headers['x-organization'] || req.headers['x-orga'];
/** @type {any} */ (req.session).superadmin = req.headers['x-superadmin-uid'] === 'true';
// Duplicate headers arrive as an array - normalize to a single value.
let targetOrg = Array.isArray(targetOrgHeader) ? targetOrgHeader[0] : targetOrgHeader;
// Missing organisation context: neither `x-organization` nor `x-orga` was sent.
// Before, `targetOrg.match()` threw a TypeError on `undefined` and the catch
// below turned this client error into a 500 "Error processing organization context".
if (!targetOrg) {
console.warn('[checkRoot] Missing organization header (x-organization / x-orga)');
return res.status(400).json({
success: false,
missingOrga: true,
message: 'Missing organization header (x-organization or x-orga)'
});
}
// 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'
});
}
};