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.
152 lines
4.1 KiB
Elixir
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
|