diff --git a/docs/docs.logflare.com/docs/concepts/access-tokens/index.md b/docs/docs.logflare.com/docs/concepts/access-tokens/index.md index f33044cd7..7f2257c2d 100644 --- a/docs/docs.logflare.com/docs/concepts/access-tokens/index.md +++ b/docs/docs.logflare.com/docs/concepts/access-tokens/index.md @@ -36,9 +36,9 @@ There are 3 supported methods to attach an accees token with an API request: ## Client-side is Public -Access tokens are considered public for client-side usage. Public-only tokens will not be able to access the Logflare Management API, which provides capabilities to manage user resources on Logflare. +Access tokens can be exposed for client-side usage. Consider restricting tokens to ingest into specific sources or to considered public for client-side usage. Public-only tokens will not be able to access the Logflare Management API beyond the ingest/query APIs. -Please use a separate private access token for the Management API. +Private access tokens should not be exposed to the client side, as private access tokens have complete access to all management APIs used for account control. ## Rotation 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..3dc431bc6 --- /dev/null +++ b/lib/logflare_web/controllers/plugs/verify_resource_access.ex @@ -0,0 +1,82 @@ +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: + %{ + user: %User{id: id}, + source: %Source{id: source_id, user_id: user_id} + } = assigns + } = conn, + _opts + ) + when id == user_id do + access_token = Map.get(assigns, :access_token) + + # legacy api key + cond do + access_token == nil -> + conn + + :ok == Auth.check_scopes(access_token, ["ingest", "ingest:source:#{source_id}"]) or + :ok == Auth.check_scopes(access_token, ["ingest", "ingest:collection:#{source_id}"]) -> + conn + + true -> + FallbackController.call(conn, {:error, :unauthorized}) + end + end + + # check endpoint + def call( + %{ + assigns: + %{ + user: %User{id: id}, + endpoint: %Query{id: endpoint_id, user_id: user_id} + } = assigns + } = conn, + _opts + ) + when id == user_id do + access_token = Map.get(assigns, :access_token) + + cond do + # legacy api key + access_token == nil -> + conn + + :ok == Auth.check_scopes(access_token, ["query", "query:endpoint:#{endpoint_id}"]) -> + conn + + true -> + 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..305022491 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,40 @@ 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. Allows ingest into all sources if no specific source is selected." }, %{ + value: "query", + description: "For querying an endpoint. Allows querying of all endpoints if no specific endpoint is selected" + },%{ value: "private", - description: "For account management" + description: "For account management, has all privileges" }] do %> -
- - - <%= description %> +
+ +
<% end %>
@@ -101,14 +116,20 @@ 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") %> -