Files
MIKROTIK-RMM/backend/src/db/migrate.ts
T
2GT-RichandClaude Opus 4.8 c0f230bdf2 feat(auth): OIDC / SSO single sign-on (v0.17.0-beta)
Add OpenID Connect single sign-on so the platform can authenticate against
any standards-compliant provider (Entra ID, Okta, Google Workspace, Keycloak,
Authentik, …) alongside local accounts. Resolves the feature request in #73.

Flow (backend-driven Authorization Code + PKCE):
- GET /api/auth/oidc/login builds the authorize URL and stores the PKCE/nonce
  transaction in Redis (single-use, 10m TTL); the callback validates the ID
  token via openid-client (JWKS signature, iss/aud/exp/nonce), resolves the
  identity to a local user, and mints the platform's normal session JWT —
  handed to the SPA via URL fragment. No sessions/cookies introduced.

Identity mapping (users table gains email/oidc_subject/oidc_issuer/
auth_provider; password_hash now nullable; partial unique indexes):
- First sign-in auto-provisions a user, or links to an existing local account
  when the IdP email is verified and matches.
- Roles derive from a configurable IdP group -> role map with a default
  fallback; existing users are never auto-demoted on a groupless login.

Configuration (admin-only, no env/redeploy needed):
- Stored as an app_settings row with the client secret encrypted at rest
  (utils/crypto); masked in responses and excluded from GET /api/settings.
- New UI: Settings -> SSO / OIDC panel (issuer/client, claim + group->role
  mapping, provisioning toggles, discovery test, copyable redirect URI), a
  login-page SSO button, and an SSO badge in Users & Roles.

Security: local break-glass login preserved; PKCE + single-use state; email
linking requires email_verified; same-site-only post-login redirect.

Adds openid-client@^5 (CommonJS). 17 new unit tests (claim/role mapping,
username derivation, config secret round-trip/masking); 114 backend tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:54:36 -07:00

569 lines
23 KiB
TypeScript

