686 lines
18 KiB
Markdown
686 lines
18 KiB
Markdown
# Refactor Plan: Make Sites Optional in Towerops
|
|
|
|
## Goal
|
|
Remove the "create site first" wall for new users. Allow devices to belong directly to organizations without requiring site creation, while preserving backward compatibility for existing multi-site users.
|
|
|
|
## Architecture Decision
|
|
**Approach**: True optional sites with nullable `site_id`
|
|
- Add `organization_id` foreign key directly to devices table
|
|
- Make `site_id` nullable
|
|
- Support 2-tier credential cascade (org → device) for site-less devices
|
|
- Preserve 3-tier cascade (org → site → device) when site is present
|
|
|
|
## Current State Analysis
|
|
|
|
### Database Schema
|
|
```
|
|
Organization (id)
|
|
└── Site (organization_id, NOT NULL)
|
|
└── Device (site_id, NOT NULL) ← Devices MUST have site
|
|
```
|
|
|
|
**Problems**:
|
|
- Devices have NO direct `organization_id` - only indirect via `site.organization_id`
|
|
- Authorization checks crash if site is nil: `device.site.organization_id`
|
|
- Credential resolution crashes: `device.site.snmp_community`
|
|
|
|
### Code Impact
|
|
- **42 functions** access `device.site.*` pattern and will crash if site is nil
|
|
- **17 credential resolution functions** in `lib/towerops/devices.ex` need refactoring
|
|
- **5 API controller functions** use `device.site.organization_id` for auth
|
|
- **3-tier credential cascade** for SNMP v2, SNMP v3, and MikroTik credentials
|
|
|
|
## MVP Scope (Minimal Viable Onboarding Fix)
|
|
|
|
### In Scope ✅
|
|
1. Database migrations (add organization_id, make site_id nullable)
|
|
2. Core credential resolution refactor (CredentialResolver module)
|
|
3. Device form - remove site requirement, make site optional
|
|
4. Basic authorization fixes (API controllers, agent channel)
|
|
5. Core query functions (list_organization_devices, etc.)
|
|
|
|
### Out of Scope (Future Enhancements) 🚫
|
|
- Progressive disclosure UI (`use_sites` toggle in org settings)
|
|
- Conditional navigation (hiding "Sites" link)
|
|
- Device grouping UI changes (flat vs grouped view)
|
|
- Automatic default site creation
|
|
- Settings page site toggle
|
|
- Help text and education materials
|
|
- Mobile app changes
|
|
|
|
**Rationale**: Focus on core backend functionality first. UI polish can come in follow-up iterations.
|
|
|
|
---
|
|
|
|
## Implementation Plan
|
|
|
|
### Phase 1: Database Schema Changes
|
|
|
|
#### Migration 1: Add organization_id (with backfill)
|
|
**File**: `priv/repo/migrations/XXXXXX_add_organization_id_to_devices.exs`
|
|
|
|
```elixir
|
|
def up do
|
|
# 1. Add nullable organization_id column
|
|
alter table(:devices) do
|
|
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all)
|
|
end
|
|
|
|
# 2. Backfill from existing site relationships
|
|
execute """
|
|
UPDATE devices d
|
|
SET organization_id = s.organization_id
|
|
FROM sites s
|
|
WHERE d.site_id = s.id
|
|
"""
|
|
|
|
# 3. Make organization_id NOT NULL (data is backfilled)
|
|
alter table(:devices) do
|
|
modify :organization_id, :binary_id, null: false
|
|
end
|
|
|
|
# 4. Add indexes
|
|
create index(:devices, [:organization_id])
|
|
create index(:devices, [:organization_id, :display_order, :name])
|
|
|
|
# 5. Add check constraint (site must belong to same org)
|
|
create constraint(:devices, :devices_site_organization_match,
|
|
check: """
|
|
site_id IS NULL OR
|
|
organization_id = (SELECT organization_id FROM sites WHERE id = site_id)
|
|
"""
|
|
)
|
|
end
|
|
|
|
def down do
|
|
drop constraint(:devices, :devices_site_organization_match)
|
|
drop index(:devices, [:organization_id, :display_order, :name])
|
|
drop index(:devices, [:organization_id])
|
|
|
|
alter table(:devices) do
|
|
remove :organization_id
|
|
end
|
|
end
|
|
```
|
|
|
|
**Safety**:
|
|
- No downtime (backfill happens before NOT NULL)
|
|
- Rollbackable (can drop organization_id column)
|
|
- Check constraint prevents invalid data
|
|
|
|
#### Migration 2: Make site_id nullable
|
|
**File**: `priv/repo/migrations/XXXXXX_make_site_id_nullable_on_devices.exs`
|
|
|
|
```elixir
|
|
def up do
|
|
alter table(:devices) do
|
|
modify :site_id, :binary_id, null: true, from: {:binary_id, null: false}
|
|
end
|
|
end
|
|
|
|
def down do
|
|
# Cannot rollback if NULL site_id values exist
|
|
execute """
|
|
DO $$
|
|
BEGIN
|
|
IF EXISTS (SELECT 1 FROM devices WHERE site_id IS NULL) THEN
|
|
RAISE EXCEPTION 'Cannot revert: devices with NULL site_id exist';
|
|
END IF;
|
|
END $$;
|
|
"""
|
|
|
|
alter table(:devices) do
|
|
modify :site_id, :binary_id, null: false
|
|
end
|
|
end
|
|
```
|
|
|
|
**Safety**: One-way migration once devices without sites exist
|
|
|
|
---
|
|
|
|
### Phase 2: Core Schema Updates
|
|
|
|
#### Update Device Schema
|
|
**File**: `lib/towerops/devices/device.ex`
|
|
|
|
**Changes**:
|
|
1. Add `belongs_to :organization, Organization` (new field)
|
|
2. Change `validate_required` to include `:organization_id`, remove `:site_id`
|
|
3. Add `validate_site_belongs_to_organization/1` function
|
|
4. Update typespec to show `site_id: UUID.t() | nil`
|
|
|
|
**Key validation**:
|
|
```elixir
|
|
defp validate_site_belongs_to_organization(changeset) do
|
|
site_id = get_field(changeset, :site_id)
|
|
org_id = get_field(changeset, :organization_id)
|
|
|
|
if site_id && org_id do
|
|
site = Repo.get!(Site, site_id)
|
|
if site.organization_id != org_id do
|
|
add_error(changeset, :site_id, "must belong to the same organization")
|
|
else
|
|
changeset
|
|
end
|
|
else
|
|
changeset
|
|
end
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 3: Credential Resolution Refactor
|
|
|
|
#### Create CredentialResolver Module
|
|
**File**: `lib/towerops/devices/credential_resolver.ex` (new file)
|
|
|
|
Centralizes safe credential resolution with 2-tier and 3-tier support:
|
|
|
|
```elixir
|
|
defmodule Towerops.Devices.CredentialResolver do
|
|
@moduledoc """
|
|
Safe credential resolution with optional sites.
|
|
|
|
Supports:
|
|
- 2-tier: device → organization (when site_id is nil)
|
|
- 3-tier: device → site → organization (when site_id present)
|
|
"""
|
|
|
|
def ensure_credentials_loaded(%Device{} = device) do
|
|
cond do
|
|
!Ecto.assoc_loaded?(device.organization) ->
|
|
preload_all(device)
|
|
device.site_id && !Ecto.assoc_loaded?(device.site) ->
|
|
Repo.preload(device, [:site])
|
|
true ->
|
|
device
|
|
end
|
|
end
|
|
|
|
defp preload_all(device) do
|
|
if device.site_id do
|
|
Repo.preload(device, [:organization, :site])
|
|
else
|
|
Repo.preload(device, [:organization])
|
|
end
|
|
end
|
|
|
|
def resolve_field(device, device_field, site_field, org_field, opts \\ []) do
|
|
default = Keyword.get(opts, :default)
|
|
device = ensure_credentials_loaded(device)
|
|
|
|
device_value = Map.get(device, device_field)
|
|
|
|
site_value =
|
|
if device.site_id && device.site do
|
|
Map.get(device.site, site_field)
|
|
else
|
|
nil
|
|
end
|
|
|
|
org_value = Map.get(device.organization, org_field)
|
|
|
|
device_value || site_value || org_value || default
|
|
end
|
|
|
|
def determine_source(device, device_field, site_field, org_field) do
|
|
device = ensure_credentials_loaded(device)
|
|
|
|
cond do
|
|
Map.get(device, device_field) != nil -> :device
|
|
device.site_id && device.site && Map.get(device.site, site_field) != nil -> :site
|
|
Map.get(device.organization, org_field) != nil -> :organization
|
|
true -> :default
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
#### Update Credential Resolution Functions
|
|
**File**: `lib/towerops/devices.ex`
|
|
|
|
**Refactor these functions** (lines 299-628):
|
|
- `resolve_snmp_config/1` - SNMP v2c credentials
|
|
- `resolve_snmpv3_config/1` - SNMP v3 credentials (6 fields)
|
|
- `resolve_mikrotik_config/1` - MikroTik API credentials (5 fields)
|
|
|
|
**Pattern**:
|
|
```elixir
|
|
# BEFORE (crashes if site is nil):
|
|
defp resolve_snmp_community(device) do
|
|
device.snmp_community ||
|
|
device.site.snmp_community || # ⚠️ Crashes
|
|
device.site.organization.snmp_community
|
|
end
|
|
|
|
# AFTER (safe with CredentialResolver):
|
|
alias Towerops.Devices.CredentialResolver
|
|
|
|
defp resolve_snmp_community(device) do
|
|
CredentialResolver.resolve_field(
|
|
device,
|
|
:snmp_community,
|
|
:snmp_community,
|
|
:snmp_community
|
|
)
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 4: Query Function Updates
|
|
|
|
#### Update Organization Device Queries
|
|
**File**: `lib/towerops/devices.ex`
|
|
|
|
**Functions to update**:
|
|
1. `list_organization_devices/2` (lines 39-63)
|
|
2. `count_organization_devices/1` (lines 83-91)
|
|
3. `list_devices_for_organizations/1` (lines 68-78)
|
|
4. `list_mikrotik_devices_with_api/0` (lines 141-156)
|
|
|
|
**Pattern**:
|
|
```elixir
|
|
# BEFORE (requires site join):
|
|
def list_organization_devices(organization_id, filters) do
|
|
from(d in Device,
|
|
join: s in assoc(d, :site), # ⚠️ Inner join excludes site-less devices
|
|
where: s.organization_id == ^organization_id,
|
|
preload: [site: s]
|
|
)
|
|
end
|
|
|
|
# AFTER (direct organization_id):
|
|
def list_organization_devices(organization_id, filters) do
|
|
from(d in Device,
|
|
where: d.organization_id == ^organization_id,
|
|
order_by: [asc: d.display_order, asc: d.name],
|
|
preload: [:site, :organization] # Left join - includes site-less devices
|
|
)
|
|
end
|
|
```
|
|
|
|
#### Update Preload Functions
|
|
Add safe preload helper:
|
|
|
|
```elixir
|
|
def preload_device_with_associations(device) do
|
|
if device.site_id do
|
|
Repo.preload(device, [:organization, :site])
|
|
else
|
|
Repo.preload(device, [:organization])
|
|
end
|
|
end
|
|
```
|
|
|
|
Update `get_device/1`, `get_device!/1`, `get_device_with_details/1` to use it.
|
|
|
|
---
|
|
|
|
### Phase 5: Authorization Fixes
|
|
|
|
#### API Controllers
|
|
**File**: `lib/towerops_web/controllers/api/v1/devices_controller.ex`
|
|
|
|
**Update authorization checks** (lines 147, 190, 236):
|
|
```elixir
|
|
# BEFORE:
|
|
if device.site.organization_id == organization_id do # ⚠️ Crashes
|
|
|
|
# AFTER:
|
|
if device.organization_id == organization_id do # ✅ Safe
|
|
```
|
|
|
|
**Update site verification** (line 260):
|
|
```elixir
|
|
# BEFORE:
|
|
defp verify_site_access(nil, _organization_id),
|
|
do: {:error, "site_id is required"}
|
|
|
|
# AFTER (site is optional):
|
|
defp verify_site_access(nil, _organization_id),
|
|
do: :ok
|
|
```
|
|
|
|
#### Agent Channel
|
|
**File**: `lib/towerops_web/channels/agent_channel.ex`
|
|
|
|
**Update device verification** (line 639):
|
|
```elixir
|
|
# BEFORE:
|
|
if is_nil(organization_id) or device.site.organization_id == organization_id do
|
|
|
|
# AFTER:
|
|
if is_nil(organization_id) or device.organization_id == organization_id do
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 6: Device Form Updates
|
|
|
|
#### Remove Site Requirement
|
|
**File**: `lib/towerops_web/live/device_live/form.ex`
|
|
|
|
**Remove blocking check** (lines 34-38):
|
|
```elixir
|
|
# DELETE THIS:
|
|
if Enum.empty?(sites) do
|
|
{:ok,
|
|
socket
|
|
|> put_flash(:info, "Please create a site before adding a device.")
|
|
|> push_navigate(to: ~p"/sites/new")}
|
|
```
|
|
|
|
**Update changeset to auto-inject organization_id**:
|
|
```elixir
|
|
def handle_event("save", %{"device" => device_params}, socket) do
|
|
organization = socket.assigns.current_scope.organization
|
|
|
|
# Inject organization_id from current context
|
|
device_params = Map.put(device_params, "organization_id", organization.id)
|
|
|
|
save_device(socket, socket.assigns.live_action, device_params)
|
|
end
|
|
```
|
|
|
|
#### Make Site Selector Optional in Form
|
|
**File**: `lib/towerops_web/live/device_live/form.html.heex`
|
|
|
|
**Update site input** (lines 168-177):
|
|
```heex
|
|
<!-- Site is now optional -->
|
|
<div class="col-span-full">
|
|
<.input
|
|
field={@form[:site_id]}
|
|
type="select"
|
|
label="Site (Optional)"
|
|
prompt="No site (ungrouped)"
|
|
options={Enum.map(@available_sites, &{&1.name, &1.id})}
|
|
/>
|
|
<p class="mt-1 text-sm text-gray-500">
|
|
Optionally group this device into a site (office, datacenter, etc.)
|
|
</p>
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 7: API Updates
|
|
|
|
#### Device Create Endpoint
|
|
**File**: `lib/towerops_web/controllers/api/v1/devices_controller.ex`
|
|
|
|
**Update to auto-inject organization_id**:
|
|
```elixir
|
|
def create(conn, %{"device" => device_params}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
# Inject organization_id from authenticated context
|
|
device_params = Map.put(device_params, "organization_id", organization_id)
|
|
|
|
# site_id is now optional
|
|
with :ok <- verify_site_access(device_params["site_id"], organization_id),
|
|
{:ok, device} <- Devices.create_device(device_params) do
|
|
# ...
|
|
end
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 8: Agent Assignment Updates
|
|
|
|
#### Safe Agent Resolution
|
|
**File**: `lib/towerops/devices.ex` (lines 167-189)
|
|
|
|
**Update agent fallback chain**:
|
|
```elixir
|
|
def resolve_agent_token_id(device) do
|
|
device =
|
|
if device.site_id do
|
|
Repo.preload(device, [:organization, :site, agent_assignments: []])
|
|
else
|
|
Repo.preload(device, [:organization, agent_assignments: []])
|
|
end
|
|
|
|
get_direct_agent(device) ||
|
|
get_site_agent(device) ||
|
|
get_org_default_agent(device) ||
|
|
get_global_default_agent()
|
|
end
|
|
|
|
# Safe: only access site if site_id present
|
|
defp get_site_agent(device) do
|
|
if device.site_id && device.site do
|
|
device.site.agent_token_id
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
# Safe: use direct organization relationship
|
|
defp get_org_default_agent(device) do
|
|
device.organization.default_agent_token_id
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 9: Propagation Functions
|
|
|
|
#### Update Credential Propagation
|
|
**File**: `lib/towerops/devices.ex`
|
|
|
|
**Functions to update**:
|
|
- `propagate_organization_community_change/2` (lines 905-934)
|
|
- `propagate_organization_mikrotik_change/2` (lines 490-521)
|
|
- `propagate_organization_snmpv3_change/2` (lines 662-688)
|
|
|
|
**Pattern**:
|
|
```elixir
|
|
# BEFORE (inner join excludes site-less devices):
|
|
from(d in Device,
|
|
join: s in assoc(d, :site),
|
|
where: s.organization_id == ^organization_id,
|
|
...
|
|
)
|
|
|
|
# AFTER (includes site-less devices):
|
|
from(d in Device,
|
|
left_join: s in assoc(d, :site),
|
|
where: d.organization_id == ^organization_id,
|
|
where: (is_nil(d.site_id)) or (is_nil(s.snmp_community) or s.snmp_community == ""),
|
|
...
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 10: Device Quota Validation
|
|
|
|
#### Update Quota Checks
|
|
**File**: `lib/towerops/devices.ex` (lines 727-756)
|
|
|
|
**Change to use organization_id**:
|
|
```elixir
|
|
# BEFORE (derives org from site):
|
|
defp do_check_quota(changeset) do
|
|
case Ecto.Changeset.fetch_change(changeset, :site_id) do
|
|
{:ok, site_id} ->
|
|
validate_device_quota(changeset, site_id)
|
|
:error ->
|
|
{:ok, changeset}
|
|
end
|
|
end
|
|
|
|
defp validate_device_quota(changeset, site_id) do
|
|
site = site_id |> Sites.get_site!() |> Repo.preload(:organization)
|
|
organization = site.organization
|
|
# ...
|
|
end
|
|
|
|
# AFTER (uses organization_id directly):
|
|
defp do_check_quota(changeset) do
|
|
case Ecto.Changeset.fetch_change(changeset, :organization_id) do
|
|
{:ok, organization_id} ->
|
|
validate_device_quota(changeset, organization_id)
|
|
:error ->
|
|
{:ok, changeset}
|
|
end
|
|
end
|
|
|
|
defp validate_device_quota(changeset, organization_id) do
|
|
organization = Repo.get!(Organization, organization_id)
|
|
# ...
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## Testing Strategy
|
|
|
|
### Unit Tests
|
|
|
|
**File**: `test/towerops/devices/credential_resolver_test.exs` (new)
|
|
|
|
Test scenarios:
|
|
- 3-tier resolution (device with site): device → site → org → default
|
|
- 2-tier resolution (device without site): device → org → default
|
|
- All credential types (SNMP v2, SNMP v3, MikroTik)
|
|
|
|
**File**: `test/towerops/devices_test.exs`
|
|
|
|
Add tests for:
|
|
- `get_snmp_config/1` with and without site
|
|
- `resolve_agent_token_id/1` with and without site
|
|
- Query functions return site-less devices
|
|
- Propagation functions handle site-less devices
|
|
|
|
### Integration Tests
|
|
|
|
**File**: `test/towerops_web/controllers/api/v1/devices_controller_test.exs`
|
|
|
|
Test scenarios:
|
|
- Create device without site_id → succeeds
|
|
- Create device with site_id from different org → fails
|
|
- Show/update/delete device without site → succeeds
|
|
|
|
**File**: `test/towerops_web/live/device_live_test.exs`
|
|
|
|
Test scenarios:
|
|
- Navigate to /devices/new without sites → no redirect
|
|
- Submit device form without site_id → succeeds
|
|
- Device appears in list view
|
|
|
|
### Migration Tests
|
|
|
|
Verify:
|
|
- Migration 1 backfills all organization_id values correctly
|
|
- Check constraint prevents mismatched site/org
|
|
- Migration 2 rollback blocked when NULL site_id exists
|
|
|
|
---
|
|
|
|
## Implementation Sequence
|
|
|
|
### Day 1-2: Schema Foundation
|
|
1. Write and test migration 1 (add organization_id)
|
|
2. Update Device schema module
|
|
3. Write CredentialResolver module
|
|
4. Add unit tests for CredentialResolver
|
|
|
|
### Day 3-4: Credential Resolution
|
|
1. Refactor SNMP credential resolution
|
|
2. Refactor MikroTik credential resolution
|
|
3. Refactor SNMPv3 credential resolution
|
|
4. Update agent assignment resolution
|
|
5. Add integration tests
|
|
|
|
### Day 5-6: Query Updates
|
|
1. Update list_organization_devices and related functions
|
|
2. Update preload functions
|
|
3. Update propagation functions
|
|
4. Update quota validation
|
|
5. Add query tests
|
|
|
|
### Day 7-8: Authorization & Forms
|
|
1. Update API controller authorization
|
|
2. Update agent channel authorization
|
|
3. Update device form (remove site wall, make optional)
|
|
4. Update API create endpoint
|
|
5. Add E2E tests
|
|
|
|
### Day 9: Migration & Deployment
|
|
1. Write and test migration 2 (make site_id nullable)
|
|
2. Run migrations in staging
|
|
3. Manual testing with real devices
|
|
4. Deploy to production
|
|
|
|
### Day 10: Validation
|
|
1. Monitor error rates
|
|
2. Test creating devices without sites
|
|
3. Verify existing devices still work
|
|
4. Performance testing
|
|
|
|
---
|
|
|
|
## Critical Files Summary
|
|
|
|
### Migrations (New)
|
|
- `priv/repo/migrations/XXXXXX_add_organization_id_to_devices.exs`
|
|
- `priv/repo/migrations/XXXXXX_make_site_id_nullable_on_devices.exs`
|
|
|
|
### Schema & Context (Modified)
|
|
- `lib/towerops/devices/device.ex` - Add organization_id field, validation
|
|
- `lib/towerops/devices/credential_resolver.ex` - New module for safe resolution
|
|
- `lib/towerops/devices.ex` - 30+ functions need updates
|
|
|
|
### Controllers & LiveViews (Modified)
|
|
- `lib/towerops_web/controllers/api/v1/devices_controller.ex` - Auth fixes
|
|
- `lib/towerops_web/channels/agent_channel.ex` - Auth fixes
|
|
- `lib/towerops_web/live/device_live/form.ex` - Remove site wall
|
|
- `lib/towerops_web/live/device_live/form.html.heex` - Optional site selector
|
|
|
|
### Tests (New/Modified)
|
|
- `test/towerops/devices/credential_resolver_test.exs` - New unit tests
|
|
- `test/towerops/devices_test.exs` - Updated integration tests
|
|
- `test/towerops_web/controllers/api/v1/devices_controller_test.exs` - API tests
|
|
- `test/towerops_web/live/device_live_test.exs` - LiveView tests
|
|
|
|
---
|
|
|
|
## Rollback Plan
|
|
|
|
If issues arise:
|
|
1. **Revert application code** (Git revert)
|
|
2. **Keep migration 1** (organization_id column is additive, doesn't break anything)
|
|
3. **Revert migration 2** (only if no NULL site_id values exist)
|
|
4. System continues working with 3-tier credential resolution only
|
|
|
|
---
|
|
|
|
## Success Criteria
|
|
|
|
✅ New user registers → can add device immediately (no site required)
|
|
✅ Existing multi-site users see no change in behavior
|
|
✅ All credential resolution works (SNMP, SNMPv3, MikroTik)
|
|
✅ Agent polling continues for all devices
|
|
✅ API endpoints work for site-less devices
|
|
✅ No N+1 query regressions
|
|
✅ All tests passing (unit, integration, E2E)
|
|
✅ Zero downtime deployment
|
|
|
|
---
|
|
|
|
## Future Enhancements (Post-MVP)
|
|
|
|
After core functionality is stable, consider:
|
|
- Progressive disclosure UI (`use_sites` toggle in org settings)
|
|
- Conditional navigation (hide "Sites" link when unused)
|
|
- Device grouping UI (flat vs grouped view)
|
|
- Auto-create default site on org creation
|
|
- Help text and onboarding education
|
|
- Mobile app updates
|
|
- Advanced site features (hierarchical sites, site tags, etc.)
|