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 bb0a01c7d8..467f313e59 100644 --- a/docs/docs.logflare.com/docs/concepts/access-tokens/index.md +++ b/docs/docs.logflare.com/docs/concepts/access-tokens/index.md @@ -14,7 +14,8 @@ Access tokens can be scoped to specific functionality. 1. `ingest` - allows ingestion of events into sources. 2. `query` - allows querying of Logflare Endpoints. -3. `management` - allows management of resources and programmatic access to Logflare functionality. +3. `private` - allows management of team resources +4. `private:admin` allows management of team resources and team users. `ingest` and `query` scopes can be scoped to specific sources and endpoints. It is recommended to use the least privileged token for the given task, especially for public ingestion. @@ -30,21 +31,21 @@ Then, enter a description for the token for reference. Click on **Create** once  -You will be shown the access token ony **once**. Do copy the token to a safe location. +You will be shown the access token only **once**. Do copy the token to a safe location.  -To revoke access tokens, clikc on the **Revoke** button. This would immediately reject all incoming API requests. +To revoke access tokens, click on the **Revoke** button. This would immediately reject all incoming API requests.  ## Authentication -There are 3 supported methods to attach an accees token with an API request: +There are 3 supported methods to attach an access token with an API request: 1. Using the `Authorization` header, with the format `Authorization: Bearer your-access-token-here` 2. Using the `X-API-KEY` header, with the format `X-API-KEY: your-access-token-here` -3. Using the `api_key` query parameter, wuth the format `?api_key=your-access-token-here` +3. Using the `api_key` query parameter, with the format `?api_key=your-access-token-here` ## Client-side is Public diff --git a/lib/logflare/auth.ex b/lib/logflare/auth.ex index e7f3a9898f..9253b5483d 100644 --- a/lib/logflare/auth.ex +++ b/lib/logflare/auth.ex @@ -10,11 +10,16 @@ defmodule Logflare.Auth do alias Logflare.Partners.Partner alias Logflare.Repo alias Logflare.Teams.Team + alias Logflare.Teams.TeamContext alias Logflare.User alias Logflare.Users alias Phoenix.Token @max_age_default 86_400 + @admin_scope "private:admin" + + @spec admin_scope() :: String.t() + def admin_scope, do: @admin_scope defp env_salt, do: Application.get_env(:logflare, LogflareWeb.Endpoint)[:secret_key_base] defp env_oauth_config, do: Application.get_env(:logflare, ExOauth2Provider) @@ -178,9 +183,13 @@ defmodule Logflare.Auth do @doc """ Checks that an access token contains any scopes that are provided in a given required scopes list. - Private scope will allways return `:ok` + Private scope will always return `:ok` iex> check_scopes(access_token, ["private"]) + Admin scope (`private:admin`) implies private scope access: + iex> check_scopes(%OauthAccessToken{scopes: "private:admin"}, ["private"]) + :ok + 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 @@ -195,9 +204,13 @@ defmodule Logflare.Auth do """ def check_scopes(%_{} = access_token, required_scopes) when is_list(required_scopes) do token_scopes = String.split(access_token.scopes || "") + requires_admin = @admin_scope in required_scopes cond do - "private" in token_scopes -> + @admin_scope in token_scopes -> + :ok + + "private" in token_scopes and not requires_admin -> :ok Enum.empty?(required_scopes) -> @@ -234,4 +247,24 @@ defmodule Logflare.Auth do :ok end end + + @doc """ + Checks if a user can create access tokens with the admin scope. + + ## API Context (with OauthAccessToken) + Token must have the `private:admin` scope. + + ## LiveView Context (with TeamContext) + User must be signed in as team owner. + """ + @spec can_create_admin_token?(OauthAccessToken.t() | TeamContext.t()) :: + boolean() + def can_create_admin_token?(%TeamContext{} = team_context), + do: TeamContext.team_owner?(team_context) + + def can_create_admin_token?(%OauthAccessToken{scopes: scopes}) when is_binary(scopes) do + @admin_scope in String.split(scopes) + end + + def can_create_admin_token?(%OauthAccessToken{scopes: nil}), do: false end diff --git a/lib/logflare/teams/team_context.ex b/lib/logflare/teams/team_context.ex index d1de6d441c..e67fd00994 100644 --- a/lib/logflare/teams/team_context.ex +++ b/lib/logflare/teams/team_context.ex @@ -104,6 +104,12 @@ defmodule Logflare.Teams.TeamContext do end end + @spec team_owner?(t()) :: boolean() + def team_owner?(%__MODULE__{team: team, user: user, team_user: nil}), + do: team_owner?(team, user.email) + + def team_owner?(%__MODULE__{}), do: false + defp team_owner?(team, email), do: team.user.email == email defp fetch_team_user(team, email) do diff --git a/lib/logflare_web/controllers/api/access_token_controller.ex b/lib/logflare_web/controllers/api/access_token_controller.ex index c0103b40a2..64816238da 100644 --- a/lib/logflare_web/controllers/api/access_token_controller.ex +++ b/lib/logflare_web/controllers/api/access_token_controller.ex @@ -26,7 +26,7 @@ defmodule LogflareWeb.Api.AccessTokenController do end operation(:create, - summary: "Create source", + summary: "Create access token", request_body: AccessToken.params(), responses: %{ 201 => Created.response(AccessToken), @@ -35,20 +35,36 @@ defmodule LogflareWeb.Api.AccessTokenController do } ) - def create(%{assigns: %{user: user}} = conn, params) do - scopes_input = Map.get(params, "scopes", "") + def create(%{assigns: %{user: user, access_token: current_token}} = conn, params) do + scopes_list = + Map.get(params, "scopes", "") + |> String.split() - with {:scopes, true} <- {:scopes, "partner" not in String.split(scopes_input)}, + with :ok <- verify_create_scopes(scopes_list, current_token), {:ok, access_token} <- Auth.create_access_token(user, params) do conn |> put_status(201) |> json(access_token) else - {:scopes, false} -> {:error, :unauthorized} + {:partner_scope, false} -> {:error, :unauthorized} + {:admin_scope, false} -> {:error, :unauthorized} {:error, _} = err -> err end end + defp verify_create_scopes(scopes, token) do + cond do + "partner" in scopes -> + {:partner_scope, false} + + Auth.admin_scope() in scopes -> + if Auth.can_create_admin_token?(token), do: :ok, else: {:admin_scope, false} + + true -> + :ok + end + end + operation(:delete, summary: "Delete access token", parameters: [token: [in: :path, description: "Access Token", type: :string]], diff --git a/lib/logflare_web/live/access_tokens_live.ex b/lib/logflare_web/live/access_tokens_live.ex index 28b3a323a7..01d0260259 100644 --- a/lib/logflare_web/live/access_tokens_live.ex +++ b/lib/logflare_web/live/access_tokens_live.ex @@ -1,12 +1,35 @@ defmodule LogflareWeb.AccessTokensLive do @moduledoc false use LogflareWeb, :live_view + require Logger + alias Logflare.Auth - alias Logflare.Sources alias Logflare.Endpoints + alias Logflare.Sources + alias Logflare.Teams.TeamContext def render(assigns) do + assigns = + assigns + |> assign(:scopes, [ + { + "ingest", + "For ingestion into a source. Allows ingest into all sources if no specific source is selected." + }, + { + "query", + "For querying an endpoint. Allows querying of all endpoints if no specific endpoint is selected" + }, + { + "private", + "Create and modify account resources" + }, + if(Auth.can_create_admin_token?(assigns.team_context), + do: {Auth.admin_scope(), "Create and modify account resources and team users."} + ) + ]) + ~H""" <.subheader> <:path> @@ -38,32 +61,7 @@ defmodule LogflareWeb.AccessTokensLive do