Skip to content

Feature Request: Implement IP Whitelisting #269

@mustafaneguib

Description

@mustafaneguib

Implement IP Whitelisting

Priority

LOW - Business+ tier, security feature

Labels

enhancement, security, backend

Description

Allow organizations to restrict API and dashboard access to specific IP addresses or CIDR ranges. Essential for enterprise security policies.

Current State

  • No IP-based access control
  • Rate limiting uses IP for identification
  • No IP whitelist/blacklist functionality

Implementation Overview

Database Schema

// backend/src/models/DRAIPWhitelist.ts
@Entity('dra_ip_whitelists')
export class DRAIPWhitelist {
    @PrimaryGeneratedColumn()
    id!: number;

    @ManyToOne(() => DRAOrganization, { onDelete: 'CASCADE' })
    @JoinColumn({ name: 'organization_id' })
    organization!: Relation<DRAOrganization>;

    @Column({ type: 'varchar', length: 100 })
    ip_address!: string; // Supports CIDR notation: 192.168.1.0/24

    @Column({ type: 'varchar', length: 255, nullable: true })
    description!: string | null; // e.g., "Office VPN"

    @Column({ type: 'boolean', default: true })
    is_active!: boolean;

    @ManyToOne(() => DRAUsersPlatform)
    @JoinColumn({ name: 'created_by_user_id' })
    created_by!: Relation<DRAUsersPlatform>;

    @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
    created_at!: Date;

    @Column({ type: 'timestamp', nullable: true })
    last_used_at!: Date | null;

    @Column({ type: 'int', default: 0 })
    usage_count!: number;
}

IP Matching Service

// backend/src/services/IPWhitelistService.ts
import ipaddr from 'ipaddr.js';

export class IPWhitelistService {
    private static instance: IPWhitelistService;
    
    public static getInstance(): IPWhitelistService {
        if (!IPWhitelistService.instance) {
            IPWhitelistService.instance = new IPWhitelistService();
        }
        return IPWhitelistService.instance;
    }
    
    /**
     * Check if IP is whitelisted for organization
     */
    async isWhitelisted(
        organizationId: number,
        ipAddress: string
    ): Promise<boolean> {
        const driver = await DBDriver.getInstance().getDriver(EDataSourceType.POSTGRESQL);
        const manager = (await driver.getConcreteDriver()).manager;
        
        // Get all active whitelist entries
        const entries = await manager.find(DRAIPWhitelist, {
            where: {
                organization: { id: organizationId },
                is_active: true
            }
        });
        
        // If no entries, allow all (whitelist not enabled)
        if (entries.length === 0) {
            return true;
        }
        
        // Check if IP matches any entry
        for (const entry of entries) {
            if (this.matchesIPOrCIDR(ipAddress, entry.ip_address)) {
                // Update usage stats
                await this.recordUsage(entry.id);
                return true;
            }
        }
        
        return false;
    }
    
    /**
     * Match IP against IP or CIDR range
     */
    private matchesIPOrCIDR(requestIP: string, whitelistEntry: string): boolean {
        try {
            const addr = ipaddr.process(requestIP);
            
            // Check if entry is CIDR notation
            if (whitelistEntry.includes('/')) {
                const [network, prefix] = whitelistEntry.split('/');
                const networkAddr = ipaddr.process(network);
                const prefixLength = parseInt(prefix);
                
                return addr.match(networkAddr, prefixLength);
            } else {
                // Exact IP match
                return requestIP === whitelistEntry;
            }
        } catch (error) {
            console.error('IP matching error:', error);
            return false;
        }
    }
    
    /**
     * Record IP usage
     */
    private async recordUsage(whitelistId: number): Promise<void> {
        const driver = await DBDriver.getInstance().getDriver(EDataSourceType.POSTGRESQL);
        const manager = (await driver.getConcreteDriver()).manager;
        
        await manager.increment(
            DRAIPWhitelist,
            { id: whitelistId },
            'usage_count',
            1
        );
        
        await manager.update(DRAIPWhitelist, { id: whitelistId }, {
            last_used_at: new Date()
        });
    }
    
