Skip to content

Feature Request: Implement IP Whitelisting #269

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

    Fields

    No fields configured for Feature.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions