Files
MultiRoombaRover/server/src/services/identityAdminService/index.js
T
2026-08-07 22:34:04 -04:00

144 lines
4.5 KiB
JavaScript

// Identity Admin Service
// Purpose: Exposes lockdown admin socket operations for inspecting and editing the central identity database.
// Scope: Owns transport-level authorization and delegates every database mutation to identityService.
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('identityAdminService');
const { getRole } = require('../roleService');
const {
listUsersForAdmin,
getUserForAdmin,
addUserSignal,
removeUserSignal,
setVerified,
setDeterrence,
setMuted,
setUserPermission,
listRegisteredPermissions,
setFeatureState,
deleteFeatureState,
} = require('../identityService');
const MAX_FEATURE_STATE_JSON_BYTES = 64 * 1024;
function isLockdownAdminSocket(socket) {
const role = getRole(socket);
return role === 'lockdown';
}
function requireLockdownAdmin(socket) {
if (!isLockdownAdminSocket(socket)) {
throw new Error('Lockdown admin required.');
}
}
function normalizeFeaturePayload(namespace, value) {
const ns = String(namespace || '').trim();
if (!ns) throw new Error('Feature namespace required.');
if (!/^[a-zA-Z0-9_.:-]{1,80}$/.test(ns)) {
throw new Error('Feature namespace contains invalid characters.');
}
/*
Feature state is intentionally JSON-shaped. Strings are not accepted as the
stored value because they make the admin editor ambiguous: a textarea JSON
string should represent a real JSON object/array, not an escaped blob.
*/
if (!value || typeof value !== 'object') {
throw new Error('Feature state must be a JSON object or array.');
}
const encoded = JSON.stringify(value);
if (Buffer.byteLength(encoded, 'utf8') > MAX_FEATURE_STATE_JSON_BYTES) {
throw new Error('Feature state is too large.');
}
return { namespace: ns, value };
}
function ackHandler(socket, eventName, handler) {
socket.on(eventName, (payload = {}, cb = () => {}) => {
try {
requireLockdownAdmin(socket);
cb({ success: true, ...handler(payload || {}) });
} catch (err) {
logger.warn('Identity admin request failed', {
eventName,
socketId: socket.id,
role: getRole(socket),
error: err.message,
});
cb({ error: err.message });
}
});
}
io.on('connection', (socket) => {
ackHandler(socket, 'identityAdmin:listUsers', () => ({
users: listUsersForAdmin(),
permissions: listRegisteredPermissions(),
}));
ackHandler(socket, 'identityAdmin:listPermissions', () => ({
permissions: listRegisteredPermissions(),
}));
ackHandler(socket, 'identityAdmin:getUser', ({ userId }) => {
const user = getUserForAdmin(userId);
if (!user) throw new Error('User not found.');
return { user };
});
ackHandler(socket, 'identityAdmin:addSignal', ({ userId, type, value }) => ({
user: addUserSignal(userId, type, value),
}));
ackHandler(socket, 'identityAdmin:removeSignal', ({ userId, type, value }) => ({
user: removeUserSignal(userId, type, value),
}));
ackHandler(socket, 'identityAdmin:setVerified', ({ userId, enabled }) => ({
user: getUserForAdmin(setVerified(userId, {
enabled: Boolean(enabled),
actor: socket?.data?.user?.username || socket.id,
at: Date.now(),
}).id),
}));
ackHandler(socket, 'identityAdmin:setDeterrence', ({ userId, enabled, reason }) => ({
user: getUserForAdmin(setDeterrence(userId, {
enabled: Boolean(enabled),
reason: String(reason || '').trim() || null,
actor: socket?.data?.user?.username || socket.id,
at: Date.now(),
}).id),
}));
ackHandler(socket, 'identityAdmin:setMuted', ({ userId, enabled }) => ({
user: getUserForAdmin(setMuted(userId, {
enabled: Boolean(enabled),
actor: socket?.data?.user?.username || socket.id,
at: Date.now(),
}).id),
}));
ackHandler(socket, 'identityAdmin:setPermission', ({ userId, permissionKey, enabled }) => ({
user: getUserForAdmin(setUserPermission(userId, permissionKey, {
enabled: Boolean(enabled),
actor: socket?.data?.user?.username || socket.id,
at: Date.now(),
}).id),
}));
ackHandler(socket, 'identityAdmin:updateFeatureState', ({ userId, namespace, value }) => {
const normalized = normalizeFeaturePayload(namespace, value);
setFeatureState(userId, normalized.namespace, normalized.value);
return { user: getUserForAdmin(userId) };
});
ackHandler(socket, 'identityAdmin:deleteFeatureState', ({ userId, namespace }) => ({
user: deleteFeatureState(userId, namespace),
}));
});
module.exports = {
isLockdownAdminSocket,
};