    /**
     * Add IP to whitelist
     */
    async addIP(
        organizationId: number,
        ipAddress: string,
        description: string,
        userId: number
    ): Promise<DRAIPWhitelist> {
        // Validate IP/CIDR format
        if (!this.isValidIPOrCIDR(ipAddress)) {
            throw new Error('Invalid IP address or CIDR notation');
        }
        
        const driver = await DBDriver.getInstance().getDriver(EDataSourceType.POSTGRESQL);
        const manager = (await driver.getConcreteDriver()).manager;
        
        const entry = new DRAIPWhitelist();
        entry.organization = { id: organizationId } as any;
        entry.ip_address = ipAddress;
        entry.description = description;
        entry.created_by = { id: userId } as any;
        
        return manager.save(entry);
    }
    
    /**
     * Validate IP or CIDR format
     */
    private isValidIPOrCIDR(input: string): boolean {
        try {
            if (input.includes('/')) {
                const [ip, prefix] = input.split('/');
                ipaddr.process(ip);
                const prefixNum = parseInt(prefix);
                return prefixNum >= 0 && prefixNum <= 32;
            } else {
                ipaddr.process(input);
                return true;
            }
        } catch {
            return false;
        }
    }
}

Middleware

// backend/src/middleware/ipWhitelist.ts
export function checkIPWhitelist() {
    return async (req: Request, res: Response, next: NextFunction) => {
        try {
            const organizationId = req.body.organizationContext?.organizationId;
            
            if (!organizationId) {
                return next();
            }
            
            const clientIP = req.ip || req.socket.remoteAddress || 'unknown';
            
            const ipWhitelistService = IPWhitelistService.getInstance();
            const isAllowed = await ipWhitelistService.isWhitelisted(organizationId, clientIP);
            
            if (!isAllowed) {
                console.warn(`❌ IP ${clientIP} not whitelisted for org ${organizationId}`);
                
                // Log security event
                await AuditLogService.getInstance().logEvent({
                    eventType: 'security',
                    action: 'ip_whitelist_violation',
                    userId: req.body.tokenDetails?.user_id,
                    ipAddress: clientIP,
                    metadata: { organizationId },
                    severity: 'warning',
                    success: false,
                    errorMessage: 'IP not whitelisted'
                });
                
                return res.status(403).json({
                    success: false,
                    message: 'Access denied: IP address not whitelisted',
                    code: 'IP_NOT_WHITELISTED'
                });
            }
            
            next();
        } catch (error) {
            console.error('IP whitelist check error:', error);
            // Fail open (allow access) on error to prevent lockouts
            next();
        }
    };
}

// Apply to sensitive routes
app.use('/admin', authenticate, organizationContext(), checkIPWhitelist());
app.use('/data-source', authenticate, organizationContext(), checkIPWhitelist());

API Routes

// backend/src/routes/admin/ip_whitelist.ts
router.get('/ip-whitelist',
    authenticate,
    authorizeOrgAdmin(),
    async (req, res) => {
        const { organizationId } = req.body.organizationContext;
        
        const driver = await DBDriver.getInstance().getDriver(EDataSourceType.POSTGRESQL);
        const manager = (await driver.getConcreteDriver()).manager;
        
        const entries = await manager.find(DRAIPWhitelist, {
            where: { organization: { id: organizationId } },
            relations: ['created_by'],
            order: { created_at: 'DESC' }
        });
        
        res.json({ success: true, data: entries });
    }
);

