From dfdd77b773f146a4f55856fdd1cec7789ca94e51 Mon Sep 17 00:00:00 2001 From: TzeYiing Date: Fri, 18 Oct 2024 23:02:42 +0800 Subject: [PATCH 1/5] feat: initial ingest query scopes implementation --- lib/logflare/auth.ex | 52 ++++- lib/logflare/sources.ex | 2 +- .../plugs/verify_resource_access.ex | 66 +++++++ .../plugs/verify_resource_ownership.ex | 37 ---- lib/logflare_web/live/access_tokens_live.ex | 122 ++++++++++-- lib/logflare_web/router.ex | 8 +- test/logflare/auth_test.exs | 78 +++++++- .../live/access_tokens_live_test.exs | 70 ++++++- .../plugs/verify_api_access_test.exs | 28 --- .../plugs/verify_resource_access_test.exs | 178 ++++++++++++++++++ .../plugs/verify_resource_ownership_test.exs | 91 --------- 11 files changed, 541 insertions(+), 191 deletions(-) create mode 100644 lib/logflare_web/controllers/plugs/verify_resource_access.ex delete mode 100644 lib/logflare_web/controllers/plugs/verify_resource_ownership.ex create mode 100644 test/logflare_web/plugs/verify_resource_access_test.exs delete mode 100644 test/logflare_web/plugs/verify_resource_ownership_test.exs diff --git a/lib/logflare/auth.ex b/lib/logflare/auth.ex index 211c23c45..b6d0e7ba6 100644 --- a/lib/logflare/auth.ex +++ b/lib/logflare/auth.ex @@ -164,25 +164,57 @@ defmodule Logflare.Auth do resource_owner = Keyword.get(config, :resource_owner) with {:ok, access_token} <- ExOauth2Provider.authenticate_token(str_token, config), - token_scopes <- String.split(access_token.scopes || ""), - :ok <- check_scopes(token_scopes, required_scopes), + :ok <- check_scopes(access_token, required_scopes), owner <- get_resource_owner_by_id(resource_owner, access_token.resource_owner_id) do {:ok, owner} - else - {:scope, false} -> {:error, :unauthorized} - err -> err end end defp get_resource_owner_by_id(User, id), do: Users.Cache.get(id) defp get_resource_owner_by_id(Partner, id), do: Partners.Cache.get_partner(id) - defp check_scopes(token_scopes, required) do + @doc """ + Checks that an access token contains all scopes that are provided in a given required scopes list. + + Private scope will allways return `:ok` + iex> check_scopes(access_token, ["private"]) + + iex> check_scopes(%OauthAccessToken{scopes: "ingest:source:1"}, ["ingest:source:1"]) + If multiple scopes are provided, each scope must be in the access token's scope string + :ok + only can ingest to token-specified scopes + iex> check_scopes(%OauthAccessToken{scopes: "ingest:source:1"}, ["ingest"]) + {:error, :unauthorized} + + if scopes to check for are missing, will be unauthorized + iex> check_scopes(%OauthAccessToken{scopes: "ingest"}, ["ingest", "source:1"]) + {:error, :unauthorized} + + """ + def check_scopes(%_{} = access_token, required_scopes) when is_list(required_scopes) do + token_scopes = String.split(access_token.scopes || "") + cond do - "private" in token_scopes -> :ok - Enum.empty?(required) -> :ok - Enum.any?(token_scopes, fn scope -> scope in required end) -> :ok - true -> {:error, :unauthorized} + "private" in token_scopes -> + :ok + + Enum.empty?(required_scopes) -> + :ok + + # legacy behaviours + # empty scope + Enum.empty?(token_scopes) and Enum.any?(required_scopes, &String.starts_with?(&1, "ingest")) -> + :ok + + # deprecated scope + "public" in token_scopes and Enum.any?(required_scopes, &String.starts_with?(&1, "ingest")) -> + :ok + + Enum.any?(token_scopes, fn scope -> scope in required_scopes end) -> + :ok + + true -> + {:error, :unauthorized} end end diff --git a/lib/logflare/sources.ex b/lib/logflare/sources.ex index 20c30e3d5..e2f65442e 100644 --- a/lib/logflare/sources.ex +++ b/lib/logflare/sources.ex @@ -117,7 +117,7 @@ defmodule Logflare.Sources do get_source_by_token(source_token) end - def get(source_id) when is_integer(source_id) do + def get(source_id) when is_integer(source_id) or is_binary(source_id) do Repo.get(Source, source_id) |> put_retention_days() end diff --git a/lib/logflare_web/controllers/plugs/verify_resource_access.ex b/lib/logflare_web/controllers/plugs/verify_resource_access.ex new file mode 100644 index 000000000..cce3ed196 --- /dev/null +++ b/lib/logflare_web/controllers/plugs/verify_resource_access.ex @@ -0,0 +1,66 @@ +defmodule LogflareWeb.Plugs.VerifyResourceAccess do + @moduledoc """ + Plug that checks for ownership of the a provided resource. + + If the `:user` assign is not set, verification is assumed to have passed and as passthroguh is performed. + Also checks any API scopes that are set. + If no resource is set, performs a passthrough. + """ + alias Logflare.Source + alias Logflare.Endpoints.Query + alias Logflare.User + alias Logflare.Auth + alias LogflareWeb.Api.FallbackController + def init(_opts), do: nil + + def call(%{assigns: %{endpoint: %Query{enable_auth: false}}} = conn, _opts) do + conn + end + + # check source + def call( + %{ + assigns: %{ + access_token: access_token, + user: %User{id: id}, + source: %Source{id: source_id, user_id: user_id} + } + } = conn, + _opts + ) + when id == user_id do + if :ok == Auth.check_scopes(access_token, ["ingest", "ingest:source:#{source_id}"]) or + :ok == Auth.check_scopes(access_token, ["ingest", "ingest:collection:#{source_id}"]) do + conn + else + FallbackController.call(conn, {:error, :unauthorized}) + end + end + + # check endpoint + def call( + %{ + assigns: %{ + access_token: access_token, + user: %User{id: id}, + endpoint: %Query{id: endpoint_id, user_id: user_id} + } + } = conn, + _opts + ) + when id == user_id do + if :ok == Auth.check_scopes(access_token, ["query", "query:endpoint:#{endpoint_id}"]) do + conn + else + FallbackController.call(conn, {:error, :unauthorized}) + end + end + + # halts all others + def call(%{assigns: assigns} = conn, _) when is_map_key(assigns, :resource_type) do + FallbackController.call(conn, {:error, :unauthorized}) + end + + # no resource is set, passthrough + def call(conn, _), do: conn +end diff --git a/lib/logflare_web/controllers/plugs/verify_resource_ownership.ex b/lib/logflare_web/controllers/plugs/verify_resource_ownership.ex deleted file mode 100644 index 96ddb4670..000000000 --- a/lib/logflare_web/controllers/plugs/verify_resource_ownership.ex +++ /dev/null @@ -1,37 +0,0 @@ -defmodule LogflareWeb.Plugs.VerifyResourceOwnership do - @moduledoc """ - Plug that checks for ownership of the a provided resource. - - If the `:user` assign is not set, verification is assumed to have passed and as passthroguh is performed. - If no resource is set, performs a passthrough. - """ - alias Logflare.Source - alias Logflare.Endpoints.Query - alias Logflare.User - alias LogflareWeb.Api.FallbackController - def init(_opts), do: nil - - def call(%{assigns: %{endpoint: %Query{enable_auth: false}}} = conn, _opts) do - conn - end - - # check source - def call(%{assigns: %{user: %User{id: id}, source: %Source{user_id: user_id}}} = conn, _opts) - when id == user_id do - conn - end - - # check endpoint - def call(%{assigns: %{user: %User{id: id}, endpoint: %Query{user_id: user_id}}} = conn, _opts) - when id == user_id do - conn - end - - # halts all others - def call(%{assigns: assigns} = conn, _) when is_map_key(assigns, :resource_type) do - FallbackController.call(conn, {:error, :unauthorized}) - end - - # no resource is set, passthrough - def call(conn, _), do: conn -end diff --git a/lib/logflare_web/live/access_tokens_live.ex b/lib/logflare_web/live/access_tokens_live.ex index ecc98fd3c..0daed6a52 100644 --- a/lib/logflare_web/live/access_tokens_live.ex +++ b/lib/logflare_web/live/access_tokens_live.ex @@ -3,6 +3,8 @@ defmodule LogflareWeb.AccessTokensLive do use LogflareWeb, :live_view require Logger alias Logflare.Auth + alias Logflare.Sources + alias Logflare.Endpoints def render(assigns) do ~H""" @@ -26,27 +28,36 @@ defmodule LogflareWeb.AccessTokensLive do The X-API-KEY header method expects the header format X-API-KEY: your-access-token. The api_key query parameter method expects the search format ?api_key=your-access-token.

