diff --git a/src/db/activeSchema.ts b/src/db/activeSchema.ts index 6aaacb9d0..47e480951 100644 --- a/src/db/activeSchema.ts +++ b/src/db/activeSchema.ts @@ -98,6 +98,11 @@ import { embedProfilesSqlite, embedProfilesPostgres, embedProfilesMysql, } from './schema/embedProfiles.js'; +// Node Admin Permissions table +import { + nodeAdminPermissionsSqlite, nodeAdminPermissionsPostgres, nodeAdminPermissionsMysql, +} from './schema/nodeAdminPermissions.js'; + /** * Runtime table map interface. * @@ -162,6 +167,9 @@ export interface ActiveSchema { // Embed Profiles embedProfiles: any; + // Node Admin Permissions + nodeAdminPermissions: any; + // Allow dynamic access for flexibility [key: string]: any; } @@ -209,6 +217,7 @@ const SCHEMA_MAP: Record = { meshcoreNodes: meshcoreNodesSqlite, meshcoreMessages: meshcoreMessagesSqlite, embedProfiles: embedProfilesSqlite, + nodeAdminPermissions: nodeAdminPermissionsSqlite, }, postgres: { nodes: nodesPostgres, @@ -249,6 +258,7 @@ const SCHEMA_MAP: Record = { meshcoreNodes: meshcoreNodesPostgres, meshcoreMessages: meshcoreMessagesPostgres, embedProfiles: embedProfilesPostgres, + nodeAdminPermissions: nodeAdminPermissionsPostgres, }, mysql: { nodes: nodesMysql, @@ -289,6 +299,7 @@ const SCHEMA_MAP: Record = { meshcoreNodes: meshcoreNodesMysql, meshcoreMessages: meshcoreMessagesMysql, embedProfiles: embedProfilesMysql, + nodeAdminPermissions: nodeAdminPermissionsMysql, }, }; diff --git a/src/db/migrations.test.ts b/src/db/migrations.test.ts index 4463c556b..1f41ef691 100644 --- a/src/db/migrations.test.ts +++ b/src/db/migrations.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest'; import { registry } from './migrations.js'; describe('migrations registry', () => { - it('has all 19 migrations registered', () => { - expect(registry.count()).toBe(19); + it('has all 20 migrations registered', () => { + expect(registry.count()).toBe(20); }); it('first migration is v37 baseline', () => { @@ -12,14 +12,14 @@ describe('migrations registry', () => { expect(all[0].name).toContain('v37_baseline'); }); - it('last migration is channel column on traceroutes', () => { + it('last migration is node admin permissions', () => { const all = registry.getAll(); const last = all[all.length - 1]; - expect(last.number).toBe(19); - expect(last.name).toContain('channel_to_traceroutes'); + expect(last.number).toBe(20); + expect(last.name).toContain('node_admin_permissions'); }); - it('migrations are sequentially numbered from 1 to 19', () => { + it('migrations are sequentially numbered from 1 to 20', () => { const all = registry.getAll(); for (let i = 0; i < all.length; i++) { expect(all[i].number).toBe(i + 1); diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 809c64cb4..878c02299 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -1,7 +1,7 @@ /** * Migration Registry Barrel File * - * Registers all 19 migrations in sequential order for use by the migration runner. + * Registers all 20 migrations in sequential order for use by the migration runner. * Migration 001 is the v3.7 baseline (selfIdempotent — handles its own detection). * Migrations 002-011 were originally 078-087 and retain their original settingsKeys * for upgrade compatibility. @@ -31,6 +31,7 @@ import { migration as renameSystemBackupColumnsMigration, runMigration016Postgre import { migration as apiTokensNameMigration, runMigration017Postgres, runMigration017Mysql } from '../server/migrations/017_add_api_tokens_name_column.js'; import { migration as addMuteColumnsMigration, runMigration018Postgres, runMigration018Mysql } from '../server/migrations/018_add_mute_columns.js'; import { migration as addChannelToTraceroutesMigration, runMigration019Postgres, runMigration019Mysql } from '../server/migrations/019_add_channel_to_traceroutes.js'; +import { migration as createNodeAdminPermissionsMigration, runMigration020Postgres, runMigration020Mysql } from '../server/migrations/020_create_node_admin_permissions.js'; // ============================================================================ // Registry @@ -263,3 +264,17 @@ registry.register({ postgres: (client) => runMigration019Postgres(client), mysql: (pool) => runMigration019Mysql(pool), }); + +// --------------------------------------------------------------------------- +// Migration 020: Create node_admin_permissions table +// Enables per-node delegated admin access for non-admin users. +// --------------------------------------------------------------------------- + +registry.register({ + number: 20, + name: 'create_node_admin_permissions', + settingsKey: 'migration_020_create_node_admin_permissions', + sqlite: (db) => createNodeAdminPermissionsMigration.up(db), + postgres: (client) => runMigration020Postgres(client), + mysql: (pool) => runMigration020Mysql(pool), +}); diff --git a/src/db/repositories/auth.ts b/src/db/repositories/auth.ts index a1c361717..0251bb8f7 100644 --- a/src/db/repositories/auth.ts +++ b/src/db/repositories/auth.ts @@ -834,4 +834,61 @@ export class AuthRepository extends BaseRepository { } } } + + // ============ NODE ADMIN PERMISSIONS ============ + + async hasNodeAdminPermission(userId: number, nodeNum: number): Promise { + const { nodeAdminPermissions } = this.tables; + const result = await this.db + .select() + .from(nodeAdminPermissions) + .where(and( + eq(nodeAdminPermissions.userId, userId), + eq(nodeAdminPermissions.nodeNum, nodeNum) + )) + .limit(1); + return result.length > 0; + } + + async getNodeAdminPermissionsForUser(userId: number): Promise<{ nodeNum: number; grantedAt: number; grantedBy: number | null }[]> { + const { nodeAdminPermissions } = this.tables; + const result = await this.db + .select({ + nodeNum: nodeAdminPermissions.nodeNum, + grantedAt: nodeAdminPermissions.grantedAt, + grantedBy: nodeAdminPermissions.grantedBy, + }) + .from(nodeAdminPermissions) + .where(eq(nodeAdminPermissions.userId, userId)); + return this.normalizeBigInts(result) as { nodeNum: number; grantedAt: number; grantedBy: number | null }[]; + } + + async grantNodeAdminPermission(userId: number, nodeNum: number, grantedBy: number): Promise { + const { nodeAdminPermissions } = this.tables; + await this.insertIgnore(nodeAdminPermissions, { + userId, + nodeNum, + grantedBy, + grantedAt: Date.now(), + }); + } + + async revokeNodeAdminPermission(userId: number, nodeNum: number): Promise { + const { nodeAdminPermissions } = this.tables; + const result = await this.db + .delete(nodeAdminPermissions) + .where(and( + eq(nodeAdminPermissions.userId, userId), + eq(nodeAdminPermissions.nodeNum, nodeNum) + )); + return this.getAffectedRows(result) > 0; + } + + async revokeAllNodeAdminPermissions(userId: number): Promise { + const { nodeAdminPermissions } = this.tables; + const result = await this.db + .delete(nodeAdminPermissions) + .where(eq(nodeAdminPermissions.userId, userId)); + return this.getAffectedRows(result); + } } diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index c32357912..1cc304f4a 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -36,3 +36,6 @@ export * from './meshcoreMessages.js'; // Embed Profiles table export * from './embedProfiles.js'; + +// Node Admin Permissions table +export * from './nodeAdminPermissions.js'; diff --git a/src/db/schema/nodeAdminPermissions.ts b/src/db/schema/nodeAdminPermissions.ts new file mode 100644 index 000000000..1ca43863a --- /dev/null +++ b/src/db/schema/nodeAdminPermissions.ts @@ -0,0 +1,53 @@ +/** + * Drizzle schema definition for node admin permissions table + * Allows admins to grant non-admin users remote admin access to specific nodes. + * Supports SQLite, PostgreSQL, and MySQL + */ +import { sqliteTable, integer, unique } from 'drizzle-orm/sqlite-core'; +import { pgTable, integer as pgInteger, bigint as pgBigint, serial as pgSerial, unique as pgUnique } from 'drizzle-orm/pg-core'; +import { mysqlTable, int as myInt, bigint as myBigint, serial as mySerial, unique as myUnique } from 'drizzle-orm/mysql-core'; +import { usersSqlite, usersPostgres, usersMysql } from './auth.js'; + +// ============ SQLite ============ + +export const nodeAdminPermissionsSqlite = sqliteTable('node_admin_permissions', { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: integer('user_id').notNull().references(() => usersSqlite.id, { onDelete: 'cascade' }), + nodeNum: integer('node_num').notNull(), + grantedBy: integer('granted_by').references(() => usersSqlite.id, { onDelete: 'set null' }), + grantedAt: integer('granted_at').notNull(), +}, (table) => ({ + uniqueUserNode: unique().on(table.userId, table.nodeNum), +})); + +// ============ PostgreSQL ============ + +export const nodeAdminPermissionsPostgres = pgTable('node_admin_permissions', { + id: pgSerial('id').primaryKey(), + userId: pgInteger('userId').notNull().references(() => usersPostgres.id, { onDelete: 'cascade' }), + nodeNum: pgBigint('nodeNum', { mode: 'number' }).notNull(), + grantedBy: pgInteger('grantedBy').references(() => usersPostgres.id, { onDelete: 'set null' }), + grantedAt: pgBigint('grantedAt', { mode: 'number' }).notNull(), +}, (table) => ({ + uniqueUserNode: pgUnique().on(table.userId, table.nodeNum), +})); + +// ============ MySQL ============ + +export const nodeAdminPermissionsMysql = mysqlTable('node_admin_permissions', { + id: mySerial('id').primaryKey(), + userId: myInt('userId').notNull().references(() => usersMysql.id, { onDelete: 'cascade' }), + nodeNum: myBigint('nodeNum', { mode: 'number' }).notNull(), + grantedBy: myInt('grantedBy').references(() => usersMysql.id, { onDelete: 'set null' }), + grantedAt: myBigint('grantedAt', { mode: 'number' }).notNull(), +}, (table) => ({ + uniqueUserNode: myUnique().on(table.userId, table.nodeNum), +})); + +// Type inference +export type NodeAdminPermissionSqlite = typeof nodeAdminPermissionsSqlite.$inferSelect; +export type NewNodeAdminPermissionSqlite = typeof nodeAdminPermissionsSqlite.$inferInsert; +export type NodeAdminPermissionPostgres = typeof nodeAdminPermissionsPostgres.$inferSelect; +export type NewNodeAdminPermissionPostgres = typeof nodeAdminPermissionsPostgres.$inferInsert; +export type NodeAdminPermissionMysql = typeof nodeAdminPermissionsMysql.$inferSelect; +export type NewNodeAdminPermissionMysql = typeof nodeAdminPermissionsMysql.$inferInsert; diff --git a/src/server/auth/authMiddleware.ts b/src/server/auth/authMiddleware.ts index 885469952..5110cd585 100644 --- a/src/server/auth/authMiddleware.ts +++ b/src/server/auth/authMiddleware.ts @@ -393,6 +393,83 @@ export function requireAdmin() { }; } +/** + * Require admin role OR per-node admin permission for a specific node. + * Full admins pass through unconditionally. + * Non-admin users are checked against node_admin_permissions for the + * requested nodeNum. Local node operations remain admin-only. + */ +export function requireAdminOrNodeAdmin() { + return async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.session.userId) { + return res.status(401).json({ + error: 'Authentication required', + code: 'UNAUTHORIZED' + }); + } + + const user = await databaseService.findUserByIdAsync(req.session.userId); + + if (!user || !user.isActive) { + req.session.userId = undefined; + req.session.username = undefined; + req.session.authProvider = undefined; + req.session.isAdmin = undefined; + + return res.status(401).json({ + error: 'Authentication required', + code: 'UNAUTHORIZED' + }); + } + + if (user.isAdmin) { + req.user = user; + return next(); + } + + const nodeNum = req.body?.nodeNum ?? req.params?.nodeNum; + + if (nodeNum === undefined || nodeNum === null) { + return res.status(403).json({ + error: 'Admin access required (no node specified — defaults to local node)', + code: 'FORBIDDEN_ADMIN' + }); + } + + const destinationNodeNum = Number(nodeNum); + const localNodeNumStr = databaseService.getSetting('localNodeNum'); + const localNodeNum = localNodeNumStr ? parseInt(localNodeNumStr, 10) : null; + + if (destinationNodeNum === 0 || (localNodeNum !== null && destinationNodeNum === localNodeNum)) { + return res.status(403).json({ + error: 'Admin access required for local node operations', + code: 'FORBIDDEN_ADMIN' + }); + } + + const hasPermission = await databaseService.hasNodeAdminPermissionAsync(user.id, destinationNodeNum); + + if (!hasPermission) { + logger.debug(`❌ User ${user.username} denied node admin access to node ${destinationNodeNum}`); + return res.status(403).json({ + error: 'Admin access required', + code: 'FORBIDDEN_ADMIN' + }); + } + + req.user = user; + next(); + } catch (error) { + logger.error('Error in requireAdminOrNodeAdmin middleware:', error); + return res.status(500).json({ + error: 'Internal server error', + code: 'INTERNAL_ERROR' + }); + } + }; +} + /** * Check if user has a specific permission (async version) */ diff --git a/src/server/migrations/020_create_node_admin_permissions.ts b/src/server/migrations/020_create_node_admin_permissions.ts new file mode 100644 index 000000000..f71100709 --- /dev/null +++ b/src/server/migrations/020_create_node_admin_permissions.ts @@ -0,0 +1,104 @@ +/** + * Migration 020: Create node_admin_permissions table + * + * Allows admins to grant non-admin users remote admin access to specific + * mesh nodes. Each row links a userId to a nodeNum, enabling delegated + * node management without full admin privileges. Local node operations + * remain admin-only for security. + */ +import type { Database } from 'better-sqlite3'; +import { logger } from '../../utils/logger.js'; + +// ============ SQLite ============ + +export const migration = { + up: (db: Database): void => { + logger.info('Running migration 020 (SQLite): Creating node_admin_permissions table...'); + + try { + db.exec(` + CREATE TABLE IF NOT EXISTS node_admin_permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + node_num INTEGER NOT NULL, + granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + granted_at INTEGER NOT NULL, + UNIQUE(user_id, node_num) + ) + `); + logger.debug('Created node_admin_permissions table'); + } catch (e: any) { + if (e.message?.includes('already exists')) { + logger.debug('node_admin_permissions table already exists, skipping'); + } else { + logger.warn('Could not create node_admin_permissions:', e.message); + } + } + + logger.info('Migration 020 complete (SQLite): node_admin_permissions created'); + }, + + down: (_db: Database): void => { + logger.debug('Migration 020 down: Not implemented (destructive table drops)'); + } +}; + +// ============ PostgreSQL ============ + +export async function runMigration020Postgres(client: import('pg').PoolClient): Promise { + logger.info('Running migration 020 (PostgreSQL): Creating node_admin_permissions table...'); + + try { + await client.query(` + CREATE TABLE IF NOT EXISTS node_admin_permissions ( + id SERIAL PRIMARY KEY, + "userId" INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + "nodeNum" BIGINT NOT NULL, + "grantedBy" INTEGER REFERENCES users(id) ON DELETE SET NULL, + "grantedAt" BIGINT NOT NULL, + UNIQUE("userId", "nodeNum") + ) + `); + logger.debug('Ensured node_admin_permissions table exists'); + } catch (error: any) { + logger.error('Migration 020 (PostgreSQL) failed:', error.message); + throw error; + } + + logger.info('Migration 020 complete (PostgreSQL): node_admin_permissions created'); +} + +// ============ MySQL ============ + +export async function runMigration020Mysql(pool: import('mysql2/promise').Pool): Promise { + logger.info('Running migration 020 (MySQL): Creating node_admin_permissions table...'); + + try { + const [rows] = await pool.query(` + SELECT TABLE_NAME FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'node_admin_permissions' + `); + if (!Array.isArray(rows) || rows.length === 0) { + await pool.query(` + CREATE TABLE node_admin_permissions ( + id SERIAL PRIMARY KEY, + userId INT NOT NULL, + nodeNum BIGINT NOT NULL, + grantedBy INT, + grantedAt BIGINT NOT NULL, + UNIQUE(userId, nodeNum), + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (grantedBy) REFERENCES users(id) ON DELETE SET NULL + ) + `); + logger.debug('Created node_admin_permissions table'); + } else { + logger.debug('node_admin_permissions table already exists, skipping'); + } + } catch (error: any) { + logger.error('Migration 020 (MySQL) failed:', error.message); + throw error; + } + + logger.info('Migration 020 complete (MySQL): node_admin_permissions created'); +} diff --git a/src/server/routes/userRoutes.ts b/src/server/routes/userRoutes.ts index 8b3dde93f..d56b69b8a 100644 --- a/src/server/routes/userRoutes.ts +++ b/src/server/routes/userRoutes.ts @@ -571,6 +571,101 @@ router.put('/:id/channel-database-permissions', async (req: Request, res: Respon } }); +// Get node admin permissions for a user +router.get('/:id/node-admin-permissions', async (req: Request, res: Response) => { + try { + const userId = parseInt(req.params.id); + if (isNaN(userId)) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + + const user = await databaseService.findUserByIdAsync(userId); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const permissions = await databaseService.getNodeAdminPermissionsForUserAsync(userId); + return res.json({ permissions }); + } catch (error) { + logger.error('Error getting node admin permissions:', error); + return res.status(500).json({ error: 'Failed to get node admin permissions' }); + } +}); + +// Set node admin permissions for a user (replaces all existing) +router.put('/:id/node-admin-permissions', async (req: Request, res: Response) => { + try { + const userId = parseInt(req.params.id); + if (isNaN(userId)) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + + const { nodeNums } = req.body; + if (!Array.isArray(nodeNums) || !nodeNums.every((n: any) => typeof n === 'number')) { + return res.status(400).json({ error: 'nodeNums must be an array of numbers' }); + } + + const user = await databaseService.findUserByIdAsync(userId); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const grantedBy = req.user!.id; + + // Revoke all existing, then grant the new set + await databaseService.revokeAllNodeAdminPermissionsAsync(userId); + for (const nodeNum of nodeNums) { + await databaseService.grantNodeAdminPermissionAsync(userId, Number(nodeNum), grantedBy); + } + + logger.info(`Node admin permissions updated for user ${userId} by ${req.user?.username ?? 'unknown'}: [${nodeNums.join(', ')}]`); + + return res.json({ + success: true, + message: 'Node admin permissions updated successfully' + }); + } catch (error) { + logger.error('Error updating node admin permissions:', error); + return res.status(500).json({ error: 'Failed to update node admin permissions' }); + } +}); + +// Revoke a single node admin permission +router.delete('/:id/node-admin-permissions/:nodeNum', async (req: Request, res: Response) => { + try { + const userId = parseInt(req.params.id); + const nodeNum = parseInt(req.params.nodeNum); + + if (isNaN(userId)) { + return res.status(400).json({ error: 'Invalid user ID' }); + } + if (isNaN(nodeNum)) { + return res.status(400).json({ error: 'Invalid node number' }); + } + + const user = await databaseService.findUserByIdAsync(userId); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const revoked = await databaseService.revokeNodeAdminPermissionAsync(userId, nodeNum); + + if (!revoked) { + return res.status(404).json({ error: 'Permission not found' }); + } + + logger.info(`Node admin permission revoked for user ${userId}, node ${nodeNum} by ${req.user?.username ?? 'unknown'}`); + + return res.json({ + success: true, + message: 'Node admin permission revoked successfully' + }); + } catch (error) { + logger.error('Error revoking node admin permission:', error); + return res.status(500).json({ error: 'Failed to revoke node admin permission' }); + } +}); + // Force-disable MFA for a user (admin only) router.delete('/:id/mfa', async (req: Request, res: Response) => { try { diff --git a/src/server/server.ts b/src/server/server.ts index 7e8ae5e18..5f08a6ade 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -19,7 +19,7 @@ import { normalizeTriggerPatterns } from '../utils/autoResponderUtils.js'; import { getSessionMiddleware } from './auth/sessionConfig.js'; import { initializeWebSocket } from './services/webSocketService.js'; import { initializeOIDC } from './auth/oidcAuth.js'; -import { optionalAuth, requireAuth, requirePermission, requireAdmin, hasPermission } from './auth/authMiddleware.js'; +import { optionalAuth, requireAuth, requirePermission, requireAdmin, requireAdminOrNodeAdmin, hasPermission } from './auth/authMiddleware.js'; import { apiLimiter } from './middleware/rateLimiters.js'; import { setupAccessLogger } from './middleware/accessLogger.js'; import { getEnvironmentConfig, resetEnvironmentConfig } from './config/environment.js'; @@ -6083,7 +6083,7 @@ apiRouter.post('/device/reboot', requirePermission('configuration', 'write'), as // Admin commands endpoint - requires admin role // Admin load config endpoint - requires admin role -apiRouter.post('/admin/load-config', requireAdmin(), async (req, res) => { +apiRouter.post('/admin/load-config', requireAdminOrNodeAdmin(), async (req, res) => { try { const { nodeNum, configType, channelIndex } = req.body; @@ -6565,7 +6565,7 @@ apiRouter.post('/admin/load-config', requireAdmin(), async (req, res) => { // Admin ensure session passkey endpoint - requires admin role // This ensures we have a valid session passkey before making multiple requests -apiRouter.post('/admin/ensure-session-passkey', requireAdmin(), async (req, res) => { +apiRouter.post('/admin/ensure-session-passkey', requireAdminOrNodeAdmin(), async (req, res) => { try { const { nodeNum } = req.body; @@ -6603,7 +6603,7 @@ apiRouter.post('/admin/ensure-session-passkey', requireAdmin(), async (req, res) // Admin get session passkey status - requires admin role // This just checks the status without triggering a new request -apiRouter.post('/admin/session-passkey-status', requireAdmin(), async (req, res) => { +apiRouter.post('/admin/session-passkey-status', requireAdminOrNodeAdmin(), async (req, res) => { try { const { nodeNum } = req.body; @@ -6630,7 +6630,7 @@ apiRouter.post('/admin/session-passkey-status', requireAdmin(), async (req, res) }); // Admin get channel endpoint - requires admin role -apiRouter.post('/admin/get-channel', requireAdmin(), async (req, res) => { +apiRouter.post('/admin/get-channel', requireAdminOrNodeAdmin(), async (req, res) => { try { const { nodeNum, channelIndex } = req.body; @@ -6721,7 +6721,7 @@ apiRouter.post('/admin/get-channel', requireAdmin(), async (req, res) => { }); // Admin load owner endpoint - requires admin role -apiRouter.post('/admin/load-owner', requireAdmin(), async (req, res) => { +apiRouter.post('/admin/load-owner', requireAdminOrNodeAdmin(), async (req, res) => { try { const { nodeNum } = req.body; @@ -6770,7 +6770,7 @@ apiRouter.post('/admin/load-owner', requireAdmin(), async (req, res) => { }); // Admin get device metadata endpoint - requires admin role -apiRouter.post('/admin/get-device-metadata', requireAdmin(), async (req, res) => { +apiRouter.post('/admin/get-device-metadata', requireAdminOrNodeAdmin(), async (req, res) => { try { const { nodeNum } = req.body; @@ -6843,7 +6843,7 @@ apiRouter.post('/admin/get-device-metadata', requireAdmin(), async (req, res) => }); // Admin reboot endpoint - sends reboot command to a node -apiRouter.post('/admin/reboot', requireAdmin(), async (req, res) => { +apiRouter.post('/admin/reboot', requireAdminOrNodeAdmin(), async (req, res) => { try { const { nodeNum, seconds = 10 } = req.body; @@ -6886,7 +6886,7 @@ apiRouter.delete('/admin/suppressed-ghosts/:nodeNum', requireAdmin(), async (req }); // Admin set-time endpoint - sets time on a node to current server time -apiRouter.post('/admin/set-time', requireAdmin(), async (req, res) => { +apiRouter.post('/admin/set-time', requireAdminOrNodeAdmin(), async (req, res) => { try { const { nodeNum } = req.body; @@ -6904,7 +6904,7 @@ apiRouter.post('/admin/set-time', requireAdmin(), async (req, res) => { // Admin commands endpoint - requires admin role // Admin endpoint: Export configuration for remote nodes -apiRouter.post('/admin/export-config', requireAdmin(), async (req, res) => { +apiRouter.post('/admin/export-config', requireAdminOrNodeAdmin(), async (req, res) => { try { const { nodeNum, channelIds, includeLoraConfig } = req.body; @@ -7033,7 +7033,7 @@ apiRouter.post('/admin/export-config', requireAdmin(), async (req, res) => { }); // Admin endpoint: Import configuration for remote nodes -apiRouter.post('/admin/import-config', requireAdmin(), async (req, res) => { +apiRouter.post('/admin/import-config', requireAdminOrNodeAdmin(), async (req, res) => { try { const { nodeNum, url: configUrl } = req.body; @@ -7181,7 +7181,7 @@ apiRouter.post('/admin/import-config', requireAdmin(), async (req, res) => { } }); -apiRouter.post('/admin/commands', requireAdmin(), async (req, res) => { +apiRouter.post('/admin/commands', requireAdminOrNodeAdmin(), async (req, res) => { try { const { command, nodeNum, ...params } = req.body; diff --git a/src/services/database.ts b/src/services/database.ts index 3a42a91e2..2b68b2d4d 100644 --- a/src/services/database.ts +++ b/src/services/database.ts @@ -9852,6 +9852,28 @@ class DatabaseService { } } + // ============ NODE ADMIN PERMISSION METHODS ============ + + async hasNodeAdminPermissionAsync(userId: number, nodeNum: number): Promise { + return this.auth.hasNodeAdminPermission(userId, nodeNum); + } + + async getNodeAdminPermissionsForUserAsync(userId: number): Promise<{ nodeNum: number; grantedAt: number; grantedBy: number | null }[]> { + return this.auth.getNodeAdminPermissionsForUser(userId); + } + + async grantNodeAdminPermissionAsync(userId: number, nodeNum: number, grantedBy: number): Promise { + return this.auth.grantNodeAdminPermission(userId, nodeNum, grantedBy); + } + + async revokeNodeAdminPermissionAsync(userId: number, nodeNum: number): Promise { + return this.auth.revokeNodeAdminPermission(userId, nodeNum); + } + + async revokeAllNodeAdminPermissionsAsync(userId: number): Promise { + return this.auth.revokeAllNodeAdminPermissions(userId); + } + // ============ ASYNC MESSAGE METHODS ============ // These methods provide async access to message operations for multi-database support