diff --git a/client/public/help.json b/client/public/help.json index c4e754c..56552ec 100644 --- a/client/public/help.json +++ b/client/public/help.json @@ -40,6 +40,10 @@ "keys": "Slash", "description": "Start chat" }, + { + "keys": "Shift+Z", + "description": "Admin menu (when your role allows it)" + }, { "keys": "Comma / Period", "description": "Previous/next message" diff --git a/client/public/version.js b/client/public/version.js index ddcd473..06e5de6 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.27 R284"; +window.CHGRID_WEB_VERSION = "2026.02.27 R285"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/input/mainCommandRouter.ts b/client/src/input/mainCommandRouter.ts index e7b3e6d..fb76b1f 100644 --- a/client/src/input/mainCommandRouter.ts +++ b/client/src/input/mainCommandRouter.ts @@ -29,6 +29,7 @@ export type MainModeCommand = | 'locateOrListUsers' | 'openHelp' | 'openChat' + | 'openAdminMenu' | 'chatPrev' | 'chatNext' | 'chatFirst' @@ -61,6 +62,7 @@ export function resolveMainModeCommand(code: string, shiftKey: boolean): MainMod if (code === 'KeyP') return shiftKey ? null : 'pingServer'; if (code === 'KeyL') return 'locateOrListUsers'; if (code === 'Slash') return shiftKey ? 'openHelp' : 'openChat'; + if (code === 'KeyZ') return shiftKey ? 'openAdminMenu' : null; if (code === 'Comma') return shiftKey ? 'chatFirst' : 'chatPrev'; if (code === 'Period') return shiftKey ? 'chatLast' : 'chatNext'; if (code === 'Escape') return 'escape'; diff --git a/client/src/main.ts b/client/src/main.ts index ce655d6..5e17e13 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -186,6 +186,28 @@ type AuthPolicy = { passwordMaxLength: number; }; +type AdminMenuActionId = 'manage_roles' | 'change_user_role' | 'ban_user' | 'unban_user'; + +type AdminMenuAction = { + id: AdminMenuActionId; + label: string; +}; + +type AdminRoleSummary = { + id: number; + name: string; + isSystem: boolean; + userCount: number; + permissions: string[]; +}; + +type AdminUserSummary = { + id: string; + username: string; + role: string; + status: 'active' | 'disabled'; +}; + /** Builds linearized help-view lines from sectioned help content. */ function buildHelpLines(help: HelpData): string[] { const lines: string[] = []; @@ -236,6 +258,9 @@ let authMode: 'login' | 'register' = 'login'; let authSessionToken = settings.loadAuthSessionToken(); let authUsername = settings.loadAuthUsername(); let authPolicy: AuthPolicy | null = null; +let authRole = 'user'; +let authPermissions = new Set(); +let voiceSendAllowed = true; let pendingAuthRequest = false; const messageBuffer: string[] = []; let messageCursor = -1; @@ -274,6 +299,18 @@ let suppressItemPropertyEchoUntilMs = 0; let itemPropertiesShowAll = false; let activeTeleportLoopStop: (() => void) | null = null; let activeTeleportLoopToken = 0; +const adminMenuActions: AdminMenuAction[] = []; +let adminMenuIndex = 0; +let adminRoles: AdminRoleSummary[] = []; +let adminRoleIndex = 0; +let adminPermissionKeys: string[] = []; +let adminRolePermissionIndex = 0; +let adminRoleDeleteReplacementIndex = 0; +let adminUsers: AdminUserSummary[] = []; +let adminUserIndex = 0; +let adminPendingUserAction: 'set_role' | 'ban' | 'unban' | null = null; +let adminSelectedRoleName = ''; +let adminSelectedUsername = ''; let activeTeleport: | { startX: number; @@ -546,6 +583,28 @@ function loadPersistedAuthPolicy(): void { } } +/** Returns whether currently authenticated user has a specific permission key. */ +function hasPermission(key: string): boolean { + return authPermissions.has(key); +} + +/** Applies latest role + permission set from server auth packets. */ +function applyAuthPermissions(role: string | null | undefined, permissions: string[] | null | undefined): void { + authRole = String(role || 'user').trim() || 'user'; + authPermissions = new Set((permissions || []).map((value) => String(value).trim()).filter((value) => value.length > 0)); + applyVoiceSendPermission(); +} + +/** Applies server-authoritative voice.send permission immediately to local outbound track state. */ +function applyVoiceSendPermission(): void { + voiceSendAllowed = hasPermission('voice.send'); + if (voiceSendAllowed) { + mediaSession.applyMuteToTrack(state.isMuted); + return; + } + mediaSession.applyMuteToTrack(true); +} + /** Enables/disables the connect button based on state and nickname validity. */ function updateConnectAvailability(): void { const hasSessionToken = authSessionToken.trim().length > 0; @@ -1011,6 +1070,7 @@ function textInputMaxLengthForMode(mode: typeof state.mode): number | null { if (mode === 'chat') return 500; if (mode === 'itemPropertyEdit') return 500; if (mode === 'micGainEdit') return 8; + if (mode === 'adminRoleNameEdit') return 32; return null; } @@ -1030,7 +1090,13 @@ function pasteIntoActiveTextInput(raw: string): boolean { /** Whether the current mode uses the shared single-line text editing pipeline. */ function isTextEditingMode(mode: typeof state.mode): boolean { - return mode === 'nickname' || mode === 'chat' || mode === 'itemPropertyEdit' || mode === 'micGainEdit'; + return ( + mode === 'nickname' || + mode === 'chat' || + mode === 'itemPropertyEdit' || + mode === 'micGainEdit' || + mode === 'adminRoleNameEdit' + ); } /** Applies keyboard edits to the shared text buffer and emits cursor/deletion speech hints. */ @@ -1276,6 +1342,7 @@ async function checkMicPermission(): Promise { /** Starts local microphone capture and rebuilds the outbound track pipeline. */ async function setupLocalMedia(audioDeviceId = ''): Promise { await mediaSession.setupLocalMedia(audioDeviceId); + applyVoiceSendPermission(); } /** Runs a short RMS sample to estimate and apply a usable microphone input gain. */ @@ -1417,6 +1484,7 @@ function sendAuthRequest(): void { /** Handles server auth-required prompts prior to world welcome. */ function handleAuthRequired(message: Extract): void { applyAuthPolicy(message.authPolicy); + applyAuthPermissions('user', []); setConnectionStatus('Authentication required.'); updateStatus(message.message); } @@ -1433,6 +1501,7 @@ async function handleAuthResult(message: Extract): void { + const hadVoiceSend = voiceSendAllowed; + applyAuthPermissions(message.role, message.permissions); + if (hadVoiceSend && !voiceSendAllowed) { + updateStatus('Voice send permission revoked.'); + } + if (!hadVoiceSend && voiceSendAllowed) { + updateStatus('Voice send permission granted.'); + } +} + +/** Returns available admin-menu root actions based on current permission set. */ +function getAvailableAdminActions(): AdminMenuAction[] { + const actions: AdminMenuAction[] = []; + if (hasPermission('role.manage')) { + actions.push({ id: 'manage_roles', label: 'Role management' }); + } + if (hasPermission('user.change_role')) { + actions.push({ id: 'change_user_role', label: 'Change user role' }); + } + if (hasPermission('user.ban_unban')) { + actions.push({ id: 'ban_user', label: 'Ban user' }); + actions.push({ id: 'unban_user', label: 'Unban user' }); + } + return actions; +} + +/** Handles server role-list response for admin menu flows. */ +function handleAdminRolesList(message: Extract): void { + adminRoles = [...message.roles].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + adminPermissionKeys = [...message.permissionKeys].sort((a, b) => a.localeCompare(b)); + if (adminPendingUserAction === 'set_role' && adminSelectedUsername) { + state.mode = 'adminUserRoleSelect'; + adminRoleIndex = 0; + const first = adminRoles[0]; + if (first) { + updateStatus(`Set ${adminSelectedUsername} role. ${first.name}.`); + audio.sfxUiBlip(); + } else { + updateStatus('No roles available.'); + audio.sfxUiCancel(); + state.mode = 'normal'; + adminPendingUserAction = null; + adminSelectedUsername = ''; + } + return; + } + state.mode = 'adminRoleList'; + adminRoleIndex = 0; + const first = adminRoles[0]; + if (first) { + updateStatus(`${first.name}, ${first.userCount}.`); + } else { + updateStatus('No roles found.'); + } + audio.sfxUiBlip(); +} + +/** Handles server user-list response for admin menu flows. */ +function handleAdminUsersList(message: Extract): void { + adminUsers = [...message.users].sort((a, b) => a.username.localeCompare(b.username, undefined, { sensitivity: 'base' })); + if (adminUsers.length === 0) { + updateStatus('No users available.'); + audio.sfxUiCancel(); + state.mode = 'normal'; + adminPendingUserAction = null; + return; + } + state.mode = 'adminUserList'; + adminUserIndex = 0; + const first = adminUsers[0]; + updateStatus(`${first.username}, ${first.role}, ${first.status}.`); + audio.sfxUiBlip(); +} + +/** Handles structured admin action result packets. */ +function handleAdminActionResult(message: Extract): void { + updateStatus(message.message); + if (message.ok) { + audio.sfxUiConfirm(); + } else { + audio.sfxUiCancel(); + } +} + /** Builds dependencies shared by connect/disconnect flow helpers. */ function getConnectionFlowDeps(): ConnectFlowDeps { return { @@ -1625,6 +1782,10 @@ const onAppMessage = createOnMessageHandler({ }, handleAuthRequired, handleAuthResult, + handleAuthPermissions, + handleAdminRolesList, + handleAdminUsersList, + handleAdminActionResult, isPeerNegotiationReady: () => peerNegotiationReady, enqueuePendingSignal: (message) => { pendingSignalMessages.push(message); @@ -1644,6 +1805,7 @@ async function onSignalingMessage(message: IncomingMessage): Promise { let connectedAnnouncement: string | null = null; if (message.type === 'welcome') { applyAuthPolicy(message.auth?.policy); + applyAuthPermissions(message.auth?.role, message.auth?.permissions); const incomingInstanceId = String(message.serverInfo?.instanceId ?? '').trim() || null; const incomingVersion = String(message.serverInfo?.version ?? '').trim() || 'unknown'; connectedAnnouncement = reconnectInFlight @@ -1715,6 +1877,11 @@ async function setupMediaAfterAuth(): Promise { /** Toggles local microphone track mute state. */ function toggleMute(): void { + if (!voiceSendAllowed) { + updateStatus('Voice send is disabled for this account.'); + audio.sfxUiCancel(); + return; + } state.isMuted = !state.isMuted; mediaSession.applyMuteToTrack(state.isMuted); updateStatus(state.isMuted ? 'Muted.' : 'Unmuted.'); @@ -1797,6 +1964,11 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void { audio.sfxUiBlip(); return; case 'openMicGainEdit': + if (!voiceSendAllowed) { + updateStatus('Voice send is disabled for this account.'); + audio.sfxUiCancel(); + return; + } state.mode = 'micGainEdit'; state.nicknameInput = formatSteppedNumber(audio.getOutboundInputGain(), MIC_INPUT_GAIN_STEP); state.cursorPos = state.nicknameInput.length; @@ -1807,8 +1979,25 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void { audio.sfxUiBlip(); return; case 'calibrateMicrophone': + if (!voiceSendAllowed) { + updateStatus('Voice send is disabled for this account.'); + audio.sfxUiCancel(); + return; + } void calibrateMicInputGain(); return; + case 'openAdminMenu': { + const actions = getAvailableAdminActions(); + if (actions.length === 0) { + return; + } + adminMenuActions.splice(0, adminMenuActions.length, ...actions); + adminMenuIndex = 0; + state.mode = 'adminMenu'; + updateStatus(`Admin: ${adminMenuActions[0].label}.`); + audio.sfxUiBlip(); + return; + } case 'useItem': { const carried = getCarriedItem(); if (carried) { @@ -2425,6 +2614,292 @@ function handleSelectItemModeInput(code: string, key: string): void { } } +/** Handles top-level Shift+Z admin menu action selection. */ +function handleAdminMenuModeInput(code: string, key: string): void { + if (adminMenuActions.length === 0) { + state.mode = 'normal'; + return; + } + const control = handleListControlKey(code, key, adminMenuActions, adminMenuIndex, (entry) => entry.label); + if (control.type === 'move') { + adminMenuIndex = control.index; + updateStatus(adminMenuActions[adminMenuIndex].label); + audio.sfxUiBlip(); + return; + } + if (control.type === 'select') { + const selected = adminMenuActions[adminMenuIndex]; + if (!selected) return; + if (selected.id === 'manage_roles') { + signaling.send({ type: 'admin_roles_list' }); + updateStatus('Loading roles...'); + return; + } + if (selected.id === 'change_user_role') { + adminPendingUserAction = 'set_role'; + signaling.send({ type: 'admin_users_list' }); + updateStatus('Loading users...'); + return; + } + if (selected.id === 'ban_user') { + adminPendingUserAction = 'ban'; + signaling.send({ type: 'admin_users_list' }); + updateStatus('Loading users...'); + return; + } + if (selected.id === 'unban_user') { + adminPendingUserAction = 'unban'; + signaling.send({ type: 'admin_users_list' }); + updateStatus('Loading users...'); + } + return; + } + if (control.type === 'cancel') { + state.mode = 'normal'; + updateStatus('Cancelled.'); + audio.sfxUiCancel(); + } +} + +/** Handles role list selection flow, including add-role entry. */ +function handleAdminRoleListModeInput(code: string, key: string): void { + const entries: Array<{ label: string; role?: AdminRoleSummary }> = [ + ...adminRoles.map((role) => ({ label: `${role.name}, ${role.userCount}`, role })), + { label: 'Add role' }, + ]; + const control = handleListControlKey(code, key, entries, adminRoleIndex, (entry) => entry.label); + if (control.type === 'move') { + adminRoleIndex = control.index; + updateStatus(entries[adminRoleIndex]?.label || ''); + audio.sfxUiBlip(); + return; + } + if (control.type === 'select') { + const selected = entries[adminRoleIndex]; + if (!selected) return; + if (!selected.role) { + state.mode = 'adminRoleNameEdit'; + state.nicknameInput = ''; + state.cursorPos = 0; + replaceTextOnNextType = false; + updateStatus('New role name.'); + audio.sfxUiBlip(); + return; + } + adminSelectedRoleName = selected.role.name; + adminRolePermissionIndex = 0; + state.mode = 'adminRolePermissionList'; + updateStatus(`${adminSelectedRoleName} permissions.`); + audio.sfxUiBlip(); + return; + } + if (control.type === 'cancel') { + state.mode = 'normal'; + updateStatus('Cancelled.'); + audio.sfxUiCancel(); + } +} + +/** Handles role permission toggle and delete flow. */ +function handleAdminRolePermissionListModeInput(code: string, key: string): void { + const role = adminRoles.find((entry) => entry.name === adminSelectedRoleName); + if (!role) { + state.mode = 'adminRoleList'; + return; + } + const entries = [...adminPermissionKeys, '__delete_role__']; + const control = handleListControlKey(code, key, entries, adminRolePermissionIndex, (entry) => + entry === '__delete_role__' ? `Delete role ${role.name}` : `${entry} ${role.permissions.includes(entry) ? 'on' : 'off'}`, + ); + if (control.type === 'move') { + adminRolePermissionIndex = control.index; + const value = entries[adminRolePermissionIndex]; + if (value === '__delete_role__') { + updateStatus(`Delete role ${role.name}.`); + } else { + updateStatus(`${value} ${role.permissions.includes(value) ? 'on' : 'off'}`); + } + audio.sfxUiBlip(); + return; + } + if (control.type === 'select') { + const value = entries[adminRolePermissionIndex]; + if (value === '__delete_role__') { + if (role.name === 'admin') { + updateStatus('Admin role cannot be deleted.'); + audio.sfxUiCancel(); + return; + } + const replacementCandidates = adminRoles.filter((entry) => entry.name !== role.name); + if (replacementCandidates.length === 0) { + updateStatus('No replacement role available.'); + audio.sfxUiCancel(); + return; + } + adminRoleDeleteReplacementIndex = 0; + state.mode = 'adminRoleDeleteReplacement'; + updateStatus(`Replacement role: ${replacementCandidates[0].name}.`); + audio.sfxUiBlip(); + return; + } + const nextPermissions = new Set(role.permissions); + if (nextPermissions.has(value)) { + nextPermissions.delete(value); + } else { + nextPermissions.add(value); + } + role.permissions = [...nextPermissions].sort((a, b) => a.localeCompare(b)); + signaling.send({ type: 'admin_role_update_permissions', role: role.name, permissions: role.permissions }); + updateStatus(`${value} ${role.permissions.includes(value) ? 'on' : 'off'}`); + audio.sfxUiBlip(); + return; + } + if (control.type === 'cancel') { + state.mode = 'adminRoleList'; + updateStatus('Roles.'); + audio.sfxUiCancel(); + } +} + +/** Handles replacement-role selection while deleting a role. */ +function handleAdminRoleDeleteReplacementModeInput(code: string, key: string): void { + const candidates = adminRoles.filter((entry) => entry.name !== adminSelectedRoleName); + if (candidates.length === 0) { + state.mode = 'adminRolePermissionList'; + return; + } + const control = handleListControlKey(code, key, candidates, adminRoleDeleteReplacementIndex, (entry) => entry.name); + if (control.type === 'move') { + adminRoleDeleteReplacementIndex = control.index; + updateStatus(`Replacement role: ${candidates[adminRoleDeleteReplacementIndex].name}.`); + audio.sfxUiBlip(); + return; + } + if (control.type === 'select') { + const replacement = candidates[adminRoleDeleteReplacementIndex]; + signaling.send({ + type: 'admin_role_delete', + role: adminSelectedRoleName, + replacementRole: replacement.name, + }); + state.mode = 'adminRoleList'; + updateStatus(`Deleting ${adminSelectedRoleName}...`); + return; + } + if (control.type === 'cancel') { + state.mode = 'adminRolePermissionList'; + updateStatus(`${adminSelectedRoleName} permissions.`); + audio.sfxUiCancel(); + } +} + +/** Handles user list selection for change-role/ban/unban flows. */ +function handleAdminUserListModeInput(code: string, key: string): void { + if (adminUsers.length === 0) { + state.mode = 'normal'; + adminPendingUserAction = null; + return; + } + const control = handleListControlKey(code, key, adminUsers, adminUserIndex, (entry) => `${entry.username}, ${entry.role}, ${entry.status}`); + if (control.type === 'move') { + adminUserIndex = control.index; + const selected = adminUsers[adminUserIndex]; + updateStatus(`${selected.username}, ${selected.role}, ${selected.status}.`); + audio.sfxUiBlip(); + return; + } + if (control.type === 'select') { + const selected = adminUsers[adminUserIndex]; + if (!selected) return; + adminSelectedUsername = selected.username; + if (adminPendingUserAction === 'set_role') { + signaling.send({ type: 'admin_roles_list' }); + updateStatus(`Select new role for ${selected.username}.`); + return; + } + if (adminPendingUserAction === 'ban') { + signaling.send({ type: 'admin_user_ban', username: selected.username }); + state.mode = 'normal'; + adminPendingUserAction = null; + updateStatus(`Banning ${selected.username}...`); + return; + } + if (adminPendingUserAction === 'unban') { + signaling.send({ type: 'admin_user_unban', username: selected.username }); + state.mode = 'normal'; + adminPendingUserAction = null; + updateStatus(`Unbanning ${selected.username}...`); + return; + } + return; + } + if (control.type === 'cancel') { + state.mode = 'adminMenu'; + adminPendingUserAction = null; + updateStatus('Admin menu.'); + audio.sfxUiCancel(); + } +} + +/** Handles role selection for a previously selected user target. */ +function handleAdminUserRoleSelectModeInput(code: string, key: string): void { + if (adminRoles.length === 0) { + state.mode = 'normal'; + adminPendingUserAction = null; + return; + } + const control = handleListControlKey(code, key, adminRoles, adminRoleIndex, (entry) => entry.name); + if (control.type === 'move') { + adminRoleIndex = control.index; + updateStatus(`${adminSelectedUsername}: ${adminRoles[adminRoleIndex].name}.`); + audio.sfxUiBlip(); + return; + } + if (control.type === 'select') { + const selectedRole = adminRoles[adminRoleIndex]; + signaling.send({ type: 'admin_user_set_role', username: adminSelectedUsername, role: selectedRole.name }); + state.mode = 'normal'; + adminPendingUserAction = null; + updateStatus(`Setting ${adminSelectedUsername} to ${selectedRole.name}...`); + return; + } + if (control.type === 'cancel') { + state.mode = 'adminUserList'; + updateStatus('Select user.'); + audio.sfxUiCancel(); + } +} + +/** Handles text edit for new-role creation from admin role list. */ +function handleAdminRoleNameEditModeInput(code: string, key: string, ctrlKey: boolean): void { + const editAction = getEditSessionAction(code); + if (editAction === 'submit') { + const name = state.nicknameInput.trim().toLowerCase(); + if (!name) { + updateStatus('Role name required.'); + audio.sfxUiCancel(); + return; + } + signaling.send({ type: 'admin_role_create', name }); + state.mode = 'adminRoleList'; + state.nicknameInput = ''; + state.cursorPos = 0; + replaceTextOnNextType = false; + updateStatus(`Creating role ${name}...`); + return; + } + if (editAction === 'cancel') { + state.mode = 'adminRoleList'; + state.nicknameInput = ''; + state.cursorPos = 0; + replaceTextOnNextType = false; + updateStatus('Cancelled.'); + audio.sfxUiCancel(); + return; + } + applyTextInputEdit(code, key, 32, ctrlKey, true); +} + const itemPropertyEditor = createItemPropertyEditor({ state, signalingSend: (message) => signaling.send(message as OutgoingMessage), @@ -2607,6 +3082,14 @@ function setupInputHandlers(): void { listItems: (currentCode, currentKey) => handleListItemsModeInput(currentCode, currentKey), addItem: (currentCode, currentKey) => handleAddItemModeInput(currentCode, currentKey), selectItem: (currentCode, currentKey) => handleSelectItemModeInput(currentCode, currentKey), + adminMenu: (currentCode, currentKey) => handleAdminMenuModeInput(currentCode, currentKey), + adminRoleList: (currentCode, currentKey) => handleAdminRoleListModeInput(currentCode, currentKey), + adminRolePermissionList: (currentCode, currentKey) => handleAdminRolePermissionListModeInput(currentCode, currentKey), + adminRoleDeleteReplacement: (currentCode, currentKey) => handleAdminRoleDeleteReplacementModeInput(currentCode, currentKey), + adminUserList: (currentCode, currentKey) => handleAdminUserListModeInput(currentCode, currentKey), + adminUserRoleSelect: (currentCode, currentKey) => handleAdminUserRoleSelectModeInput(currentCode, currentKey), + adminRoleNameEdit: (currentCode, currentKey, currentCtrlKey) => + handleAdminRoleNameEditModeInput(currentCode, currentKey, currentCtrlKey), itemProperties: (currentCode, currentKey) => itemPropertyEditor.handleItemPropertiesModeInput(currentCode, currentKey), itemPropertyEdit: (currentCode, currentKey, currentCtrlKey) => itemPropertyEditor.handleItemPropertyEditModeInput(currentCode, currentKey, currentCtrlKey), diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index 4fd861d..7a85a09 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -72,6 +72,10 @@ type MessageHandlerDeps = { playClockAnnouncement: (sounds: string[], x: number, y: number, range?: number) => void; handleAuthRequired: (message: Extract) => void; handleAuthResult: (message: Extract) => Promise; + handleAuthPermissions: (message: Extract) => void; + handleAdminRolesList: (message: Extract) => void; + handleAdminUsersList: (message: Extract) => void; + handleAdminActionResult: (message: Extract) => void; isPeerNegotiationReady: () => boolean; enqueuePendingSignal: (message: Extract) => void; }; @@ -89,6 +93,18 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco case 'auth_result': await deps.handleAuthResult(message); break; + case 'auth_permissions': + deps.handleAuthPermissions(message); + break; + case 'admin_roles_list': + deps.handleAdminRolesList(message); + break; + case 'admin_users_list': + deps.handleAdminUsersList(message); + break; + case 'admin_action_result': + deps.handleAdminActionResult(message); + break; case 'welcome': if (message.worldConfig?.gridSize && Number.isInteger(message.worldConfig.gridSize) && message.worldConfig.gridSize > 0) { diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index bfce08c..7cab1e4 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -56,6 +56,7 @@ export const welcomeMessageSchema = z.object({ userId: z.string().nullable().optional(), username: z.string().nullable().optional(), role: z.string().nullable().optional(), + permissions: z.array(z.string()).optional(), policy: z .object({ usernameMinLength: z.number().int().positive(), @@ -123,6 +124,7 @@ export const authResultSchema = z.object({ sessionToken: z.string().optional(), username: z.string().optional(), role: z.string().optional(), + permissions: z.array(z.string()).optional(), nickname: z.string().optional(), authPolicy: z .object({ @@ -261,6 +263,52 @@ export const itemPianoStatusSchema = z.object({ recordingState: z.enum(['idle', 'recording', 'paused', 'playback']).optional(), }); +export const authPermissionsSchema = z.object({ + type: z.literal('auth_permissions'), + role: z.string(), + permissions: z.array(z.string()), +}); + +const adminRoleSummarySchema = z.object({ + id: z.number().int(), + name: z.string(), + isSystem: z.boolean(), + userCount: z.number().int().nonnegative(), + permissions: z.array(z.string()), +}); + +export const adminRolesListSchema = z.object({ + type: z.literal('admin_roles_list'), + roles: z.array(adminRoleSummarySchema), + permissionKeys: z.array(z.string()), +}); + +export const adminUsersListSchema = z.object({ + type: z.literal('admin_users_list'), + users: z.array( + z.object({ + id: z.string(), + username: z.string(), + role: z.string(), + status: z.enum(['active', 'disabled']), + }), + ), +}); + +export const adminActionResultSchema = z.object({ + type: z.literal('admin_action_result'), + ok: z.boolean(), + action: z.enum([ + 'role_create', + 'role_update_permissions', + 'role_delete', + 'user_set_role', + 'user_ban', + 'user_unban', + ]), + message: z.string(), +}); + export const incomingMessageSchema = z.discriminatedUnion('type', [ authRequiredSchema, authResultSchema, @@ -280,6 +328,10 @@ export const incomingMessageSchema = z.discriminatedUnion('type', [ itemClockAnnounceSchema, itemPianoNoteSchema, itemPianoStatusSchema, + authPermissionsSchema, + adminRolesListSchema, + adminUsersListSchema, + adminActionResultSchema, ]); export type IncomingMessage = z.infer; @@ -289,6 +341,14 @@ export type OutgoingMessage = | { type: 'auth_login'; username: string; password: string } | { type: 'auth_resume'; sessionToken: string } | { type: 'auth_logout' } + | { type: 'admin_roles_list' } + | { type: 'admin_role_create'; name: string } + | { type: 'admin_role_update_permissions'; role: string; permissions: string[] } + | { type: 'admin_role_delete'; role: string; replacementRole: string } + | { type: 'admin_users_list' } + | { type: 'admin_user_set_role'; username: string; role: string } + | { type: 'admin_user_ban'; username: string } + | { type: 'admin_user_unban'; username: string } | { type: 'signal'; targetId: string; sdp?: RTCSessionDescriptionInit; ice?: RTCIceCandidateInit } | { type: 'update_position'; x: number; y: number } | { type: 'teleport_complete'; x: number; y: number } diff --git a/client/src/state/gameState.ts b/client/src/state/gameState.ts index f789175..9541a21 100644 --- a/client/src/state/gameState.ts +++ b/client/src/state/gameState.ts @@ -39,6 +39,13 @@ export type GameMode = | 'itemProperties' | 'itemPropertyEdit' | 'itemPropertyOptionSelect' + | 'adminMenu' + | 'adminRoleList' + | 'adminRolePermissionList' + | 'adminRoleDeleteReplacement' + | 'adminUserList' + | 'adminUserRoleSelect' + | 'adminRoleNameEdit' | 'pianoUse'; export type Player = { diff --git a/docs/controls.md b/docs/controls.md index 713ecf6..186f89c 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -16,6 +16,7 @@ This document is the authoritative keymap for the client. - `U`: Speak connected users - `N`: Edit nickname - `/`: Start chat +- `Shift+Z`: Admin menu (when role permissions allow) - `,` / `.`: Previous/next message - `<` / `>`: First/last message @@ -75,6 +76,20 @@ Applies to effect select, user/item list modes, item selection, item property li - `Space`: Read tooltip/help for current option (where metadata is available) - First-letter navigation: jump to next matching entry +## Admin Modes + +- `Shift+Z`: Open admin menu +- Admin menu options are permission-gated and include: + - role management + - change user role + - ban user + - unban user +- In admin role management: + - role list includes role user-counts + - `Enter` on role opens permission toggles + - `Enter` on `Add role` opens role name editor + - role delete prompts replacement role selection + ## Piano Use Mode - `1-9` (and `0` for the 10th slot): Switch instrument preset quickly diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 979245f..e936bbd 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -14,6 +14,13 @@ This is a behavior guide for packet semantics beyond raw schemas. - `auth_login`: authenticate with username/password. - `auth_resume`: resume prior session via stored session token. - `auth_logout`: revoke current session and disconnect. +- `admin_roles_list`: request server role list (with user counts + permission sets). +- `admin_role_create`: create role. +- `admin_role_update_permissions`: replace one role permission set. +- `admin_role_delete`: delete role with replacement role reassignment. +- `admin_users_list`: request user list for admin actions. +- `admin_user_set_role`: set target user role. +- `admin_user_ban` / `admin_user_unban`: disable/enable user account. - `update_position`: client movement intent; server enforces world bounds and movement rate policy. - `teleport_complete`: client signals teleport landing; server rebroadcasts spatial landing cue. - `update_nickname`: nickname change request (server enforces uniqueness). @@ -28,6 +35,10 @@ This is a behavior guide for packet semantics beyond raw schemas. - `auth_required`: authentication challenge after websocket connect. - `auth_result`: auth success/failure and session/account metadata. +- `auth_permissions`: server-pushed live role/permission refresh for current session. +- `admin_roles_list`: role list response payload. +- `admin_users_list`: user list response payload. +- `admin_action_result`: structured result for admin actions. - `welcome`: initial snapshot with users/items plus server UI/world metadata. - `signal`: forwarded WebRTC offer/answer/ICE. - `update_position`, `update_nickname`, `user_left`: presence updates. @@ -72,6 +83,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - `userId` - `username` - `role` + - `permissions` - `policy` (`usernameMinLength`, `usernameMaxLength`, `passwordMinLength`, `passwordMaxLength`) - `auth_required.authPolicy`: server auth limits advertised before login/register submit. - `auth_result.authPolicy`: server auth limits echoed on auth success/failure responses. @@ -103,6 +115,8 @@ This is a behavior guide for packet semantics beyond raw schemas. - repeated auth failures are rate-limited by IP and IP+identity windows - auth failures include small randomized response jitter to reduce high-resolution probing - Client validates incoming packet shapes and applies runtime behavior. +- Server is authoritative for role/permission checks on every privileged packet. +- `voice.send` permission changes are pushed at runtime via `auth_permissions`. - Sound/media field normalization uses shared server policy helpers: - `none/off` normalize to empty values - bare filenames normalize to `sounds/` for sound-reference fields diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index 2ed2c12..d901a1f 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -9,6 +9,7 @@ - includes `authPolicy` limits for username/password. 5. Client sends `auth_login`, `auth_register`, or `auth_resume`. 6. Server sends `auth_result`. + - includes role + permissions for authenticated session. 7. Server sends `welcome` with users/items snapshot. 8. Client: - applies `welcome.worldConfig.gridSize` for authoritative grid bounds/rendering @@ -44,6 +45,10 @@ Core incoming message effects: - `signal`: WebRTC negotiation and ICE exchange. - `auth_required`: prompt client to authenticate before gameplay messages. - `auth_result`: auth success/failure with optional session token + account metadata + `authPolicy`. +- `auth_permissions`: live permission refresh (role + permission set) after role/permission admin changes. +- `admin_roles_list`: role metadata + user counts + permission keys for role management UI. +- `admin_users_list`: user metadata list for role/ban admin flows. +- `admin_action_result`: success/error for role/user admin mutations. - `update_position`: update peer position; may play movement/teleport world sound. - `teleport_complete`: play peer teleport landing sound at final tile. - `update_nickname`: update peer display name. @@ -67,6 +72,12 @@ Core incoming message effects: - If reconnect lands on a different `welcome.serverInfo.instanceId`, client announces server restart. - Connect/reconnect status message is emitted from `welcome` and includes server version. +## Authorization Runtime + +- Server enforces item/chat/nickname/voice/admin permissions for each packet. +- Role and permission changes apply live to connected users without reconnect. +- `voice.send` revocation is pushed immediately via `auth_permissions`; client mutes outbound voice track. + ## Disconnect/Cleanup On disconnect: diff --git a/server/app/auth_service.py b/server/app/auth_service.py index 4d2a528..671bea7 100644 --- a/server/app/auth_service.py +++ b/server/app/auth_service.py @@ -1,4 +1,4 @@ -"""Account and session persistence service for websocket authentication.""" +"""Account, role, permission, and session persistence service for websocket authentication.""" from __future__ import annotations @@ -24,8 +24,61 @@ ARGON2_PARALLELISM = 1 ARGON2_HASH_LEN = 32 ARGON2_SALT_LEN = 16 USERNAME_PATTERN = re.compile(r"^[a-z0-9_-]+$") +ROLE_NAME_PATTERN = re.compile(r"^[a-z0-9_-]+$") LOGGER = logging.getLogger("chgrid.server.auth") +PERMISSIONS: tuple[str, ...] = ( + "item.create", + "item.edit.own", + "item.edit.any", + "item.delete.own", + "item.delete.any", + "item.use", + "item.pickup_drop.own", + "item.pickup_drop.any", + "chat.send", + "voice.send", + "profile.update_nickname", + "account.delete.any", + "user.ban_unban", + "user.change_role", + "role.manage", + "server.manage_settings", +) + +DEFAULT_ROLE_PERMISSIONS: dict[str, set[str]] = { + "admin": set(PERMISSIONS), + "editor": { + "item.create", + "item.edit.own", + "item.edit.any", + "item.delete.own", + "item.delete.any", + "item.use", + "item.pickup_drop.any", + "chat.send", + "voice.send", + "profile.update_nickname", + }, + "user": { + "item.create", + "item.edit.own", + "item.delete.own", + "item.use", + "item.pickup_drop.own", + "chat.send", + "voice.send", + "profile.update_nickname", + }, + "guest": { + "item.use", + "chat.send", + "voice.send", + "profile.update_nickname", + }, +} +DEFAULT_ROLE_ORDER: tuple[str, ...] = ("admin", "editor", "user", "guest") + def _build_dummy_password_hash(password_hasher: PasswordHasher) -> str: """Build one deterministic Argon2id hash used to equalize login miss timing.""" @@ -40,6 +93,7 @@ class AuthUser: id: str username: str role: str + permissions: tuple[str, ...] status: str email: str | None last_nickname: str | None @@ -61,7 +115,7 @@ class AuthError(ValueError): class AuthService: - """Manages account registration, login, and rolling session validation.""" + """Manages account registration, roles/permissions, and rolling session validation.""" def __init__( self, @@ -112,11 +166,295 @@ class AuthService: return created.user def has_admin(self) -> bool: - """Return True when at least one admin account exists.""" + """Return True when at least one active admin account exists.""" - existing = self._db_fetchone("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1") + existing = self._db_fetchone( + """ + SELECT 1 + FROM users u + JOIN roles r ON r.id = u.role_id + WHERE r.name = 'admin' AND u.status = 'active' + LIMIT 1 + """ + ) return existing is not None + def list_all_permissions(self) -> list[str]: + """Return canonical sorted permission key list.""" + + return list(PERMISSIONS) + + def get_user_permissions(self, user_id: str) -> set[str]: + """Return current permission set for one user id.""" + + try: + user_id_value = int(user_id) + except (TypeError, ValueError): + return set() + rows = self._db_fetchall( + """ + SELECT rp.permission_key + FROM users u + JOIN role_permissions rp ON rp.role_id = u.role_id + WHERE u.id = ? + """, + (user_id_value,), + ) + return {str(row["permission_key"]) for row in rows} + + def has_permission(self, user_id: str, permission_key: str) -> bool: + """Return whether one user currently has a specific permission key.""" + + return permission_key in self.get_user_permissions(user_id) + + def list_roles_with_counts(self) -> list[dict[str, object]]: + """Return all roles with permission sets and assigned-user counts.""" + + rows = self._db_fetchall( + """ + SELECT + r.id, + r.name, + r.is_system, + COUNT(u.id) AS user_count + FROM roles r + LEFT JOIN users u ON u.role_id = r.id + GROUP BY r.id + ORDER BY r.name ASC + """ + ) + permissions_by_role = self._permissions_by_role_id() + return [ + { + "id": int(row["id"]), + "name": str(row["name"]), + "isSystem": bool(int(row["is_system"])), + "userCount": int(row["user_count"]), + "permissions": sorted(list(permissions_by_role.get(int(row["id"]), set()))), + } + for row in rows + ] + + def create_role(self, name: str) -> dict[str, object]: + """Create one custom role with no permissions.""" + + normalized = self._normalize_role_name(name) + self._validate_role_name(normalized) + now_ms = self.now_ms() + try: + self._db_execute( + "INSERT INTO roles (name, is_system, created_at_ms, updated_at_ms) VALUES (?, 0, ?, ?)", + (normalized, now_ms, now_ms), + ) + self._db_commit() + except sqlite3.IntegrityError as exc: + raise AuthError("Role already exists.") from exc + role = self.get_role_by_name(normalized) + if role is None: + raise AuthError("Failed to create role.") + return role + + def get_role_by_name(self, role_name: str) -> dict[str, object] | None: + """Return one role metadata row by normalized role name.""" + + normalized = self._normalize_role_name(role_name) + row = self._db_fetchone("SELECT id, name, is_system FROM roles WHERE name = ?", (normalized,)) + if row is None: + return None + permissions = self._permissions_by_role_id().get(int(row["id"]), set()) + return { + "id": int(row["id"]), + "name": str(row["name"]), + "isSystem": bool(int(row["is_system"])), + "permissions": sorted(list(permissions)), + } + + def update_role_permissions(self, role_name: str, permission_keys: list[str]) -> set[str]: + """Replace one role's permission assignment with validated keys.""" + + normalized_role = self._normalize_role_name(role_name) + role_row = self._db_fetchone("SELECT id, name FROM roles WHERE name = ?", (normalized_role,)) + if role_row is None: + raise AuthError("Role not found.") + + validated = self._validate_permission_keys(permission_keys) + role_id = int(role_row["id"]) + now_ms = self.now_ms() + + self._db_execute("DELETE FROM role_permissions WHERE role_id = ?", (role_id,)) + for key in sorted(validated): + self._db_execute( + "INSERT INTO role_permissions (role_id, permission_key) VALUES (?, ?)", + (role_id, key), + ) + self._db_execute("UPDATE roles SET updated_at_ms = ? WHERE id = ?", (now_ms, role_id)) + self._db_commit() + return validated + + def delete_role(self, role_name: str, replacement_role_name: str) -> tuple[list[str], str]: + """Delete one role, reassigning users to a replacement role.""" + + normalized_role = self._normalize_role_name(role_name) + normalized_replacement = self._normalize_role_name(replacement_role_name) + if normalized_role == "admin": + raise AuthError("Admin role cannot be deleted.") + if normalized_role == normalized_replacement: + raise AuthError("Replacement role must differ from deleted role.") + + role_row = self._db_fetchone("SELECT id FROM roles WHERE name = ?", (normalized_role,)) + replacement_row = self._db_fetchone("SELECT id FROM roles WHERE name = ?", (normalized_replacement,)) + if role_row is None: + raise AuthError("Role not found.") + if replacement_row is None: + raise AuthError("Replacement role not found.") + + role_id = int(role_row["id"]) + replacement_id = int(replacement_row["id"]) + affected_rows = self._db_fetchall("SELECT username FROM users WHERE role_id = ?", (role_id,)) + affected_usernames = [str(row["username"]) for row in affected_rows] + + self._db_execute("UPDATE users SET role_id = ?, updated_at_ms = ? WHERE role_id = ?", (replacement_id, self.now_ms(), role_id)) + self._db_execute("DELETE FROM roles WHERE id = ?", (role_id,)) + self._db_commit() + return affected_usernames, normalized_replacement + + def list_users_for_admin(self) -> list[dict[str, str]]: + """Return users ordered alphabetically with role + status for admin menus.""" + + rows = self._db_fetchall( + """ + SELECT u.id, u.username, r.name AS role_name, u.status + FROM users u + JOIN roles r ON r.id = u.role_id + ORDER BY u.username COLLATE NOCASE ASC + """ + ) + return [ + { + "id": str(row["id"]), + "username": str(row["username"]), + "role": str(row["role_name"]), + "status": str(row["status"]), + } + for row in rows + ] + + def set_user_role(self, target_username: str, role_name: str, *, actor_user_id: str | None = None) -> str: + """Assign one user's role by normalized role name.""" + + normalized_username = self._normalize_username(target_username) + normalized_role = self._normalize_role_name(role_name) + role_row = self._db_fetchone("SELECT id FROM roles WHERE name = ?", (normalized_role,)) + if role_row is None: + raise AuthError("Role not found.") + user_row = self._db_fetchone( + """ + SELECT u.id, u.status, r.name AS role_name + FROM users u + JOIN roles r ON r.id = u.role_id + WHERE u.username = ? + """, + (normalized_username,), + ) + if user_row is None: + raise AuthError("User not found.") + + current_role = str(user_row["role_name"]) + if current_role == "admin" and normalized_role != "admin" and self._active_admin_count() <= 1: + raise AuthError("Cannot change role for the last active admin.") + if actor_user_id is not None and str(user_row["id"]) == str(actor_user_id): + if current_role == "admin" and normalized_role != "admin" and self._active_admin_count() <= 1: + raise AuthError("Cannot self-demote the last active admin.") + + self._db_execute( + "UPDATE users SET role_id = ?, updated_at_ms = ? WHERE id = ?", + (int(role_row["id"]), self.now_ms(), int(user_row["id"])), + ) + self._db_commit() + return normalized_username + + def set_user_status(self, target_username: str, status: str) -> str: + """Set one account status to active/disabled.""" + + normalized_username = self._normalize_username(target_username) + normalized_status = status.strip().lower() + if normalized_status not in {"active", "disabled"}: + raise AuthError("Invalid status.") + user_row = self._db_fetchone( + """ + SELECT u.id, u.status, r.name AS role_name + FROM users u + JOIN roles r ON r.id = u.role_id + WHERE u.username = ? + """, + (normalized_username,), + ) + if user_row is None: + raise AuthError("User not found.") + current_status = str(user_row["status"]) + current_role = str(user_row["role_name"]) + if current_role == "admin" and current_status == "active" and normalized_status != "active" and self._active_admin_count() <= 1: + raise AuthError("Cannot disable the last active admin.") + self._db_execute( + "UPDATE users SET status = ?, updated_at_ms = ? WHERE id = ?", + (normalized_status, self.now_ms(), int(user_row["id"])), + ) + self._db_commit() + return normalized_username + + def get_user_by_id(self, user_id: str) -> AuthUser | None: + """Return one user by id with current role and permissions.""" + + try: + user_id_value = int(user_id) + except (TypeError, ValueError): + return None + row = self._db_fetchone( + """ + SELECT + u.id, + u.username, + r.name AS role_name, + u.status, + u.email, + us.last_nickname, + us.last_x, + us.last_y + FROM users u + JOIN roles r ON r.id = u.role_id + LEFT JOIN user_state us ON us.user_id = u.id + WHERE u.id = ? + """, + (user_id_value,), + ) + if row is None: + return None + return self._row_to_user(row) + + def list_connected_user_ids_for_role(self, role_name: str) -> list[str]: + """Return user id strings currently assigned to one role name.""" + + normalized = self._normalize_role_name(role_name) + rows = self._db_fetchall( + """ + SELECT u.id + FROM users u + JOIN roles r ON r.id = u.role_id + WHERE r.name = ? + """, + (normalized,), + ) + return [str(row["id"]) for row in rows] + + def get_user_id_by_username(self, username: str) -> str | None: + """Return user id for one username, or None when missing.""" + + normalized = self._normalize_username(username) + row = self._db_fetchone("SELECT id FROM users WHERE username = ?", (normalized,)) + if row is None: + return None + return str(row["id"]) + def register( self, username: str, @@ -127,181 +465,187 @@ class AuthService: ) -> AuthSession: """Register an account and issue a session token.""" - with self._conn_lock: - normalized_username = self._normalize_username(username) - try: - self._validate_username(normalized_username) - self._validate_password(password) - normalized_email = self._normalize_email(email) - if role not in {"user", "admin"}: - raise AuthError("role must be user or admin.") - now_ms = self.now_ms() - password_hash = self._hash_password(password) - self._db_execute( - """ - INSERT INTO users ( - username, password_hash, email, role, status, created_at_ms, updated_at_ms, last_login_at_ms - ) VALUES (?, ?, ?, ?, 'active', ?, ?, ?) - """, - (normalized_username, password_hash, normalized_email, role, now_ms, now_ms, now_ms), - ) - self._db_commit() - except sqlite3.IntegrityError as exc: - message = str(exc).lower() - if "users.username" in message: - LOGGER.warning("register rejected username_taken username=%s", normalized_username) - raise AuthError("Username is already taken.") from exc - if "users.email" in message: - LOGGER.warning("register rejected email_taken username=%s", normalized_username) - raise AuthError("Email is already in use.") from exc - LOGGER.exception("register sqlite integrity failure username=%s", normalized_username) - raise AuthError("Registration failed due to a database constraint.") from exc - except AuthError as exc: - LOGGER.warning("register rejected username=%s reason=%s", normalized_username, str(exc)) - raise - except Exception as exc: - LOGGER.exception("register unexpected failure username=%s", normalized_username) - raise AuthError("Registration failed due to a server error.") from exc - user = self._get_user_by_username(normalized_username) - if user is None: - LOGGER.error("register created user missing username=%s", normalized_username) - raise AuthError("Failed to load newly created user.") + normalized_username = self._normalize_username(username) + normalized_role = self._normalize_role_name(role) + try: + self._validate_username(normalized_username) + self._validate_password(password) + self._validate_role_name(normalized_role) + normalized_email = self._normalize_email(email) + role_row = self._db_fetchone("SELECT id FROM roles WHERE name = ?", (normalized_role,)) + if role_row is None: + raise AuthError("Role not found.") + now_ms = self.now_ms() + password_hash = self._hash_password(password) self._db_execute( """ - INSERT OR IGNORE INTO user_state (user_id, last_nickname, last_x, last_y, updated_at_ms) - VALUES (?, ?, NULL, NULL, ?) + INSERT INTO users ( + username, password_hash, email, role_id, status, created_at_ms, updated_at_ms, last_login_at_ms + ) VALUES (?, ?, ?, ?, 'active', ?, ?, ?) """, - (int(user.id), user.username, now_ms), + (normalized_username, password_hash, normalized_email, int(role_row["id"]), now_ms, now_ms, now_ms), ) self._db_commit() + except sqlite3.IntegrityError as exc: + message = str(exc).lower() + if "users.username" in message: + LOGGER.warning("register rejected username_taken username=%s", normalized_username) + raise AuthError("Username is already taken.") from exc + if "users.email" in message: + LOGGER.warning("register rejected email_taken username=%s", normalized_username) + raise AuthError("Email is already in use.") from exc + LOGGER.exception("register sqlite integrity failure username=%s", normalized_username) + raise AuthError("Registration failed due to a database constraint.") from exc + except AuthError as exc: + LOGGER.warning("register rejected username=%s reason=%s", normalized_username, str(exc)) + raise + except Exception as exc: + LOGGER.exception("register unexpected failure username=%s", normalized_username) + raise AuthError("Registration failed due to a server error.") from exc + + user = self._get_user_by_username(normalized_username) + if user is None: + LOGGER.error("register created user missing username=%s", normalized_username) + raise AuthError("Failed to load newly created user.") + self._db_execute( + """ + INSERT OR IGNORE INTO user_state (user_id, last_nickname, last_x, last_y, updated_at_ms) + VALUES (?, ?, NULL, NULL, ?) + """, + (int(user.id), user.username, self.now_ms()), + ) + self._db_commit() + user = AuthUser( + id=user.id, + username=user.username, + role=user.role, + permissions=user.permissions, + status=user.status, + email=user.email, + last_nickname=user.username, + last_x=user.last_x, + last_y=user.last_y, + ) + return self._create_session(user) + + def login(self, username: str, password: str) -> AuthSession: + """Authenticate credentials and issue a fresh session.""" + + normalized_username = self._normalize_username(username) + user_row = self._db_fetchone( + """ + SELECT + u.id, + u.username, + u.password_hash, + u.email, + r.name AS role_name, + u.status, + us.last_nickname, + us.last_x, + us.last_y + FROM users u + JOIN roles r ON r.id = u.role_id + LEFT JOIN user_state us ON us.user_id = u.id + WHERE u.username = ? + """, + (normalized_username,), + ) + if user_row is None: + self._verify_password(password, self._dummy_password_hash) + raise AuthError("Invalid username or password.") + if user_row["status"] != "active": + raise AuthError("Account is disabled.") + if not self._verify_password(password, user_row["password_hash"]): + raise AuthError("Invalid username or password.") + if self._password_hasher.check_needs_rehash(user_row["password_hash"]): + self._db_execute( + "UPDATE users SET password_hash = ?, updated_at_ms = ? WHERE id = ?", + (self._hash_password(password), self.now_ms(), user_row["id"]), + ) + user = self._row_to_user(user_row) + if not user.last_nickname: + self.set_last_nickname(user.id, user.username) user = AuthUser( id=user.id, username=user.username, role=user.role, + permissions=user.permissions, status=user.status, email=user.email, last_nickname=user.username, last_x=user.last_x, last_y=user.last_y, ) - return self._create_session(user) - - def login(self, username: str, password: str) -> AuthSession: - """Authenticate credentials and issue a fresh session.""" - - with self._conn_lock: - normalized_username = self._normalize_username(username) - user_row = self._db_fetchone( - """ - SELECT - u.id, - u.username, - u.password_hash, - u.email, - u.role, - u.status, - us.last_nickname, - us.last_x, - us.last_y - FROM users u - LEFT JOIN user_state us ON us.user_id = u.id - WHERE u.username = ? - """, - (normalized_username,), - ) - if user_row is None: - # Keep response timing aligned with existing-user password checks. - self._verify_password(password, self._dummy_password_hash) - raise AuthError("Invalid username or password.") - if user_row["status"] != "active": - raise AuthError("Account is disabled.") - if not self._verify_password(password, user_row["password_hash"]): - raise AuthError("Invalid username or password.") - if self._password_hasher.check_needs_rehash(user_row["password_hash"]): - self._db_execute( - "UPDATE users SET password_hash = ?, updated_at_ms = ? WHERE id = ?", - (self._hash_password(password), self.now_ms(), user_row["id"]), - ) - user = self._row_to_user(user_row) - if not user.last_nickname: - self.set_last_nickname(user.id, user.username) - user = AuthUser( - id=user.id, - username=user.username, - role=user.role, - status=user.status, - email=user.email, - last_nickname=user.username, - last_x=user.last_x, - last_y=user.last_y, - ) - now_ms = self.now_ms() - self._db_execute( - "UPDATE users SET last_login_at_ms = ?, updated_at_ms = ? WHERE id = ?", - (now_ms, now_ms, user.id), - ) - self._db_commit() - return self._create_session(user) + now_ms = self.now_ms() + self._db_execute( + "UPDATE users SET last_login_at_ms = ?, updated_at_ms = ? WHERE id = ?", + (now_ms, now_ms, user.id), + ) + self._db_commit() + return self._create_session(user) def resume(self, token: str) -> AuthSession: """Validate a session token and apply rolling expiry.""" - with self._conn_lock: - cleaned = token.strip() - if not cleaned: - raise AuthError("Missing session token.") - token_hash = self._hash_token(cleaned) - row = self._db_fetchone( - """ - SELECT s.id AS session_id, s.user_id, s.expires_at_ms, s.revoked_at_ms, - u.username, u.role, u.status, u.email, us.last_nickname, us.last_x, us.last_y - FROM sessions s - JOIN users u ON u.id = s.user_id - LEFT JOIN user_state us ON us.user_id = u.id - WHERE s.token_hash = ? - """, - (token_hash,), - ) - if row is None: - raise AuthError("Invalid session.") - if row["revoked_at_ms"] is not None: - raise AuthError("Session has been revoked.") - now_ms = self.now_ms() - if int(row["expires_at_ms"]) <= now_ms: - self._db_execute("UPDATE sessions SET revoked_at_ms = ? WHERE id = ?", (now_ms, row["session_id"])) - self._db_commit() - raise AuthError("Session has expired.") - if row["status"] != "active": - raise AuthError("Account is disabled.") - new_expiry = now_ms + SESSION_TTL_MS - self._db_execute( - "UPDATE sessions SET last_seen_at_ms = ?, expires_at_ms = ? WHERE id = ?", - (now_ms, new_expiry, row["session_id"]), - ) + cleaned = token.strip() + if not cleaned: + raise AuthError("Missing session token.") + token_hash = self._hash_token(cleaned) + row = self._db_fetchone( + """ + SELECT s.id AS session_id, s.user_id, s.expires_at_ms, s.revoked_at_ms, + u.username, r.name AS role_name, u.status, u.email, us.last_nickname, us.last_x, us.last_y + FROM sessions s + JOIN users u ON u.id = s.user_id + JOIN roles r ON r.id = u.role_id + LEFT JOIN user_state us ON us.user_id = u.id + WHERE s.token_hash = ? + """, + (token_hash,), + ) + if row is None: + raise AuthError("Invalid session.") + if row["revoked_at_ms"] is not None: + raise AuthError("Session has been revoked.") + now_ms = self.now_ms() + if int(row["expires_at_ms"]) <= now_ms: + self._db_execute("UPDATE sessions SET revoked_at_ms = ? WHERE id = ?", (now_ms, row["session_id"])) self._db_commit() + raise AuthError("Session has expired.") + if row["status"] != "active": + raise AuthError("Account is disabled.") + new_expiry = now_ms + SESSION_TTL_MS + self._db_execute( + "UPDATE sessions SET last_seen_at_ms = ?, expires_at_ms = ? WHERE id = ?", + (now_ms, new_expiry, row["session_id"]), + ) + self._db_commit() + user = AuthUser( + id=str(row["user_id"]), + username=row["username"], + role=row["role_name"], + permissions=tuple(sorted(self.get_user_permissions(str(row["user_id"])))), + status=row["status"], + email=row["email"], + last_nickname=row["last_nickname"], + last_x=row["last_x"] if "last_x" in row.keys() else None, + last_y=row["last_y"] if "last_y" in row.keys() else None, + ) + if not user.last_nickname: + self.set_last_nickname(user.id, user.username) user = AuthUser( - id=str(row["user_id"]), - username=row["username"], - role=row["role"], - status=row["status"], - email=row["email"], - last_nickname=row["last_nickname"], - last_x=row["last_x"] if "last_x" in row.keys() else None, - last_y=row["last_y"] if "last_y" in row.keys() else None, + id=user.id, + username=user.username, + role=user.role, + permissions=user.permissions, + status=user.status, + email=user.email, + last_nickname=user.username, + last_x=user.last_x, + last_y=user.last_y, ) - if not user.last_nickname: - self.set_last_nickname(user.id, user.username) - user = AuthUser( - id=user.id, - username=user.username, - role=user.role, - status=user.status, - email=user.email, - last_nickname=user.username, - last_x=user.last_x, - last_y=user.last_y, - ) - return AuthSession(session_id=row["session_id"], token=cleaned, user=user) + return AuthSession(session_id=str(row["session_id"]), token=cleaned, user=user) def revoke(self, token: str) -> None: """Revoke a session token if it exists.""" @@ -373,24 +717,73 @@ class AuthService: def _ensure_schema(self) -> None: """Create required auth tables and indexes when missing.""" - with self._conn_lock: - self._db_execute("PRAGMA foreign_keys = ON") - self._db_execute( + self._db_execute("PRAGMA foreign_keys = ON") + + self._db_execute( + """ + CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + is_system INTEGER NOT NULL DEFAULT 0, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL + ) + """ + ) + self._db_execute( + """ + CREATE TABLE IF NOT EXISTS permissions ( + key TEXT PRIMARY KEY, + description TEXT NOT NULL + ) + """ + ) + self._db_execute( + """ + CREATE TABLE IF NOT EXISTS role_permissions ( + role_id INTEGER NOT NULL, + permission_key TEXT NOT NULL, + PRIMARY KEY(role_id, permission_key), + FOREIGN KEY(role_id) REFERENCES roles(id) ON DELETE CASCADE, + FOREIGN KEY(permission_key) REFERENCES permissions(key) ON DELETE CASCADE + ) + """ + ) + + self._db_execute( """ CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, email TEXT UNIQUE, - role TEXT NOT NULL CHECK(role IN ('user', 'admin')) DEFAULT 'user', + role_id INTEGER, + role TEXT, status TEXT NOT NULL CHECK(status IN ('active', 'disabled')) DEFAULT 'active', created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, - last_login_at_ms INTEGER + last_login_at_ms INTEGER, + FOREIGN KEY(role_id) REFERENCES roles(id) ) """ ) - self._db_execute( + + user_cols = {str(row["name"]) for row in self._db_fetchall("PRAGMA table_info(users)")} + if "role_id" not in user_cols: + self._db_execute("ALTER TABLE users ADD COLUMN role_id INTEGER") + user_cols.add("role_id") + if "status" not in user_cols: + self._db_execute("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'") + if "created_at_ms" not in user_cols: + self._db_execute("ALTER TABLE users ADD COLUMN created_at_ms INTEGER NOT NULL DEFAULT 0") + if "updated_at_ms" not in user_cols: + self._db_execute("ALTER TABLE users ADD COLUMN updated_at_ms INTEGER NOT NULL DEFAULT 0") + if "last_login_at_ms" not in user_cols: + self._db_execute("ALTER TABLE users ADD COLUMN last_login_at_ms INTEGER") + if "email" not in user_cols: + self._db_execute("ALTER TABLE users ADD COLUMN email TEXT") + + self._db_execute( """ CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -406,7 +799,7 @@ class AuthService: ) """ ) - self._db_execute( + self._db_execute( """ CREATE TABLE IF NOT EXISTS user_state ( user_id INTEGER PRIMARY KEY, @@ -418,15 +811,114 @@ class AuthService: ) """ ) - self._db_execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)") + + self._seed_permissions_and_roles() + self._backfill_user_roles() + + self._db_execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)") + self._db_execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL") + self._db_execute("CREATE INDEX IF NOT EXISTS idx_users_role_id ON users(role_id)") + self._db_execute("CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)") + self._db_execute("CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at_ms)") + self._db_execute("CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash)") + self._db_execute("CREATE INDEX IF NOT EXISTS idx_user_state_updated ON user_state(updated_at_ms)") + self._db_commit() + + def _seed_permissions_and_roles(self) -> None: + """Insert canonical permissions and default roles when missing.""" + + now_ms = self.now_ms() + for key in PERMISSIONS: + description = f"Permission: {key}" self._db_execute( - "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL" + "INSERT OR IGNORE INTO permissions (key, description) VALUES (?, ?)", + (key, description), ) - self._db_execute("CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)") - self._db_execute("CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at_ms)") - self._db_execute("CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash)") - self._db_execute("CREATE INDEX IF NOT EXISTS idx_user_state_updated ON user_state(updated_at_ms)") - self._db_commit() + + for role_name in DEFAULT_ROLE_ORDER: + self._db_execute( + """ + INSERT OR IGNORE INTO roles (name, is_system, created_at_ms, updated_at_ms) + VALUES (?, 1, ?, ?) + """, + (role_name, now_ms, now_ms), + ) + + role_id_by_name = self._role_id_by_name() + for role_name in DEFAULT_ROLE_ORDER: + role_id = role_id_by_name.get(role_name) + if role_id is None: + continue + if role_name == "admin": + # Keep admin as superuser role: always full permission set. + self._db_execute("DELETE FROM role_permissions WHERE role_id = ?", (role_id,)) + allowed = set(PERMISSIONS) + else: + existing = self._db_fetchall("SELECT permission_key FROM role_permissions WHERE role_id = ?", (role_id,)) + if existing: + # Preserve existing customizations for non-admin defaults. + continue + allowed = DEFAULT_ROLE_PERMISSIONS.get(role_name, set()) + for key in sorted(allowed): + self._db_execute( + "INSERT OR IGNORE INTO role_permissions (role_id, permission_key) VALUES (?, ?)", + (role_id, key), + ) + + def _backfill_user_roles(self) -> None: + """Backfill users.role_id from legacy users.role text, defaulting to user.""" + + role_id_by_name = self._role_id_by_name() + default_user_role_id = role_id_by_name.get("user") + if default_user_role_id is None: + raise AuthError("Default user role missing.") + + user_cols = {str(row["name"]) for row in self._db_fetchall("PRAGMA table_info(users)")} + has_legacy_role = "role" in user_cols + if has_legacy_role: + rows = self._db_fetchall("SELECT id, role, role_id FROM users") + for row in rows: + if row["role_id"] is not None: + continue + legacy_role = str(row["role"] or "").strip().lower() + mapped_role = legacy_role if legacy_role in role_id_by_name else "user" + self._db_execute( + "UPDATE users SET role_id = ?, updated_at_ms = ? WHERE id = ?", + (role_id_by_name.get(mapped_role, default_user_role_id), self.now_ms(), int(row["id"])), + ) + self._db_execute( + "UPDATE users SET role_id = ?, updated_at_ms = ? WHERE role_id IS NULL", + (default_user_role_id, self.now_ms()), + ) + + def _role_id_by_name(self) -> dict[str, int]: + """Return mapping of role name to role id.""" + + rows = self._db_fetchall("SELECT id, name FROM roles") + return {str(row["name"]): int(row["id"]) for row in rows} + + def _permissions_by_role_id(self) -> dict[int, set[str]]: + """Return mapping from role id to assigned permission keys.""" + + rows = self._db_fetchall("SELECT role_id, permission_key FROM role_permissions") + permissions_by_role: dict[int, set[str]] = {} + for row in rows: + role_id = int(row["role_id"]) + permissions_by_role.setdefault(role_id, set()).add(str(row["permission_key"])) + return permissions_by_role + + def _active_admin_count(self) -> int: + """Return count of active users currently assigned admin role.""" + + row = self._db_fetchone( + """ + SELECT COUNT(*) AS c + FROM users u + JOIN roles r ON r.id = u.role_id + WHERE r.name = 'admin' AND u.status = 'active' + """ + ) + return int(row["c"]) if row is not None else 0 def _create_session(self, user: AuthUser) -> AuthSession: """Issue and persist a new session token for a user.""" @@ -457,13 +949,14 @@ class AuthService: SELECT u.id, u.username, - u.role, + r.name AS role_name, u.status, u.email, us.last_nickname, us.last_x, us.last_y FROM users u + JOIN roles r ON r.id = u.role_id LEFT JOIN user_state us ON us.user_id = u.id WHERE u.username = ? """, @@ -485,6 +978,12 @@ class AuthService: with self._conn_lock: return self._conn.execute(sql, params or ()).fetchone() + def _db_fetchall(self, sql: str, params: tuple | None = None) -> list[sqlite3.Row]: + """Run one query and fetch all rows with connection locking.""" + + with self._conn_lock: + return self._conn.execute(sql, params or ()).fetchall() + def _db_commit(self) -> None: """Commit pending DB writes with connection locking.""" @@ -497,15 +996,16 @@ class AuthService: with self._conn_lock: self._conn.rollback() - @staticmethod - def _row_to_user(row: sqlite3.Row) -> AuthUser: + def _row_to_user(self, row: sqlite3.Row) -> AuthUser: """Convert a DB row into AuthUser.""" + user_id = str(row["id"]) return AuthUser( - id=str(row["id"]), - username=row["username"], - role=row["role"], - status=row["status"], + id=user_id, + username=str(row["username"]), + role=str(row["role_name"]), + permissions=tuple(sorted(self.get_user_permissions(user_id))), + status=str(row["status"]), email=row["email"], last_nickname=row["last_nickname"] if "last_nickname" in row.keys() else None, last_x=row["last_x"] if "last_x" in row.keys() else None, @@ -518,6 +1018,12 @@ class AuthService: return username.strip().lower() + @staticmethod + def _normalize_role_name(role_name: str) -> str: + """Normalize role names to canonical lowercase identifiers.""" + + return role_name.strip().lower() + @staticmethod def _normalize_email(email: str | None) -> str | None: """Normalize optional email and collapse blanks to None.""" @@ -537,6 +1043,31 @@ class AuthService: if USERNAME_PATTERN.fullmatch(username) is None: raise AuthError("Username may include lowercase letters, numbers, underscores, and dashes only.") + @staticmethod + def _validate_role_name(role_name: str) -> None: + """Validate role name syntax and max length for custom-role creation.""" + + if not role_name: + raise AuthError("Role name is required.") + if len(role_name) > 32: + raise AuthError("Role name must be 32 characters or fewer.") + if ROLE_NAME_PATTERN.fullmatch(role_name) is None: + raise AuthError("Role name may include lowercase letters, numbers, underscores, and dashes only.") + + @staticmethod + def _validate_permission_keys(permission_keys: list[str]) -> set[str]: + """Validate and normalize permission key sets for role updates.""" + + validated: set[str] = set() + for raw in permission_keys: + key = str(raw).strip() + if not key: + continue + if key not in PERMISSIONS: + raise AuthError(f"Unknown permission: {key}") + validated.add(key) + return validated + def _validate_password(self, password: str) -> None: """Validate password length policy.""" diff --git a/server/app/client.py b/server/app/client.py index c522f42..b1a0c11 100644 --- a/server/app/client.py +++ b/server/app/client.py @@ -17,6 +17,7 @@ class ClientConnection: user_id: str | None = None username: str | None = None role: str = "user" + permissions: set[str] | None = None session_token: str | None = None nickname: str = "user..." saved_x: int | None = None diff --git a/server/app/models.py b/server/app/models.py index d063328..8ff159c 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -63,6 +63,47 @@ class AuthLogoutPacket(BasePacket): type: Literal["auth_logout"] +class AdminRolesListPacket(BasePacket): + type: Literal["admin_roles_list"] + + +class AdminRoleCreatePacket(BasePacket): + type: Literal["admin_role_create"] + name: str = Field(min_length=1, max_length=32) + + +class AdminRoleUpdatePermissionsPacket(BasePacket): + type: Literal["admin_role_update_permissions"] + role: str = Field(min_length=1, max_length=32) + permissions: list[str] + + +class AdminRoleDeletePacket(BasePacket): + type: Literal["admin_role_delete"] + role: str = Field(min_length=1, max_length=32) + replacementRole: str = Field(min_length=1, max_length=32) + + +class AdminUsersListPacket(BasePacket): + type: Literal["admin_users_list"] + + +class AdminUserSetRolePacket(BasePacket): + type: Literal["admin_user_set_role"] + username: str = Field(min_length=1, max_length=128) + role: str = Field(min_length=1, max_length=32) + + +class AdminUserBanPacket(BasePacket): + type: Literal["admin_user_ban"] + username: str = Field(min_length=1, max_length=128) + + +class AdminUserUnbanPacket(BasePacket): + type: Literal["admin_user_unban"] + username: str = Field(min_length=1, max_length=128) + + class PingPacket(BasePacket): type: Literal["ping"] clientSentAt: int @@ -131,6 +172,14 @@ ClientPacket = ( | AuthLoginPacket | AuthResumePacket | AuthLogoutPacket + | AdminRolesListPacket + | AdminRoleCreatePacket + | AdminRoleUpdatePermissionsPacket + | AdminRoleDeletePacket + | AdminUsersListPacket + | AdminUserSetRolePacket + | AdminUserBanPacket + | AdminUserUnbanPacket | PingPacket | ItemAddPacket | ItemPickupPacket @@ -176,10 +225,17 @@ class AuthResultPacket(BasePacket): sessionToken: str | None = None username: str | None = None role: str | None = None + permissions: list[str] | None = None nickname: str | None = None authPolicy: dict | None = None +class AuthPermissionsPacket(BasePacket): + type: Literal["auth_permissions"] + role: str + permissions: list[str] + + class UserLeftPacket(BasePacket): type: Literal["user_left"] id: str @@ -343,3 +399,43 @@ class ItemPianoStatusPacket(BasePacket): "playback_stopped", ] recordingState: Literal["idle", "recording", "paused", "playback"] | None = None + + +class AdminRoleSummary(BaseModel): + id: int + name: str + isSystem: bool + userCount: int + permissions: list[str] + + +class AdminRolesListResultPacket(BasePacket): + type: Literal["admin_roles_list"] + roles: list[AdminRoleSummary] + permissionKeys: list[str] + + +class AdminUserSummary(BaseModel): + id: str + username: str + role: str + status: Literal["active", "disabled"] + + +class AdminUsersListResultPacket(BasePacket): + type: Literal["admin_users_list"] + users: list[AdminUserSummary] + + +class AdminActionResultPacket(BasePacket): + type: Literal["admin_action_result"] + ok: bool + action: Literal[ + "role_create", + "role_update_permissions", + "role_delete", + "user_set_role", + "user_ban", + "user_unban", + ] + message: str diff --git a/server/app/server.py b/server/app/server.py index 2f98fc6..cb2c75c 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -48,10 +48,22 @@ from .items.types.clock.time_format import parse_alarm_time_flexible from .models import ( AuthLoginPacket, AuthLogoutPacket, + AuthPermissionsPacket, AuthRegisterPacket, AuthRequiredPacket, AuthResultPacket, AuthResumePacket, + AdminActionResultPacket, + AdminRoleCreatePacket, + AdminRoleDeletePacket, + AdminRoleUpdatePermissionsPacket, + AdminRolesListPacket, + AdminRolesListResultPacket, + AdminUserBanPacket, + AdminUserSetRolePacket, + AdminUserUnbanPacket, + AdminUsersListPacket, + AdminUsersListResultPacket, BroadcastChatMessagePacket, BroadcastNicknamePacket, BroadcastPositionPacket, @@ -225,6 +237,61 @@ class SignalingServer: "passwordMaxLength": self.auth_service.password_max_length, } + @staticmethod + def _sorted_permissions(values: set[str] | tuple[str, ...] | None) -> list[str]: + """Return deterministic sorted permission list.""" + + if not values: + return [] + return sorted(str(value) for value in values if str(value).strip()) + + def _client_has_permission(self, client: ClientConnection, key: str) -> bool: + """Return whether one authenticated client currently has a permission key.""" + + if not client.authenticated or not client.user_id: + return False + if client.permissions is None: + client.permissions = self.auth_service.get_user_permissions(client.user_id) + return key in client.permissions + + def _refresh_client_permissions(self, client: ClientConnection) -> list[str]: + """Refresh one client's role/permissions from auth storage and return permissions list.""" + + if not client.user_id: + client.permissions = set() + return [] + user = self.auth_service.get_user_by_id(client.user_id) + if user is None: + client.permissions = set() + return [] + client.role = user.role + client.permissions = set(user.permissions) + return self._sorted_permissions(client.permissions) + + async def _send_auth_permissions(self, client: ClientConnection) -> None: + """Push one authenticated client's current role + permission set.""" + + permissions = self._refresh_client_permissions(client) + await self._send( + client.websocket, + AuthPermissionsPacket( + type="auth_permissions", + role=client.role, + permissions=permissions, + ), + ) + + async def _sync_permissions_for_user_ids(self, user_ids: list[str]) -> None: + """Refresh and push permissions for active websocket clients matching user ids.""" + + wanted = {str(user_id) for user_id in user_ids} + if not wanted: + return + for active in self.clients.values(): + if not active.user_id or active.user_id not in wanted: + continue + await self._send_auth_permissions(active) + def _flush_state_save(self) -> None: """Immediately flush pending state persistence and clear debounce state.""" @@ -412,6 +479,14 @@ class SignalingServer: actor_name = client.username or client.nickname or actor_id return actor_id, actor_name + @staticmethod + def _owns_item(client: ClientConnection, item: WorldItem) -> bool: + """Return whether the authenticated client is the creator/owner of an item.""" + + if not client.user_id: + return False + return item.createdBy == client.user_id + def _get_item_emit_range(self, item: WorldItem) -> int: """Return effective emit range for one item with sane bounds.""" @@ -1211,6 +1286,7 @@ class SignalingServer: "userId": client.user_id, "username": client.username, "role": client.role if client.authenticated else None, + "permissions": self._sorted_permissions(client.permissions), "policy": self._auth_policy(), }, ) @@ -1230,6 +1306,7 @@ class SignalingServer: client.x = random.randrange(self.grid_size) client.y = random.randrange(self.grid_size) now_ms = self.item_service.now_ms() + self._refresh_client_permissions(client) client.last_position_update_ms = now_ms client.movement_window_index = self._movement_window_index(now_ms) client.movement_window_steps_used = 0 @@ -1324,6 +1401,7 @@ class SignalingServer: if client.session_token: self.auth_service.revoke(client.session_token) client.session_token = None + client.permissions = set() LOGGER.info("auth logout id=%s ip=%s username=%s", client.id, self._client_ip(client), client.username) await self._send( client.websocket, @@ -1387,6 +1465,7 @@ class SignalingServer: client.user_id = session.user.id client.username = session.user.username client.role = session.user.role + client.permissions = set(session.user.permissions) client.session_token = session.token client.nickname = session.user.last_nickname or client.nickname client.saved_x = session.user.last_x @@ -1400,6 +1479,7 @@ class SignalingServer: sessionToken=session.token, username=session.user.username, role=session.user.role, + permissions=self._sorted_permissions(session.user.permissions), nickname=client.nickname, authPolicy=self._auth_policy(), ), @@ -1449,6 +1529,220 @@ class SignalingServer: BroadcastChatMessagePacket(type="chat_message", message=self_message, system=True), ) + async def _send_admin_action_result( + self, + client: ClientConnection, + *, + ok: bool, + action: Literal[ + "role_create", + "role_update_permissions", + "role_delete", + "user_set_role", + "user_ban", + "user_unban", + ], + message: str, + ) -> None: + """Send one structured admin action result packet to caller.""" + + await self._send( + client.websocket, + AdminActionResultPacket(type="admin_action_result", ok=ok, action=action, message=message), + ) + + async def _handle_admin_packet(self, client: ClientConnection, packet: ClientPacket) -> bool: + """Handle role/user administration packets with permission checks.""" + + if not isinstance( + packet, + ( + AdminRolesListPacket, + AdminRoleCreatePacket, + AdminRoleUpdatePermissionsPacket, + AdminRoleDeletePacket, + AdminUsersListPacket, + AdminUserSetRolePacket, + AdminUserBanPacket, + AdminUserUnbanPacket, + ), + ): + return False + + async def deny(action: str, message: str) -> None: + await self._send_admin_action_result(client, ok=False, action=action, message=message) + + if isinstance(packet, AdminRolesListPacket): + if not ( + self._client_has_permission(client, "role.manage") + or self._client_has_permission(client, "user.change_role") + ): + await deny("role_update_permissions", "Not authorized.") + return True + roles = self.auth_service.list_roles_with_counts() + await self._send( + client.websocket, + AdminRolesListResultPacket( + type="admin_roles_list", + roles=roles, + permissionKeys=self.auth_service.list_all_permissions(), + ), + ) + return True + + if isinstance(packet, AdminUsersListPacket): + if not ( + self._client_has_permission(client, "user.change_role") + or self._client_has_permission(client, "user.ban_unban") + ): + await deny("user_set_role", "Not authorized.") + return True + users = self.auth_service.list_users_for_admin() + await self._send(client.websocket, AdminUsersListResultPacket(type="admin_users_list", users=users)) + return True + + if isinstance(packet, AdminRoleCreatePacket): + if not self._client_has_permission(client, "role.manage"): + await deny("role_create", "Not authorized.") + return True + try: + created = self.auth_service.create_role(packet.name) + except AuthError as exc: + await deny("role_create", str(exc)) + return True + LOGGER.info("role created actor=%s role=%s", client.user_id, created["name"]) + await self._send_admin_action_result(client, ok=True, action="role_create", message=f"Created role {created['name']}.") + return True + + if isinstance(packet, AdminRoleUpdatePermissionsPacket): + if not self._client_has_permission(client, "role.manage"): + await deny("role_update_permissions", "Not authorized.") + return True + affected_user_ids = self.auth_service.list_connected_user_ids_for_role(packet.role) + try: + assigned = self.auth_service.update_role_permissions(packet.role, packet.permissions) + except AuthError as exc: + await deny("role_update_permissions", str(exc)) + return True + LOGGER.info( + "role permissions updated actor=%s role=%s permission_count=%d", + client.user_id, + packet.role, + len(assigned), + ) + await self._sync_permissions_for_user_ids(affected_user_ids) + await self._send_admin_action_result( + client, + ok=True, + action="role_update_permissions", + message=f"Updated permissions for {packet.role}.", + ) + return True + + if isinstance(packet, AdminRoleDeletePacket): + if not self._client_has_permission(client, "role.manage"): + await deny("role_delete", "Not authorized.") + return True + try: + affected_usernames, replacement = self.auth_service.delete_role(packet.role, packet.replacementRole) + except AuthError as exc: + await deny("role_delete", str(exc)) + return True + affected_ids = [ + user_id + for username in affected_usernames + for user_id in [self.auth_service.get_user_id_by_username(username)] + if user_id is not None + ] + await self._sync_permissions_for_user_ids(affected_ids) + LOGGER.info( + "role deleted actor=%s role=%s replacement=%s affected=%d", + client.user_id, + packet.role, + replacement, + len(affected_usernames), + ) + await self._send_admin_action_result( + client, + ok=True, + action="role_delete", + message=f"Deleted role {packet.role}; reassigned {len(affected_usernames)} users to {replacement}.", + ) + return True + + if isinstance(packet, AdminUserSetRolePacket): + if not self._client_has_permission(client, "user.change_role"): + await deny("user_set_role", "Not authorized.") + return True + target_id = self.auth_service.get_user_id_by_username(packet.username) + try: + username = self.auth_service.set_user_role(packet.username, packet.role, actor_user_id=client.user_id) + except AuthError as exc: + await deny("user_set_role", str(exc)) + return True + if target_id: + await self._sync_permissions_for_user_ids([target_id]) + LOGGER.info("user role changed actor=%s target=%s role=%s", client.user_id, username, packet.role) + await self._send_admin_action_result( + client, + ok=True, + action="user_set_role", + message=f"Set role for {username} to {packet.role}.", + ) + return True + + if isinstance(packet, AdminUserBanPacket): + if not self._client_has_permission(client, "user.ban_unban"): + await deny("user_ban", "Not authorized.") + return True + target_id = self.auth_service.get_user_id_by_username(packet.username) + try: + username = self.auth_service.set_user_status(packet.username, "disabled") + except AuthError as exc: + await deny("user_ban", str(exc)) + return True + if target_id: + await self._sync_permissions_for_user_ids([target_id]) + for active in list(self.clients.values()): + if active.user_id != target_id: + continue + await self._send( + active.websocket, + AuthResultPacket(type="auth_result", ok=False, message="Account is disabled."), + ) + await active.websocket.close() + LOGGER.info("user banned actor=%s target=%s", client.user_id, username) + await self._send_admin_action_result( + client, + ok=True, + action="user_ban", + message=f"Banned {username}.", + ) + return True + + if isinstance(packet, AdminUserUnbanPacket): + if not self._client_has_permission(client, "user.ban_unban"): + await deny("user_unban", "Not authorized.") + return True + target_id = self.auth_service.get_user_id_by_username(packet.username) + try: + username = self.auth_service.set_user_status(packet.username, "active") + except AuthError as exc: + await deny("user_unban", str(exc)) + return True + if target_id: + await self._sync_permissions_for_user_ids([target_id]) + LOGGER.info("user unbanned actor=%s target=%s", client.user_id, username) + await self._send_admin_action_result( + client, + ok=True, + action="user_unban", + message=f"Unbanned {username}.", + ) + return True + + return True + async def _handle_message(self, client: ClientConnection, raw_message: str) -> None: """Decode, validate, and route one inbound client packet.""" @@ -1470,6 +1764,8 @@ class SignalingServer: client.authenticated = True client.user_id = client.user_id or client.id client.username = client.username or client.nickname + client.role = "admin" + client.permissions = set(self.auth_service.list_all_permissions()) if await self._handle_auth_packet(client, packet): return @@ -1480,6 +1776,9 @@ class SignalingServer: ) return + if await self._handle_admin_packet(client, packet): + return + if isinstance(packet, UpdatePositionPacket): if not self._is_in_bounds(packet.x, packet.y): PACKET_LOGGER.warning( @@ -1585,6 +1884,18 @@ class SignalingServer: return if isinstance(packet, UpdateNicknamePacket): + if not self._client_has_permission(client, "profile.update_nickname"): + await self._send( + client.websocket, + NicknameResultPacket( + type="nickname_result", + accepted=False, + requestedNickname=packet.nickname, + effectiveNickname=client.nickname, + reason="Not authorized to change nickname.", + ), + ) + return requested_nickname = packet.nickname.strip() if not requested_nickname: await self._send( @@ -1676,6 +1987,16 @@ class SignalingServer: return if isinstance(packet, ChatMessagePacket): + if not self._client_has_permission(client, "chat.send"): + await self._send( + client.websocket, + BroadcastChatMessagePacket( + type="chat_message", + message="You are not allowed to send chat messages.", + system=True, + ), + ) + return await self._broadcast( BroadcastChatMessagePacket( type="chat_message", @@ -1695,6 +2016,9 @@ class SignalingServer: return if isinstance(packet, ItemAddPacket): + if not self._client_has_permission(client, "item.create"): + await self._send_item_result(client, False, "add", "Not authorized to create items.") + return if not is_known_item_type(packet.itemType): await self._send_item_result(client, False, "add", "Unknown item type.") return @@ -1744,6 +2068,11 @@ class SignalingServer: if item.carrierId is None and (item.x != client.x or item.y != client.y): await self._send_item_result(client, False, "pickup", "Item is not on your square.", item.id) return + can_pickup_any = self._client_has_permission(client, "item.pickup_drop.any") + can_pickup_own = self._client_has_permission(client, "item.pickup_drop.own") and self._owns_item(client, item) + if not can_pickup_any and not can_pickup_own: + await self._send_item_result(client, False, "pickup", "Not authorized to pick up this item.", item.id) + return item.carrierId = client.id item.x = client.x item.y = client.y @@ -1776,6 +2105,11 @@ class SignalingServer: if not self._is_in_bounds(packet.x, packet.y): await self._send_item_result(client, False, "drop", "Drop position is out of bounds.", item.id) return + can_drop_any = self._client_has_permission(client, "item.pickup_drop.any") + can_drop_own = self._client_has_permission(client, "item.pickup_drop.own") and self._owns_item(client, item) + if not can_drop_any and not can_drop_own: + await self._send_item_result(client, False, "drop", "Not authorized to drop this item.", item.id) + return item.carrierId = None item.x = packet.x item.y = packet.y @@ -1808,6 +2142,11 @@ class SignalingServer: if item.carrierId is None and (item.x != client.x or item.y != client.y): await self._send_item_result(client, False, "delete", "Item is not on your square.", item.id) return + can_delete_any = self._client_has_permission(client, "item.delete.any") + can_delete_own = self._client_has_permission(client, "item.delete.own") and self._owns_item(client, item) + if not can_delete_any and not can_delete_own: + await self._send_item_result(client, False, "delete", "Not authorized to delete this item.", item.id) + return LOGGER.info( "item deleted by=%s item_id=%s type=%s title=%s", client.nickname, @@ -1833,6 +2172,9 @@ class SignalingServer: return if isinstance(packet, ItemUsePacket): + if not self._client_has_permission(client, "item.use"): + await self._send_item_result(client, False, "use", "Not authorized to use items.") + return item = self.items.get(packet.itemId) if not item: await self._send_item_result(client, False, "use", "Item not found.") @@ -1918,6 +2260,9 @@ class SignalingServer: return if isinstance(packet, ItemSecondaryUsePacket): + if not self._client_has_permission(client, "item.use"): + await self._send_item_result(client, False, "secondary_use", "Not authorized to use items.") + return item = self.items.get(packet.itemId) if not item: await self._send_item_result(client, False, "secondary_use", "Item not found.") @@ -1965,6 +2310,8 @@ class SignalingServer: return if isinstance(packet, ItemPianoNotePacket): + if not self._client_has_permission(client, "item.use"): + return item = self.items.get(packet.itemId) if not item or item.type != "piano": return @@ -2021,6 +2368,9 @@ class SignalingServer: return if isinstance(packet, ItemPianoRecordingPacket): + if not self._client_has_permission(client, "item.use"): + await self._send_item_result(client, False, "use", "Not authorized to use items.") + return item = self.items.get(packet.itemId) if not item or item.type != "piano": await self._send_item_result(client, False, "use", "Piano not found.") @@ -2111,6 +2461,11 @@ class SignalingServer: if item.carrierId is None and (item.x != client.x or item.y != client.y): await self._send_item_result(client, False, "update", "Item is not on your square.", item.id) return + can_edit_any = self._client_has_permission(client, "item.edit.any") + can_edit_own = self._client_has_permission(client, "item.edit.own") and self._owns_item(client, item) + if not can_edit_any and not can_edit_own: + await self._send_item_result(client, False, "update", "Not authorized to edit this item.", item.id) + return if packet.title is not None: title = packet.title.strip() if not title: @@ -2136,6 +2491,8 @@ class SignalingServer: await self._send_item_result(client, True, "update", f"Updated {item.title}.", item.id) return + if not self._client_has_permission(client, "voice.send"): + return target = self._find_by_id(packet.targetId) if not target: PACKET_LOGGER.info("signal target not found sender=%s target=%s", client.id, packet.targetId)