towerops/lib/towerops/accounts/sessions.ex
Graham McIntire 247bbe58da refactor: address audit recommendations from results.md
Applies the 11 recommendations from the codebase optimization review.

Module decomposition:
- Extract Towerops.Topology.InferenceEngine: pulls device-role inference
  (capability rules, vendor matching, evidence merging) out of the topology
  context. Topology now delegates merge_confidence/2,
  infer_role_from_capabilities/1, and infer_device_role/1.
- Extract Towerops.Accounts.TOTP: TOTP secret/QR generation, multi-device
  verification, recovery code lifecycle, and sudo MFA logic. Accounts
  delegates the public TOTP API for backwards compatibility.
- Extract Towerops.Accounts.Sessions: browser session create/list/touch/
  revoke/anonymize/expire. Accounts delegates the public session API.
- Extract Towerops.Snmp.Queries: time-series read paths
  (sensor readings, interface stats, latest-per-id batches).
- Extract Towerops.Snmp.Monitoring: time-series write paths
  (sensor/interface/processor reading inserts, batch inserts via
  Repo.insert_all). Snmp.ex shrinks from 2985 to 2742 lines.

DRY refactoring:
- Add per-schema Query modules (Devices.DeviceQuery, Alerts.AlertQuery)
  with composable for_organization/for_site/with_status/etc filters;
  refactor list_site_devices, count_organization_devices,
  count_site_devices, count_site_devices_down, count_organization_alerts
  to compose them.
- Introduce create_discovered_checks/6 helper in Snmp; collapses 4 nearly
  identical Enum.reduce blocks for sensors/interfaces/processors/storage.
- Unify MAC and ARP evidence collectors behind collect_lookup_evidence/3
  (lldp/cdp were already factored).

Pattern matching:
- Replace cond block in infer_role_from_capabilities/1 with a declarative
  @role_rules table consulted via Enum.find_value (Elixir 1.19 forbids `in`
  with runtime lists in guards, so multi-clause functions weren't an option).
- Replace inline `if since` in get_sensor_readings/2 with a maybe_filter_since
  pipe helper.

Performance:
- Replace Enum.each + Repo.insert per evidence row in upsert_link with a
  single Repo.insert_all batch (truncates :observed_at to seconds).

Human-centric:
- Decompose build_device_lookup/1 into fetch_lookup_devices, map_ips_to_ids,
  map_names_to_ids, map_macs_to_ids.
- Adopt Towerops.Result.map/2 in Snmp.Client.do_get_multiple_sequential
  (kept narrow; `with` is preferred over Result.and_then for chained
  operations and is already used widely).

CLAUDE.md updates:
- Replace stale main→staging / production-branch deployment notes with the
  current flow: PRs deploy to Dokku staging, push to main runs the
  ExUnit gate then builds an image; argocd-image-updater rolls production.
- Refresh data-model diagram and references from "Equipment" to "Device"
  (post equipment→devices rename migration).
- Correct stale architecture facts: Hammer → in-tree Towerops.RateLimit,
  Rust NIF → C NIF (libnetsnmp), gitlab-registry → forgejo-registry,
  expand the Oban queue and cron lists, list actual implemented Ecto types.

All 10,228 tests pass.
2026-04-30 12:33:41 -05:00

152 lines
4.1 KiB
Elixir

defmodule Towerops.Accounts.Sessions do
@moduledoc """
Browser session lifecycle: create / list / touch / revoke / anonymize.
Extracted from `Towerops.Accounts` to keep the context module focused on
user identity. Operates on `Towerops.Accounts.BrowserSession` and the
underlying `UserToken` (deleting a user token cascades to the session).
"""
import Ecto.Query, warn: false
alias Towerops.Accounts.BrowserSession
alias Towerops.Accounts.UserAgentParser
alias Towerops.Accounts.UserToken
alias Towerops.GeoIP
alias Towerops.Repo
@doc """
Create a browser session, parsing the User-Agent and enriching with GeoIP.
## Required attributes
- `:user_id`, `:user_token_id`, `:ip_address`, `:user_agent`,
`:last_activity_at`, `:expires_at`
"""
def create(attrs) do
user_agent = Map.get(attrs, :user_agent)
device_info = UserAgentParser.parse(user_agent)
location = location_for(Map.get(attrs, :ip_address))
attrs_with_metadata =
attrs
|> Map.merge(device_info)
|> Map.merge(location)
%BrowserSession{}
|> BrowserSession.create_changeset(attrs_with_metadata)
|> Repo.insert()
end
defp location_for(ip_address) do
case GeoIP.lookup_full(ip_address) do
%{} = loc ->
%{
country_code: loc.country_code,
country_name: loc.country_name,
city_name: loc.city_name,
subdivision_1_name: loc.subdivision_1_name
}
nil ->
%{}
end
end
@doc "List active (non-expired, non-anonymized) sessions for a user."
def list_active(user_id) do
now = DateTime.utc_now()
BrowserSession
|> where([bs], bs.user_id == ^user_id)
|> where([bs], bs.expires_at > ^now)
|> where([bs], is_nil(bs.anonymized_at))
|> order_by([bs], desc: bs.last_activity_at)
|> Repo.all()
end
@doc "Look up a session by its `user_token_id`."
def get_by_token(user_token_id) do
Repo.get_by(BrowserSession, user_token_id: user_token_id)
end
@doc "Look up a session by the raw token cookie value."
def get_by_token_value(token) when is_binary(token) do
hashed_token = :crypto.hash(:sha256, token)
user_token =
UserToken
|> where([ut], ut.token == ^hashed_token)
|> where([ut], ut.context == "session")
|> Repo.one()
case user_token do
nil -> nil
token -> get_by_token(token.id)
end
end
@doc "Update `last_activity_at`."
def touch(%BrowserSession{} = session) do
session
|> BrowserSession.touch_changeset()
|> Repo.update()
end
@doc """
Revoke a session, refusing to revoke the current request's session.
Cascades through the underlying UserToken.
"""
def revoke(session_id, _user_id, current_token_id) do
session = Repo.get(BrowserSession, session_id)
cond do
is_nil(session) ->
{:error, :not_found}
session.user_token_id == current_token_id ->
{:error, :self_revoke}
true ->
case Repo.get(UserToken, session.user_token_id) do
nil -> {:error, :not_found}
user_token -> Repo.delete(user_token)
end
end
end
@doc "Revoke every session for a user except `current_token_id`."
def revoke_all_other(user_id, current_token_id) do
session_ids =
BrowserSession
|> where([bs], bs.user_id == ^user_id)
|> where([bs], bs.user_token_id != ^current_token_id)
|> select([bs], bs.user_token_id)
|> Repo.all()
UserToken
|> where([ut], ut.id in ^session_ids)
|> Repo.delete_all()
end
@doc "GDPR: null out user_id on a user's sessions, keep metadata for analysis."
def anonymize_for_user(user_id) do
now = DateTime.utc_now()
BrowserSession
|> where([bs], bs.user_id == ^user_id)
|> where([bs], is_nil(bs.anonymized_at))
|> Repo.update_all(set: [user_id: nil, anonymized_at: now])
end
@doc "Delete sessions whose expires_at is in the past. Returns count."
def delete_expired do
now = DateTime.utc_now()
{count, _} =
BrowserSession
|> where([bs], bs.expires_at < ^now)
|> Repo.delete_all()
count
end
end