import { pool } from '../config/database';
import bcrypt from 'bcryptjs';
const MIGRATION_SQL = `
-- Users
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'admin',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Devices
CREATE TABLE IF NOT EXISTS devices (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
ip_address VARCHAR(45) NOT NULL,
api_port INTEGER NOT NULL DEFAULT 8728,
ssh_port INTEGER NOT NULL DEFAULT 22,
api_username VARCHAR(50) NOT NULL,
api_password_encrypted TEXT NOT NULL,
ssh_username VARCHAR(50),
ssh_password_encrypted TEXT,
model VARCHAR(100),
serial_number VARCHAR(50),
firmware_version VARCHAR(50),
ros_version VARCHAR(20),
device_type VARCHAR(20) DEFAULT 'router',
status VARCHAR(20) DEFAULT 'unknown',
last_seen TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Device full config snapshots
CREATE TABLE IF NOT EXISTS device_configs (
id SERIAL PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
config_json JSONB NOT NULL,
collected_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_device_configs_device ON device_configs(device_id, collected_at DESC);
-- Network interfaces
CREATE TABLE IF NOT EXISTS interfaces (
id SERIAL PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
name VARCHAR(50) NOT NULL,
type VARCHAR(30),
mac_address VARCHAR(17),
mtu INTEGER,
running BOOLEAN DEFAULT FALSE,
disabled BOOLEAN DEFAULT FALSE,
comment TEXT,
speed VARCHAR(20),
full_duplex BOOLEAN,
config_json JSONB,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(device_id, name)
);
-- VLANs
CREATE TABLE IF NOT EXISTS vlans (
id SERIAL PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
vlan_id INTEGER NOT NULL,
name VARCHAR(100),
bridge VARCHAR(50),
tagged_ports TEXT[],
untagged_ports TEXT[],
config_json JSONB,
UNIQUE(device_id, vlan_id)
);
-- Bridge VLAN table entries (for switch port VLAN mapping)
CREATE TABLE IF NOT EXISTS bridge_vlan_entries (
id SERIAL PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
bridge VARCHAR(50) NOT NULL,
port VARCHAR(50) NOT NULL,
vlan_ids TEXT[],
pvid INTEGER,
tagged BOOLEAN DEFAULT FALSE,
config_json JSONB,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(device_id, bridge, port)
);
-- Network clients (ARP/DHCP/wireless leases)
CREATE TABLE IF NOT EXISTS clients (
id SERIAL PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
mac_address VARCHAR(17) NOT NULL,
hostname VARCHAR(255),
ip_address VARCHAR(45),
interface_name VARCHAR(50),
tx_bytes BIGINT DEFAULT 0,
rx_bytes BIGINT DEFAULT 0,
signal_strength INTEGER,
comment TEXT,
client_type VARCHAR(20) DEFAULT 'wired',
active BOOLEAN DEFAULT FALSE,
last_seen TIMESTAMPTZ,
UNIQUE(device_id, mac_address)
);
CREATE INDEX IF NOT EXISTS idx_clients_active ON clients(active, last_seen DESC);
CREATE INDEX IF NOT EXISTS idx_clients_mac ON clients(mac_address);
-- Events and alerts
CREATE TABLE IF NOT EXISTS events (
id SERIAL PRIMARY KEY,
device_id INTEGER REFERENCES devices(id) ON DELETE CASCADE,
event_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
severity VARCHAR(20) NOT NULL DEFAULT 'info',
topic VARCHAR(100),
message TEXT NOT NULL,
raw_json JSONB
);
CREATE INDEX IF NOT EXISTS idx_events_device_time ON events(device_id, event_time DESC);
CREATE INDEX IF NOT EXISTS idx_events_time ON events(event_time DESC);
CREATE INDEX IF NOT EXISTS idx_events_severity ON events(severity, event_time DESC);
-- Backups
CREATE TABLE IF NOT EXISTS backups (
id SERIAL PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
file_path VARCHAR(500) NOT NULL,
size_bytes INTEGER,
backup_type VARCHAR(20) DEFAULT 'manual',
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Topology links (discovered via LLDP/CDP/neighbor)
CREATE TABLE IF NOT EXISTS topology_links (
id SERIAL PRIMARY KEY,
from_device_id INTEGER REFERENCES devices(id) ON DELETE CASCADE,
from_interface VARCHAR(50),
to_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
to_interface VARCHAR(50),
neighbor_address VARCHAR(45),
neighbor_identity VARCHAR(255),
neighbor_platform VARCHAR(255),
link_type VARCHAR(20) DEFAULT 'lldp',
discovered_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(from_device_id, from_interface)
);
-- Application settings
CREATE TABLE IF NOT EXISTS app_settings (
key VARCHAR(100) PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Incremental schema updates
ALTER TABLE clients ADD COLUMN IF NOT EXISTS vendor VARCHAR(255);
ALTER TABLE clients ADD COLUMN IF NOT EXISTS vlan_id INTEGER;
ALTER TABLE clients ADD COLUMN IF NOT EXISTS custom_name VARCHAR(255);
-- first_seen: when this client was first discovered ("connected since"); set once, preserved across polls
ALTER TABLE clients ADD COLUMN IF NOT EXISTS first_seen TIMESTAMPTZ;
UPDATE clients SET first_seen = last_seen WHERE first_seen IS NULL;
-- custom_category: user override of the fingerprinted device category
ALTER TABLE clients ADD COLUMN IF NOT EXISTS custom_category VARCHAR(30);
-- Firmware orchestration: staged fleet upgrades in waves
CREATE TABLE IF NOT EXISTS firmware_rollouts (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending|running|completed|failed|cancelled
halt_on_failure BOOLEAN NOT NULL DEFAULT TRUE,
pre_backup BOOLEAN NOT NULL DEFAULT TRUE,
scheduled_at TIMESTAMPTZ,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS firmware_rollout_devices (
id SERIAL PRIMARY KEY,
rollout_id INTEGER NOT NULL REFERENCES firmware_rollouts(id) ON DELETE CASCADE,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
wave INTEGER NOT NULL DEFAULT 1,
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending|backing_up|upgrading|rebooting|verifying|success|failed|skipped
from_version VARCHAR(30),
to_version VARCHAR(30),
error TEXT,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_fw_rollout_devices ON firmware_rollout_devices(rollout_id, wave, id);
-- Platform & automation: scoped API tokens, outbound webhooks, scheduled reports
CREATE TABLE IF NOT EXISTS api_tokens (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
token_hash VARCHAR(64) NOT NULL UNIQUE,
prefix VARCHAR(12) NOT NULL,
scope VARCHAR(10) NOT NULL DEFAULT 'read', -- read|write
created_by VARCHAR(50),
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS webhooks (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
url TEXT NOT NULL,
secret VARCHAR(128),
events TEXT[] NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_status INTEGER,
last_fired_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS report_schedules (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
frequency VARCHAR(10) NOT NULL DEFAULT 'weekly', -- daily|weekly|monthly
recipients TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_sent_at TIMESTAMPTZ,
next_run_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE events ADD COLUMN IF NOT EXISTS log_id VARCHAR(20);
CREATE UNIQUE INDEX IF NOT EXISTS idx_events_device_log_id ON events(device_id, log_id);
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS neighbor_mac VARCHAR(17);
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS stp_role VARCHAR(20);
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS stp_state VARCHAR(20);
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS bridge_name VARCHAR(50);
ALTER TABLE devices ADD COLUMN IF NOT EXISTS location_address TEXT;
ALTER TABLE devices ADD COLUMN IF NOT EXISTS location_lat NUMERIC(10,7);
ALTER TABLE devices ADD COLUMN IF NOT EXISTS location_lng NUMERIC(10,7);
ALTER TABLE devices ADD COLUMN IF NOT EXISTS rack_name VARCHAR(100);
ALTER TABLE devices ADD COLUMN IF NOT EXISTS rack_slot VARCHAR(20);
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS neighbor_caps VARCHAR(255);
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS discovered_by VARCHAR(50);
-- RouterOS can return a long comma-separated discovered-by list; 50 chars was too small.
ALTER TABLE topology_links ALTER COLUMN discovered_by TYPE VARCHAR(512);
-- Interface names from neighbor discovery can exceed 50 chars (long bridge/bond names).
ALTER TABLE topology_links ALTER COLUMN from_interface TYPE VARCHAR(512);
ALTER TABLE topology_links ALTER COLUMN to_interface TYPE VARCHAR(512);
ALTER TABLE devices ADD COLUMN IF NOT EXISTS firmware_update_available BOOLEAN DEFAULT FALSE;
ALTER TABLE devices ADD COLUMN IF NOT EXISTS latest_ros_version VARCHAR(20);
ALTER TABLE devices ADD COLUMN IF NOT EXISTS routerboard_upgrade_available BOOLEAN DEFAULT FALSE;
ALTER TABLE devices ADD COLUMN IF NOT EXISTS upgrade_firmware_version VARCHAR(20);
-- Config history / drift detection: each snapshot stores the canonical /export
-- .rsc text (config_text), deduped by content hash, and links to the restorable
-- backup that holds the same .rsc. The snapshot and its backup are one artifact,
-- so deleting the backup cascades to remove the snapshot (kept consistent).
ALTER TABLE device_configs ADD COLUMN IF NOT EXISTS config_hash VARCHAR(64);
ALTER TABLE device_configs ADD COLUMN IF NOT EXISTS change_summary TEXT;
ALTER TABLE device_configs ADD COLUMN IF NOT EXISTS config_text TEXT;
ALTER TABLE device_configs ADD COLUMN IF NOT EXISTS backup_id INTEGER REFERENCES backups(id) ON DELETE SET NULL;
-- Upgrade the backup_id FK from SET NULL to CASCADE so a snapshot and its backup
-- stay in lockstep (deleting the backup removes the now-unrestorable snapshot).
ALTER TABLE device_configs DROP CONSTRAINT IF EXISTS device_configs_backup_id_fkey;
ALTER TABLE device_configs ADD CONSTRAINT device_configs_backup_id_fkey
FOREIGN KEY (backup_id) REFERENCES backups(id) ON DELETE CASCADE;
-- Cached IPv4/IPv6 addresses from /ip/address (per device) for topology resolution:
-- neighbors seen only by IP (CDP/MNDP) can be matched to managed devices even
-- when the address is not the device's management IP.
ALTER TABLE devices ADD COLUMN IF NOT EXISTS ip_addresses_jsonb JSONB;
-- Allow multiple neighbors per interface (one row per neighbor, not per port)
ALTER TABLE topology_links DROP CONSTRAINT IF EXISTS topology_links_from_device_id_from_interface_key;
-- Alert rules — one row per event type
CREATE TABLE IF NOT EXISTS alert_rules (
event_type VARCHAR(50) PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false,
threshold INTEGER,
cooldown_min INTEGER NOT NULL DEFAULT 15,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Alert channels — email / Slack / Discord / Telegram
CREATE TABLE IF NOT EXISTS alert_channels (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('email','slack','discord','telegram')),
enabled BOOLEAN NOT NULL DEFAULT true,
config JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Alert send history
CREATE TABLE IF NOT EXISTS alert_history (
id SERIAL PRIMARY KEY,
event_type VARCHAR(50) NOT NULL,
device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
device_name VARCHAR(255),
message TEXT NOT NULL,
channels_notified JSONB,
sent_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_alert_history_sent ON alert_history(sent_at DESC);
-- Wireless interfaces (radio hardware config + SSID settings)
CREATE TABLE IF NOT EXISTS wireless_interfaces (
id SERIAL PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
name VARCHAR(50) NOT NULL,
ssid VARCHAR(100),
mode VARCHAR(30),
band VARCHAR(50),
frequency INTEGER,
channel_width VARCHAR(30),
tx_power INTEGER,
tx_power_mode VARCHAR(30),
antenna_gain INTEGER,
country VARCHAR(50),
installation VARCHAR(20) DEFAULT 'indoor',
disabled BOOLEAN DEFAULT FALSE,
running BOOLEAN DEFAULT FALSE,
mac_address VARCHAR(17),
security_profile VARCHAR(100),
noise_floor INTEGER,
registered_clients INTEGER DEFAULT 0,
config_json JSONB,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(device_id, name)
);
-- Spectral scan snapshots
CREATE TABLE IF NOT EXISTS spectral_scan_data (
id SERIAL PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
interface_name TEXT NOT NULL,
scanned_at TIMESTAMPTZ DEFAULT NOW(),
data JSONB NOT NULL,
scan_type TEXT DEFAULT 'scheduled'
);
CREATE INDEX IF NOT EXISTS idx_spectral_scan_device
ON spectral_scan_data(device_id, interface_name, scanned_at DESC);
-- AP scan results (nearby access points discovered by wireless scan)
CREATE TABLE IF NOT EXISTS ap_scan_data (
id SERIAL PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
scanned_at TIMESTAMPTZ DEFAULT NOW(),
data JSONB NOT NULL,
scan_type TEXT DEFAULT 'scheduled'
);
CREATE INDEX IF NOT EXISTS idx_ap_scan_device
ON ap_scan_data(device_id, scanned_at DESC);
-- Device credential presets — reusable API/SSH credential sets, referenced
-- by name when adding or editing a managed device. Passwords are stored
-- encrypted at rest (same scheme as devices.api_password_encrypted) so the
-- plaintext never leaves the backend.
CREATE TABLE IF NOT EXISTS credential_presets (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
api_username VARCHAR(50) NOT NULL,
api_password_encrypted TEXT NOT NULL,
api_port INTEGER,
ssh_username VARCHAR(50),
ssh_password_encrypted TEXT,
ssh_port INTEGER,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE credential_presets ADD COLUMN IF NOT EXISTS allow_operator_use BOOLEAN NOT NULL DEFAULT TRUE;
-- Maintenance windows — suppress alerts for planned downtime
CREATE TABLE IF NOT EXISTS maintenance_windows (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
device_ids INTEGER[] NOT NULL DEFAULT '{}',
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
recurring_cron VARCHAR(100),
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_maintenance_windows_active ON maintenance_windows(active, start_at, end_at);
-- Device tags
CREATE TABLE IF NOT EXISTS tags (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
color VARCHAR(20) NOT NULL DEFAULT '#6366f1',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS device_tags (
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (device_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_device_tags_tag ON device_tags(tag_id);
-- Audit log — records all write operations performed by authenticated users
CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY,
user_id INTEGER,
username VARCHAR(50),
method VARCHAR(10) NOT NULL,
path TEXT NOT NULL,
entity_type VARCHAR(50),
entity_id INTEGER,
summary TEXT,
ip_address VARCHAR(45),
status_code INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log(entity_type, entity_id, created_at DESC);
-- Device availability (offline/online outage tracking)
CREATE TABLE IF NOT EXISTS device_availability (
id SERIAL PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
went_offline_at TIMESTAMPTZ NOT NULL,
came_back_online_at TIMESTAMPTZ,
duration_seconds INTEGER
);
CREATE INDEX IF NOT EXISTS idx_device_availability_device ON device_availability(device_id, went_offline_at DESC);
-- Manual topology links — user-drawn connections for devices with no auto-discovery
CREATE TABLE IF NOT EXISTS manual_topology_links (
id SERIAL PRIMARY KEY,
from_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
to_device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
label VARCHAR(100),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(from_device_id, to_device_id)
);
CREATE INDEX IF NOT EXISTS idx_manual_topology_links_from ON manual_topology_links(from_device_id);
CREATE INDEX IF NOT EXISTS idx_manual_topology_links_to ON manual_topology_links(to_device_id);
-- Configuration templates (reusable config sets pushed to devices or groups)
CREATE TABLE IF NOT EXISTS config_templates (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
applies_to_type VARCHAR(20),
template_json JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- TOTP two-factor authentication
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(64);
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT false;
-- OIDC / SSO identity mapping. Local users keep password_hash; SSO users are
-- keyed by (oidc_issuer, oidc_subject) and have a null password_hash.
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_subject VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_issuer VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS auth_provider VARCHAR(20) NOT NULL DEFAULT 'local';
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc ON users (oidc_issuer, oidc_subject) WHERE oidc_subject IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_lower ON users (lower(email)) WHERE email IS NOT NULL;
-- Per-client daily traffic rollups from the NetFlow collector. mac_address
-- also holds the pseudo-clients 'unknown' (unmapped local IPs) and 'other'
-- (clients folded by the top-N cardinality cap).
CREATE TABLE IF NOT EXISTS client_traffic_daily (
mac_address VARCHAR(17) NOT NULL,
day DATE NOT NULL,
upload_bytes BIGINT NOT NULL DEFAULT 0,
download_bytes BIGINT NOT NULL DEFAULT 0,
app_breakdown JSONB NOT NULL DEFAULT '{}',
PRIMARY KEY (mac_address, day)
);
CREATE INDEX IF NOT EXISTS idx_client_traffic_daily_day ON client_traffic_daily(day DESC);
-- Wireless security profiles (WPA/WPA2/WPA3 config)
CREATE TABLE IF NOT EXISTS wireless_security_profiles (
id SERIAL PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
mode VARCHAR(30) DEFAULT 'none',
authentication_types TEXT[] DEFAULT '{}',
unicast_ciphers TEXT[] DEFAULT '{}',
group_ciphers TEXT[] DEFAULT '{}',
management_protection VARCHAR(20) DEFAULT 'disabled',
config_json JSONB,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(device_id, name)
);
`;
const DEFAULT_SETTINGS = [
{ key: 'polling_fast_interval', value: 30 },
{ key: 'polling_slow_interval', value: 300 },
{ key: 'polling_logs_interval', value: 60 },
{ key: 'retention_events_days', value: 30 },
{ key: 'backup_schedule_enabled', value: false },
{ key: 'backup_schedule_cron', value: '0 2 * * *' },
{ key: 'mac_scan_enabled', value: true },
{ key: 'mac_scan_interval', value: 300 },
{ key: 'reverse_dns_enabled', value: false },
{ key: 'retention_clients_days', value: 7 },
{ key: 'spectral_scan_enabled', value: false },
{ key: 'spectral_scan_interval_hours', value: 24 },
{ key: 'ap_scan_enabled', value: false },
{ key: 'ap_scan_interval_hours', value: 24 },
{ key: 'login_rate_limit_window_sec', value: 60 },
{ key: 'login_rate_limit_max', value: 10 },
{ key: 'config_snapshot_enabled', value: true },
{ key: 'config_snapshot_interval_min', value: 60 },
{ key: 'config_snapshot_retention', value: 30 },
{ key: 'netflow_enabled', value: false },
{ key: 'netflow_collector_address', value: '' },
{ key: 'netflow_collector_port', value: 2055 },
{ key: 'netflow_version', value: '9' },
{ key: 'netflow_active_timeout', value: '1m' },
{ key: 'netflow_inactive_timeout', value: '15s' },
{ key: 'netflow_topn_clients', value: 50 },
{ key: 'netflow_accept_unknown', value: true },
{ key: 'netflow_retention_days', value: 30 },
{ key: 'netflow_daily_retention_days', value: 365 },
];
export async function runMigrations(): Promise<void> {
console.log('Running database migrations...');
const client = await pool.connect();
try {
await client.query(MIGRATION_SQL);
console.log('Schema created/verified');
// Insert default settings
for (const setting of DEFAULT_SETTINGS) {
await client.query(
`INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
[setting.key, JSON.stringify(setting.value)]
);
}
// Create default admin user if no users exist
const userCount = await client.query('SELECT COUNT(*) FROM users');
if (parseInt(userCount.rows[0].count, 10) === 0) {
const hash = await bcrypt.hash('admin', 12);
await client.query(
`INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3)`,
['admin', hash, 'admin']
);
console.log('Default admin user created (username: admin, password: admin)');
console.log('⚠️ Please change the default password after first login!');
}
console.log('Database migrations completed successfully');
} finally {
client.release();
}
}
// Run directly if called as script
if (require.main === module) {
runMigrations()
.then(() => process.exit(0))
.catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});
}