refactor: use API token auth for profile imports instead of session cookies

Changes profile import endpoint to use standard API token authentication:

API Token Changes:
- Add user_id to api_tokens table (tracks who created the token)
- Update ApiTokens.verify_token/1 to return user along with org_id
- Update ApiAuth plug to assign current_user from token

Profile Import Changes:
- Move endpoint from /api/v1/admin/profiles/import to /api/v1/profiles/import
- Check user.is_superuser in controller instead of using RequireSuperuser plug
- Use api_v1 pipeline (Bearer token auth) instead of browser session
- Update documentation to show API token usage

Security:
- Only API tokens created by superusers can import profiles
- Returns 403 Forbidden if token user is not a superuser
- Logs import attempts with user email for audit trail

This provides a consistent API experience using Bearer tokens
instead of requiring browser session cookies.
This commit is contained in:
Graham McIntire 2026-01-18 09:30:21 -06:00
parent 2f7f6370e3
commit 56093bb493
No known key found for this signature in database
8 changed files with 81 additions and 48 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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} ->

View file

@ -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

View file

@ -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