router.post('/ip-whitelist',
    authenticate,
    authorizeOrgAdmin(),
    [
        body('ipAddress').notEmpty().isString(),
        body('description').optional().isString()
    ],
    async (req, res) => {
        try {
            const { user_id } = req.body.tokenDetails;
            const { organizationId } = req.body.organizationContext;
            const { ipAddress, description } = req.body;
            
            const ipWhitelistService = IPWhitelistService.getInstance();
            const entry = await ipWhitelistService.addIP(
                organizationId,
                ipAddress,
                description || '',
                user_id
            );
            
            res.json({ success: true, data: entry });
        } catch (error: any) {
            res.status(400).json({
                success: false,
                message: error.message
            });
        }
    }
);

router.delete('/ip-whitelist/:id',
    authenticate,
    authorizeOrgAdmin(),
    async (req, res) => {
        const entryId = parseInt(req.params.id);
        const { organizationId } = req.body.organizationContext;
        
        const driver = await DBDriver.getInstance().getDriver(EDataSourceType.POSTGRESQL);
        const manager = (await driver.getConcreteDriver()).manager;
        
        await manager.delete(DRAIPWhitelist, {
            id: entryId,
            organization: { id: organizationId }
        });
        
        res.json({ success: true, message: 'IP removed from whitelist' });
    }
);

Testing

describe('IPWhitelistService', () => {
    it('should allow whitelisted IP', async () => {
        await service.addIP(orgId, '192.168.1.100', 'Office', userId);
        const allowed = await service.isWhitelisted(orgId, '192.168.1.100');
        expect(allowed).toBe(true);
    });
    
    it('should block non-whitelisted IP', async () => {
        await service.addIP(orgId, '192.168.1.100', 'Office', userId);
        const allowed = await service.isWhitelisted(orgId, '192.168.1.200');
        expect(allowed).toBe(false);
    });
    
    it('should support CIDR notation', async () => {
        await service.addIP(orgId, '192.168.1.0/24', 'Office Network', userId);
        const allowed = await service.isWhitelisted(orgId, '192.168.1.150');
        expect(allowed).toBe(true);
    });
    
    it('should allow all if no whitelist entries', async () => {
        const allowed = await service.isWhitelisted(orgId, '10.0.0.1');
        expect(allowed).toBe(true);
    });
});

Frontend UI

<template>
    <div class="ip-whitelist-manager">
        <h2>IP Whitelist</h2>
        <p>Restrict access to specific IP addresses</p>
        
        <div class="add-ip">
            <input v-model="newIP" placeholder="192.168.1.0/24" />
            <input v-model="description" placeholder="Description" />
            <button @click="addIP">Add IP</button>
        </div>
        
        <table class="whitelist-table">
            <thead>
                <tr>
                    <th>IP/CIDR</th>
                    <th>Description</th>
                    <th>Last Used</th>
                    <th>Usage Count</th>
                    <th>Actions</th>
                </tr>
            </thead>
            <tbody>
                <tr v-for="entry in entries" :key="entry.id">
                    <td>{{ entry.ip_address }}</td>
                    <td>{{ entry.description }}</td>
                    <td>{{ formatDate(entry.last_used_at) }}</td>
                    <td>{{ entry.usage_count }}</td>
                    <td>
                        <button @click="removeIP(entry.id)">Remove</button>
                    </td>
                </tr>
            </tbody>
        </table>
    </div>
</template>

Estimated Effort

  • Database schema: 2 hours
  • IP matching service: 4 hours
  • Middleware: 3 hours
  • API routes: 3 hours
  • Frontend UI: 4 hours
  • Testing: 4 hours
  • Total: ~20 hours (2-3 developer days)

Success Criteria

  • Blocked IPs cannot access API
  • CIDR ranges work correctly
  • IPv6 support
  • No false positives (legitimate IPs blocked)
  • Clear error messages
  • Audit logging of violations
  • Admin can manage whitelist easily

Security Notes

  • Fail Open: On error, allow access (prevent lockouts)
  • Bypass for Superadmin: Platform admins bypass whitelist
  • Emergency Access: Provide override mechanism
  • Logging: Log all whitelist violations

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions