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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/db/activeSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -162,6 +167,9 @@ export interface ActiveSchema {
// Embed Profiles
embedProfiles: any;

// Node Admin Permissions
nodeAdminPermissions: any;

// Allow dynamic access for flexibility
[key: string]: any;
}
Expand Down Expand Up @@ -209,6 +217,7 @@ const SCHEMA_MAP: Record<DatabaseType, ActiveSchema> = {
meshcoreNodes: meshcoreNodesSqlite,
meshcoreMessages: meshcoreMessagesSqlite,
embedProfiles: embedProfilesSqlite,
nodeAdminPermissions: nodeAdminPermissionsSqlite,
},
postgres: {
nodes: nodesPostgres,
Expand Down Expand Up @@ -249,6 +258,7 @@ const SCHEMA_MAP: Record<DatabaseType, ActiveSchema> = {
meshcoreNodes: meshcoreNodesPostgres,
meshcoreMessages: meshcoreMessagesPostgres,
embedProfiles: embedProfilesPostgres,
nodeAdminPermissions: nodeAdminPermissionsPostgres,
},
mysql: {
nodes: nodesMysql,
Expand Down Expand Up @@ -289,6 +299,7 @@ const SCHEMA_MAP: Record<DatabaseType, ActiveSchema> = {
meshcoreNodes: meshcoreNodesMysql,
meshcoreMessages: meshcoreMessagesMysql,
embedProfiles: embedProfilesMysql,
nodeAdminPermissions: nodeAdminPermissionsMysql,
},
};

Expand Down
12 changes: 6 additions & 6 deletions src/db/migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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);
Expand Down
17 changes: 16 additions & 1 deletion src/db/migrations.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
});
57 changes: 57 additions & 0 deletions src/db/repositories/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -834,4 +834,61 @@ export class AuthRepository extends BaseRepository {
}
}
}

// ============ NODE ADMIN PERMISSIONS ============

async hasNodeAdminPermission(userId: number, nodeNum: number): Promise<boolean> {
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<void> {
const { nodeAdminPermissions } = this.tables;
await this.insertIgnore(nodeAdminPermissions, {
userId,
nodeNum,
grantedBy,
grantedAt: Date.now(),
});
}

async revokeNodeAdminPermission(userId: number, nodeNum: number): Promise<boolean> {
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<number> {
const { nodeAdminPermissions } = this.tables;
const result = await this.db
.delete(nodeAdminPermissions)
.where(eq(nodeAdminPermissions.userId, userId));
return this.getAffectedRows(result);
}
}
3 changes: 3 additions & 0 deletions src/db/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ export * from './meshcoreMessages.js';

// Embed Profiles table
export * from './embedProfiles.js';

// Node Admin Permissions table
export * from './nodeAdminPermissions.js';
53 changes: 53 additions & 0 deletions src/db/schema/nodeAdminPermissions.ts
Original file line number Diff line number Diff line change
@@ -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;
77 changes: 77 additions & 0 deletions src/server/auth/authMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down
Loading