diff --git a/PROFILES.md b/PROFILES.md index eb411bf9..daf1c0f1 100644 --- a/PROFILES.md +++ b/PROFILES.md @@ -46,21 +46,22 @@ The task generates a JSON file containing all profile data (detection rules, sen **Requirements:** - Superuser account (user with `is_superuser = true`) -- Active browser session -- Session cookie from browser +- API token created by superuser account -### Step 1: Get Session Cookie +### Step 1: Create Superuser API Token 1. Log in to Towerops as a superuser -2. Open browser Developer Tools (F12) -3. Navigate to: Application > Cookies > https://towerops.net -4. Copy the value of `_towerops_key` cookie +2. Navigate to your organization settings +3. Create a new API token +4. Copy the token (shown only once) - it will start with `towerops_` + +**Note**: Only API tokens created by superuser accounts can import device profiles. ### Step 2: Upload Profiles ```bash -curl -X POST https://towerops.net/api/v1/admin/profiles/import \ - -H "Cookie: _towerops_key=YOUR_SESSION_COOKIE" \ +curl -X POST https://towerops.net/api/v1/profiles/import \ + -H "Authorization: Bearer YOUR_SUPERUSER_API_TOKEN" \ -H "Content-Type: application/json" \ -d @profiles.json ``` @@ -199,11 +200,11 @@ mix export_profiles \ ### Import to production ```bash -# Get session cookie from browser first -SESSION_COOKIE="SFMyNTY.g3QAAAACbQ..." +# Use your superuser API token +SUPERUSER_TOKEN="towerops_abc123..." -curl -X POST https://towerops.net/api/v1/admin/profiles/import \ - -H "Cookie: _towerops_key=${SESSION_COOKIE}" \ +curl -X POST https://towerops.net/api/v1/profiles/import \ + -H "Authorization: Bearer ${SUPERUSER_TOKEN}" \ -H "Content-Type: application/json" \ -d @production_profiles.json ``` @@ -225,11 +226,12 @@ kubectl logs -n towerops deployment/towerops --tail=100 | grep "Import complete" ## Security -- Profile import requires superuser authentication -- Uses browser session cookies (not API tokens) +- Profile import requires superuser API token +- Only tokens created by superusers can import profiles +- User who created the token must still be a superuser - Runs in background to prevent timeouts - Validates profile data before importing -- Logs all import operations +- Logs all import operations with user email ## Supported Profile Count diff --git a/lib/mix/tasks/export_profiles.ex b/lib/mix/tasks/export_profiles.ex index f233ae3e..6c8c0409 100644 --- a/lib/mix/tasks/export_profiles.ex +++ b/lib/mix/tasks/export_profiles.ex @@ -106,17 +106,17 @@ defmodule Mix.Tasks.ExportProfiles do file_size = format_bytes(File.stat!(output_file).size) Mix.shell().info("✓ Exported #{length(profiles)} profiles (#{file_size})") - Mix.shell().info("\nUpload to production (requires superuser session):") + Mix.shell().info("\nUpload to production (requires superuser API token):") Mix.shell().info(""" - curl -X POST https://towerops.net/api/v1/admin/profiles/import \\ - -H "Cookie: _towerops_key=YOUR_SESSION_COOKIE" \\ + curl -X POST https://towerops.net/api/v1/profiles/import \\ + -H "Authorization: Bearer YOUR_SUPERUSER_API_TOKEN" \\ -H "Content-Type: application/json" \\ -d @#{output_file} - Note: You must be logged in as a superuser. Get the session cookie from - your browser's developer tools (Application > Cookies > _towerops_key). + Note: You must create an API token using a superuser account. + Only tokens created by superusers can import device profiles. """) end diff --git a/lib/towerops/api_tokens.ex b/lib/towerops/api_tokens.ex index 410b40d0..312fbb76 100644 --- a/lib/towerops/api_tokens.ex +++ b/lib/towerops/api_tokens.ex @@ -72,16 +72,16 @@ defmodule Towerops.ApiTokens do def get_api_token!(id), do: Repo.get!(ApiToken, id) @doc """ - Verifies an API token and returns the associated organization ID. + Verifies an API token and returns the associated organization ID and user. Also updates the last_used_at timestamp. - Returns {:ok, organization_id} if valid, {:error, :invalid_token} otherwise. + Returns {:ok, organization_id, user} if valid, {:error, :invalid_token} otherwise. ## Examples iex> verify_token("valid_token") - {:ok, organization_id} + {:ok, organization_id, %User{}} iex> verify_token("invalid") {:error, :invalid_token} @@ -100,7 +100,10 @@ defmodule Towerops.ApiTokens do else # Update last_used_at update_last_used(token) - {:ok, token.organization_id} + + # Load user if present + token = Repo.preload(token, :user) + {:ok, token.organization_id, token.user} end end end diff --git a/lib/towerops/api_tokens/api_token.ex b/lib/towerops/api_tokens/api_token.ex index d740a01c..faa37048 100644 --- a/lib/towerops/api_tokens/api_token.ex +++ b/lib/towerops/api_tokens/api_token.ex @@ -22,6 +22,7 @@ defmodule Towerops.ApiTokens.ApiToken do field :expires_at, :utc_datetime belongs_to :organization, Towerops.Organizations.Organization + belongs_to :user, Towerops.Accounts.User timestamps(type: :utc_datetime) end @@ -29,10 +30,11 @@ defmodule Towerops.ApiTokens.ApiToken do @doc false def changeset(api_token, attrs) do api_token - |> cast(attrs, [:organization_id, :name, :token_hash, :expires_at, :last_used_at]) + |> cast(attrs, [:organization_id, :user_id, :name, :token_hash, :expires_at, :last_used_at]) |> validate_required([:organization_id, :name, :token_hash]) |> validate_length(:name, min: 1, max: 255) |> unique_constraint(:token_hash) |> foreign_key_constraint(:organization_id) + |> foreign_key_constraint(:user_id) end end diff --git a/lib/towerops_web/controllers/api/v1/profiles_controller.ex b/lib/towerops_web/controllers/api/v1/profiles_controller.ex index c17fa060..9132adb8 100644 --- a/lib/towerops_web/controllers/api/v1/profiles_controller.ex +++ b/lib/towerops_web/controllers/api/v1/profiles_controller.ex @@ -13,6 +13,8 @@ defmodule ToweropsWeb.Api.V1.ProfilesController do Imports device profiles from JSON data exported via `mix export_profiles`. + **Requires superuser API token** - Only API tokens created by superuser accounts can import profiles. + Request body: [ { @@ -25,27 +27,52 @@ defmodule ToweropsWeb.Api.V1.ProfilesController do Response: { - "success": 15, - "failed": 2, - "errors": [ - {"os": "profile_name", "reason": "error message"} - ] + "status": "queued", + "job_id": "uuid", + "profile_count": 15, + "message": "Import job queued successfully..." } Example: curl -X POST https://towerops.net/api/v1/profiles/import \\ - -H "Authorization: Bearer YOUR_TOKEN" \\ + -H "Authorization: Bearer YOUR_SUPERUSER_TOKEN" \\ -H "Content-Type: application/json" \\ -d @profiles.json """ def import_bulk(conn, %{"_json" => profiles}) when is_list(profiles) do - Logger.info("Queueing import for #{length(profiles)} device profiles via API") + # Check if user is a superuser + user = conn.assigns[:current_user] + if !user || !user.is_superuser do + Logger.warning("Profile import attempted by non-superuser: #{inspect(user && user.email)}") + + conn + |> put_status(:forbidden) + |> json(%{error: "Superuser access required. Only API tokens created by superusers can import profiles."}) + else + Logger.info("Queueing import for #{length(profiles)} device profiles via API (user: #{user.email})") + + do_import_bulk(conn, profiles) + end + end + + def import_bulk(conn, _params) do + conn + |> put_status(:bad_request) + |> json(%{error: "Invalid request format. Expected array of profile objects."}) + end + + # Private functions + + defp do_import_bulk(conn, profiles) do # Generate unique job ID for tracking job_id = Ecto.UUID.generate() # Enqueue background job - case Exq.enqueue(Exq, "maintenance", Towerops.Workers.ProfileImportWorker, [profiles, job_id]) do + case Exq.enqueue(Exq, "maintenance", Towerops.Workers.ProfileImportWorker, [ + profiles, + job_id + ]) do {:ok, _job_jid} -> Logger.info("Profile import job #{job_id} queued successfully") @@ -64,12 +91,4 @@ defmodule ToweropsWeb.Api.V1.ProfilesController do |> json(%{error: "Failed to queue import job", reason: inspect(reason)}) end end - - def import_bulk(conn, _params) do - conn - |> put_status(:bad_request) - |> json(%{error: "Invalid request format. Expected array of profile objects."}) - end - - # Private functions (removed - logic moved to ProfileImportWorker) end diff --git a/lib/towerops_web/plugs/api_auth.ex b/lib/towerops_web/plugs/api_auth.ex index 99e3a5f5..76053b11 100644 --- a/lib/towerops_web/plugs/api_auth.ex +++ b/lib/towerops_web/plugs/api_auth.ex @@ -37,9 +37,10 @@ defmodule ToweropsWeb.Plugs.ApiAuth do defp authenticate_with_token(conn, token) do case ApiTokens.verify_token(token) do - {:ok, organization_id} -> + {:ok, organization_id, user} -> conn |> assign(:current_organization_id, organization_id) + |> assign(:current_user, user) |> assign(:api_authenticated, true) {:error, :invalid_token} -> diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 50299a40..2865ec19 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -77,13 +77,8 @@ defmodule ToweropsWeb.Router do resources "/sites", SitesController, except: [:new, :edit] resources "/devices", DevicesController, except: [:new, :edit] - end - # Admin API routes (requires superuser authentication via browser session) - scope "/api/v1/admin", V1 do - pipe_through [:api, :require_authenticated_user, ToweropsWeb.Plugs.RequireSuperuser] - - # Profile management (superuser only) + # Profile management (requires superuser API token) post "/profiles/import", ProfilesController, :import_bulk end diff --git a/priv/repo/migrations/20260118152752_add_user_id_to_api_tokens.exs b/priv/repo/migrations/20260118152752_add_user_id_to_api_tokens.exs new file mode 100644 index 00000000..07153a40 --- /dev/null +++ b/priv/repo/migrations/20260118152752_add_user_id_to_api_tokens.exs @@ -0,0 +1,11 @@ +defmodule Towerops.Repo.Migrations.AddUserIdToApiTokens do + use Ecto.Migration + + def change do + alter table(:api_tokens) do + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all) + end + + create index(:api_tokens, [:user_id]) + end +end