- <.form for={%{}} action="#" phx-submit="create-token" class={["mt-4", "jumbotron jumbotron-fluid tw-p-4", if(@show_create_form == false, do: "hidden")]}> + <.form for={@create_token_form} action="#" phx-change="update-token-form" phx-submit="create-token" class={["mt-4", "jumbotron jumbotron-fluid tw-p-4", if(@show_create_form == false, do: "hidden")]}>
New Access Token
- + A short description for identifying what this access token is to be used for.
<%= for %{value: value, description: description} <- [%{ - value: "public", - description: "For ingestion and endpoints" + value: "ingest", + description: "For ingestion into a source" }, %{ + value: "query", + description: "For querying an endpoint" + },%{ value: "private", - description: "For account management" + description: "For account management, has all privileges" }] do %> -
- - - <%= description %> +
+ +
<% end %>
@@ -101,7 +112,13 @@ defmodule LogflareWeb.AccessTokensLive do - <%= scope %> + + <%= case scope do + "ingest" <> _ -> get_ingest_label(assigns, scope) + "query" <> _ -> get_query_label(assigns, scope) + scope -> scope + end %> + <%= Calendar.strftime(token.inserted_at, "%d %b %Y, %I:%M:%S %p") %> @@ -123,14 +140,22 @@ defmodule LogflareWeb.AccessTokensLive do """ end + @default_create_form %{"description" => "", "scopes" => ["ingest"]} def mount(_params, %{"user_id" => user_id}, socket) do user = Logflare.Users.get(user_id) + sources = Sources.list_sources_by_user(user) + endpoints = Endpoints.list_endpoints_by(user_id: user.id) socket = socket |> assign(:user, user) |> assign(:show_create_form, false) |> assign(:created_token, nil) + |> assign(:sources, sources) + |> assign(:endpoints, endpoints) + |> assign(scopes_ingest_sources: %{}) + |> assign(scopes_query_endpoints: %{}) + |> assign(create_token_form: @default_create_form) |> do_refresh() {:ok, socket} @@ -147,13 +172,13 @@ defmodule LogflareWeb.AccessTokensLive do def handle_event( "create-token", params, - %{assigns: %{user: user}} = socket + %{assigns: %{user: user, create_token_form: create_token_form}} = socket ) do Logger.debug( "Creating access token for user, user_id=#{inspect(user.id)}, params: #{inspect(params)}" ) - attrs = Map.take(params, ["description", "scopes"]) + attrs = Map.take(create_token_form, ["description", "scopes"]) {:ok, token} = Auth.create_access_token(user, attrs) @@ -161,11 +186,34 @@ defmodule LogflareWeb.AccessTokensLive do socket |> do_refresh() |> assign(:show_create_form, false) + |> assign(:create_token_form, @default_create_form) |> assign(:created_token, token) {:noreply, socket} end + def handle_event( + "update-token-form", + payload, + socket + ) do + data = + Map.drop(payload, ["_csrf_token", "_target"]) + |> Map.update!("scopes", fn scopes -> + if Enum.any?(scopes, &(&1 =~ "ingest:")) do + Enum.filter(scopes, &(&1 == "ingest")) + else + scopes + end + end) + + merged = Map.merge(socket.assigns.create_token_form, data) + + dbg(merged) + + {:noreply, assign(socket, :create_token_form, merged)} + end + def handle_event( "revoke-token", %{"token-id" => token_id}, @@ -185,8 +233,58 @@ defmodule LogflareWeb.AccessTokensLive do defp do_refresh(%{assigns: %{user: user}} = socket) do tokens = user |> Auth.list_valid_access_tokens() |> Enum.sort_by(& &1.inserted_at, :desc) + scopes_ingest_sources = + for token <- tokens, + str_id <- parse_ingest_scope_source_id(token.scopes), + source = Sources.get(str_id), + into: socket.assigns.scopes_ingest_sources do + {str_id, source} + end + + scopes_query_endpoints = + for token <- tokens, + str_id <- parse_query_scope_endpoint_id(token.scopes), + endpoint = Endpoints.get_endpoint_query(str_id), + into: socket.assigns.scopes_query_endpoints do + {str_id, endpoint} + end + socket |> assign(access_tokens: tokens) + |> assign(scopes_ingest_sources: scopes_ingest_sources) + |> assign(scopes_query_endpoints: scopes_query_endpoints) |> assign(created_token: nil) end + + # get list of string ids from scopes string + defp parse_ingest_scope_source_id(scopes) do + Regex.scan(~r/ingest:source:([0-9]+)/, scopes, capture: :all_but_first) + |> List.flatten() + end + + # get list of string ids from scopes string + defp parse_query_scope_endpoint_id(scopes) do + Regex.scan(~r/query:endpoint:([0-9]+)/, scopes, capture: :all_but_first) + |> List.flatten() + end + + defp get_query_label(_assigns, "query"), do: "query" + + defp get_query_label(%{scopes_query_endpoints: endpoint_map}, "query:endpoint:" <> str_id) do + if endpoint = Map.get(endpoint_map, str_id) do + "query for #{endpoint.name}" + else + "query for (deleted)" + end + end + + defp get_ingest_label(_assigns, "ingest"), do: "ingest" + + defp get_ingest_label(%{scopes_ingest_sources: source_map}, "ingest:source:" <> str_id) do + if source = Map.get(source_map, str_id) do + "ingest for #{source.name}" + else + "ingest for (deleted)" + end + end end diff --git a/lib/logflare_web/router.ex b/lib/logflare_web/router.ex index d72ad3d8e..a5ee417d0 100644 --- a/lib/logflare_web/router.ex +++ b/lib/logflare_web/router.ex @@ -70,15 +70,15 @@ defmodule LogflareWeb.Router do end pipeline :require_endpoint_auth do - plug(LogflareWeb.Plugs.VerifyApiAccess, scopes: ~w(public)) + plug(LogflareWeb.Plugs.VerifyApiAccess) plug(LogflareWeb.Plugs.FetchResource) - plug(LogflareWeb.Plugs.VerifyResourceOwnership) + plug(LogflareWeb.Plugs.VerifyResourceAccess) end pipeline :require_ingest_api_auth do - plug(LogflareWeb.Plugs.VerifyApiAccess, scopes: ~w(public)) + plug(LogflareWeb.Plugs.VerifyApiAccess) plug(LogflareWeb.Plugs.FetchResource) - plug(LogflareWeb.Plugs.VerifyResourceOwnership) + plug(LogflareWeb.Plugs.VerifyResourceAccess) # We are ensuring source start in Logs.ingest # plug LogflareWeb.Plugs.EnsureSourceStarted plug(LogflareWeb.Plugs.SetPlanFromCache) diff --git a/test/logflare/auth_test.exs b/test/logflare/auth_test.exs index be26155f4..5432c0519 100644 --- a/test/logflare/auth_test.exs +++ b/test/logflare/auth_test.exs @@ -41,18 +41,82 @@ defmodule Logflare.AuthTest do end end - test "verify_access_token/2 public scope", %{user: user} do - # no scope set + test "verify_access_token/2 ingest scope", %{user: user} do + # no scope set on token, defaults to ingest to any sources {:ok, key} = Auth.create_access_token(user) - assert {:ok, _} = Auth.verify_access_token(key.token, ~w(public)) - assert {:ok, _} = Auth.verify_access_token(key.token, "public") + assert {:ok, _} = Auth.verify_access_token(key.token, ~w(ingest)) + assert {:ok, _} = Auth.verify_access_token(key.token, "ingest") assert {:ok, _} = Auth.verify_access_token(key.token) + assert {:ok, _} = Auth.verify_access_token(key.token, ~w(ingest source:2)) # scope is set - {:ok, key} = Auth.create_access_token(user, %{scopes: "public"}) - assert {:ok, _} = Auth.verify_access_token(key.token, ~w(public)) - assert {:ok, _} = Auth.verify_access_token(key.token, "public") + {:ok, key} = Auth.create_access_token(user, %{scopes: "ingest"}) + assert {:ok, _} = Auth.verify_access_token(key.token, ~w(ingest)) + assert {:ok, _} = Auth.verify_access_token(key.token, "ingest") + assert {:ok, _} = Auth.verify_access_token(key.token) + + # scope to a specific resource + # source and collection are resource aliases. i.e. they refer to the same resource. + for name <- ["source", "collection"] do + {:ok, key} = Auth.create_access_token(user, %{scopes: "ingest:#{name}:3 ingest:#{name}:1"}) + assert {:ok, _} = Auth.verify_access_token(key.token, ~w(ingest:#{name}:1)) + assert {:ok, _} = Auth.verify_access_token(key.token, ~w(ingest:#{name}:3)) + end + end + + test "verify_access_token/2 query scope", %{user: user} do + # no scope set on token + {:ok, key} = Auth.create_access_token(user) + assert {:error, _} = Auth.verify_access_token(key.token, ~w(query)) + + # scope is set on token + {:ok, key} = Auth.create_access_token(user, %{scopes: "query"}) + assert {:error, _} = Auth.verify_access_token(key.token, ~w(ingest)) + assert {:ok, _} = Auth.verify_access_token(key.token, ~w(query)) assert {:ok, _} = Auth.verify_access_token(key.token) + + # scope to a specific resource + {:ok, key} = Auth.create_access_token(user, %{scopes: "query:endpoint:3 query:endpoint:1"}) + assert {:ok, _} = Auth.verify_access_token(key.token, ~w(query:endpoint:1)) + assert {:ok, _} = Auth.verify_access_token(key.token, ~w(query:endpoint:3)) + end + + test "check_scopes/2 private scope ", %{user: user} do + {:ok, key} = Auth.create_access_token(user, %{scopes: "private"}) + assert :ok = Auth.check_scopes(key, ~w(query)) + assert :ok = Auth.check_scopes(key, ~w(ingest)) + assert :ok = Auth.check_scopes(key, ~w(ingest:endpoint:3)) + assert :ok = Auth.check_scopes(key, ~w(ingest:source:3)) + end + + test "check_scopes/2 default empty scopes", %{user: user} do + # empty scopes means ingest into any source, the legacy behaviour + {:ok, key} = Auth.create_access_token(user, %{scopes: ""}) + assert :ok = Auth.check_scopes(key, ~w(ingest)) + assert :ok = Auth.check_scopes(key, ~w(ingest:source:1)) + end + + test "check_scopes/2 deprecated public scope", %{user: user} do + # empty scopes means ingest into any source, the legacy behaviour + {:ok, key} = Auth.create_access_token(user, %{scopes: "public"}) + assert :ok = Auth.check_scopes(key, ~w(ingest)) + assert :ok = Auth.check_scopes(key, ~w(ingest:source:1)) + assert {:error, :unauthorized} = Auth.check_scopes(key, ~w(query)) + assert {:error, :unauthorized} = Auth.check_scopes(key, ~w(private)) + end + + test "check_scopes/2 matches all required scopes ", %{user: user} do + # should only allow ingest into source 1 + {:ok, key} = Auth.create_access_token(user, %{scopes: "ingest:source:1 ingest:source:4"}) + assert {:error, _} = Auth.check_scopes(key, ~w(ingest)) + assert {:error, _} = Auth.check_scopes(key, ~w(ingest:source:3)) + assert {:error, _} = Auth.check_scopes(key, ~w(query)) + assert {:error, _} = Auth.check_scopes(key, ~w(query:source:4)) + assert {:error, _} = Auth.check_scopes(key, ~w(query:source:1)) + + assert :ok = Auth.check_scopes(key, ~w(ingest:source:1)) + assert :ok = Auth.check_scopes(key, ~w(ingest:source:4)) + assert :ok = Auth.check_scopes(key, ~w(ingest:source:4 ingest:source:1)) end test "verify_access_token/2 private scope", %{user: user} do diff --git a/test/logflare_web/live/access_tokens_live_test.exs b/test/logflare_web/live/access_tokens_live_test.exs index 4ef0a4f44..4e2a5d1d9 100644 --- a/test/logflare_web/live/access_tokens_live_test.exs +++ b/test/logflare_web/live/access_tokens_live_test.exs @@ -31,7 +31,7 @@ defmodule LogflareWeb.AccessTokensLiveTest do assert html =~ "Copy" end - test "public token", %{conn: conn, user: user} do + test "deprecated: public token", %{conn: conn, user: user} do token = insert(:access_token, scopes: "public", resource_owner: user) {:ok, view, _html} = live(conn, ~p"/access-tokens") html = render(view) @@ -46,6 +46,55 @@ defmodule LogflareWeb.AccessTokensLiveTest do assert html =~ "No description" end + test "create token - ingest into any", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/access-tokens") + html = do_ui_create_token(view, "ingest") + html = render(view) + # able to copy, visible + assert view + |> element("button", "Copy") + |> has_element?() + + assert html =~ "ingest" + end + + test "create token - ingest into one source", %{conn: conn, user: user} do + source = insert(:source, user: user) + {:ok, view, _html} = live(conn, ~p"/access-tokens") + html = do_ui_create_token(view, "ingest:source:#{source.id}") + # able to copy, visible + assert view + |> element("button", "Copy") + |> has_element?() + + assert html =~ "ingest for #{source.name}" + end + + test "create token - query for one endpoint", %{conn: conn, user: user} do + endpoint = insert(:endpoint, user: user) + {:ok, view, _html} = live(conn, ~p"/access-tokens") + html = do_ui_create_token(view, "query:endpoint:#{endpoint.id}") + + assert view + |> element("button", "Copy") + |> has_element?() + + assert html =~ "query for #{endpoint.name}" + end + + test "create ingest token", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/access-tokens") + + html = do_ui_create_token(view, "ingest") + + assert view + |> element("button", "Copy") + |> has_element?() + + assert html =~ "some description" + assert html =~ "ingest" + end + test "create private token", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/access-tokens") @@ -81,4 +130,23 @@ defmodule LogflareWeb.AccessTokensLiveTest do refute html =~ token.token assert html =~ "private" end + + # returns the rendered table html + defp do_ui_create_token(view, scopes) do + assert view + |> element("button", "Create access token") + |> render_click() + + assert view |> element("button", "Create") |> has_element?() + assert view |> element("label", "Scope") |> has_element?() + + assert view + |> element("form") + |> render_submit(%{ + description: "some description", + scopes: scopes + }) =~ "created successfully" + + view |> element("table") |> render() + end end diff --git a/test/logflare_web/plugs/verify_api_access_test.exs b/test/logflare_web/plugs/verify_api_access_test.exs index 3947bf8c7..ead926161 100644 --- a/test/logflare_web/plugs/verify_api_access_test.exs +++ b/test/logflare_web/plugs/verify_api_access_test.exs @@ -176,34 +176,6 @@ defmodule LogflareWeb.Plugs.VerifyApiAccessTest do end end - test "public scope", %{user: user} do - {:ok, access_token} = Logflare.Auth.create_access_token(user, %{scopes: "public"}) - - build_conn(:get, "/any", %{}) - |> put_req_header("x-api-key", access_token.token) - |> VerifyApiAccess.call(%{scopes: ~w(public)}) - |> assert_authorized(user) - - build_conn(:get, "/any", %{}) - |> put_req_header("x-api-key", access_token.token) - |> VerifyApiAccess.call(%{scopes: ~w(public)}) - |> assert_authorized(user) - - # no scope set - {:ok, access_token} = Logflare.Auth.create_access_token(user) - - build_conn(:get, "/any", %{}) - |> put_req_header("x-api-key", access_token.token) - |> VerifyApiAccess.call(%{scopes: ~w(public)}) - |> assert_authorized(user) - - # user.api_key - build_conn(:get, "/any", %{}) - |> put_req_header("x-api-key", user.api_key) - |> VerifyApiAccess.call(%{scopes: ~w(public)}) - |> assert_authorized(user) - end - test "private scope", %{user: user} do {:ok, access_token} = Logflare.Auth.create_access_token(user, %{scopes: "private"}) diff --git a/test/logflare_web/plugs/verify_resource_access_test.exs b/test/logflare_web/plugs/verify_resource_access_test.exs new file mode 100644 index 000000000..5828b4af6 --- /dev/null +++ b/test/logflare_web/plugs/verify_resource_access_test.exs @@ -0,0 +1,178 @@ +defmodule LogflareWeb.Plugs.VerifyResourceAccessTest do + @moduledoc false + use LogflareWeb.ConnCase + alias LogflareWeb.Plugs.VerifyResourceAccess + + setup do + user = insert(:user) + endpoint = insert(:endpoint, user: user, enable_auth: true) + source = insert(:source, user: user) + {:ok, user: user, source: source, endpoint: endpoint} + end + + describe "source" do + setup %{source: source, user: user} do + conn = + build_conn(:post, "/logs", %{"source" => Atom.to_string(source.token)}) + |> assign(:user, user) + |> assign(:source, source) + |> assign(:resource_type, :source) + + [conn: conn] + end + + test "valid - ingest into any", %{conn: initial, user: user} do + for scopes <- [ + "", + "public", + "ingest" + ] do + {:ok, access_token} = Logflare.Auth.create_access_token(user, %{scopes: scopes}) + + conn = + initial + |> assign(:access_token, access_token) + |> VerifyResourceAccess.call(%{}) + + refute conn.halted + end + end + + test "valid - ingest into one source", %{conn: conn, source: source, user: user} do + {:ok, access_token} = + Logflare.Auth.create_access_token(user, %{scopes: "ingest:source:#{source.id}"}) + + conn = + conn + |> assign(:access_token, access_token) + |> VerifyResourceAccess.call(%{}) + + refute conn.halted + end + + test "invalid - no access token", %{conn: initial_conn, source: source, user: user} do + # no access token + conn = + initial_conn + |> assign(:user, insert(:user)) + |> VerifyResourceAccess.call(%{}) + + assert conn.halted + assert conn.status == 401 + + # invalid scope check + {:ok, access_token} = + Logflare.Auth.create_access_token(user, %{scopes: "ingest:source:#{source.id + 4}"}) + + conn = + initial_conn + |> assign(:access_token, access_token) + |> VerifyResourceAccess.call(%{}) + + assert conn.halted + assert conn.status == 401 + end + + test "invalid - specific source", %{conn: initial_conn, source: source, user: user} do + {:ok, access_token} = + Logflare.Auth.create_access_token(user, %{scopes: "ingest:source:#{source.id + 4}"}) + + conn = + initial_conn + |> assign(:access_token, access_token) + |> VerifyResourceAccess.call(%{}) + + assert conn.halted and conn.status == 401 + end + end + + describe "endpoints" do + setup %{endpoint: endpoint, user: user} do + conn = + build_conn(:get, "/endpoints/query/#{endpoint.token}", %{"token" => endpoint.token}) + |> assign(:user, user) + |> assign(:resource_type, :endpoint) + |> assign(:endpoint, endpoint) + + [conn: conn] + end + + test "valid - query one", %{conn: conn, user: user, endpoint: endpoint} do + {:ok, access_token} = + Logflare.Auth.create_access_token(user, %{scopes: "query:endpoint:#{endpoint.id}"}) + + conn = + conn + |> assign(:access_token, access_token) + |> VerifyResourceAccess.call(%{}) + + refute conn.halted + end + + test "valid - query any", %{conn: conn, user: user} do + {:ok, access_token} = Logflare.Auth.create_access_token(user, %{scopes: "query"}) + + conn = + conn + |> assign(:access_token, access_token) + |> VerifyResourceAccess.call(%{}) + + refute conn.halted + end + + test "invalid - wrong scope action", %{conn: conn, user: user} do + {:ok, access_token} = Logflare.Auth.create_access_token(user, %{scopes: "ingest"}) + + conn = + conn + |> assign(:access_token, access_token) + |> VerifyResourceAccess.call(%{}) + + assert conn.halted and conn.status == 401 + end + + test "invalid - wrong resource scope", %{conn: conn, user: user, endpoint: endpoint} do + {:ok, access_token} = + Logflare.Auth.create_access_token(user, %{scopes: "query:endpoint:#{endpoint.id + 4}"}) + + conn = + conn + |> assign(:access_token, access_token) + |> VerifyResourceAccess.call(%{}) + + assert conn.halted and conn.status == 401 + end + + test "invalid - no scope", %{conn: conn, user: user} do + {:ok, access_token} = Logflare.Auth.create_access_token(user, %{scopes: ""}) + + conn = + conn + |> assign(:access_token, access_token) + |> VerifyResourceAccess.call(%{}) + + assert conn.halted and conn.status == 401 + end + end + + test "no resource provided", %{user: user} do + conn = + build_conn(:get, "/any", %{}) + |> assign(:user, user) + + refute conn.halted + end + + test "no user/resource provided" do + conn = build_conn(:get, "/any", %{}) + refute conn.halted + end + + test "no user provided", %{endpoint: endpoint} do + conn = + build_conn(:get, "/any", %{}) + |> assign(:endpoint, endpoint) + + refute conn.halted + end +end diff --git a/test/logflare_web/plugs/verify_resource_ownership_test.exs b/test/logflare_web/plugs/verify_resource_ownership_test.exs deleted file mode 100644 index 417cf077d..000000000 --- a/test/logflare_web/plugs/verify_resource_ownership_test.exs +++ /dev/null @@ -1,91 +0,0 @@ -defmodule LogflareWeb.Plugs.VerifyResourceOwnershipTest do - @moduledoc false - use LogflareWeb.ConnCase - alias LogflareWeb.Plugs.VerifyResourceOwnership - - setup do - user = insert(:user) - endpoint = insert(:endpoint, user: user, enable_auth: true) - source = insert(:source, user: user) - {:ok, user: user, source: source, endpoint: endpoint} - end - - describe "source" do - setup %{source: source, user: user} do - conn = - build_conn(:post, "/logs", %{"source" => Atom.to_string(source.token)}) - |> assign(:user, user) - |> assign(:source, source) - |> assign(:resource_type, :source) - - [conn: conn] - end - - test "valid", %{conn: conn} do - conn = VerifyResourceOwnership.call(conn, %{}) - - refute conn.halted - end - - test "invalid", %{conn: conn} do - conn = - conn - |> assign(:user, insert(:user)) - |> VerifyResourceOwnership.call(%{}) - - assert conn.halted - assert conn.status == 401 - end - end - - describe "endpoints" do - setup %{endpoint: endpoint, user: user} do - conn = - build_conn(:get, "/endpoints/query/#{endpoint.token}", %{"token" => endpoint.token}) - |> assign(:user, user) - |> assign(:resource_type, :endpoint) - |> assign(:endpoint, endpoint) - - [conn: conn] - end - - test "valid", %{conn: conn} do - conn = - conn - |> VerifyResourceOwnership.call(%{}) - - refute conn.halted - end - - test "invalid", %{conn: conn} do - conn = - conn - |> assign(:user, insert(:user)) - |> VerifyResourceOwnership.call(%{}) - - assert conn.halted - assert conn.status == 401 - end - end - - test "no resource provided", %{user: user} do - conn = - build_conn(:get, "/any", %{}) - |> assign(:user, user) - - refute conn.halted - end - - test "no user/resource provided" do - conn = build_conn(:get, "/any", %{}) - refute conn.halted - end - - test "no user provided", %{endpoint: endpoint} do - conn = - build_conn(:get, "/any", %{}) - |> assign(:endpoint, endpoint) - - refute conn.halted - end -end From 90bbf1c5069a5ff41c8d7cd31460a88b178ca3a2 Mon Sep 17 00:00:00 2001 From: TzeYiing Date: Fri, 8 Nov 2024 03:18:32 +0800 Subject: [PATCH 2/5] feat: ui adjustments --- lib/logflare_web/live/access_tokens_live.ex | 82 +++++++++++++------ .../live/access_tokens_live_test.exs | 38 ++++----- 2 files changed, 71 insertions(+), 49 deletions(-) diff --git a/lib/logflare_web/live/access_tokens_live.ex b/lib/logflare_web/live/access_tokens_live.ex index 0daed6a52..fb2555060 100644 --- a/lib/logflare_web/live/access_tokens_live.ex +++ b/lib/logflare_web/live/access_tokens_live.ex @@ -40,22 +40,26 @@ defmodule LogflareWeb.AccessTokensLive do <%= for %{value: value, description: description} <- [%{ value: "ingest", - description: "For ingestion into a source" + description: "For ingestion into a source. Allows ingest into all sources if no specific source is selected." }, %{ value: "query", - description: "For querying an endpoint" + description: "For querying an endpoint. Allows querying of all endpoints if no specific endpoint is selected" },%{ value: "private", description: "For account management, has all privileges" }] do %>
- -
@@ -112,7 +116,7 @@ defmodule LogflareWeb.AccessTokensLive do - + <%= case scope do "ingest" <> _ -> get_ingest_label(assigns, scope) "query" <> _ -> get_query_label(assigns, scope) @@ -125,7 +129,7 @@ defmodule LogflareWeb.AccessTokensLive do -