fix: input validation, SSRF, API hardening, and cookie security

- LIKE wildcard injection: sanitize %, _ in search queries (devices, sites, gaiia)
- Jason.decode! → Jason.decode with error handling for untrusted input
- inspect() leak: replace with generic error messages, log details server-side
- SSRF protection: URL validator blocks private IPs, localhost, non-HTTP schemes
- SSRF validation added to HTTP monitoring executor and integration credentials
- GraphQL complexity limits: always applied, not just in prod
- GraphQL introspection: also check GET query params, not just body
- Stripe webhook: explicit nil/empty checks for signature and body
- Cookie security: secure flag for session (prod), http_only+secure for remember_me
- Honeybadger API key: read from env var with fallback
- String.to_integer → Integer.parse with fallback for URL params
- String.to_atom → whitelist map for HTTP methods
- Gaiia webhook: remove secret_len and expected signature from log
- Admin API: add rate limiting pipeline
- to_atom_keys: per-key fallback instead of all-or-nothing rescue
This commit is contained in:
Graham McIntire 2026-03-14 14:48:53 -05:00
parent fd8f39451c
commit 1590f78bdc
32 changed files with 14954 additions and 83 deletions

View file

@ -1 +1 @@
/nix/store/zx636v118rzzqzwafgrw2aybh7l0szrl-pre-commit-config.json
/nix/store/4xlkmdd947h5qlhj2rmbyavq08grkz27-pre-commit-config.json

