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:
parent
2f7f6370e3
commit
56093bb493
8 changed files with 81 additions and 48 deletions
32
PROFILES.md
32
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue