Implement server-authoritative roles and Shift+Z admin flows
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string>();
|
||||
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<boolean> {
|
||||
/** Starts local microphone capture and rebuilds the outbound track pipeline. */
|
||||
async function setupLocalMedia(audioDeviceId = ''): Promise<void> {
|
||||
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<IncomingMessage, { type: 'auth_required' }>): void {
|
||||
applyAuthPolicy(message.authPolicy);
|
||||
applyAuthPermissions('user', []);
|
||||
setConnectionStatus('Authentication required.');
|
||||
updateStatus(message.message);
|
||||
}
|
||||
@@ -1433,6 +1501,7 @@ async function handleAuthResult(message: Extract<IncomingMessage, { type: 'auth_
|
||||
authSessionToken = '';
|
||||
settings.saveAuthSessionToken('');
|
||||
}
|
||||
applyAuthPermissions('user', []);
|
||||
setConnectionStatus(message.message);
|
||||
mediaSession.setConnecting(false);
|
||||
updateConnectAvailability();
|
||||
@@ -1456,6 +1525,7 @@ async function handleAuthResult(message: Extract<IncomingMessage, { type: 'auth_
|
||||
state.player.nickname = resolved;
|
||||
}
|
||||
}
|
||||
applyAuthPermissions(message.role, message.permissions);
|
||||
dom.authPassword.value = '';
|
||||
dom.registerPassword.value = '';
|
||||
dom.registerPasswordConfirm.value = '';
|
||||
@@ -1468,6 +1538,7 @@ function logOutAccount(): void {
|
||||
authUsername = '';
|
||||
settings.saveAuthSessionToken('');
|
||||
settings.saveAuthUsername('');
|
||||
applyAuthPermissions('user', []);
|
||||
if (state.running) {
|
||||
signaling.send({ type: 'auth_logout' });
|
||||
disconnect();
|
||||
@@ -1477,6 +1548,92 @@ function logOutAccount(): void {
|
||||
updateConnectAvailability();
|
||||
}
|
||||
|
||||
/** Handles server-pushed role/permission refresh events for the current session. */
|
||||
function handleAuthPermissions(message: Extract<IncomingMessage, { type: 'auth_permissions' }>): 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<IncomingMessage, { type: 'admin_roles_list' }>): 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<IncomingMessage, { type: 'admin_users_list' }>): 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<IncomingMessage, { type: 'admin_action_result' }>): 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<void> {
|
||||
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<void> {
|
||||
|
||||
/** 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),
|
||||
|
||||
@@ -72,6 +72,10 @@ type MessageHandlerDeps = {
|
||||
playClockAnnouncement: (sounds: string[], x: number, y: number, range?: number) => void;
|
||||
handleAuthRequired: (message: Extract<IncomingMessage, { type: 'auth_required' }>) => void;
|
||||
handleAuthResult: (message: Extract<IncomingMessage, { type: 'auth_result' }>) => Promise<void>;
|
||||
handleAuthPermissions: (message: Extract<IncomingMessage, { type: 'auth_permissions' }>) => void;
|
||||
handleAdminRolesList: (message: Extract<IncomingMessage, { type: 'admin_roles_list' }>) => void;
|
||||
handleAdminUsersList: (message: Extract<IncomingMessage, { type: 'admin_users_list' }>) => void;
|
||||
handleAdminActionResult: (message: Extract<IncomingMessage, { type: 'admin_action_result' }>) => void;
|
||||
isPeerNegotiationReady: () => boolean;
|
||||
enqueuePendingSignal: (message: Extract<IncomingMessage, { type: 'signal' }>) => 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) {
|
||||
|
||||
@@ -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<typeof incomingMessageSchema>;
|
||||
@@ -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 }
|
||||
|
||||
@@ -39,6 +39,13 @@ export type GameMode =
|
||||
| 'itemProperties'
|
||||
| 'itemPropertyEdit'
|
||||
| 'itemPropertyOptionSelect'
|
||||
| 'adminMenu'
|
||||
| 'adminRoleList'
|
||||
| 'adminRolePermissionList'
|
||||
| 'adminRoleDeleteReplacement'
|
||||
| 'adminUserList'
|
||||
| 'adminUserRoleSelect'
|
||||
| 'adminRoleNameEdit'
|
||||
| 'pianoUse';
|
||||
|
||||
export type Player = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<name>` for sound-reference fields
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,23 +465,25 @@ class AuthService:
|
||||
) -> AuthSession:
|
||||
"""Register an account and issue a session token."""
|
||||
|
||||
with self._conn_lock:
|
||||
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)
|
||||
if role not in {"user", "admin"}:
|
||||
raise AuthError("role must be user or admin.")
|
||||
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 INTO users (
|
||||
username, password_hash, email, role, status, created_at_ms, updated_at_ms, last_login_at_ms
|
||||
username, password_hash, email, role_id, 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),
|
||||
(normalized_username, password_hash, normalized_email, int(role_row["id"]), now_ms, now_ms, now_ms),
|
||||
)
|
||||
self._db_commit()
|
||||
except sqlite3.IntegrityError as exc:
|
||||
@@ -162,6 +502,7 @@ class AuthService:
|
||||
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)
|
||||
@@ -171,13 +512,14 @@ class AuthService:
|
||||
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, now_ms),
|
||||
(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,
|
||||
@@ -189,7 +531,6 @@ class AuthService:
|
||||
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(
|
||||
"""
|
||||
@@ -198,19 +539,19 @@ class AuthService:
|
||||
u.username,
|
||||
u.password_hash,
|
||||
u.email,
|
||||
u.role,
|
||||
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:
|
||||
# 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":
|
||||
@@ -229,6 +570,7 @@ class AuthService:
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
permissions=user.permissions,
|
||||
status=user.status,
|
||||
email=user.email,
|
||||
last_nickname=user.username,
|
||||
@@ -246,7 +588,6 @@ class AuthService:
|
||||
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.")
|
||||
@@ -254,9 +595,10 @@ class AuthService:
|
||||
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
|
||||
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 = ?
|
||||
""",
|
||||
@@ -282,7 +624,8 @@ class AuthService:
|
||||
user = AuthUser(
|
||||
id=str(row["user_id"]),
|
||||
username=row["username"],
|
||||
role=row["role"],
|
||||
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"],
|
||||
@@ -295,13 +638,14 @@ class AuthService:
|
||||
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 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,8 +717,39 @@ 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(
|
||||
"""
|
||||
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 (
|
||||
@@ -382,14 +757,32 @@ class AuthService:
|
||||
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)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
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 (
|
||||
@@ -418,16 +811,115 @@ class AuthService:
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
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 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(
|
||||
"INSERT OR IGNORE INTO permissions (key, description) VALUES (?, ?)",
|
||||
(key, description),
|
||||
)
|
||||
|
||||
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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user