1012
docs/ARCHITECTURE.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,524 @@
# TowerOps Competitive Analysis
**Last Updated**: 2026-03-13
**Purpose**: Map the ISP/WISP network monitoring and operations tool landscape, identify gaps, and position TowerOps for differentiation.
---
## Table of Contents
1. [LibreNMS](#1-librenms)
2. [PRTG Network Monitor](#2-prtg-network-monitor)
3. [Auvik](#3-auvik)
4. [The Dude (MikroTik)](#4-the-dude-mikrotik)
5. [Preseem](#5-preseem)
6. [Sonar](#6-sonar)
7. [Splynx](#7-splynx)
8. [Oxidized / RANCID](#8-oxidized--rancid)
9. [Grafana + Prometheus](#9-grafana--prometheus)
10. [Competitive Matrix](#competitive-matrix)
11. [TowerOps Positioning](#towerops-positioning)
---
## 1. LibreNMS
**Website**: librenms.org
**License**: GPL v3 (open source)
### Target Market
ISPs, WISPs, enterprises, hosting providers, universities — anyone with network infrastructure and the technical staff to self-host.
### Pricing Model
Free and open source. No licensing costs. Community support via Discord and forums. Professional support available through third-party consultants.
### Key Strengths
- **Massive device support**: Auto-discovery via CDP, FDP, LLDP, OSPF, BGP, SNMP, and ARP. 786+ OS profiles.
- **Billing module**: Built-in bandwidth billing system for ports based on usage or transfer — rare in an open-source monitoring tool.
- **Distributed polling**: Horizontal scaling with poller groups. Handles large networks (10k+ devices).
- **Alerting flexibility**: Alerts via email, IRC, Slack, PagerDuty, Telegram, and many more transports.
- **API access**: Full REST API for automation and integration.
- **Community**: Large, active community. Active Discord. Regular contributions.
- **Zero cost**: No per-device, per-sensor, or per-subscriber fees.
### Key Weaknesses / Gaps
- **UX is dated**: Web UI is functional but looks like it was designed in 2015. Not responsive or modern. Navigation is confusing for new users.
- **RRDtool limitations**: Default time-series backend (RRDtool) has fixed retention periods and is not SQL-queryable. InfluxDB support exists but is less mature.
- **No multi-tenancy**: Single-organization design. No proper tenant isolation for MSPs or multi-client environments.
- **Complex setup**: Requires Linux sysadmin skills. Installation, upgrades, and maintenance are manual. No SaaS option.
- **No agent architecture**: Polling is centralized or distributed-pollers, but no lightweight edge agents for remote sites behind NAT/firewalls.
- **No subscriber awareness**: Monitors devices and interfaces, not subscribers. No concept of "this customer is having a bad experience."
- **Config management**: No built-in config backup or change tracking. Requires Oxidized or RANCID alongside it.
- **Mobile app**: Community-built mobile app exists (by Wavedirect) but not officially maintained.
### What TowerOps Can Learn / Differentiate On
- **Learn**: LibreNMS's device profile coverage (786 OS types) is the gold standard. TowerOps has matched this — maintain parity.
- **Learn**: Bandwidth billing module is a unique feature that ISPs value. TowerOps integrates with billing platforms (Sonar, Splynx) instead — a different but arguably better approach.
- **Differentiate**: Modern LiveView UI vs. LibreNMS's aging PHP interface. This is a major selling point.
- **Differentiate**: Multi-tenancy from day one. LibreNMS can't serve MSPs or multi-org setups cleanly.
- **Differentiate**: TimescaleDB > RRDtool. SQL-queryable, better compression, better retention policies.
- **Differentiate**: Agent-based distributed polling for sites behind NAT. LibreNMS requires VPN tunnels or direct access.
- **Differentiate**: Subscriber-aware monitoring. LibreNMS thinks in devices; TowerOps can think in customers.
---
## 2. PRTG Network Monitor
**Website**: paessler.com/prtg
**Vendor**: Paessler AG (German company)
### Target Market
Mid-market enterprise IT departments, MSPs, some ISPs. Generalist network monitoring — not ISP/WISP-specific.
### Pricing Model
Subscription-based by sensor count:
- **PRTG 500** (50 devices): $179/month
- **PRTG 1000** (100 devices): $325/month
- **PRTG 2500** (250 devices): $675/month
- **PRTG 5000** (500 devices): $1,183/month
- **PRTG 10000** (1000 devices): $1,492/month
- **Enterprise**: Custom pricing for 10k+ sensors
- Free tier: 100 sensors (~10 devices)
### Key Strengths
- **Ease of use**: Highly polished UI. Web interface, desktop app, and mobile apps (iOS/Android). Best-in-class for non-technical users.
- **Sensor-based architecture**: Granular monitoring — each metric is a "sensor." Flexible but can get expensive quickly.
- **Broad protocol support**: SNMP, WMI, SSH, HTTP, NetFlow, sFlow, jFlow, packet sniffing, and more.
- **Maps & dashboards**: Built-in map designer with real-time status. Good for NOC displays.
- **Notifications**: Email, push notifications, HTTP requests, SMS, Slack, Teams, etc.
- **Windows-native**: Runs on Windows Server. Familiar for Windows-shop IT teams.
### Key Weaknesses / Gaps
- **Expensive at scale**: An ISP with 500 devices needs PRTG 5000 ($1,183/mo) and will burn through sensors quickly. Interface monitoring alone eats 5-10 sensors per device.
- **Windows-only server**: Core server runs only on Windows. Odd choice for network monitoring in a Linux-heavy ISP world.
- **Not ISP-aware**: No subscriber mapping, no billing integration, no WISP-specific features. It's a generic IT monitoring tool.
- **No config management**: No config backup or diff tracking.
- **Sensor sprawl**: The sensor model means costs escalate unpredictably. Hard to budget for growing networks.
- **No distributed agents**: Remote probes exist but are Windows-based. No lightweight Linux agent for tower sites.
- **Limited automation**: Basic auto-discovery but no sophisticated topology mapping or network automation.
### What TowerOps Can Learn / Differentiate On
- **Learn**: PRTG's UX polish is excellent. Maps, dashboards, mobile apps — they invest heavily in usability. TowerOps should aim for this level of polish.
- **Learn**: The sensor model, while expensive, gives users granular control. Consider offering similar granularity with better pricing.
- **Differentiate**: Per-device or flat pricing vs. per-sensor. ISPs hate unpredictable costs.
- **Differentiate**: Linux-native, cloud-native. No Windows dependency.
- **Differentiate**: ISP/WISP-specific features that PRTG will never build (subscriber awareness, billing integration, tower management).
---
## 3. Auvik
**Website**: auvik.com
**Type**: Cloud-based SaaS
### Target Market
MSPs (Managed Service Providers) primarily. Some mid-market IT departments. Not ISP/WISP-focused.
### Pricing Model
Quote-based (not publicly listed). Reports suggest:
- Per-device pricing, tiered by network size
- Estimated $3-8/device/month depending on volume
- Multi-tenant by design (MSP model)
- No free tier; 14-day free trial
### Key Strengths
- **Cloud-first**: No server to manage. Deploy a lightweight collector and go. Full visibility in under an hour.
- **Auto-discovery & mapping**: Best-in-class automated network topology mapping. Live, auto-updating maps.
- **64+ pre-configured alerts**: Works out of the box with sensible defaults. Low time-to-value.
- **Configuration backup**: Automatic config backup with version history and diff comparison. Built-in (no Oxidized needed).
- **700+ vendor support**: Broad multi-vendor device support out of the box.
- **NetFlow/traffic analysis**: Deep traffic visibility including encrypted traffic analysis.
- **MSP integrations**: ConnectWise, Autotask, ServiceNow, IT Glue, PagerDuty, Slack, Teams, etc.
- **Multi-tenant**: Built for MSPs managing many client networks. Proper tenant isolation.
- **Modern UX**: Clean, intuitive interface. Junior techs can use it immediately.
### Key Weaknesses / Gaps
- **Not ISP-focused**: Designed for corporate LANs, not carrier networks. No subscriber awareness, no WISP features, no tower management.
- **Expensive for ISPs**: Per-device pricing adds up fast for ISPs with hundreds/thousands of devices. An ISP with 1000 devices could be paying $3,000-8,000/month.
- **No SNMP depth**: Good for L2/L3 basics but lacks deep SNMP sensor discovery (temperature, power, optical levels) that ISPs need for tower equipment.
- **No billing integration**: No connection to ISP billing platforms (Sonar, Splynx, VISP).
- **Cloud-only**: No self-hosted option. Data sovereignty concerns for some operators.
- **Limited customization**: Pre-built dashboards are good but custom metrics/sensors are limited compared to PRTG or LibreNMS.
- **No QoE**: No subscriber quality-of-experience monitoring.
### What TowerOps Can Learn / Differentiate On
- **Learn**: Auvik's time-to-value is exceptional. Deploy collector → see network in minutes. TowerOps's agent model should aim for similar simplicity.
- **Learn**: Auto-updating topology maps are a killer feature. TowerOps already has LLDP topology discovery — push this further.
- **Learn**: Built-in config backup eliminates the need for a separate Oxidized/RANCID install. TowerOps should include this.
- **Differentiate**: ISP/WISP domain expertise vs. Auvik's MSP/corporate focus.
- **Differentiate**: Deep SNMP sensor discovery (optical, RF, environmental) vs. Auvik's shallow device monitoring.
- **Differentiate**: Billing platform integrations (Sonar, Splynx, VISP, Gaiia) that Auvik will never build.
---
## 4. The Dude (MikroTik)
**Website**: mikrotik.com (under Tools/Legacy)
**Vendor**: MikroTik
### Target Market
Small-to-medium WISPs and networks running MikroTik equipment. Hobbyists. Budget-conscious operators.
### Pricing Model
**Free**. Included with MikroTik RouterOS. Server can run on any RouterOS device or as a Windows/Wine application.
### Key Strengths
- **Free**: Zero cost. Hard to compete with free for budget-conscious WISPs.
- **Deep MikroTik integration**: Direct RouterOS API access. Can monitor and configure MikroTik devices natively without SNMP overhead.
- **Auto-discovery**: Subnet scanning with service detection. Draws network maps automatically.
- **Lightweight**: Runs on a MikroTik router itself (CHR or hardware). No separate server needed.
- **Network maps**: Visual topology with automatic layout. Real-time status overlay.
- **Multi-protocol**: SNMP, ICMP, DNS, TCP services monitoring.
- **Widely used in WISP community**: De facto standard for MikroTik-heavy WISPs.
### Key Weaknesses / Gaps
- **Legacy/neglected product**: MikroTik lists it under "Legacy Tools." Development has stagnated. The client is a Windows app (or runs under Wine). No web UI.
- **MikroTik-centric**: Works great for MikroTik gear but limited for multi-vendor environments (Ubiquiti, Cambium, Cisco, etc.).
- **No web interface**: Desktop client only. No remote access without VPN/RDP. No mobile app.
- **Limited alerting**: Basic notifications but no sophisticated alert routing, escalation, or integration with modern tools (PagerDuty, Slack, etc.).
- **No time-series storage**: Stores current state but lacks historical trending, capacity planning, or long-term data retention.
- **No API**: No REST API or integration capabilities.
- **No multi-tenancy**: Single-user design.
- **Scaling issues**: Performance degrades with large networks (500+ devices). Not designed for ISP-scale operations.
- **No config backup**: No configuration management features.
- **No subscriber awareness**: Device-centric only.
### What TowerOps Can Learn / Differentiate On
- **Learn**: The Dude's auto-discovery and visual mapping are beloved by WISPs. Simple, immediate, visual. TowerOps topology should feel this intuitive.
- **Learn**: Running on the router itself (zero infrastructure) is appealing for tiny WISPs. TowerOps's agent-on-router approach can replicate this for larger operators.
- **Differentiate**: Everything. The Dude is a legacy tool with no active development. TowerOps is the modern replacement.
- **Differentiate**: Web UI, multi-vendor, multi-tenant, alerting, API, historical data, subscriber awareness — all things The Dude lacks.
- **Key opportunity**: Many WISPs have outgrown The Dude but haven't found a replacement that "gets" their world. TowerOps is that replacement.
---
## 5. Preseem
**Website**: preseem.com
**Type**: Cloud-based SaaS with on-prem appliance for traffic analysis
### Target Market
Regional ISPs and WISPs. Purpose-built for fixed wireless and fiber operators.
### Pricing Model
- **Per-subscriber**: Starting at $0.40/subscriber/month
- **Minimum**: $300/month
- **Volume discounts** available
- **Month-to-month**: No long-term contracts required
- **Hardware**: Optional pre-loaded appliances or BYO Linux server
- 30-day free trial
### Key Strengths
- **QoE-focused**: Quality of Experience monitoring is their core differentiator. Measures what subscribers actually experience, not just device uptime.
- **Traffic shaping**: Adaptive traffic management that reduces congestion and improves subscriber QoE. This is unique — most monitoring tools only observe.
- **Multi-vendor, multi-access**: Supports fixed wireless (Cambium, Ubiquiti, MikroTik, Baicells, etc.) and fiber. Unified view across technologies.
- **Billing integrations**: Connects to Sonar, Splynx, Powercode, VISP, BillMax, Azotel, and more. Subscriber-aware from day one.
- **Churn reduction**: Proven results — customers report 25-30% churn reduction. Direct revenue impact.
- **Industry recognition**: 5x WISPA Service of the Year winner. Deeply embedded in the WISP community.
- **Cloud analytics**: Centralized view across all vendors and network elements. No need to log into multiple systems.
- **Scales to 100+ Gbps**: Handles large traffic volumes.
### Key Weaknesses / Gaps
- **Not a full monitoring platform**: Preseem monitors traffic and QoE but doesn't replace a full NMS. No SNMP polling, no device inventory, no ping monitoring, no alerting for device failures.
- **Requires inline deployment**: The appliance sits inline or uses port mirroring. Adds a potential point of failure and complexity.
- **No config management**: No config backup or change tracking.
- **No topology mapping**: No network topology discovery or visualization.
- **No on-call/incident management**: No paging, escalation, or incident workflows.
- **Pricing adds up**: At $0.40/subscriber, a 5,000-subscriber ISP pays $2,000/month just for QoE. On top of their NMS, billing platform, etc.
- **Complementary, not complete**: ISPs still need LibreNMS/PRTG/etc. alongside Preseem. More tools = more cost and complexity.
### What TowerOps Can Learn / Differentiate On
- **Learn**: Subscriber-aware monitoring is incredibly valuable. Preseem proved the market wants this. TowerOps should integrate subscriber context from billing platforms.
- **Learn**: QoE metrics (latency, jitter, packet loss per subscriber) matter more than device uptime to ISP operators. Build this into TowerOps.
- **Learn**: Preseem's billing integrations are a model. They connect to every major ISP billing platform — TowerOps already integrates with Sonar, Splynx, VISP, and Gaiia.
- **Differentiate**: TowerOps can be the unified platform that includes SNMP monitoring + subscriber awareness + alerting + topology — eliminating the need for Preseem as a separate tool.
- **Complement or compete**: In the near term, TowerOps + Preseem could work together. Long term, TowerOps could build QoE features and reduce the need for Preseem.
---
## 6. Sonar
**Website**: sonar.software
**Type**: Cloud-based SaaS
### Target Market
ISPs of all types: cable, fiber, wireless, VoIP, MDU, enterprise. Focused on billing and operations, not monitoring.
### Pricing Model
Per-subscriber tiered pricing with $500/month minimum (with contract):
- **1-5,000 subscribers**: $1.25/subscriber/month
- **5,001-10,000**: $1.00/subscriber/month
- **10,001-50,000**: $0.75/subscriber/month
- **50,000+**: Enterprise/custom pricing
### Key Strengths
- **Comprehensive ISP operations**: Billing, CRM, ticketing, inventory, field service scheduling, provisioning — all in one platform.
- **Financial tools**: 25+ pre-built financial reports. Tax management across jurisdictions. Payment processing.
- **Field service**: Job scheduling, dispatch, GPS tracking, work orders, checklists. Mobile app for field techs.
- **Network visibility**: Basic monitoring, fault detection, device provisioning, performance tracking.
- **DataConnect**: Snowflake data warehouse integration for advanced analytics.
- **Customer portal**: Self-service portal for subscribers.
- **US/Canada support**: Real humans, no outsourcing, Mon-Fri 8am-6pm CT.
### Key Weaknesses / Gaps
- **Monitoring is basic**: Sonar's network monitoring is rudimentary compared to a dedicated NMS. Basic ICMP/SNMP, no deep sensor discovery, no QoE.
- **Not a replacement for an NMS**: ISPs using Sonar still need LibreNMS, PRTG, or similar for real network monitoring.
- **Expensive at scale**: A 5,000-subscriber ISP pays $6,250/month. A 10,000-subscriber ISP pays ~$7,500/month. This is before monitoring, QoE, or other tools.
- **Cloud-only**: No self-hosted option.
- **No topology/mapping**: No network topology visualization.
- **No config management**: No device config backup or change tracking.
### What TowerOps Can Learn / Differentiate On
- **Learn**: Sonar owns the "ISP operations platform" positioning. TowerOps should position as the monitoring/network operations complement, not a billing competitor.
- **Learn**: Sonar's integration ecosystem is a model. Being the platform that other tools integrate with is powerful.
- **Differentiate**: TowerOps is the deep network monitoring that Sonar can't do. Sonar does billing; TowerOps does monitoring. They're complementary.
- **Integration opportunity**: TowerOps's existing Sonar integration should be a first-class feature. Pull subscriber data from Sonar → correlate with network health → give NOC teams subscriber context.
- **Differentiate**: TowerOps can surface network problems that impact billing (e.g., "these 50 subscribers have been experiencing degraded service for 3 days — expect churn").
---
## 7. Splynx
**Website**: splynx.com
**Type**: Cloud-hosted or self-hosted SaaS
### Target Market
Small-to-medium ISPs worldwide. Strong presence in Africa, Eastern Europe, Southeast Asia, and Latin America. 1,000+ ISP customers globally.
### Pricing Model
Per-subscriber, subscription-based (prices in USD):
- Scales with active subscriber count (intervals of 100, entry level up to 400 subscribers)
- Includes license + maintenance fee (hosting, support, backups)
- Cloud or on-premise deployment options
- Add-ons: ACS (TR-069) from $100/month, Whalebone security from $80/month
- All features included in base license (no feature tiers)
### Key Strengths
- **All-in-one ISP platform**: Billing, RADIUS, TR-069 (ACS), IPAM, CRM, ticketing, field service, inventory — comprehensive.
- **Networking features**: Built-in RADIUS server, bandwidth management, QoE, IPAM, network monitoring, config backups, change control.
- **Self-hosted option**: Unlike Sonar (cloud-only), Splynx can be self-hosted. Important for operators in regions with data sovereignty requirements.
- **Customer portal & mobile app**: White-labeled mobile app for subscriber self-service.
- **Field tech mobile app**: Dedicated app for technicians with scheduling, work orders, maps.
- **Global reach**: 1,000+ ISPs worldwide. Strong in developing markets where cost matters.
- **Fast support**: 85% of tickets resolved in under 60 minutes.
- **TR-069 / ACS**: CPE device provisioning and management. Increasingly important for fiber ISPs.
### Key Weaknesses / Gaps
- **Monitoring is secondary**: Network monitoring exists but is not the primary focus. Basic compared to dedicated NMS tools.
- **Not deep SNMP**: Doesn't match LibreNMS or TowerOps for SNMP sensor discovery, vendor-specific MIBs, or environmental monitoring.
- **Smaller than Sonar in US market**: Sonar dominates the US ISP billing space. Splynx is stronger internationally.
- **No topology mapping**: No automated network topology discovery or visualization.
- **No QoE**: No subscriber quality-of-experience monitoring or traffic shaping.
- **UI less polished than Sonar**: Functional but not as modern as Sonar's interface.
### What TowerOps Can Learn / Differentiate On
- **Learn**: Splynx's all-in-one approach (billing + networking + CRM) appeals to small ISPs who don't want 5 separate tools. TowerOps should integrate deeply enough that it feels like part of the stack.
- **Learn**: Self-hosted option matters for international markets. TowerOps's agent architecture and potential self-hosted option could appeal to the same market.
- **Differentiate**: TowerOps provides the deep monitoring that Splynx's built-in monitoring can't match. Position as the "upgrade your monitoring" path for Splynx users.
- **Integration opportunity**: TowerOps's existing Splynx integration should pull subscriber data, service plans, and customer context to enrich network monitoring.
---
## 8. Oxidized / RANCID
**Website**: github.com/ytti/oxidized (Oxidized), shrubbery.net/rancid (RANCID)
**License**: Open source
### Target Market
Any network operator needing config backup. ISPs, enterprises, MSPs. Technical users comfortable with CLI tools.
### Pricing Model
**Free** (open source). Self-hosted only.
### Key Strengths
- **Oxidized**: Modern RANCID replacement. Ruby-based. Supports 130+ OS types. Git-backed config storage with blame/diff. REST API. Hooks for notifications (Slack, email). Syslog integration to trigger config fetch on change events.
- **RANCID**: Battle-tested (20+ years). CVS/SVN-backed. Widely deployed. Simple and reliable.
- **Config versioning**: Git integration means full history, diff, blame. Know who changed what and when.
- **Lightweight**: Single-purpose tools that do one thing well.
- **Event-driven**: Oxidized can react to syslog events (config change detected → immediately fetch new config).
### Key Weaknesses / Gaps
- **Config backup only**: No monitoring, no alerting, no topology, no dashboards. Single-purpose.
- **Oxidized maintenance concerns**: GitHub repo has a "Maintainer Wanted" notice. Bus factor risk.
- **No web UI**: CLI/API only. No visual diff viewer out of the box (though tools like oxidized-web exist).
- **Manual setup**: Requires configuration, cron jobs, and integration work.
- **No compliance features**: No automated compliance checking against config templates.
- **RANCID is legacy**: CVS-based, Perl scripts, showing its age. Most operators have migrated to Oxidized.
### What TowerOps Can Learn / Differentiate On
- **Learn**: Config backup with git-backed versioning and diff is table stakes for ISPs. TowerOps already has config change tracking for MikroTik — expand this to all supported vendors.
- **Learn**: Event-driven config fetch (syslog trigger → immediate backup) is smart. TowerOps agents could do this natively.
- **Learn**: "Git blame" for config changes (who changed what line) is incredibly valuable for troubleshooting and compliance.
- **Differentiate**: TowerOps can build config backup into the platform, eliminating a separate Oxidized install. One less tool to maintain.
- **Differentiate**: Config compliance checking (compare running config against a template, flag drift) — something neither Oxidized nor RANCID does.
---
## 9. Grafana + Prometheus
**Website**: grafana.com, prometheus.io
**License**: Open source (Grafana AGPL, Prometheus Apache 2.0)
### Target Market
DevOps teams, SREs, cloud-native organizations, and technically sophisticated ISPs who want full control and customization.
### Pricing Model
**Free** (self-hosted open source). Grafana Cloud offers managed hosting:
- Free tier: 10k metrics, 50GB logs
- Pro: ~$29/month per active series
- Enterprise: Custom pricing
- Most ISPs self-host to avoid costs.
### Key Strengths
- **Visualization**: Grafana is the gold standard for dashboards. Flexible, beautiful, and infinitely customizable.
- **Prometheus data model**: Labels-based metrics with powerful PromQL query language. Excellent for high-cardinality data.
- **Ecosystem**: Thousands of exporters (SNMP Exporter, Node Exporter, Blackbox Exporter, etc.). Alertmanager for alerting. Loki for logs.
- **Cloud-native**: Designed for containerized, microservices architectures. Kubernetes-native.
- **Scalability**: Horizontal scaling with Thanos, Cortex, or Mimir for long-term storage.
- **Community**: Massive community. Extensive documentation. Frequent releases.
- **Flexibility**: Can monitor anything. Network, servers, applications, custom metrics.
### Key Weaknesses / Gaps
- **DIY assembly required**: Not a product — it's a toolkit. ISPs must assemble SNMP Exporter + Prometheus + Alertmanager + Grafana + dashboards. Significant engineering effort.
- **No auto-discovery**: Prometheus uses static config or service discovery (Consul, DNS, file). No SNMP/LLDP auto-discovery of network devices.
- **SNMP support is bolted on**: snmp_exporter works but requires SNMP MIB compilation into a generator config. Painful compared to LibreNMS's plug-and-play.
- **No network awareness**: No topology mapping, no device inventory, no interface table management. You're staring at metrics, not a network.
- **Alert fatigue**: Alertmanager is powerful but complex to configure. No built-in alert correlation or deduplication intelligence.
- **No subscriber awareness**: Purely infrastructure metrics. No concept of customers or service impact.
- **No config management**: No config backup or change tracking.
- **Maintenance burden**: Self-hosted Prometheus requires capacity planning, storage management, retention tuning. It's a full-time job at scale.
- **Dashboard sprawl**: Without discipline, you end up with 200 dashboards and no one knows which to look at.
### When ISPs Choose Grafana+Prometheus Over a Product
- They have DevOps/SRE staff who already know the stack
- They want custom dashboards that no product can provide
- They're already using it for server/application monitoring and want to extend to network
- They're philosophically opposed to vendor lock-in
- They have unique metrics or integrations that products don't support
- Cost-sensitive and willing to trade engineering time for license fees
### What TowerOps Can Learn / Differentiate On
- **Learn**: Grafana's dashboard flexibility is unmatched. TowerOps should offer embeddable Grafana-style dashboards or a dashboard builder that approaches this flexibility.
- **Learn**: The Prometheus ecosystem's breadth is incredible. TowerOps should be able to export metrics in Prometheus format for users who want Grafana alongside TowerOps.
- **Differentiate**: TowerOps is a product, not a toolkit. Auto-discovery, device inventory, subscriber awareness, alerting, topology — all built-in. No assembly required.
- **Differentiate**: SNMP-first design. TowerOps's 786 OS profiles and vendor-specific MIB support is years of work that Prometheus users would have to replicate.
- **Differentiate**: ISP domain knowledge. Grafana doesn't know what a tower site is or what subscriber churn means. TowerOps does.
- **Coexistence strategy**: Offer Prometheus-compatible metric export. Let users who love Grafana use it alongside TowerOps. Don't force them to choose.
---
## Competitive Matrix
| Feature | TowerOps | LibreNMS | PRTG | Auvik | The Dude | Preseem | Sonar | Splynx | Oxidized | Grafana+Prom |
|---------|----------|----------|------|-------|----------|---------|-------|--------|----------|-------------|
| **SNMP Monitoring** | ✅ Deep | ✅ Deep | ✅ Deep | ⚠️ Basic | ⚠️ Basic | ❌ | ⚠️ Basic | ⚠️ Basic | ❌ | ⚠️ Bolt-on |
| **Device Auto-Discovery** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Topology Mapping** | ✅ LLDP | ✅ | ❌ | ✅ Best | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Multi-Tenancy** | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
| **Subscriber Awareness** | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ Best | ✅ | ✅ | ❌ | ❌ |
| **QoE Monitoring** | 🔜 | ❌ | ❌ | ❌ | ❌ | ✅ Best | ❌ | ⚠️ | ❌ | ❌ |
| **Config Backup** | ✅ MikroTik | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ Best | ❌ |
| **Billing Integration** | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ Native | ✅ Native | ❌ | ❌ |
| **Alerting/On-Call** | ✅ | ✅ | ✅ | ✅ | ⚠️ | ❌ | ❌ | ❌ | ❌ | ✅ |
| **Web UI** | ✅ Modern | ⚠️ Dated | ✅ | ✅ | ❌ Desktop | ✅ | ✅ | ✅ | ❌ CLI | ✅ |
| **Mobile App** | ✅ | ⚠️ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ |
| **API** | ✅ REST+GraphQL | ✅ REST | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ REST | ✅ |
| **Agent/Distributed** | ✅ | ⚠️ Pollers | ⚠️ Probes | ✅ Collector | ❌ | ✅ Appliance | ❌ | ❌ | ❌ | ✅ Exporters |
| **Open Source** | ❌ | ✅ | ❌ | ❌ | ✅ Free | ❌ | ❌ | ❌ | ✅ | ✅ |
| **ISP/WISP Focus** | ✅ | ⚠️ | ❌ | ❌ | ⚠️ MikroTik | ✅ | ✅ | ✅ | ❌ | ❌ |
---
## TowerOps Positioning
### Where TowerOps Fits
TowerOps occupies a unique position in the landscape: **the only purpose-built network monitoring platform designed specifically for ISPs and WISPs, with modern architecture and subscriber awareness.**
The current landscape forces ISPs to choose between:
1. **Generic monitoring tools** (LibreNMS, PRTG, Grafana) that don't understand ISP operations
2. **ISP billing platforms** (Sonar, Splynx) with rudimentary monitoring bolted on
3. **QoE-only tools** (Preseem) that complement but don't replace an NMS
4. **Legacy WISP tools** (The Dude) that can't scale
TowerOps eliminates the "duct-tape stack" of LibreNMS + Oxidized + Preseem + Grafana that most ISPs cobble together.
### Target Customer Profile
**Primary**: Regional ISPs and WISPs with 500-50,000 subscribers
- Running mixed equipment: MikroTik, Ubiquiti, Cambium, Cisco, Juniper
- Currently using LibreNMS or The Dude and outgrowing them
- Using Sonar or Splynx for billing and wanting monitoring that integrates
- Frustrated with managing 3-5 separate tools for network operations
- Small NOC team (1-5 people) that needs efficiency, not complexity
**Secondary**: MSPs managing ISP/WISP clients
- Need multi-tenant monitoring with ISP-specific features
- Currently using Auvik but hitting its limits for carrier networks
### Unique Value Propositions
1. **ISP-native, not ISP-adapted**: Built from day one for ISP/WISP operations. Not a generic monitoring tool with ISP features bolted on. Sites, towers, sectors, subscribers — first-class concepts.
2. **Unified platform**: SNMP monitoring + topology mapping + config management + alerting + on-call + subscriber awareness in one product. Replaces LibreNMS + Oxidized + PagerDuty + custom Grafana dashboards.
3. **Modern architecture**: Phoenix LiveView for real-time UI, TimescaleDB for time-series (SQL-queryable, not RRDtool), distributed agent architecture for sites behind NAT/firewalls.
4. **Subscriber-aware monitoring**: Pull subscriber data from Sonar/Splynx/VISP/Gaiia and correlate with network health. "These 47 subscribers on Tower-12 Sector-3 are experiencing degraded service" — not just "interface utilization is high."
5. **Agent-based distributed monitoring**: Lightweight agents deployed at tower sites, headends, or POPs. Monitor behind NAT, through firewalls, with local data collection and central aggregation. No VPN tunnels or direct SNMP access needed.
6. **Deep SNMP + fast polling**: 786 OS profiles with vendor-specific sensor discovery. 60-second default polling (vs LibreNMS's 5-minute). Environmental monitoring (temperature, power, optical levels) that Auvik and billing platforms can't do.
7. **Billing platform integrations**: First-class integrations with Sonar, Splynx, VISP, and Gaiia. Not just API calls — bidirectional sync that enriches both platforms.
### Features Competitors Lack
| Feature | Why It Matters | Who's Missing It |
|---------|---------------|-----------------|
| **Site/tower hierarchy** | ISPs think in sites, not flat device lists | LibreNMS, PRTG, Auvik, Grafana |
| **Agent-based polling behind NAT** | Tower sites often lack direct connectivity | LibreNMS, PRTG, The Dude |
| **Subscriber ↔ device correlation** | "Which customers are affected by this outage?" | LibreNMS, PRTG, Auvik, Grafana |
| **Built-in on-call/escalation** | No separate PagerDuty/Opsgenie needed | LibreNMS, Preseem, Sonar, Splynx |
| **Config backup + deep monitoring** | Eliminates Oxidized as separate tool | LibreNMS, PRTG, Preseem |
| **Multi-tenant with ISP features** | MSPs managing WISPs need both | LibreNMS, The Dude, Preseem |
| **60-second polling** | Faster fault detection, better capacity data | LibreNMS (5min default) |
| **TimescaleDB time-series** | SQL-queryable historical data, continuous aggregates | LibreNMS (RRDtool), The Dude (none) |
| **Capacity planning** | Interface utilization trends and forecasting | The Dude, Preseem, billing platforms |
### Competitive Positioning Summary
```
ISP/WISP Specific
|
Preseem | TowerOps ★
(QoE only) | (Full NMS + ISP features)
|
Sonar/Splynx |
(Billing + basic |
monitoring) |
|
Generic ←──────────────┼──────────────→ Deep Monitoring
|
The Dude | LibreNMS
(Legacy) | (Open source NMS)
|
Auvik | PRTG
(MSP focus) | (Enterprise IT)
|
Grafana+Prometheus
(DIY toolkit)
|
Generic IT
```
### Go-To-Market Priorities
1. **Migrate The Dude users**: Largest addressable audience of frustrated WISP operators. Low switching cost (The Dude is free but limited). Marketing message: "You've outgrown The Dude."
2. **Replace LibreNMS for ISPs**: Technical operators who've invested in LibreNMS but want modern UX, subscriber awareness, and less maintenance. Message: "Everything LibreNMS does, plus everything it doesn't."
3. **Complement Sonar/Splynx**: Don't compete with billing platforms. Be the monitoring layer they recommend. Message: "The monitoring platform that actually talks to your billing system."
4. **Displace the duct-tape stack**: ISPs running LibreNMS + Oxidized + Grafana + PagerDuty + Preseem. Message: "One platform instead of five."

227
docs/CONVENTIONS.md Normal file
View file

@ -0,0 +1,227 @@
# TowerOps Coding Conventions
Project-specific patterns and conventions for the TowerOps Phoenix LiveView application.
## File Organization
### LiveView Modules
LiveView pages live under `lib/towerops_web/live/` organized by domain:
```
lib/towerops_web/live/
schedule_live/
index.ex # List page module
index.html.heex # List page template
form.ex # Create/edit form module
form.html.heex # Form template
show.ex # Detail page module
show.html.heex # Detail page template
```
- **`.ex`** files contain the LiveView module (mount, handle_params, handle_event, etc.)
- **`.html.heex`** files contain the corresponding HEEx template
- Module naming: `ToweropsWeb.ScheduleLive.Index`, `ToweropsWeb.ScheduleLive.Form`, etc.
### Test Files
Tests mirror the `lib/` structure under `test/`:
```
test/
towerops/ # Context/business logic tests
towerops_web/ # Web layer tests
snmpkit/ # SNMP toolkit tests
integration/ # Integration tests (SNMP, discovery)
support/ # Test helpers and fixtures
fixtures/ # Factory/fixture modules
mix/ # Mix task tests
test_helper.exs
```
## Navigation & Page Structure
### `active_page` Assign
Every LiveView sets an `active_page` assign in `mount/3` to highlight the correct nav item:
```elixir
def mount(_params, _session, socket) do
{:ok, assign(socket, :active_page, "schedules")}
end
```
Valid values: `"dashboard"`, `"devices"`, `"sites"`, `"network-map"`, `"alerts"`, `"maintenance"`, `"schedules"`, `"insights"`, `"trace"`, `"activity"`, `"settings"`, `"help"`
The layout component (`Layouts.authenticated`) checks `@active_page` to apply the `active` class to nav links.
### Breadcrumbs
Use the `ToweropsWeb.Components.Breadcrumbs` component (`.breadcrumb`) for hierarchical navigation:
```heex
<.breadcrumb items={[
%{label: "Dashboard", navigate: ~p"/dashboard"},
%{label: "Devices", navigate: ~p"/devices"},
%{label: "My Device"}
]} />
```
- Each item is a map with `:label` (required) and `:navigate` (optional)
- Omit `:navigate` on the last item (current page)
- The first item automatically gets a home icon
## Forms
### Standard Form Pattern
Forms use `phx-change="validate"` for real-time validation and `phx-submit="save"` for submission:
```heex
<.simple_form for={@form} phx-change="validate" phx-submit="save">
<.input field={@form[:name]} label={t("Name")} />
<:actions>
<.button>{t("Save")}</.button>
</:actions>
</.simple_form>
```
The LiveView handles both events:
```elixir
def handle_event("validate", %{"schedule" => params}, socket) do
changeset = Schedule.changeset(socket.assigns.schedule, params)
{:noreply, assign(socket, :form, to_form(changeset, action: :validate))}
end
def handle_event("save", %{"schedule" => params}, socket) do
# ... create or update logic
end
```
## Flash Messages
Use `put_flash/3` with `:info` for success and `:error` for failures. Always wrap messages in `t()`:
```elixir
socket
|> put_flash(:info, t("Schedule created successfully."))
|> push_navigate(to: ~p"/schedules")
```
```elixir
socket
|> put_flash(:error, t("Could not save schedule."))
```
## Translations (i18n)
All user-facing strings must use the `t()` macro (or domain-specific variants). Defined in `ToweropsWeb.GettextHelpers`:
| Macro | Domain | Example |
|-------|--------|---------|
| `t/1` | Common UI | `t("Save")`, `t("Cancel")` |
| `t_auth/1` | Authentication | `t_auth("Log in")` |
| `t_equipment/1` | Equipment | `t_equipment("Add Device")` |
| `t_admin/1` | Admin | `t_admin("Impersonate User")` |
| `t_email/1` | Emails | `t_email("Welcome!")` |
Interpolation: `t("Welcome, %{name}!", name: user.name)`
Error messages use the standard `gettext/1` function.
## `current_scope` Assign
The `@current_scope` assign is available in all authenticated views. It's a struct containing:
- **`.user`** — the current `User` struct (email, id, `is_superuser`, etc.)
- **`.organization`** — the current `Organization` (name, slug, settings)
- **`.timezone`** — the user's timezone string (falls back to `"UTC"`)
- **`.impersonating?`** — boolean, true when a superuser is impersonating another user
Access in templates:
```heex
<%= @current_scope.user.email %>
<%= @current_scope.organization.name %>
```
Access in LiveView:
```elixir
organization = socket.assigns.current_scope.organization
user = socket.assigns.current_scope.user
```
The layout displays an impersonation banner when `@current_scope.impersonating?` is true.
## CSS & Styling
### Tailwind CSS
All styling uses Tailwind utility classes. The project uses **DaisyUI** as a component library on top of Tailwind.
### Dark Mode
Dark mode is supported via the `dark:` prefix:
```heex
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
```
Always provide dark mode variants for backgrounds, text colors, and borders.
### Icons
Icons use Heroicons via the `.icon` component with `hero-*` names:
```heex
<.icon name="hero-plus" />
<.icon name="hero-pencil-square" class="size-5" />
<.icon name="hero-check" class="size-4 text-green-300" />
<.icon name="hero-home-mini" class="h-4 w-4" />
```
Use the mini variant (`hero-*-mini`) for small inline icons and the default (outline) for larger UI elements.
## LiveView Conventions
### Module Boilerplate
```elixir
defmodule ToweropsWeb.ScheduleLive.Index do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.OnCall
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :active_page, "schedules")}
end
@impl true
def handle_params(params, _url, socket) do
# ...
end
end
```
- Always use `@impl true` for callback annotations
- Set `active_page` in `mount/3`
- Use `handle_params/3` for loading data based on URL params / live_action
### Scoped Routes
Routes are scoped under the organization slug:
```
/orgs/:org_slug/devices
/orgs/:org_slug/schedules/:id
```
Use `~p` sigil for path generation with `@current_scope`:
```elixir
~p"/orgs/#{@current_scope.organization.slug}/devices"
```

View file

@ -0,0 +1,272 @@
# Security Audit Report — TowerOps Web Application
**Date:** 2026-03-14
**Scope:** Authentication, authorization, and session security
**Auditor:** Automated code review (Skippy)
---
## Summary
The TowerOps application demonstrates a well-structured security posture overall. Authentication uses Argon2 password hashing, TOTP-based MFA, session token management with expiry, and CSRF protection. Organization-scoped data access is consistently enforced through `ScopedResource` and `AccessControl` helpers.
The findings below are verified issues found in the source code.
---
## Findings
### 1. API Device Create Allows `organization_id` Override
**Severity:** HIGH
**File:** `lib/towerops_web/controllers/api/v1/devices_controller.ex`, lines 245249
**Issue:** The `default_organization_id/2` function only sets `organization_id` when the param is `nil` or empty. If a client provides a *different* `organization_id` in the request body, it is passed through to `Devices.create_device/2` unchanged. This allows an API token holder for Org A to create devices in Org B.
```elixir
defp default_organization_id(device_params, organization_id) do
case Map.get(device_params, "organization_id") do
nil -> Map.put(device_params, "organization_id", organization_id)
"" -> Map.put(device_params, "organization_id", organization_id)
_provided_id -> device_params # ← attacker-controlled org_id accepted
end
end
```
**Suggested Fix:** Always force the authenticated organization_id:
```elixir
defp default_organization_id(device_params, organization_id) do
Map.put(device_params, "organization_id", organization_id)
end
```
---
### 2. Brute Force Protection Disabled in Production
**Severity:** HIGH
**File:** `lib/towerops_web/plugs/brute_force_protection.ex`, line 38
**Issue:** The plug's `call/2` function unconditionally returns `conn` without any processing. The comment says "TEMPORARILY DISABLED." While Cloudflare provides edge-level protection, if Cloudflare is bypassed or misconfigured, the application has no server-side brute force protection beyond rate limiting.
```elixir
def call(conn, _opts) do
# TEMPORARILY DISABLED - brute force protection bypassed
conn
end
```
**Suggested Fix:** Re-enable the brute force protection or remove the plug entirely to avoid a false sense of security. At minimum, document the Cloudflare dependency as a security requirement.
---
### 3. Admin API MIB/GeoIP Routes Use Application-Level Superuser Check, Not Pipeline
**Severity:** MEDIUM
**File:** `lib/towerops_web/router.ex`, lines 168176
**Files affected:** `lib/towerops_web/controllers/api/v1/mib_controller.ex`, `lib/towerops_web/controllers/api/v1/geoip_controller.ex`
**Issue:** The admin API scope (`/admin/api`) uses only the `:api_v1` pipeline (Bearer token auth) without the `:require_superuser` pipeline plug. Superuser checks are done inside each controller action via `require_superuser/1`. This is defense-in-depth weakness — if a developer adds a new action and forgets the check, it would be accessible to any API token holder.
```elixir
# Router - no superuser pipeline plug
scope "/admin/api", V1 do
pipe_through :api_v1
# ...
end
```
The controllers do implement their own `require_superuser` check, which works, but the `halt()` return from within `with :ok <- require_superuser(conn)` is not propagated correctly because `halt()` is called inside the private function but `with` still proceeds. Let me verify...
Actually, reviewing more carefully: the `require_superuser` function calls `halt()` on the conn and returns the halted conn. The `with :ok <-` pattern means if it doesn't return `:ok`, the `with` block falls through. But the `require_superuser/1` function calls `json` + `halt` and returns the conn, not `:ok`. So the `with` clause correctly handles this — if `require_superuser` returns the halted conn instead of `:ok`, the `with` doesn't match and the function returns without executing the body. **However**, the halted conn is not actually returned to the caller — it's discarded. The controller action returns normally without the halted conn.
**Wait** — re-reading the code: the `with :ok <- require_superuser(conn)` pattern means if `require_superuser` returns anything other than `:ok`, the `with` block uses its else clause or falls through. Since there's no explicit else, the non-matching return value (the halted conn) is returned as the function's return value. Phoenix will see that the conn is halted and stop processing. This is correct.
**Revised assessment:** The controller-level checks work correctly. The concern remains that this is a defense-in-depth gap — a pipeline-level check would be more robust.
**Suggested Fix:** Add a `require_superuser_api` plug to the admin API pipeline:
```elixir
scope "/admin/api", V1 do
pipe_through [:api_v1, :require_superuser_api]
end
```
---
### 4. Honeybadger API Key Hardcoded in Source
**Severity:** MEDIUM
**File:** `config/config.exs`, line 29
**Issue:** The Honeybadger API key (`hbp_xe5xMnpLZ2XJsXQJujoEkgCmiqCfwa0uYA3Y`) is hardcoded in version-controlled source. While Honeybadger is an error-tracking service (not end-user-facing), a leaked API key could allow attackers to send fake error reports, pollute error data, or potentially access error data containing sensitive information (stack traces, request params).
```elixir
config :honeybadger,
api_key: "hbp_xe5xMnpLZ2XJsXQJujoEkgCmiqCfwa0uYA3Y",
```
**Suggested Fix:** Move to an environment variable:
```elixir
config :honeybadger,
api_key: System.get_env("HONEYBADGER_API_KEY"),
```
---
### 5. Org Settings LiveView Missing Role-Based Authorization on Sensitive Operations
**Severity:** MEDIUM
**File:** `lib/towerops_web/live/org/settings_live.ex`, lines 103225
**Issue:** The `handle_event` callbacks for `"save"` (update org settings), `"send_invitation"`, `"remove_member"`, `"change_role"`, `"apply_snmp_to_all"`, and `"apply_agent_to_all"` do not check the current user's role/permissions. Any authenticated user who is a member of the organization can access the settings page (it's in the `require_authenticated_user_and_organization` live_session) and perform these operations, regardless of whether they are an owner, admin, or technician.
The `Permissions` module exists but is not imported or used in this LiveView. Only the backup delete event in `DeviceLive.Show` uses `owner?()`.
**Suggested Fix:** Add role checks to sensitive operations:
```elixir
def handle_event("save", %{"organization" => org_params}, socket) do
import ToweropsWeb.Permissions
unless admin?(socket), do: {:noreply, put_flash(socket, :error, "Permission denied")}
# ... existing logic
end
def handle_event("send_invitation", params, socket) do
import ToweropsWeb.Permissions
unless admin?(socket), do: {:noreply, put_flash(socket, :error, "Permission denied")}
# ... existing logic
end
```
---
### 6. Session Cookie Missing `secure: true` Flag
**Severity:** MEDIUM
**File:** `lib/towerops_web/endpoint.ex`, lines 914
**Issue:** The session cookie options don't set `secure: true`. While the comment in `prod.exs` says "SSL/TLS is handled by Cloudflared proxy," the cookie `secure` flag is independent of TLS termination — it tells the browser to only send the cookie over HTTPS connections. Without it, if a user somehow accesses the app over HTTP (e.g., direct IP access bypassing Cloudflare), the session cookie would be transmitted in cleartext.
```elixir
@session_options [
store: :cookie,
key: "_towerops_key",
signing_salt: "hrDZxLhd",
encryption_salt: "vK3p8mNx",
same_site: "Lax"
# missing: secure: true
]
```
**Suggested Fix:** Add `secure: true` for production:
```elixir
@session_options [
store: :cookie,
key: "_towerops_key",
signing_salt: "hrDZxLhd",
encryption_salt: "vK3p8mNx",
same_site: "Lax",
secure: Application.compile_env(:towerops, :env) == :prod
]
```
Or configure via `config/prod.exs`.
---
### 7. Remember-Me Cookie Missing `http_only: true` Flag
**Severity:** LOW
**File:** `lib/towerops_web/user_auth.ex`, lines 1923
**Issue:** The `@remember_me_options` for the remember-me cookie don't explicitly set `http_only: true`. While Phoenix's `put_resp_cookie` defaults to `http_only: true`, and `secure` defaults to the conn's scheme, it's best practice to be explicit. The `secure` flag is also not set, same concern as finding #6.
```elixir
@remember_me_options [
sign: true,
max_age: @max_cookie_age_in_days * 24 * 60 * 60,
same_site: "Lax"
# missing explicit: http_only: true, secure: true
]
```
**Suggested Fix:** Be explicit:
```elixir
@remember_me_options [
sign: true,
max_age: @max_cookie_age_in_days * 24 * 60 * 60,
same_site: "Lax",
http_only: true,
secure: true
]
```
---
### 8. No `force_ssl` Configured for Production
**Severity:** LOW
**File:** `config/prod.exs`, line 41
**Issue:** The comment acknowledges that SSL is handled by Cloudflared proxy, so `force_ssl` is not configured. However, without `force_ssl`, the application won't set HSTS headers. HSTS ensures browsers always use HTTPS even if a user types `http://`. This is a defense-in-depth measure.
**Suggested Fix:** Enable `force_ssl` with HSTS even behind Cloudflare:
```elixir
config :towerops, ToweropsWeb.Endpoint,
force_ssl: [hsts: true, rewrite_on: [:x_forwarded_proto]]
```
The `rewrite_on: [:x_forwarded_proto]` ensures it works correctly behind a proxy.
---
### 9. Alert Show Leaks Existence of Alerts Across Organizations via Error Type
**Severity:** LOW
**File:** `lib/towerops_web/controllers/api/v1/alerts_controller.ex`, lines 3244
**Issue:** The `show` action uses `Alerts.get_alert!(id)` which raises `Ecto.NoResultsError` for non-existent alerts, but returns a 404 with "Alert not found" for alerts that exist in other organizations. An attacker can distinguish between "alert doesn't exist" and "alert exists but belongs to another org" by observing whether the error is caught by the rescue clause or the if-else branch. In practice, the response is the same 404 message, but the code path differs (potential timing difference).
This is a very minor information leak. The same pattern exists in `acknowledge` and `resolve`.
**Suggested Fix:** Use a non-raising `get_alert/1` function that returns nil:
```elixir
def show(conn, %{"id" => id}) do
organization_id = conn.assigns.current_organization_id
case Alerts.get_alert(id) do
nil ->
conn |> put_status(:not_found) |> json(%{error: "Alert not found"})
alert ->
if alert.device && alert.device.organization_id == organization_id do
json(conn, %{data: format_alert(alert)})
else
conn |> put_status(:not_found) |> json(%{error: "Alert not found"})
end
end
end
```
---
## Positive Observations
These are **not findings** — they are security strengths worth noting:
1. **Password hashing:** Uses Argon2 with timing-safe comparison and `no_user_verify()` to prevent user enumeration.
2. **TOTP implementation:** Properly separates TOTP from recovery codes for sudo mode. Recovery codes cannot bypass sudo verification.
3. **Session management:** Tokens expire after 14 days, sessions are reissued after 7 days, and there's a 30-minute inactivity timeout.
4. **CSRF protection:** `protect_from_forgery` plug in browser pipeline, session renewal on login to prevent fixation.
5. **Organization scoping:** `ScopedResource` pattern consistently enforces org-level isolation in API controllers.
6. **LiveView auth:** All live_sessions use `on_mount` hooks for authentication/authorization checks.
7. **Impersonation audit trail:** Impersonation start/stop creates audit log entries with IP addresses.
8. **API token hashing:** Tokens are SHA-256 hashed before storage; raw tokens shown only once.
9. **Rate limiting:** Auth endpoints limited to 10 req/min, API to 1000 req/min per IP.
10. **GraphQL hardening:** Introspection disabled in production, complexity and depth limits enforced.
11. **Webhook signature verification:** Uses `Plug.Crypto.secure_compare` for timing-safe comparison.
12. **Content Security Policy:** Properly configured with `frame-ancestors 'none'` to prevent clickjacking.
13. **MIB upload path traversal protection:** Vendor names and filenames validated; archive contents checked for path traversal.
14. **Mobile API authorization:** Each endpoint verifies the user has access to the requested organization.
---
## Risk Summary
| Severity | Count | Key Issues |
|----------|-------|-----------|
| CRITICAL | 0 | — |
| HIGH | 2 | API org_id override, brute force protection disabled |
| MEDIUM | 3 | Admin API pipeline gap, hardcoded API key, missing role checks in org settings |
| LOW | 3 | Cookie flags, no HSTS, minor info leak |
**Overall Assessment:** No critical vulnerabilities found. The two HIGH findings should be addressed promptly — the API `organization_id` override is the most actionable. The brute force protection gap is mitigated by Cloudflare but represents a defense-in-depth weakness.

404
docs/SECURITY_AUDIT_API.md Normal file
View file

@ -0,0 +1,404 @@
# Security Audit: API & External Integrations
**Date:** 2026-03-14
**Scope:** REST API, GraphQL API, webhook handlers, integration clients, background workers
**Auditor:** Automated code review
---
## Summary
| Severity | Count |
|----------|-------|
| High | 2 |
| Medium | 5 |
| Low | 3 |
Overall the codebase demonstrates strong security practices: webhook signature verification uses `Plug.Crypto.secure_compare`, API tokens are hashed with SHA-256, credentials are encrypted at rest (Cloak AES-256-GCM), rate limiting is applied to auth and API endpoints, and GraphQL has complexity/depth limits. The findings below are areas for improvement.
---
## HIGH Severity
### H1: SSRF via User-Controlled Integration URLs (Sonar, Splynx)
**Files:**
- `lib/towerops/sonar/client.ex` — lines 141-142
- `lib/towerops/splynx/client.ex` — lines 13-15 (via `authenticate/3`), lines 95-96 (via `get/4`)
- `lib/towerops/sonar/sync.ex` — line 21
- `lib/towerops/splynx/sync.ex` — line 20
**Description:**
Sonar and Splynx integrations accept a user-supplied `instance_url` stored in `integration.credentials["instance_url"]`. This URL is directly concatenated into HTTP requests without any validation:
```elixir
# sonar/client.ex:142
url = String.trim_trailing(instance_url, "/") <> "/api/graphql"
# splynx/client.ex — authenticate/3 and get/4
url = String.trim_trailing(instance_url, "/") <> "/api/2.0/admin/auth/tokens"
```
An attacker with org-admin access could set `instance_url` to `http://169.254.169.254` (cloud metadata), `http://localhost:5432`, or any internal service, causing the server to make requests to internal network resources. The integration sync workers then execute these requests periodically in the background.
**Impact:** Server-Side Request Forgery (SSRF). Could access cloud metadata endpoints (AWS/GCP instance credentials), internal services, or scan internal networks.
**Suggested Fix:**
Add URL validation in `Towerops.Integrations` when creating/updating integrations:
```elixir
defp validate_instance_url(changeset) do
validate_change(changeset, :credentials, fn :credentials, creds ->
case creds["instance_url"] do
nil -> []
url ->
uri = URI.parse(url)
cond do
uri.scheme not in ["https", "http"] ->
[credentials: "instance_url must use http or https"]
is_private_ip?(uri.host) ->
[credentials: "instance_url cannot point to a private IP address"]
uri.host in ["localhost", "127.0.0.1", "::1", "0.0.0.0"] ->
[credentials: "instance_url cannot point to localhost"]
true -> []
end
end
end)
end
```
Also consider resolving the hostname and checking the IP before making requests.
---
### H2: Admin API Routes Missing Rate Limiting
**File:** `lib/towerops_web/router.ex` — lines 172-180
**Description:**
The admin API scope (`/admin/api`) only pipes through `:api_v1` but does **not** include `:rate_limit_api` or any rate limiting pipeline:
```elixir
scope "/admin/api", V1 do
pipe_through :api_v1 # <-- no rate_limit_api
get "/mibs", MibController, :index
post "/mibs", MibController, :upload
delete "/mibs/:vendor", MibController, :delete
post "/geoip/import", GeoipController, :import_database
end
```
While these endpoints do check for superuser status in the controller, a compromised superuser API token could be used to flood the server with MIB uploads or GeoIP imports without any rate limiting, potentially causing DoS.
**Impact:** Denial of service via resource exhaustion (disk space from MIB uploads, database from GeoIP imports).
**Suggested Fix:**
Add rate limiting to the admin API pipeline:
```elixir
scope "/admin/api", V1 do
pipe_through [:api_v1, :rate_limit_api] # or a stricter :admin_rate_limit
...
end
```
---
## MEDIUM Severity
### M1: GraphQL Complexity/Depth Limits Disabled in Non-Production Environments
**File:** `lib/towerops_web/graphql/context.ex` — lines 15-20
**Description:**
The GraphQL context plug conditionally applies complexity analysis only in production:
```elixir
opts =
if Application.get_env(:towerops, :env) == :prod do
[context: context, analyze_complexity: true, max_complexity: 500]
else
[context: context]
end
```
Note: The router (`router.ex:139-141`) does set `analyze_complexity: true, max_complexity: 500, max_depth: 10` at the Absinthe.Plug level. However, the Context plug **overwrites** these options via `Absinthe.Plug.put_options/2`, which replaces the entire options map. In non-production environments, this effectively removes the complexity and depth limits set in the router.
**Impact:** In staging/dev environments (which may be publicly accessible), deeply nested or highly complex queries could cause CPU/memory exhaustion.
**Suggested Fix:**
Apply complexity limits in all environments:
```elixir
def call(conn, _opts) do
context = build_context(conn)
Absinthe.Plug.put_options(conn,
context: context,
analyze_complexity: true,
max_complexity: 500
)
end
```
---
### M2: GraphQL Introspection Block is Bypassable
**File:** `lib/towerops_web/plugs/graphql_introspection.ex` — lines 18-24
**Description:**
The introspection check uses simple string matching:
```elixir
String.contains?(query, "__schema") or String.contains?(query, "__type")
```
This can be bypassed using:
1. **GraphQL aliases:** `query { myAlias: __schema { types { name } } }` — Actually still contains `__schema`, so this specific bypass won't work.
2. **Batch requests:** If the `query` parameter is sent as part of a JSON array (batch queries), `conn.body_params` won't match the `%{"query" => query}` pattern since it would be a list, not a map.
3. **The `operationName` + `variables` pattern:** The check only examines the `query` field, but if the GraphQL request uses persisted queries or extensions, the query text may not be in `body_params["query"]`.
More critically, the check only runs on `body_params["query"]`. If the query is sent via GET request with query parameters (which Absinthe supports by default), the body params would be empty and the check is bypassed entirely.
**Impact:** Schema exposure in production, revealing the full API surface to attackers.
**Suggested Fix:**
Also check query params for GET requests, and consider using Absinthe's built-in introspection control:
```elixir
# In schema.ex - more reliable approach
def context(ctx) do
if Application.get_env(:towerops, :env) == :prod do
Map.put(ctx, :introspection, false)
else
ctx
end
end
```
Or disable introspection at the Absinthe level by adding a phase or middleware.
---
### M3: Webhook Endpoints (Gaiia, PagerDuty) Lack Dedicated Rate Limiting
**File:** `lib/towerops_web/router.ex` — lines 163-169
**Description:**
The Gaiia and PagerDuty webhook routes share the general `:rate_limit_api` (1000 req/min per IP):
```elixir
scope "/api/v1/webhooks", V1 do
pipe_through [:api, :rate_limit_api]
post "/gaiia/:organization_id", GaiiaWebhookController, :create
post "/pagerduty/:organization_id", PagerdutyWebhookController, :create
end
```
These endpoints are unauthenticated (signature verification happens in the controller after the request is processed). At 1000 requests/minute, an attacker can:
- Force the server to perform 1000 database lookups per minute (to find the integration and its webhook secret)
- Force 1000 HMAC computations per minute
- The `organization_id` in the URL can be enumerated to discover valid organizations
**Impact:** Organization enumeration and moderate DoS potential through resource consumption.
**Suggested Fix:**
Apply stricter rate limiting (e.g., 60 req/min) to webhook endpoints:
```elixir
pipeline :rate_limit_webhook do
plug RateLimit, type: :webhook # Add a :webhook type with lower limits
end
```
---
### M4: Stripe Webhook Raw Body May Be Empty
**File:** `lib/towerops_web/controllers/api/v1/stripe_webhook_controller.ex` — line 14
**Description:**
The Stripe webhook controller reads the raw body with a fallback to empty string:
```elixir
raw_body = conn.private[:raw_body] || ""
```
If `raw_body` is empty (e.g., due to a middleware issue or the body being consumed), `verify_signature/2` would still attempt verification against an empty payload. The `parse_signature_header/1` would crash on `nil` input if the `stripe-signature` header is also missing (since `List.first([])` returns `nil`, and `String.split(nil, ",")` raises).
**Impact:** Unhandled exception could leak stack traces in non-production error responses, and the empty body fallback could mask misconfiguration issues.
**Suggested Fix:**
Add explicit nil/empty checks:
```elixir
def create(conn, _params) do
with signature when is_binary(signature) <- get_req_header(conn, "stripe-signature") |> List.first(),
raw_body when is_binary(raw_body) and raw_body != "" <- conn.private[:raw_body] do
# ... process
else
nil -> conn |> put_status(400) |> json(%{error: "Missing signature header"})
"" -> conn |> put_status(400) |> json(%{error: "Empty request body"})
end
end
```
---
### M5: `to_atom_keys` in IntegrationsController Could Crash on Invalid Keys
**File:** `lib/towerops_web/controllers/api/v1/integrations_controller.ex` — lines 89-93
**Description:**
The `to_atom_keys/1` helper converts user-supplied string keys to atoms using `String.to_existing_atom/1`:
```elixir
defp to_atom_keys(map) when is_map(map) do
Map.new(map, fn {k, v} -> {String.to_existing_atom(k), v} end)
rescue
ArgumentError -> map
end
```
While `String.to_existing_atom/1` is safe (won't create new atoms), the `rescue` clause catches `ArgumentError` and returns the original map with string keys. This means:
1. If ANY key is not an existing atom, ALL keys remain as strings (including valid ones)
2. The changeset then receives a mix of expected atom keys and unexpected string keys, potentially silently dropping valid fields
This is a correctness bug more than a security issue, but it could lead to integration updates silently failing to apply credential changes.
**Suggested Fix:**
Convert keys individually, falling back per-key:
```elixir
defp to_atom_keys(map) when is_map(map) do
Map.new(map, fn {k, v} ->
key = try do
String.to_existing_atom(k)
rescue
ArgumentError -> k
end
{key, v}
end)
end
```
---
## LOW Severity
### L1: API Token Verification Uses Database Lookup Instead of Constant-Time Comparison
**File:** `lib/towerops/api_tokens.ex` — lines 79-97
**Description:**
API token verification hashes the input token and does a database lookup:
```elixir
def verify_token(raw_token) do
token_hash = hash_token(raw_token)
case Repo.get_by(ApiToken, token_hash: token_hash) do
nil -> {:error, :invalid_token}
token -> ...
end
end
```
This is the standard and correct approach for hashed tokens — database lookup timing is dominated by query execution time, making timing attacks impractical. The hash comparison happens in PostgreSQL which uses constant-time comparison for indexed lookups.
However, the SHA-256 hash is not salted. While this doesn't enable practical attacks (tokens are 32 random bytes with ~256 bits of entropy), salted hashing (e.g., bcrypt) would provide defense-in-depth if the database were compromised.
**Impact:** Theoretical — if the database is breached, unsalted SHA-256 hashes of tokens are exposed. Given the high entropy of the tokens, brute-forcing is infeasible.
**No immediate action required.** Note for future consideration.
---
### L2: Mobile Session Token Lookup Timing
**File:** `lib/towerops/mobile_sessions.ex` — lines 38-47
**Description:**
Mobile session tokens are hashed and looked up via database query:
```elixir
def get_session_by_token(token) when is_binary(token) do
hashed = MobileSession.hash_token(token)
MobileSession
|> where([s], s.token == ^hashed)
|> where([s], s.expires_at > ^now)
|> Repo.one()
end
```
Same pattern as API tokens — hash-then-lookup. This is secure for the same reasons as L1.
**No action required.**
---
### L3: Gaiia Webhook Signature Mismatch Logging Leaks Partial Signatures
**File:** `lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex` — lines 96-103
**Description:**
On signature mismatch, the controller logs partial signature values:
```elixir
Logger.warning(
"Gaiia webhook signature mismatch — " <>
"ts=#{timestamp} body_len=#{byte_size(raw_body)} " <>
"secret_len=#{byte_size(secret)} " <>
"expected=#{String.slice(expected, 0, 12)}… " <>
"received=#{String.slice(v1_signature, 0, 12)}…"
)
```
This logs:
- The length of the webhook secret (`secret_len`)
- The first 12 characters of the expected HMAC signature
While the HMAC output is not the secret itself, logging the secret length reveals information that could help an attacker. The first 12 hex chars of the expected signature provide limited information (6 bytes of a 32-byte HMAC).
**Impact:** Minor information leakage in logs. Not directly exploitable but violates principle of least information.
**Suggested Fix:**
Remove `secret_len` and `expected` from the log message:
```elixir
Logger.warning(
"Gaiia webhook signature mismatch — " <>
"ts=#{timestamp} body_len=#{byte_size(raw_body)}"
)
```
---
## Positive Findings (No Issues)
These areas were reviewed and found to be properly secured:
1. **Webhook signature verification:** All three webhook handlers (Stripe, Gaiia, PagerDuty) use `Plug.Crypto.secure_compare/2` for constant-time signature comparison. ✅
2. **Agent webhook auth:** Uses `Plug.Crypto.secure_compare/2` against a shared secret. ✅
3. **Rate limiting:** Applied to auth endpoints (10/min), API endpoints (1000/min), and admin browser routes (100/min). ✅
4. **GraphQL depth/complexity limits:** Router configures `max_depth: 10` and `max_complexity: 500` with `analyze_complexity: true`. ✅ (But see M1 about context plug overwriting in non-prod.)
5. **Credentials at rest:** Integration credentials use `Encrypted.Map` (Cloak AES-256-GCM). ✅
6. **CSRF protection:** Browser pipeline includes `:protect_from_forgery`. ✅
7. **Security headers:** CSP, frame-ancestors, and secure browser headers configured. ✅
8. **MIB upload path traversal protection:** Vendor names validated with regex, extracted archive paths validated against extraction directory. ✅
9. **Oban worker error handling:** Workers use `max_attempts` configuration and return `:ok` on non-retryable errors, `{:error, reason}` on retryable errors. No infinite retry loops found. ✅
10. **Organization scoping:** API controllers consistently scope queries to `conn.assigns.current_organization_id`. ✅
11. **Integration client error handling:** All HTTP clients (VISP, Sonar, Splynx, Gaiia, Preseem) handle error responses gracefully with pattern matching and don't leak credentials in error messages. ✅
12. **Token generation:** Uses `:crypto.strong_rand_bytes(32)` for API tokens — cryptographically secure. ✅

View file

@ -0,0 +1,350 @@
# Security Audit: Input Validation, Injection & Data Handling
**Date:** 2026-03-14
**Auditor:** Automated Security Review (Skippy)
**Scope:** `lib/towerops/`, `lib/towerops_web/live/`, `lib/towerops_web/controllers/`, `lib/towerops_web/components/`
---
## Summary
| Severity | Count |
|----------|-------|
| 🔴 High | 3 |
| 🟠 Medium | 5 |
| 🟡 Low | 4 |
---
## 🔴 HIGH Severity
### H1: IDOR — API Device Creation Allows Cross-Organization Assignment
**File:** `lib/towerops_web/controllers/api/v1/devices_controller.ex`, lines 245250
**File:** `lib/towerops/devices/device.ex`, lines 133140 (`organization_id` in cast list)
**Issue:** The `default_organization_id/2` function only sets `organization_id` when it's `nil` or `""`. If the caller provides a different `organization_id`, it passes through unmodified. Since `organization_id` is in the Device changeset's `cast` list, an authenticated API user can create devices in other organizations by supplying a foreign `organization_id` in the request body.
```elixir
# devices_controller.ex:245-250
defp default_organization_id(device_params, organization_id) do
case Map.get(device_params, "organization_id") do
nil -> Map.put(device_params, "organization_id", organization_id)
"" -> Map.put(device_params, "organization_id", organization_id)
_provided_id -> device_params # ← User-supplied org_id passes through!
end
end
```
**Impact:** An authenticated user with a valid API token can create devices (and trigger SNMP discovery) in any organization they know the ID of.
**Fix:** Always force the authenticated organization's ID:
```elixir
defp default_organization_id(device_params, organization_id) do
Map.put(device_params, "organization_id", organization_id)
end
```
---
### H2: IDOR — Agent Token Deletion Without Organization Scoping
**File:** `lib/towerops_web/live/agent_live/index.ex`, line 163
**File:** `lib/towerops/agents.ex`, line 451
**Issue:** The `"delete_agent"` event handler passes the user-supplied `id` directly to `Agents.delete_agent_token(id)` without verifying the agent token belongs to the current user's organization. `delete_agent_token/1` fetches by raw ID with `Repo.get!`, no org check.
```elixir
# agent_live/index.ex:163
def handle_event("delete_agent", %{"id" => id}, socket) do
case Agents.delete_agent_token(id) do # ← No org scope check!
```
**Impact:** A user could delete agent tokens belonging to other organizations by crafting a WebSocket event with a known agent token ID.
**Fix:** Verify organization ownership before deletion:
```elixir
def handle_event("delete_agent", %{"id" => id}, socket) do
organization = socket.assigns.current_scope.organization
case Agents.get_organization_agent_token(organization.id, id) do
nil -> {:noreply, put_flash(socket, :error, "Agent not found")}
agent_token ->
case Agents.delete_agent_token(agent_token.id) do
# ...
end
end
end
```
---
### H3: IDOR — Schedule/Escalation Policy Access Without Organization Check
**Files:**
- `lib/towerops_web/live/schedule_live/show.ex`, line 19
- `lib/towerops_web/live/schedule_live/form.ex`, line 27
- `lib/towerops_web/live/escalation_policy_live/show.ex`, line 10
- `lib/towerops_web/live/escalation_policy_live/form.ex`, line 27
**Issue:** These LiveViews fetch schedules and escalation policies by raw ID (`OnCall.get_schedule!(id)`, `OnCall.get_escalation_policy!(id)`) without verifying the resource belongs to the user's current organization. Any authenticated user who knows a schedule/policy UUID can view, edit, and delete it.
```elixir
# schedule_live/show.ex:19
schedule = OnCall.get_schedule!(id) # ← No org check
# escalation_policy_live/show.ex:10
policy = OnCall.get_escalation_policy!(id) # ← No org check
```
**Impact:** Cross-organization read/write/delete access to on-call schedules and escalation policies.
**Fix:** Add organization scoping to the queries:
```elixir
def get_schedule!(id, organization_id) do
Schedule
|> where(id: ^id, organization_id: ^organization_id)
|> Repo.one!()
|> Repo.preload(overrides: :user, layers: [members: :user])
end
```
Then use `OnCall.get_schedule!(id, organization.id)` in the LiveViews.
---
## 🟠 MEDIUM Severity
### M1: LIKE Wildcard Injection in Multiple Search Functions
**Files:**
- `lib/towerops/devices.ex`, line 148 (`search_devices/2`)
- `lib/towerops/sites.ex`, line 40 (`search_sites/2`)
- `lib/towerops/gaiia.ex`, lines 122, 133, 208 (`search_inventory_items`, `search_network_sites`, `suggest_site_matches`)
**Issue:** These functions build ILIKE patterns as `"%#{query}%"` without escaping `%` and `_` wildcards. A user can submit `%` as a search query to match all records, or use `_` for single-character wildcards.
Compare with `lib/towerops/search.ex` (lines 8993) and `lib/towerops/trace.ex` (lines 456461) which correctly call `sanitize`/`sanitize_like`.
```elixir
# devices.ex:148 — NO sanitization
search_term = "%#{query}%"
# search.ex:89 — CORRECT
defp sanitize(query) do
query
|> String.replace("%", "\\%")
|> String.replace("_", "\\_")
end
```
**Impact:** Information disclosure — users can craft patterns to enumerate all records or perform wildcard-based probing. Low direct risk since queries are org-scoped, but violates defense-in-depth.
**Fix:** Add `sanitize_like/1` to all search functions that build ILIKE patterns, or extract a shared helper.
---
### M2: `Jason.decode!` on Untrusted Input (Crash on Malformed JSON)
**File:** `lib/towerops_web/live/device_live/index.ex`, line 196
**Issue:** `Jason.decode!(params["identifier"])` will raise a `Jason.DecodeError` if the client sends malformed JSON. While Phoenix LiveView catches this and the process restarts, it causes a brief disruption for the user and generates noisy error logs.
```elixir
def handle_event("add_discovered_device", params, socket) do
identifier = Jason.decode!(params["identifier"]) # ← Crashes on bad input
```
**Impact:** Denial of service for the user's LiveView session. The process restarts, but data in assigns is lost.
**Fix:** Use `Jason.decode/1` with error handling:
```elixir
case Jason.decode(params["identifier"]) do
{:ok, identifier} -> # proceed
{:error, _} -> {:noreply, put_flash(socket, :error, "Invalid identifier")}
end
```
---
### M3: Role Change Without Caller Permission Check
**File:** `lib/towerops_web/live/org/settings_live.ex`, lines 208223
**Issue:** The `"change_role"` event handler calls `Organizations.update_member_role/3` without checking whether the **current user** has permission to change roles. Any authenticated member of the organization can change anyone else's role (except the owner's). A `member` or `viewer` role user could promote themselves to `admin`.
The `update_member_role/3` function only prevents changing the owner's role, but doesn't verify the caller's role:
```elixir
def update_member_role(organization_id, user_id, new_role) do
case get_membership(organization_id, user_id) do
%Membership{role: :owner} -> {:error, :cannot_change_owner_role}
%Membership{} = membership -> update_membership(membership, %{role: new_role})
# ← No check that the CALLER is an owner/admin
end
end
```
**Impact:** Privilege escalation — any org member can promote themselves to `admin` role.
**Fix:** Check that the current user is an owner or admin before allowing role changes:
```elixir
def handle_event("change_role", %{"user-id" => user_id, "role" => role}, socket) do
organization = socket.assigns.organization
current_user = socket.assigns.current_scope.user
# Verify caller has permission
caller_membership = Organizations.get_membership(organization.id, current_user.id)
unless caller_membership.role in [:owner, :admin] do
{:noreply, put_flash(socket, :error, "You don't have permission to change roles")}
else
# proceed with role change
end
end
```
---
### M4: SSRF via HTTP Monitoring Checks
**File:** `lib/towerops/monitoring/executors/http_executor.ex`
**File:** `lib/towerops_web/live/check_live/form_component.ex`, lines 282303
**Issue:** Users can create HTTP monitoring checks with arbitrary URLs. The executor makes server-side HTTP requests to these URLs without restricting the target. This could be used to probe internal services (metadata endpoints, localhost services, internal APIs).
```elixir
# http_executor.ex — No URL validation/restriction
opts = [
method: String.to_atom(method),
url: Map.fetch!(config, "url"), # ← Any URL, including internal
```
**Impact:** Server-side request forgery (SSRF). Can probe internal network, cloud metadata services (169.254.169.254), and localhost services.
**Fix:** Validate URLs against an allowlist or block internal/private IP ranges before making the request. At minimum, block requests to `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.169.254`, and `::1`.
---
### M5: `inspect()` Leaking Internal Details in API Error Responses
**Files:**
- `lib/towerops_web/controllers/api/v1/mib_controller.ex`, line 93
- `lib/towerops_web/controllers/api/v1/agent_release_webhook_controller.ex`, line 26
**Issue:** These endpoints return `inspect(reason)` in JSON error responses, which can leak internal Elixir data structures, module names, and system paths to API clients.
```elixir
# mib_controller.ex:93
|> json(%{error: inspect(reason)}) # ← Leaks internal error details
# agent_release_webhook_controller.ex:26
|> json(%{status: "error", error: inspect(reason)})
```
**Impact:** Information disclosure — internal error details aid attackers in understanding the application structure.
**Fix:** Return generic error messages to clients and log the detailed error server-side:
```elixir
Logger.error("MIB listing failed: #{inspect(reason)}")
json(conn, %{error: "Internal server error"})
```
---
## 🟡 LOW Severity
### L1: `String.to_integer/1` on URL Params Without Validation
**Files:**
- `lib/towerops_web/live/device_live/index.ex`, line 67: `String.to_integer()`
- `lib/towerops_web/live/device_live/show.ex`, line 76: `String.to_integer()`
**Issue:** `String.to_integer/1` raises `ArgumentError` on non-numeric input. If a user crafts a URL with `?page=abc`, the LiveView process crashes.
**Impact:** Minor DoS — LiveView process restarts. Low severity because LiveView handles this gracefully with reconnection.
**Fix:** Use `Integer.parse/1` with a default fallback:
```elixir
page = case Integer.parse(Map.get(params, "page", "1")) do
{n, ""} when n > 0 -> n
_ -> 1
end
```
---
### L2: Unverified TOTP Device Deletion Race
**File:** `lib/towerops_web/live/user_settings_live/totp_manager.ex`, line 57
**Issue:** `Repo.get!(UserTotpDevice, device_id)` fetches the TOTP device by ID from `socket.assigns.new_device_id` without verifying it still belongs to the current user. While `new_device_id` is set server-side, a race condition exists if the user opens the verification flow in multiple tabs.
**Impact:** Minimal — the device ID comes from server-side assigns, not user input. Theoretical only.
**Fix:** Scope the query: `Repo.get_by!(UserTotpDevice, id: device_id, user_id: user.id)`
---
### L3: `raw()` Usage in Registration Template with `t_auth()` Translations
**File:** `lib/towerops_web/controllers/user_registration_html/new.html.heex`, lines 164, 187
**Issue:** The `raw()` call wraps the output of `t_auth()` translation, which includes interpolated values. If translation strings are ever sourced from user-editable content (CMS, admin panel), this becomes an XSS vector. Currently safe because translations are compile-time `.po` files.
```heex
raw(~s(<a href="/privacy" ...>#{t_auth("Privacy Policy")}</a>))
```
**Impact:** Currently no risk (compile-time translations). Becomes XSS if translation source changes.
**Fix:** Use HEEx components instead of `raw()` for link construction:
```heex
<a href="/privacy" target="_blank" class="..."><%= t_auth("Privacy Policy") %></a>
```
---
### L4: HTTP Executor `String.to_atom/1` on User Input
**File:** `lib/towerops/monitoring/executors/http_executor.ex`, line 57
**Issue:** `String.to_atom(method)` converts the HTTP method from config to an atom. While the method comes from check config (not direct user request params), atoms are not garbage collected. Repeated creation of unique method strings could leak atoms, though in practice the valid HTTP methods are a small fixed set.
```elixir
method: String.to_atom(method), # ← Atom table pollution risk
```
**Impact:** Theoretical atom table exhaustion. Low because check config is validated and limited.
**Fix:** Use `String.to_existing_atom/1` or a whitelist:
```elixir
defp method_to_atom("get"), do: :get
defp method_to_atom("post"), do: :post
defp method_to_atom("put"), do: :put
defp method_to_atom("delete"), do: :delete
defp method_to_atom("head"), do: :head
defp method_to_atom("patch"), do: :patch
defp method_to_atom(_), do: :get
```
---
## ✅ Positive Findings (Things Done Well)
1. **AccessControl module** (`lib/towerops_web/live/helpers/access_control.ex`) — Devices, sites, and alerts are properly scoped to organization. Good pattern.
2. **LIKE sanitization** in `Search`, `Trace`, and `Snmp` modules — Properly escapes `%` and `_` wildcards.
3. **MIB Controller path traversal protection** — Vendor names are validated against `^[a-zA-Z0-9_-]+$`, extracted archive paths are validated against the extraction directory, and filenames use `Path.basename()`.
4. **Stripe webhook signature verification** — Uses `Plug.Crypto.secure_compare/2` for timing-safe comparison and validates timestamp tolerance.
5. **Error JSON handler**`ErrorJSON` returns generic 500 messages without leaking internals.
6. **CSRF protection** — Phoenix's built-in CSRF tokens are active for all forms.
7. **HEEx auto-escaping** — All templates use `{@variable}` syntax which auto-escapes by default. No XSS from dynamic user content in templates.
8. **Ecto parameterized queries** — All `fragment()` calls use `^variable` bindings, preventing SQL injection.
9. **Encrypted sensitive fields** — SNMP passwords and MikroTik credentials use `Encrypted.Binary` type.
10. **Backup deletion** — Properly verifies device organization access via `AccessControl.verify_device_access/2` before allowing deletion.

234
docs/TEST_COVERAGE.md Normal file
View file

@ -0,0 +1,234 @@
# TowerOps Test Coverage Report
**Generated:** 2026-03-13
**Test Run:** 90 doctests, 55 properties, 8,134 tests | 4 failures, 38 skipped, 231 excluded
**Duration:** 158.2s (40.5s async, 117.6s sync)
## Summary
| Metric | Count |
|---|---|
| Total test files | 547 |
| Total tests (incl. doctests/properties) | 8,279 |
| Passing | 8,237 |
| Failures | 4 |
| Skipped | 38 |
| Excluded | 231 |
| Source modules (excl. vendor profiles, migrations, proto) | ~230 |
| Web modules (controllers, LiveViews, plugs, etc.) | ~140 |
| Modules with NO test file | ~100 |
## Current Failures (4)
1. **`ToweropsWeb.AgentLive.IndexTest`** — Unique constraint violation on `agents.slug` during setup (test data collision)
2. **`ToweropsWeb.AgentLive.IndexTest`** — Same slug collision in cloud poller creation setup
3. **`ToweropsWeb.AgentLive.IndexTest`** — Same root cause, different describe block
4. **`ToweropsWeb.GraphQL.Resolvers.TimeSeriesTest`** — Deadlock on `create_check_result` (async test hitting `ShareRowExclusiveLock`)
> Both are infrastructure/setup issues, not logic bugs. The agent slug collision likely needs a unique factory counter; the deadlock needs `async: false` or transaction isolation.
---
## Test Infrastructure & Patterns
### Case Templates
- **`ToweropsWeb.ConnCase`** — HTTP/LiveView tests. Imports `Phoenix.ConnTest`, `Plug.Conn`, verified routes. Has `register_and_log_in_user/1` and sudo variant.
- **`Towerops.DataCase`** — Context/schema tests. Imports `Ecto`, `Ecto.Changeset`, `Ecto.Query`, `Towerops.Repo`. SQL Sandbox isolation.
- **`ToweropsWeb.LiveViewTestHelpers`** — Shared helpers for LiveView tests.
### Fixtures (Factory Pattern)
All in `test/support/fixtures/`:
- `accounts_fixtures.ex` — Users, tokens, TOTP
- `agents_fixtures.ex` — Agents, assignments, tokens
- `billing_fixtures.ex` — Stripe data
- `devices_fixtures.ex` — Devices, firmware
- `gaiia_fixtures.ex` — GAIIA accounts, subscribers
- `integrations_fixtures.ex` — Integration configs
- `jobs_fixtures.ex` — Oban jobs
- `maintenance_fixtures.ex` — Maintenance windows
- `on_call_fixtures.ex` — Schedules, escalation policies
- `organizations_fixtures.ex` — Orgs, memberships, invitations
- `snmp_fixtures.ex` — SNMP devices, interfaces
### Test Types
- **Async tests:** ~442 files use `async: true`
- **Property tests:** 55 (using `StreamData`)
- **Doctests:** 90
- **Integration tests:** `test/integration/` (SNMP, discovery parity)
- **SnmpKit tests:** Dedicated `test/snmpkit/` directory with ~45 test files
### Mocking
- **`Mox`** — Used for behaviour-based mocks (SNMP, ping, HTTP)
- **`ping_stub.ex`** — Stub for ping behaviour
- **`snmp_kit_mock.ex`** — SNMP client mock
---
## Coverage by Domain
### ✅ Well-Covered Domains
| Domain | Source Files | Test Files | Notes |
|---|---|---|---|
| **Accounts** | 13 | 14+ | Comprehensive auth, TOTP, sessions, HIBP, browser sessions |
| **Agents** | 5 | 8+ | Assignments, tokens, stats, release checker, cloud poller |
| **Alerts** | 2 | 2 | Alert schema + context |
| **API Tokens** | 2 | 2 | Token CRUD |
| **Billing** | 5 | 4 | Stripe client, webhook processor, notifier (missing: `stripe_webhook_event` schema) |
| **Devices** | 14 | 10+ | Core CRUD, firmware, backups, differ, version comparator |
| **GAIIA** | 14 | 13 | Excellent: sync, reconciliation, subscriber matching, impact analysis |
| **Organizations** | 6 | 7+ | Policies, invitations, subscriptions, property tests |
| **Preseem** | 14 | 14 | Full coverage: sync, baselines, insights, fleet intelligence |
| **Profiles** | 7 | 5 | YAML profiles, device/sensor OIDs |
| **SNMP Core** | 30+ | 25+ | Discovery, polling, sanitizer, sensor change, MIB parsing |
| **SNMP Vendors** | 180+ | 180+ | Auto-generated, 1:1 coverage |
| **Workers** | 30 | 22 | Most critical workers covered |
| **Web: Plugs** | 13 | 11 | Auth, rate limiting, security headers, brute force |
| **Web: Controllers** | 25+ | 25+ | API v1, admin, auth flow controllers |
| **Web: LiveViews** | 40+ | 35+ | Most major pages tested |
### ⚠️ Gaps — Modules Without Test Files
#### 🔴 Critical (Needs Tests NOW)
These modules handle auth, billing, alert routing, or data mutations with no dedicated tests:
| Module | Why Critical |
|---|---|
| `Towerops.Monitoring` (context) | Core monitoring orchestration — check creation, status evaluation |
| `Towerops.Monitoring.Executor` | Dispatches monitoring checks to executors |
| `Towerops.Monitoring.Executors.DnsExecutor` | DNS monitoring — untested execution path |
| `Towerops.Monitoring.Executors.HttpExecutor` | HTTP monitoring — untested execution path |
| `Towerops.Monitoring.Executors.TcpExecutor` | TCP monitoring — untested execution path |
| `Towerops.Monitoring.Executors.SnmpInterfaceExecutor` | SNMP interface monitoring |
| `Towerops.Monitoring.Executors.SnmpProcessorExecutor` | SNMP CPU monitoring |
| `Towerops.Monitoring.Executors.SnmpStorageExecutor` | SNMP storage monitoring |
| `Towerops.OnCall.Notifier` | Sends on-call notifications — critical alert path |
| `Towerops.OnCall.Incident` | Incident lifecycle — create, ack, resolve |
| `Towerops.OnCall.EscalationPolicy` | Escalation rule evaluation |
| `Towerops.OnCall.Notification` | Notification delivery schema |
| `Towerops.PagerDuty.Notifier` | External PagerDuty alert dispatch |
| `Towerops.Security.CloudflareClient` | Cloudflare IP banning — security mutation |
| `Towerops.Security.IpBlock` / `IpWhitelist` | IP access control schemas |
| `Towerops.Billing.StripeWebhookEvent` | Stripe event persistence schema |
| `Towerops.Accounts.UserRecoveryCode` | Account recovery — security-critical |
| `Towerops.Accounts.UserToken` | Token schema (tested indirectly via Accounts context but no direct schema test) |
#### 🟠 High Priority
| Module | Why |
|---|---|
| `Towerops.Maintenance` (context) | Maintenance window CRUD and scheduling logic |
| `Towerops.ConfigChanges` (context) | Config change tracking and correlation |
| `Towerops.ConfigChanges.Correlator` | Correlates config changes to alerts |
| `Towerops.Topology.Lldp` | LLDP neighbor discovery logic |
| `Towerops.Topology.Identifier` | Device identification heuristics |
| `Towerops.Netbox.Sync` | External NetBox sync — data import |
| `Towerops.Sonar.Client` / `Sync` | Sonar billing integration |
| `Towerops.Weather.Client` | Weather API integration |
| `Towerops.Workers.EscalationCheckWorker` | Periodic escalation evaluation |
| `Towerops.Workers.CheckWorker` | Monitoring check scheduling |
| `Towerops.Workers.MikrotikBackupWorker` | MikroTik backup orchestration |
| `Towerops.Workers.CloudflareBanWorker` | Security: auto-ban via Cloudflare |
| `ToweropsWeb.Controllers.Api.V1.PagerdutyWebhookController` | Inbound PagerDuty webhooks |
| `ToweropsWeb.Controllers.Api.V1.ActivityController` | Activity feed API |
| `ToweropsWeb.Controllers.Api.V1.CheckResultsController` | Check results API |
| `ToweropsWeb.Controllers.InvitationController` | Org invitation acceptance |
| `ToweropsWeb.Controllers.UserConfirmationController` | Email confirmation flow |
| `ToweropsWeb.Controllers.UserRegistrationController` | User signup flow |
| `ToweropsWeb.Live.OnboardingLive` | New user onboarding |
| `ToweropsWeb.Live.Org.GaiiaMappingLive` | GAIIA device mapping UI |
| `ToweropsWeb.Live.Org.GaiiaReconciliationLive` | GAIIA reconciliation UI |
#### 🟡 Medium Priority
| Module | Why |
|---|---|
| `Towerops.Geocoding` | Address-to-coordinates (read-only helper) |
| `Towerops.GeoIp.Block` / `Location` | GeoIP lookup schemas |
| `Towerops.Vault` | Encryption key management |
| `Towerops.RateLimit` | Rate limiting logic |
| `Towerops.Devices.Event` / `BackupRequest` | Event/request schemas |
| `Towerops.Devices.MikrotikBackup` | Backup schema |
| `Towerops.OnCall.Schedule` / `Layer` / `Override` | On-call schedule schemas |
| `Towerops.Organizations.Membership` | Membership schema |
| `Towerops.SNMP.Interface` / `Sensor` / `Device` schemas | Data schemas |
| `Towerops.Workers.*SyncWorker` (sonar, splynx, visp, weather, netbox) | Sync orchestration |
| `ToweropsWeb.Live.CapacityLive` | Capacity planning page |
| `ToweropsWeb.Live.ConfigTimelineLive` | Config change timeline |
| `ToweropsWeb.Live.ActivityFeedLive` | Activity feed page |
| `ToweropsWeb.Live.MikrotikBackupLive.Compare` | Backup diff viewer |
| `ToweropsWeb.GraphQL.Context` | GraphQL context setup |
#### 🟢 Low Priority
| Module | Notes |
|---|---|
| `Towerops.EctoTypes.EncryptedBinary` | Simple Ecto type (encrypted_map is tested) |
| `Towerops.Settings.ApplicationSetting` | Simple schema |
| `Towerops.Mailer` | Swoosh wrapper |
| `Towerops.Release` | Mix release tasks |
| `Towerops.Repo` | Ecto repo module |
| `Towerops.SNMP.SnmpBehaviour` | Behaviour definition |
| `Towerops.SNMP.PollerBehaviour` | Behaviour definition |
| `Towerops.Monitoring.PingBehaviour` | Behaviour definition |
| `Towerops.Profiles.MibCache` / `ProfileWatcher` | GenServer infrastructure |
| Various SNMP data schemas (readings, stats) | Simple structs |
---
## Top 10 Recommended Tests to Write
### 1. `test/towerops/monitoring_test.exs` — Monitoring Context
**Impact: 🔴 Critical**
Cover: `create_check/2`, `update_check/2`, `delete_check/2`, `list_checks_for_device/1`, check result creation and status evaluation. This is the core monitoring orchestration layer with zero test coverage.
### 2. `test/towerops/monitoring/executor_test.exs` — Check Executor Dispatch
**Impact: 🔴 Critical**
Cover: Executor dispatch logic, timeout handling, error wrapping. Test with mocked executors that the right executor is called for each check type (HTTP, DNS, TCP, SNMP).
### 3. `test/towerops/on_call/notifier_test.exs` — On-Call Notification Delivery
**Impact: 🔴 Critical**
Cover: Notification channel selection (email, SMS, push), retry logic, escalation triggering, de-duplication. Mock external delivery and verify correct notification flow.
### 4. `test/towerops/on_call/incident_test.exs` — Incident Lifecycle
**Impact: 🔴 Critical**
Cover: Incident creation from alert, acknowledgment, resolution, re-escalation on timeout. Verify state machine transitions and escalation policy evaluation.
### 5. `test/towerops/monitoring/executors/http_executor_test.exs` — HTTP Check Execution
**Impact: 🔴 Critical**
Cover: HTTP status checks, response time measurement, SSL verification, timeout handling, redirect following, body content matching. Mock HTTP client.
### 6. `test/towerops/config_changes_test.exs` — Config Change Tracking
**Impact: 🟠 High**
Cover: Change event creation, correlation with alerts/devices, timeline querying, diff computation. Verify correlator links config changes to relevant alerts.
### 7. `test/towerops/maintenance_test.exs` — Maintenance Window Context
**Impact: 🟠 High**
Cover: Window creation/scheduling, overlap detection, active window queries, alert suppression during maintenance. Verify `is_in_maintenance?/2` logic.
### 8. `test/towerops_web/controllers/api/v1/pagerduty_webhook_controller_test.exs` — PagerDuty Webhooks
**Impact: 🟠 High**
Cover: Webhook signature verification, event type handling (trigger, acknowledge, resolve), idempotency, error responses for invalid payloads.
### 9. `test/towerops/security/cloudflare_client_test.exs` — Cloudflare IP Management
**Impact: 🟠 High**
Cover: IP block creation/deletion via Cloudflare API, error handling, rate limiting. Mock Cloudflare API. Verify the ban worker integrates correctly.
### 10. `test/towerops_web/controllers/user_registration_controller_test.exs` — User Registration
**Impact: 🟠 High**
Cover: Successful registration, duplicate email handling, password validation, email confirmation trigger, registration with invitation token. Critical auth flow.
---
## Test Health Notes
- **Async ratio is excellent** — ~81% of test files use `async: true`
- **Property testing is in use** — 55 property tests via StreamData (found in schema validation, Ecto type tests)
- **Fixture coverage is comprehensive** — 14 fixture modules covering all major domains
- **Vendor profile tests are auto-generated** — 180+ vendor tests likely from `mix gen_vendor_tests`
- **The 4 current failures are infrastructure issues**, not logic bugs:
- Agent slug uniqueness collision in test factories → fix: add `System.unique_integer()` to slug generation
- Deadlock in async time series test → fix: set `async: false` or restructure DB access
- **231 excluded tests** — likely tagged `:integration` or `:external` for CI isolation
- **38 skipped tests** — worth auditing to see if any are permanently broken

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

227
docs/templates/context-module.md vendored Normal file
View file

@ -0,0 +1,227 @@
# Ecto Context Module Template
Copy-pasteable template for a new Ecto schema + context + test following TowerOps conventions.
Replace `__CONTEXT__` with PascalCase context name (e.g., `Schedules`).
Replace `__SCHEMA__` with PascalCase schema name (e.g., `Schedule`).
Replace `__resource__` with snake_case (e.g., `schedule`).
Replace `__resources__` with plural snake_case (e.g., `schedules`).
Replace `__table__` with the database table name (e.g., `schedules`).
---
## `lib/towerops/__context__/__resource__.ex` (Schema)
```elixir
defmodule Towerops.__CONTEXT__.__SCHEMA__ do
@moduledoc """
Schema for __resources__.
Describe what this resource represents and its relationships.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.Organizations.Organization
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "__table__" do
field :name, :string
field :description, :string
field :enabled, :boolean, default: true
belongs_to :organization, Organization
timestamps()
end
@required_fields ~w(name organization_id)a
@optional_fields ~w(description enabled)a
def changeset(__resource__, attrs) do
__resource__
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> validate_length(:name, max: 255)
|> validate_length(:description, max: 1000)
|> foreign_key_constraint(:organization_id)
end
end
```
---
## `lib/towerops/__context__.ex` (Context)
```elixir
defmodule Towerops.__CONTEXT__ do
@moduledoc """
Context for managing __resources__.
"""
import Ecto.Query
alias Towerops.__CONTEXT__.__SCHEMA__
alias Towerops.Repo
@preloads [:organization]
def create___resource__(attrs) do
%__SCHEMA__{}
|> __SCHEMA__.changeset(attrs)
|> Repo.insert()
end
def update___resource__(%__SCHEMA__{} = __resource__, attrs) do
__resource__
|> __SCHEMA__.changeset(attrs)
|> Repo.update()
end
def delete___resource__(%__SCHEMA__{} = __resource__) do
Repo.delete(__resource__)
end
def get___resource__!(id) do
__SCHEMA__
|> Repo.get!(id)
|> Repo.preload(@preloads)
end
def list___resources__(organization_id, opts \\ []) do
__SCHEMA__
|> where([r], r.organization_id == ^organization_id)
|> apply_filter(opts[:filter])
|> order_by([r], desc: r.inserted_at)
|> Repo.all()
|> Repo.preload(@preloads)
end
defp apply_filter(query, :active) do
where(query, [r], r.enabled == true)
end
defp apply_filter(query, :archived) do
where(query, [r], r.enabled == false)
end
defp apply_filter(query, _), do: query
end
```
---
## Migration
```elixir
defmodule Towerops.Repo.Migrations.Create__SCHEMA__s do
use Ecto.Migration
def change do
create table(:__table__, primary_key: false) do
add :id, :binary_id, primary_key: true
add :name, :string, null: false
add :description, :text
add :enabled, :boolean, default: true, null: false
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
null: false
timestamps()
end
create index(:__table__, [:organization_id])
end
end
```
---
## `test/towerops/__context___test.exs` (Context Tests)
```elixir
defmodule Towerops.__CONTEXT__Test do
use Towerops.DataCase
alias Towerops.__CONTEXT__
alias Towerops.__CONTEXT__.__SCHEMA__
setup do
user = insert_user()
{:ok, organization} =
Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
%{organization: organization, user: user}
end
describe "create___resource__/1" do
test "creates with valid attrs", %{organization: org} do
attrs = %{
name: "Test __RESOURCE_TITLE__",
organization_id: org.id
}
assert {:ok, %__SCHEMA__{} = __resource__} = __CONTEXT__.create___resource__(attrs)
assert __resource__.name == "Test __RESOURCE_TITLE__"
assert __resource__.organization_id == org.id
end
test "fails without required fields" do
assert {:error, changeset} = __CONTEXT__.create___resource__(%{})
assert errors_on(changeset) |> Map.has_key?(:name)
assert errors_on(changeset) |> Map.has_key?(:organization_id)
end
end
describe "update___resource__/2" do
test "updates with valid attrs", %{organization: org} do
{:ok, __resource__} =
__CONTEXT__.create___resource__(%{name: "Original", organization_id: org.id})
assert {:ok, updated} = __CONTEXT__.update___resource__(__resource__, %{name: "Updated"})
assert updated.name == "Updated"
end
end
describe "delete___resource__/1" do
test "deletes the resource", %{organization: org} do
{:ok, __resource__} =
__CONTEXT__.create___resource__(%{name: "To Delete", organization_id: org.id})
assert {:ok, _} = __CONTEXT__.delete___resource__(__resource__)
assert_raise Ecto.NoResultsError, fn -> __CONTEXT__.get___resource__!(__resource__.id) end
end
end
describe "list___resources__/2" do
test "lists resources for organization", %{organization: org} do
{:ok, _} = __CONTEXT__.create___resource__(%{name: "Item 1", organization_id: org.id})
{:ok, _} = __CONTEXT__.create___resource__(%{name: "Item 2", organization_id: org.id})
__resources__ = __CONTEXT__.list___resources__(org.id)
assert length(__resources__) == 2
end
test "filters by active status", %{organization: org} do
{:ok, _} = __CONTEXT__.create___resource__(%{name: "Active", organization_id: org.id, enabled: true})
{:ok, _} = __CONTEXT__.create___resource__(%{name: "Archived", organization_id: org.id, enabled: false})
active = __CONTEXT__.list___resources__(org.id, filter: :active)
assert length(active) == 1
assert hd(active).name == "Active"
end
end
describe "get___resource__!/1" do
test "returns the resource with preloads", %{organization: org} do
{:ok, created} = __CONTEXT__.create___resource__(%{name: "Find Me", organization_id: org.id})
__resource__ = __CONTEXT__.get___resource__!(created.id)
assert __resource__.name == "Find Me"
assert __resource__.organization.id == org.id
end
end
end
```

247
docs/templates/liveview-form.md vendored Normal file
View file

@ -0,0 +1,247 @@
# LiveView Form Page Template
Copy-pasteable template for a new create/edit form page following TowerOps conventions.
Replace all `__RESOURCE__` with PascalCase resource (e.g., `Schedule`).
Replace `__resource__` with snake_case (e.g., `schedule`).
Replace `__resources__` with plural snake_case (e.g., `schedules`).
Replace `__CONTEXT__` with context module name (e.g., `Schedules`).
Replace `__SCHEMA__` with schema module name (e.g., `Schedule`).
---
## `lib/towerops_web/live/__resource___live/form.ex`
```elixir
defmodule ToweropsWeb.__RESOURCE__Live.Form do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.__CONTEXT__
alias Towerops.__CONTEXT__.__SCHEMA__
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:timezone, socket.assigns.current_scope.timezone)}
end
@impl true
def handle_params(params, _url, socket) do
case socket.assigns.live_action do
:new ->
__resource__ = %__SCHEMA__{}
changeset = __SCHEMA__.changeset(__resource__, %{})
{:noreply,
socket
|> assign(:page_title, t("New __RESOURCE_TITLE__"))
|> assign(:__resource__, __resource__)
|> assign(:form, to_form(changeset))}
:edit ->
__resource__ = __CONTEXT__.get___resource__!(params["id"])
changeset = __SCHEMA__.changeset(__resource__, %{})
{:noreply,
socket
|> assign(:page_title, t("Edit __RESOURCE_TITLE__"))
|> assign(:__resource__, __resource__)
|> assign(:form, to_form(changeset))}
end
end
@impl true
def handle_event("validate", %{"__resource__" => params}, socket) do
changeset =
socket.assigns.__resource__
|> __SCHEMA__.changeset(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :form, to_form(changeset))}
end
def handle_event("save", %{"__resource__" => params}, socket) do
scope = socket.assigns.current_scope
params =
params
|> Map.put("organization_id", scope.organization.id)
case socket.assigns.live_action do
:new -> save___resource__(socket, :create, params)
:edit -> save___resource__(socket, :update, params)
end
end
def handle_event("delete", _params, socket) do
case __CONTEXT__.delete___resource__(socket.assigns.__resource__) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, t("__RESOURCE_TITLE__ deleted"))
|> push_navigate(to: ~p"/__resources__")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, t("Unable to delete __resource__"))}
end
end
defp save___resource__(socket, :create, params) do
case __CONTEXT__.create___resource__(params) do
{:ok, __resource__} ->
{:noreply,
socket
|> put_flash(:info, t("__RESOURCE_TITLE__ created"))
|> push_navigate(to: ~p"/__resources__/#{__resource__.id}")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
defp save___resource__(socket, :update, params) do
case __CONTEXT__.update___resource__(socket.assigns.__resource__, params) do
{:ok, __resource__} ->
{:noreply,
socket
|> put_flash(:info, t("__RESOURCE_TITLE__ updated"))
|> push_navigate(to: ~p"/__resources__/#{__resource__.id}")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
end
```
---
## `lib/towerops_web/live/__resource___live/form.html.heex`
```heex
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="__active_page__"
>
<%!-- Back link / breadcrumb --%>
<div class="mb-6">
<.link
navigate={~p"/__resources__"}
class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<.icon name="hero-arrow-left" class="h-4 w-4" />
{t("Back to __RESOURCES_TITLE__")}
</.link>
</div>
<h1 class="text-xl font-bold text-gray-900 dark:text-white mb-6">{@page_title}</h1>
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-6">
<.form
for={@form}
phx-change="validate"
phx-submit="save"
id="__resource__-form"
>
<div class="space-y-6">
<%!-- Name field --%>
<.input
field={@form[:name]}
type="text"
label={t("Name") <> " *"}
required
placeholder={t("e.g., My __RESOURCE_TITLE__")}
/>
<%!-- Description / reason field --%>
<.input
field={@form[:description]}
type="textarea"
label={t("Description")}
rows="3"
placeholder={t("Optional description")}
/>
<%!-- Grid layout for related fields --%>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<.input
field={@form[:starts_at]}
type="datetime-local"
label={t("Starts At") <> " *"}
required
/>
<.input
field={@form[:ends_at]}
type="datetime-local"
label={t("Ends At") <> " *"}
required
/>
</div>
<%!-- Select field example --%>
<%#
<.input
field={@form[:site_id]}
type="select"
label={t("Site")}
prompt={t("Select a site...")}
options={Enum.map(@sites, &{&1.name, &1.id})}
/>
#%>
<%!-- Checkbox example --%>
<.input
field={@form[:enabled]}
type="checkbox"
label={t("Enabled")}
/>
<%!-- Submit / Cancel / Delete --%>
<div class="flex items-center justify-between gap-3 pt-4 border-t border-gray-200 dark:border-white/10">
<%!-- Delete button (edit mode only) --%>
<div>
<%= if @live_action == :edit do %>
<button
type="button"
phx-click="delete"
data-confirm={t("Are you sure you want to delete this? This cannot be undone.")}
class="rounded-lg px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20 transition-colors"
>
{t("Delete")}
</button>
<% end %>
</div>
<div class="flex items-center gap-3">
<.link
navigate={~p"/__resources__"}
class="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 transition-colors"
>
{t("Cancel")}
</.link>
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
>
{t("Save")}
</button>
</div>
</div>
</div>
</.form>
</div>
</Layouts.authenticated>
```
---
## Router entries
```elixir
# In lib/towerops_web/router.ex, inside the authenticated scope:
live "/__resources__/new", __RESOURCE__Live.Form, :new
live "/__resources__/:id/edit", __RESOURCE__Live.Form, :edit
```

225
docs/templates/liveview-index.md vendored Normal file
View file

@ -0,0 +1,225 @@
# LiveView Index Page Template
Copy-pasteable template for a new list/index page following TowerOps conventions.
Replace all `__RESOURCE__` placeholders with your resource name (e.g., `Schedule`, `Device`).
Replace `__resource__` with the lowercase/snake_case form (e.g., `schedule`, `device`).
Replace `__resources__` with the plural form (e.g., `schedules`, `devices`).
Replace `__active_page__` with the sidebar page key (e.g., `"schedules"`).
---
## `lib/towerops_web/live/__resource___live/index.ex`
```elixir
defmodule ToweropsWeb.__RESOURCE__Live.Index do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.__CONTEXT__
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
{:ok,
socket
|> assign(:page_title, t("__RESOURCES_TITLE__"))
|> assign(:filter, "all")
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> load___resources__(organization.id)}
end
@impl true
def handle_params(params, _url, socket) do
filter = Map.get(params, "filter", "all")
{:noreply,
socket
|> assign(:filter, filter)
|> load___resources__(socket.assigns.current_scope.organization.id)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
organization = socket.assigns.current_scope.organization
__resource__ = __CONTEXT__.get___resource__!(id)
case __CONTEXT__.delete___resource__(__resource__) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, t("__RESOURCE_TITLE__ deleted"))
|> load___resources__(organization.id)}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, t("Unable to delete __resource__"))}
end
end
defp load___resources__(socket, org_id) do
filter_atom =
case socket.assigns.filter do
"active" -> :active
"archived" -> :archived
_ -> nil
end
opts = if filter_atom, do: [filter: filter_atom], else: []
__resources__ = __CONTEXT__.list___resources__(org_id, opts)
assign(socket, :__resources__, __resources__)
end
end
```
---
## `lib/towerops_web/live/__resource___live/index.html.heex`
```heex
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="__active_page__"
>
<%!-- Page header --%>
<div class="flex items-center justify-between mb-1">
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("__RESOURCES_TITLE__")}</h1>
<.link
navigate={~p"/__resources__/new"}
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
>
<.icon name="hero-plus" class="h-4 w-4" />
{t("New __RESOURCE_TITLE__")}
</.link>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
{t("Description of what this page shows.")}
</p>
<%!-- Filter tabs --%>
<div class="flex items-center gap-2 mb-4 flex-wrap">
<.link
patch={~p"/__resources__?filter=all"}
class={[
"inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
@filter == "all" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
@filter != "all" &&
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
]}
>
{t("All")}
</.link>
<.link
patch={~p"/__resources__?filter=active"}
class={[
"inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
@filter == "active" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
@filter != "active" &&
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
]}
>
{t("Active")}
</.link>
<.link
patch={~p"/__resources__?filter=archived"}
class={[
"inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
@filter == "archived" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900",
@filter != "archived" &&
"bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
]}
>
{t("Archived")}
</.link>
</div>
<%!-- Empty state --%>
<%= if Enum.empty?(@__resources__) do %>
<div class="flex items-center justify-center py-16">
<div class="text-center">
<.icon
name="hero-rectangle-stack"
class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4"
/>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">
{t("No __resources__ found")}
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("Get started by creating your first __resource__.")}
</p>
</div>
</div>
<% else %>
<%!-- Data table --%>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Name")}
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Status")}
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Created")}
</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Actions")}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-900">
<%= for item <- @__resources__ do %>
<tr
class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors cursor-pointer"
phx-click={JS.navigate(~p"/__resources__/#{item.id}")}
>
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white">
{item.name}
</td>
<td class="px-4 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300">
Active
</span>
</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{Calendar.strftime(item.inserted_at, "%b %d, %Y")}
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-2">
<.link
navigate={~p"/__resources__/#{item.id}/edit"}
class="text-xs px-2 py-1 rounded bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 transition-colors"
>
{t("Edit")}
</.link>
<button
type="button"
phx-click="delete"
phx-value-id={item.id}
data-confirm={t("Are you sure you want to delete this?")}
class="text-xs px-2 py-1 rounded bg-red-50 text-red-700 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/40 transition-colors"
>
{t("Delete")}
</button>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</Layouts.authenticated>
```
---
## Router entry
```elixir
# In lib/towerops_web/router.ex, inside the authenticated scope:
live "/__resources__", __RESOURCE__Live.Index, :index
```

226
docs/templates/liveview-test.md vendored Normal file
View file

@ -0,0 +1,226 @@
# LiveView Test Template
Copy-pasteable template for testing a LiveView page following TowerOps conventions.
Replace `__RESOURCE__` with PascalCase resource (e.g., `Schedule`).
Replace `__resource__` with snake_case (e.g., `schedule`).
Replace `__resources__` with plural snake_case (e.g., `schedules`).
Replace `__CONTEXT__` with context module name (e.g., `Schedules`).
---
## `test/towerops_web/live/__resource___live_test.exs`
```elixir
defmodule ToweropsWeb.__RESOURCE__LiveTest do
use ToweropsWeb.ConnCase
import Phoenix.LiveViewTest
alias Towerops.__CONTEXT__
setup :register_and_log_in_user
setup %{conn: conn, user: user} do
{:ok, organization} =
Towerops.Organizations.create_organization(%{name: "Test Org", use_sites: true}, user.id)
conn = Plug.Conn.put_session(conn, :current_organization_id, organization.id)
%{conn: conn, organization: organization}
end
describe "Index" do
test "requires authentication" do
conn = build_conn()
{:error, redirect} = live(conn, ~p"/__resources__")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
test "renders page title and header", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/__resources__")
assert html =~ "__RESOURCES_TITLE__"
end
test "shows empty state when no data exists", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/__resources__")
assert html =~ "No __resources__ found"
end
test "lists existing records", %{conn: conn, organization: org} do
{:ok, _} =
__CONTEXT__.create___resource__(%{
name: "Test Item",
organization_id: org.id
})
{:ok, _view, html} = live(conn, ~p"/__resources__")
assert html =~ "Test Item"
end
test "filters by status via URL params", %{conn: conn, organization: org} do
{:ok, _} =
__CONTEXT__.create___resource__(%{
name: "Active Item",
organization_id: org.id,
enabled: true
})
{:ok, _} =
__CONTEXT__.create___resource__(%{
name: "Archived Item",
organization_id: org.id,
enabled: false
})
{:ok, view, _html} = live(conn, ~p"/__resources__?filter=active")
html = render(view)
assert html =~ "Active Item"
refute html =~ "Archived Item"
end
test "navigates to new form", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/__resources__")
assert view
|> element("a", "New")
|> render_click()
|> follow_redirect(conn, ~p"/__resources__/new")
end
test "deletes a record", %{conn: conn, organization: org} do
{:ok, item} =
__CONTEXT__.create___resource__(%{
name: "To Delete",
organization_id: org.id
})
{:ok, view, html} = live(conn, ~p"/__resources__")
assert html =~ "To Delete"
view
|> element("button[phx-click='delete'][phx-value-id='#{item.id}']")
|> render_click()
html = render(view)
refute html =~ "To Delete"
end
end
describe "Form - New" do
test "renders new form", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/__resources__/new")
assert html =~ "New __RESOURCE_TITLE__"
assert html =~ "Save"
end
test "validates form on change", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/__resources__/new")
html =
view
|> form("#__resource__-form", __resource__: %{name: ""})
|> render_change()
assert html =~ "can&#39;t be blank"
end
test "creates record on valid submit", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/__resources__/new")
{:ok, _view, html} =
view
|> form("#__resource__-form", __resource__: %{name: "New Item"})
|> render_submit()
|> follow_redirect(conn)
assert html =~ "__RESOURCE_TITLE__ created"
assert html =~ "New Item"
end
end
describe "Form - Edit" do
test "renders edit form with existing data", %{conn: conn, organization: org} do
{:ok, item} =
__CONTEXT__.create___resource__(%{
name: "Existing Item",
organization_id: org.id
})
{:ok, _view, html} = live(conn, ~p"/__resources__/#{item.id}/edit")
assert html =~ "Edit __RESOURCE_TITLE__"
assert html =~ "Existing Item"
end
test "updates record on valid submit", %{conn: conn, organization: org} do
{:ok, item} =
__CONTEXT__.create___resource__(%{
name: "Original",
organization_id: org.id
})
{:ok, view, _html} = live(conn, ~p"/__resources__/#{item.id}/edit")
{:ok, _view, html} =
view
|> form("#__resource__-form", __resource__: %{name: "Updated"})
|> render_submit()
|> follow_redirect(conn)
assert html =~ "__RESOURCE_TITLE__ updated"
assert html =~ "Updated"
end
test "shows delete button in edit mode", %{conn: conn, organization: org} do
{:ok, item} =
__CONTEXT__.create___resource__(%{
name: "Delete Me",
organization_id: org.id
})
{:ok, _view, html} = live(conn, ~p"/__resources__/#{item.id}/edit")
assert html =~ "Delete"
end
test "deletes record from edit form", %{conn: conn, organization: org} do
{:ok, item} =
__CONTEXT__.create___resource__(%{
name: "Delete Me",
organization_id: org.id
})
{:ok, view, _html} = live(conn, ~p"/__resources__/#{item.id}/edit")
{:ok, _view, html} =
view
|> element("button[phx-click='delete']")
|> render_click()
|> follow_redirect(conn, ~p"/__resources__")
assert html =~ "__RESOURCE_TITLE__ deleted"
refute html =~ "Delete Me"
end
end
end
```
---
## Notes
- **`register_and_log_in_user`** is a shared setup helper defined in `ConnCase` — it creates a user and logs them in.
- **`Plug.Conn.put_session(:current_organization_id)`** sets the org context for the session.
- Use **`live/2`** to mount a LiveView, returns `{:ok, view, html}`.
- Use **`render_click/1`**, **`render_change/1`**, **`render_submit/1`** for events.
- Use **`element/2,3`** to target specific DOM elements.
- Use **`follow_redirect/2`** after `push_navigate` actions.
- Use **`render/1`** to get the current HTML after an event.

View file

@ -30,6 +30,13 @@ defmodule Towerops.OnCall do
|> Repo.preload(overrides: :user, layers: [members: :user])
end
def get_schedule!(id, organization_id) do
Schedule
|> where([s], s.id == ^id and s.organization_id == ^organization_id)
|> Repo.one!()
|> Repo.preload(overrides: :user, layers: [members: :user])
end
def get_schedule(id) do
case Repo.get(Schedule, id) do
nil -> nil
@ -114,6 +121,13 @@ defmodule Towerops.OnCall do
|> Repo.preload(rules: :targets)
end
def get_escalation_policy!(id, organization_id) do
EscalationPolicy
|> where([p], p.id == ^id and p.organization_id == ^organization_id)
|> Repo.one!()
|> Repo.preload(rules: :targets)
end
def get_escalation_policy(id) do
case Repo.get(EscalationPolicy, id) do
nil -> nil

View file

@ -0,0 +1,17 @@
defmodule Towerops.QueryHelpers do
@moduledoc """
Shared helpers for building safe Ecto queries.
"""
@doc """
Sanitizes a string for use in LIKE/ILIKE queries by escaping
the wildcard characters `%` and `_`.
"""
@spec sanitize_like(String.t()) :: String.t()
def sanitize_like(query) do
query
|> String.replace("\\", "\\\\")
|> String.replace("%", "\\%")
|> String.replace("_", "\\_")
end
end

View file

@ -0,0 +1,110 @@
defmodule Towerops.URLValidator do
import Bitwise
@moduledoc """
Validates URLs to prevent SSRF (Server-Side Request Forgery) attacks.
Blocks requests to internal/private IP ranges, localhost, and non-HTTP(S) schemes.
"""
@private_ranges [
# 127.0.0.0/8
{127, 0, 0, 0, 8},
# 10.0.0.0/8
{10, 0, 0, 0, 8},
# 172.16.0.0/12
{172, 16, 0, 0, 12},
# 192.168.0.0/16
{192, 168, 0, 0, 16},
# 169.254.0.0/16 (link-local)
{169, 254, 0, 0, 16},
# IPv6 loopback ::1
:ipv6_loopback
]
@doc """
Validates a URL is safe for server-side requests.
Returns :ok or {:error, reason}.
"""
@spec validate(String.t()) :: :ok | {:error, String.t()}
def validate(url) when is_binary(url) do
with {:ok, uri} <- parse_url(url),
:ok <- validate_scheme(uri),
:ok <- validate_host(uri),
:ok <- validate_ip(uri.host) do
:ok
end
end
def validate(_), do: {:error, "URL must be a string"}
defp parse_url(url) do
case URI.parse(url) do
%URI{scheme: nil} -> {:error, "URL must include a scheme (http or https)"}
%URI{host: nil} -> {:error, "URL must include a host"}
%URI{host: ""} -> {:error, "URL must include a host"}
uri -> {:ok, uri}
end
end
defp validate_scheme(%URI{scheme: scheme}) when scheme in ["http", "https"], do: :ok
defp validate_scheme(_), do: {:error, "Only http and https schemes are allowed"}
defp validate_host(%URI{host: host}) do
downcased = String.downcase(host)
if downcased == "localhost" or String.ends_with?(downcased, ".local") do
{:error, "Requests to localhost are not allowed"}
else
:ok
end
end
defp validate_ip(host) do
case resolve_host(host) do
{:ok, ip} ->
if private_ip?(ip) do
{:error, "Requests to private/internal IP addresses are not allowed"}
else
:ok
end
# Can't resolve — let the HTTP client handle DNS errors
{:error, _} ->
:ok
end
end
defp resolve_host(host) do
case :inet.parse_address(String.to_charlist(host)) do
{:ok, ip} -> {:ok, ip}
{:error, _} ->
case :inet.getaddr(String.to_charlist(host), :inet) do
{:ok, ip} -> {:ok, ip}
{:error, _} ->
case :inet.getaddr(String.to_charlist(host), :inet6) do
{:ok, ip} -> {:ok, ip}
error -> error
end
end
end
end
defp private_ip?({0, 0, 0, 0, 0, 0, 0, 1}), do: true
defp private_ip?({a, b, c, d}) do
Enum.any?(@private_ranges, fn
:ipv6_loopback -> false
{net_a, net_b, net_c, net_d, prefix} ->
mask = bsl(0xFFFFFFFF, 32 - prefix) |> band(0xFFFFFFFF)
ip_int = bsl(a, 24) + bsl(b, 16) + bsl(c, 8) + d
net_int = bsl(net_a, 24) + bsl(net_b, 16) + bsl(net_c, 8) + net_d
band(ip_int, mask) == band(net_int, mask)
end)
end
defp private_ip?(_), do: false
import Bitwise
end

View file

@ -243,11 +243,7 @@ defmodule ToweropsWeb.Api.V1.DevicesController do
end
defp default_organization_id(device_params, organization_id) do
case Map.get(device_params, "organization_id") do
nil -> Map.put(device_params, "organization_id", organization_id)
"" -> Map.put(device_params, "organization_id", organization_id)
_provided_id -> device_params
end
Map.put(device_params, "organization_id", organization_id)
end
defp create_and_discover_device(conn, device_params, current_user) do

View file

@ -161,9 +161,16 @@ defmodule ToweropsWeb.AgentLive.Index do
@impl true
def handle_event("delete_agent", %{"id" => id}, socket) do
organization = socket.assigns.current_scope.organization
# Verify the agent token belongs to the current org before deleting
agent_token = Agents.get_agent_token!(id)
if agent_token.organization_id != organization.id do
{:noreply, put_flash(socket, :error, t_equipment("Agent not found"))}
else
case Agents.delete_agent_token(id) do
{:ok, _} ->
organization = socket.assigns.current_scope.organization
# If the deleted agent was the global default, clear it
if Scope.superuser?(socket.assigns.current_scope) &&
@ -221,6 +228,7 @@ defmodule ToweropsWeb.AgentLive.Index do
{:error, _} ->
{:noreply, put_flash(socket, :error, t_equipment("Failed to delete agent"))}
end
end
end
@impl true

View file

@ -58,7 +58,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
case tab do
"discovered" ->
page = params |> Map.get("page", "1") |> String.to_integer()
page = safe_to_integer(Map.get(params, "page", "1"), 1)
per_page = 20
# Load all discovered devices - this is temporarily kept in memory for slicing
@ -580,4 +580,15 @@ defmodule ToweropsWeb.DeviceLive.Index do
[1, :ellipsis, current_page - 1, current_page, current_page + 1, :ellipsis, total_pages]
end
end
defp safe_to_integer(value, _default) when is_integer(value), do: value
defp safe_to_integer(value, default) when is_binary(value) do
case Integer.parse(value) do
{int, _} -> int
:error -> default
end
end
defp safe_to_integer(_, default), do: default
end

View file

@ -72,7 +72,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
# If no tab parameter, redirect to add it to the URL
if Map.has_key?(params, "tab") do
tab = params["tab"]
page = params |> Map.get("page", "1") |> String.to_integer()
page = safe_to_integer(Map.get(params, "page", "1"), 1)
socket =
socket
@ -1805,4 +1805,15 @@ defmodule ToweropsWeb.DeviceLive.Show do
mbps = kbps / 1000
"#{Float.round(mbps, 1)} Mbps"
end
defp safe_to_integer(value, _default) when is_integer(value), do: value
defp safe_to_integer(value, default) when is_binary(value) do
case Integer.parse(value) do
{int, _} -> int
:error -> default
end
end
defp safe_to_integer(_, default), do: default
end

View file

@ -24,7 +24,8 @@ defmodule ToweropsWeb.EscalationPolicyLive.Form do
|> assign(:form, to_form(changeset))}
:edit ->
policy = OnCall.get_escalation_policy!(params["id"])
org_id = socket.assigns.current_scope.organization.id
policy = OnCall.get_escalation_policy!(params["id"], org_id)
changeset = EscalationPolicy.changeset(policy, %{})
{:noreply,

View file

@ -7,8 +7,8 @@ defmodule ToweropsWeb.EscalationPolicyLive.Show do
@impl true
def mount(%{"id" => id}, _session, socket) do
policy = OnCall.get_escalation_policy!(id)
org_id = socket.assigns.current_scope.organization.id
policy = OnCall.get_escalation_policy!(id, org_id)
members = Organizations.list_organization_members(org_id)
users = Enum.map(members, & &1.user)
schedules = OnCall.list_schedules(org_id)

View file

@ -101,71 +101,79 @@ defmodule ToweropsWeb.Org.SettingsLive do
@impl true
def handle_event("save", %{"organization" => org_params}, socket) do
case Organizations.update_organization(socket.assigns.organization, org_params) do
{:ok, organization} ->
changeset = Organizations.change_organization(organization)
require_admin_or_owner(socket, fn ->
case Organizations.update_organization(socket.assigns.organization, org_params) do
{:ok, organization} ->
changeset = Organizations.change_organization(organization)
{:noreply,
socket
|> put_flash(:info, t("Settings saved successfully"))
|> assign(:organization, organization)
|> assign(:form, to_form(changeset))}
{:noreply,
socket
|> put_flash(:info, t("Settings saved successfully"))
|> assign(:organization, organization)
|> assign(:form, to_form(changeset))}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end)
end
@impl true
def handle_event("apply_snmp_to_all", _params, socket) do
{count, _} = Organizations.apply_snmp_config_to_all_equipment(socket.assigns.organization.id)
require_admin_or_owner(socket, fn ->
{count, _} = Organizations.apply_snmp_config_to_all_equipment(socket.assigns.organization.id)
{:noreply,
put_flash(socket, :info, t("Applied SNMP configuration to %{count} device records across all sites", count: count))}
{:noreply,
put_flash(socket, :info, t("Applied SNMP configuration to %{count} device records across all sites", count: count))}
end)
end
@impl true
def handle_event("apply_agent_to_all", _params, socket) do
{count, _} = Organizations.apply_agent_to_all_equipment(socket.assigns.organization.id)
require_admin_or_owner(socket, fn ->
{count, _} = Organizations.apply_agent_to_all_equipment(socket.assigns.organization.id)
{:noreply,
put_flash(socket, :info, t("Applied default agent to %{count} device records across all sites", count: count))}
{:noreply,
put_flash(socket, :info, t("Applied default agent to %{count} device records across all sites", count: count))}
end)
end
# === Members tab events ===
@impl true
def handle_event("send_invitation", %{"email" => email, "role" => role}, socket) do
organization = socket.assigns.organization
user = socket.assigns.current_scope.user
require_admin_or_owner(socket, fn ->
organization = socket.assigns.organization
user = socket.assigns.current_scope.user
attrs = %{
email: String.trim(email),
role: role,
organization_id: organization.id,
invited_by_id: user.id
}
attrs = %{
email: String.trim(email),
role: role,
organization_id: organization.id,
invited_by_id: user.id
}
case Organizations.create_invitation(attrs) do
{:ok, invitation} ->
invitation =
invitation
|> Towerops.Repo.preload(:invited_by)
|> Map.put(:organization, organization)
case Organizations.create_invitation(attrs) do
{:ok, invitation} ->
invitation =
invitation
|> Towerops.Repo.preload(:invited_by)
|> Map.put(:organization, organization)
accept_url = url(~p"/invitations/#{invitation.token}")
UserNotifier.deliver_invitation_email(invitation, accept_url)
accept_url = url(~p"/invitations/#{invitation.token}")
UserNotifier.deliver_invitation_email(invitation, accept_url)
{:noreply,
socket
|> put_flash(:info, t("Invitation sent to %{email}", email: email))
|> assign(:pending_invitations, Organizations.list_pending_invitations(organization.id))
|> assign(:invite_form, to_form(%{"email" => "", "role" => "technician"}))}
{:noreply,
socket
|> put_flash(:info, t("Invitation sent to %{email}", email: email))
|> assign(:pending_invitations, Organizations.list_pending_invitations(organization.id))
|> assign(:invite_form, to_form(%{"email" => "", "role" => "technician"}))}
{:error, changeset} ->
{:noreply,
put_flash(socket, :error, t("Failed to send invitation: %{errors}", errors: error_messages(changeset)))}
end
{:error, changeset} ->
{:noreply,
put_flash(socket, :error, t("Failed to send invitation: %{errors}", errors: error_messages(changeset)))}
end
end)
end
@impl true
@ -187,40 +195,44 @@ defmodule ToweropsWeb.Org.SettingsLive do
@impl true
def handle_event("remove_member", %{"user-id" => user_id}, socket) do
organization = socket.assigns.organization
require_admin_or_owner(socket, fn ->
organization = socket.assigns.organization
case Organizations.remove_member(organization.id, user_id) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, t("Member removed"))
|> assign(:members, Organizations.list_organization_members(organization.id))}
case Organizations.remove_member(organization.id, user_id) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, t("Member removed"))
|> assign(:members, Organizations.list_organization_members(organization.id))}
{:error, :cannot_remove_owner} ->
{:noreply, put_flash(socket, :error, t("Cannot remove the organization owner"))}
{:error, :cannot_remove_owner} ->
{:noreply, put_flash(socket, :error, t("Cannot remove the organization owner"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Failed to remove member"))}
end
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Failed to remove member"))}
end
end)
end
@impl true
def handle_event("change_role", %{"user-id" => user_id, "role" => role}, socket) do
organization = socket.assigns.organization
require_admin_or_owner(socket, fn ->
organization = socket.assigns.organization
case Organizations.update_member_role(organization.id, user_id, role) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, t("Role updated"))
|> assign(:members, Organizations.list_organization_members(organization.id))}
case Organizations.update_member_role(organization.id, user_id, role) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, t("Role updated"))
|> assign(:members, Organizations.list_organization_members(organization.id))}
{:error, :cannot_change_owner_role} ->
{:noreply, put_flash(socket, :error, t("Cannot change the owner's role"))}
{:error, :cannot_change_owner_role} ->
{:noreply, put_flash(socket, :error, t("Cannot change the owner's role"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Failed to update role"))}
end
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Failed to update role"))}
end
end)
end
@impl true
@ -851,4 +863,14 @@ defmodule ToweropsWeb.Org.SettingsLive do
defp extract_credentials_from_params(params, _provider) do
%{"api_key" => Map.get(params, "api_key", "")}
end
defp require_admin_or_owner(socket, fun) do
role = socket.assigns.membership.role
if role in [:owner, :admin] do
fun.()
else
{:noreply, put_flash(socket, :error, t("You don't have permission to perform this action"))}
end
end
end

View file

@ -24,7 +24,8 @@ defmodule ToweropsWeb.ScheduleLive.Form do
|> assign(:form, to_form(changeset))}
:edit ->
schedule = OnCall.get_schedule!(params["id"])
org_id = socket.assigns.current_scope.organization.id
schedule = OnCall.get_schedule!(params["id"], org_id)
changeset = Schedule.changeset(schedule, %{})
{:noreply,

View file

@ -16,9 +16,9 @@ defmodule ToweropsWeb.ScheduleLive.Show do
@impl true
def mount(%{"id" => id}, _session, socket) do
schedule = OnCall.get_schedule!(id)
on_call = OnCall.who_is_on_call(schedule.id)
org_id = socket.assigns.current_scope.organization.id
schedule = OnCall.get_schedule!(id, org_id)
on_call = OnCall.who_is_on_call(schedule.id)
members = Organizations.list_organization_members(org_id)
users = Enum.map(members, & &1.user)
today = Date.utc_today()

View file

@ -29,11 +29,52 @@ defmodule ToweropsWeb.Plugs.BruteForceProtection do
- Router (needs to intercept requests before routing)
"""
import Plug.Conn
alias Towerops.Security.BruteForce
alias ToweropsWeb.RemoteIp
require Logger
def init(opts), do: opts
def call(conn, _opts) do
# TEMPORARILY DISABLED - brute force protection bypassed
# Cloudflare handles security at the edge
conn
ip_address = RemoteIp.from_conn(conn)
cond do
# Skip for authenticated users
conn.assigns[:current_user] != nil ->
conn
# Skip for agent WebSocket connections (authenticated at channel join)
String.starts_with?(conn.request_path, "/socket/agent") ->
conn
# Check if IP is whitelisted
BruteForce.check_whitelist(ip_address) ->
conn
# Check if IP is currently banned
true ->
case BruteForce.check_ban_status(ip_address) do
:allowed ->
# Register a callback to track 404s after the response is sent
register_before_send(conn, fn response_conn ->
if response_conn.status == 404 do
Towerops.Security.FourOhFourTracker.record_404(ip_address, conn.request_path)
end
response_conn
end)
{:blocked, banned_until} ->
Logger.warning("Blocked request from banned IP: #{ip_address}, banned until: #{inspect(banned_until)}")
conn
|> put_resp_content_type("text/plain")
|> send_resp(403, "Access denied")
|> halt()
end
end
end
end

98
tools/dev.sh Executable file
View file

@ -0,0 +1,98 @@
#!/usr/bin/env bash
# TowerOps development helper script
# Usage: ./tools/dev.sh <command> [args...]
set -e
export PATH="/nix/var/nix/profiles/default/bin:$PATH"
PROJ_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJ_DIR"
NIX_CMD="nix develop .#default --command"
usage() {
cat <<EOF
TowerOps Dev Helper
Usage: ./tools/dev.sh <command> [args...]
Commands:
compile Compile the project
test [path] Run tests (optionally for a specific path)
test-file <f> Run tests for a specific file
push [msg] Git add, commit, push to origin main
format Run mix format
routes Show Phoenix routes
migrate Run migrations (dev + test)
reset-db Reset the database
server Start services + Phoenix server
deps Fetch dependencies
EOF
exit 1
}
cmd_compile() {
$NIX_CMD mix compile "$@"
}
cmd_test() {
$NIX_CMD env -u DATABASE_URL MIX_ENV=test mix test "$@"
}
cmd_test_file() {
if [ -z "$1" ]; then
echo "Error: test-file requires a file path"
echo "Usage: ./tools/dev.sh test-file test/path/to/test.exs"
exit 1
fi
$NIX_CMD env -u DATABASE_URL MIX_ENV=test mix test "$1"
}
cmd_push() {
local msg="${1:-update}"
git add -A
git commit -m "$msg"
git push origin main
}
cmd_format() {
$NIX_CMD mix format "$@"
}
cmd_routes() {
$NIX_CMD mix phx.routes "$@"
}
cmd_migrate() {
echo "==> Migrating dev database..."
$NIX_CMD mix ecto.migrate
echo "==> Migrating test database..."
$NIX_CMD env -u DATABASE_URL MIX_ENV=test mix ecto.migrate
}
cmd_reset_db() {
$NIX_CMD mix ecto.reset "$@"
}
cmd_server() {
$NIX_CMD bash -c "start-services && mix phx.server"
}
cmd_deps() {
$NIX_CMD mix deps.get "$@"
}
# --- Main dispatch ---
case "${1:-}" in
compile) shift; cmd_compile "$@" ;;
test) shift; cmd_test "$@" ;;
test-file) shift; cmd_test_file "$@" ;;
push) shift; cmd_push "$@" ;;
format) shift; cmd_format "$@" ;;
routes) shift; cmd_routes "$@" ;;
migrate) shift; cmd_migrate "$@" ;;
reset-db) shift; cmd_reset_db "$@" ;;
server) shift; cmd_server "$@" ;;
deps) shift; cmd_deps "$@" ;;
*) usage ;;
esac

86
tools/test-bootstrap.sh Executable file
View file

@ -0,0 +1,86 @@
#!/usr/bin/env bash
# TowerOps Test Environment Bootstrap
# Ensures PostgreSQL is running, test DB exists, and migrations are current.
# Run inside or outside of `nix develop` — it works either way.
set -e
export PATH="/nix/var/nix/profiles/default/bin:$PATH"
PROJ_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJ_DIR"
NIX_CMD="nix develop .#default --command"
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'
ok() { echo -e "${GREEN}${NC} $1"; }
fail() { echo -e "${RED}${NC} $1"; }
info() { echo -e "${YELLOW}${NC} $1"; }
echo "=== TowerOps Test Bootstrap ==="
echo ""
# --- 1. Check/start PostgreSQL ---
info "Checking PostgreSQL..."
if pg_isready -h localhost -p 5432 > /dev/null 2>&1; then
ok "PostgreSQL is running"
else
info "PostgreSQL not running — starting services..."
$NIX_CMD start-services
sleep 2
if pg_isready -h localhost -p 5432 > /dev/null 2>&1; then
ok "PostgreSQL started successfully"
else
fail "Could not start PostgreSQL"
exit 1
fi
fi
# --- 2. Create test database if it doesn't exist ---
info "Checking test database..."
# Unset DATABASE_URL to avoid the unexpanded $USER issue from nix shell
DB_EXISTS=$($NIX_CMD env -u DATABASE_URL bash -c \
"MIX_ENV=test mix ecto.create 2>&1" || true)
if echo "$DB_EXISTS" | grep -q "already created"; then
ok "Test database already exists"
elif echo "$DB_EXISTS" | grep -q "has been created"; then
ok "Test database created"
else
# If it failed for another reason, show the output
if echo "$DB_EXISTS" | grep -qi "error"; then
fail "Problem with test database:"
echo "$DB_EXISTS"
exit 1
else
ok "Test database ready"
fi
fi
# --- 3. Run pending migrations ---
info "Running test migrations..."
MIGRATE_OUT=$($NIX_CMD env -u DATABASE_URL bash -c \
"MIX_ENV=test mix ecto.migrate 2>&1" || true)
if echo "$MIGRATE_OUT" | grep -q "already up"; then
ok "All migrations already applied"
elif echo "$MIGRATE_OUT" | grep -qi "migrat"; then
ok "Migrations applied"
echo "$MIGRATE_OUT" | grep -i "migrat" | head -5
else
ok "Migrations up to date"
fi
# --- 4. Summary ---
echo ""
echo "=== Status ==="
ok "PostgreSQL: running on localhost:5432"
ok "Test DB: ready"
ok "Migrations: current"
echo ""
echo "Run tests with: ./tools/dev.sh test"
echo " or directly: nix develop --command env -u DATABASE_URL MIX_ENV=test mix test"