Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/constants/defaultTenants.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ function normalizeTenantRow(row = {}) {
const status = TENANT_STATUSES.has(row?.status) ? row.status : 'active';
const tenantType = TENANT_TYPES.has(row?.tenantType) ? row.tenantType : 'campus';

return {
const normalized = {
tenantKey,
name: String(row?.name || tenantKey).trim(),
subdomain: String(row?.subdomain || tenantKey).trim().toLowerCase(),
Expand Down
5 changes: 3 additions & 2 deletions backend/routes/authRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ async function bindAuthTenant(req) {
};
}

req.school = tenantKey;
req.db = await connectToDatabase(tenantKey);
const dbRouteKey = tenant.subdomain || tenant.tenantKey;
req.school = tenant.tenantKey;
req.db = await connectToDatabase(dbRouteKey);
return null;
}
const { getFriendRequests } = require('../utilities/friendUtils');
Expand Down
34 changes: 30 additions & 4 deletions backend/routes/platformTenantRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const {
normalizePivotDropOverrides,
} = require('../constants/defaultTenants');
const { invalidateTenantConnection } = require('../connectionsManager');
const { renameTenantKey } = require('../services/tenantKeyRenameService');
const {
listReferralCodesForTenant,
createReferralCode,
Expand Down Expand Up @@ -122,12 +123,31 @@ router.post('/admin/platform/tenants', verifyToken, requirePlatformAdmin, async

router.put('/admin/platform/tenants/:tenantKey', verifyToken, requirePlatformAdmin, async (req, res) => {
try {
const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase();
const existing = await getTenantByKey(req, tenantKey);
let tenantKey = String(req.params.tenantKey || '').trim().toLowerCase();
let existing = await getTenantByKey(req, tenantKey);
if (!existing) {
return res.status(404).json({ success: false, message: 'Tenant not found.' });
}

const updatedBy = req.user.globalUserId || req.user.userId || null;
const requestedTenantKey = req.body.newTenantKey ?? req.body.tenantKey;
if (requestedTenantKey !== undefined) {
const nextTenantKey = String(requestedTenantKey).trim().toLowerCase();
if (nextTenantKey !== tenantKey) {
const renameResult = await renameTenantKey(req, tenantKey, nextTenantKey, updatedBy);
if (renameResult.error) {
return res.status(renameResult.status || 400).json({
success: false,
message: renameResult.error,
code: renameResult.code,
data: renameResult.updates,
});
}
tenantKey = renameResult.tenantKey;
existing = await getTenantByKey(req, tenantKey);
}
}

const metadataValidation = validateTenantMetadataUpdate(req.body);
if (metadataValidation.error) {
return res.status(400).json({ success: false, message: metadataValidation.error });
Expand Down Expand Up @@ -171,12 +191,18 @@ router.put('/admin/platform/tenants/:tenantKey', verifyToken, requirePlatformAdm
});
}

const updatedBy = req.user.globalUserId || req.user.userId || null;
invalidateTenantConnection(tenantKey);
if (updated.subdomain && updated.subdomain !== tenantKey) {
invalidateTenantConnection(updated.subdomain);
}
const saved = await upsertStoredTenantRow(req, updated, updatedBy);
const health = await pingTenantDatabase(tenantKey, saved);

res.json({ success: true, data: enrichTenantForAdmin(saved, { health }) });
res.json({
success: true,
data: enrichTenantForAdmin(saved, { health }),
renamedFrom: req.params.tenantKey !== tenantKey ? req.params.tenantKey : undefined,
});
} catch (err) {
console.error('PUT /admin/platform/tenants/:tenantKey failed:', err);
res.status(500).json({ success: false, message: err.message });
Expand Down
22 changes: 19 additions & 3 deletions backend/services/tenantConfigService.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,17 +150,31 @@ async function getMergedTenants(req) {
return mergeTenantRows(DEFAULT_TENANTS, doc?.tenants || []);
}

async function getTenantByKey(req, tenantKey) {
async function getTenantByKey(req, tenantKeyOrSubdomain, options = {}) {
const key = String(tenantKeyOrSubdomain || '').trim().toLowerCase();
if (!key) return null;
const tenants = await getMergedTenants(req);
return tenants.find((row) => row.tenantKey === tenantKey) || null;
if (options.exact) {
return tenants.find((row) => row.tenantKey === key) || null;
}
return (
tenants.find((row) => row.tenantKey === key) ||
tenants.find((row) => (row.subdomain || row.tenantKey) === key) ||
null
);
}

async function syncTenantUriCache(req) {
const tenants = await getMergedTenants(req);
const cache = {};
tenants.forEach((tenant) => {
const uri = deriveMongoUriForTenant(tenant.tenantKey, tenant);
if (uri) cache[tenant.tenantKey] = uri;
if (!uri) return;
cache[tenant.tenantKey] = uri;
const subdomain = String(tenant.subdomain || tenant.tenantKey).trim().toLowerCase();
if (subdomain && subdomain !== tenant.tenantKey) {
cache[subdomain] = uri;
}
});
setTenantUriCache(cache);
return cache;
Expand Down Expand Up @@ -381,6 +395,8 @@ module.exports = {
validateNewTenantPayload,
validateTenantMetadataUpdate,
upsertStoredTenantRow,
toStoredTenantRow,
getStoredTenantRows,
DEFAULT_TENANTS,
mergeTenantRows,
normalizeTenantRows,
Expand Down
189 changes: 189 additions & 0 deletions backend/services/tenantKeyRenameService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
const getGlobalModels = require('./getGlobalModelService');
const getModels = require('./getModelService');
const {
DEFAULT_TENANTS,
normalizeTenantRow,
} = require('../constants/defaultTenants');
const {
getTenantByKey,
getMergedTenants,
getStoredTenantRows,
saveTenantRows,
syncTenantUriCache,
toStoredTenantRow,
} = require('./tenantConfigService');
const {
connectToDatabase,
invalidateTenantConnection,
} = require('../connectionsManager');

const DEFAULT_TENANT_KEYS = new Set(DEFAULT_TENANTS.map((row) => row.tenantKey));
const RESERVED_TENANT_KEYS = new Set([...DEFAULT_TENANT_KEYS, 'www']);

function validateTenantKeyFormat(tenantKey) {
const key = String(tenantKey || '').trim().toLowerCase();
if (!/^[a-z][a-z0-9_-]{1,31}$/.test(key)) {
return {
error:
'tenantKey must be 2–32 chars, start with a letter, lowercase alphanumeric/underscore/hyphen.',
};
}
if (key === 'www') {
return { error: 'tenantKey "www" is reserved.' };
}
return { ok: true, tenantKey: key };
}

function isDefaultTenantKey(tenantKey) {
return DEFAULT_TENANT_KEYS.has(String(tenantKey || '').trim().toLowerCase());
}

async function updatePivotWeeklySnapshotTenantKeys(req, oldTenantKey, newTenantKey) {
const { PivotWeeklySnapshot } = getGlobalModels(req, 'PivotWeeklySnapshot');
const result = await PivotWeeklySnapshot.updateMany(
{ 'tenants.tenantKey': oldTenantKey },
{ $set: { 'tenants.$[elem].tenantKey': newTenantKey } },
{ arrayFilters: [{ 'elem.tenantKey': oldTenantKey }] },
);
return result.modifiedCount || 0;
}

async function updateTenantDbSchoolFields(tenant, oldTenantKey, newTenantKey) {
const dbRouteKey = tenant.subdomain || tenant.tenantKey;
let db;
try {
db = await connectToDatabase(dbRouteKey);
} catch (error) {
return {
error: `Could not connect to tenant database to update school fields: ${error.message}`,
};
}

const reqLike = { db, school: oldTenantKey };
const { SAMLConfig, ShuttleConfig } = getModels(reqLike, 'SAMLConfig', 'ShuttleConfig');

const [samlResult, shuttleResult] = await Promise.all([
SAMLConfig.updateMany({ school: oldTenantKey }, { $set: { school: newTenantKey } }),
ShuttleConfig.updateMany({ school: oldTenantKey }, { $set: { school: newTenantKey } }),
]);

return {
samlConfigsUpdated: samlResult.modifiedCount || 0,
shuttleConfigsUpdated: shuttleResult.modifiedCount || 0,
};
}

function invalidateTenantConnections(tenant, oldTenantKey, newTenantKey) {
const keys = new Set(
[
oldTenantKey,
newTenantKey,
tenant?.subdomain,
tenant?.tenantKey,
]
.filter(Boolean)
.map((value) => String(value).trim().toLowerCase()),
);
keys.forEach((key) => invalidateTenantConnection(key));
}

/**
* Rename a dynamically provisioned tenant and cascade tenantKey updates to global
* and tenant-scoped documents that reference the old key.
*/
async function renameTenantKey(req, oldTenantKey, newTenantKey, updatedBy = null) {
const oldKey = String(oldTenantKey || '').trim().toLowerCase();
const formatValidation = validateTenantKeyFormat(newTenantKey);
if (formatValidation.error) {
return { error: formatValidation.error, status: 400 };
}
const newKey = formatValidation.tenantKey;

if (oldKey === newKey) {
return { ok: true, tenantKey: newKey, renamed: false };
}

if (isDefaultTenantKey(oldKey)) {
return {
error: 'Built-in tenants (rpi, tvcog) cannot be renamed.',
status: 403,
code: 'DEFAULT_TENANT_IMMUTABLE',
};
}

if (RESERVED_TENANT_KEYS.has(newKey)) {
return {
error: `Tenant key "${newKey}" is reserved.`,
status: 400,
code: 'TENANT_KEY_RESERVED',
};
}

const existing = await getTenantByKey(req, oldKey, { exact: true });
if (!existing) {
return { error: 'Tenant not found.', status: 404, code: 'TENANT_NOT_FOUND' };
}

const tenants = await getMergedTenants(req);
if (tenants.some((row) => row.tenantKey === newKey)) {
return {
error: `Tenant "${newKey}" already exists.`,
status: 409,
code: 'TENANT_EXISTS',
};
}

const {
TenantMembership,
PivotReferralCode,
} = getGlobalModels(req, 'TenantMembership', 'PivotReferralCode');

const [membershipResult, referralResult, snapshotsUpdated, tenantDbResult] = await Promise.all([
TenantMembership.updateMany({ tenantKey: oldKey }, { $set: { tenantKey: newKey } }),
PivotReferralCode.updateMany({ tenantKey: oldKey }, { $set: { tenantKey: newKey } }),
updatePivotWeeklySnapshotTenantKeys(req, oldKey, newKey),
updateTenantDbSchoolFields(existing, oldKey, newKey),
]);

if (tenantDbResult?.error) {
return { error: tenantDbResult.error, status: 400, code: 'TENANT_DB_UPDATE_FAILED' };
}

const renamedTenant = normalizeTenantRow({ ...existing, tenantKey: newKey });
const stored = await getStoredTenantRows(req);
const withoutOld = stored.filter((row) => row.tenantKey !== oldKey);
const newStoredRow = toStoredTenantRow(renamedTenant);
if (!newStoredRow) {
return {
error: 'Unable to persist renamed tenant row.',
status: 500,
code: 'TENANT_CONFIG_PERSIST_FAILED',
};
}
await saveTenantRows(req, [...withoutOld, newStoredRow], updatedBy);

invalidateTenantConnections(existing, oldKey, newKey);
await syncTenantUriCache(req);

return {
ok: true,
renamed: true,
tenantKey: newKey,
previousTenantKey: oldKey,
updates: {
tenantMemberships: membershipResult.modifiedCount || 0,
pivotReferralCodes: referralResult.modifiedCount || 0,
pivotWeeklySnapshots: snapshotsUpdated,
samlConfigs: tenantDbResult.samlConfigsUpdated,
shuttleConfigs: tenantDbResult.shuttleConfigsUpdated,
},
};
}

module.exports = {
DEFAULT_TENANT_KEYS,
RESERVED_TENANT_KEYS,
validateTenantKeyFormat,
isDefaultTenantKey,
renameTenantKey,
};
Loading