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 ![Enter a description](./create-description.png) -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. ![Example token](./create-success.png) -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. ![Revoke token](./revoke.png) ## 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
- <%= for %{value: value, description: description} <- [%{ - 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, has all privileges" - }] do %> -
- - -
- <% end %> + <.scope_input :for={{value, description} <- @scopes} endpoints={@endpoints} sources={@sources} value={value} description={description} form={@create_token_form} />
{submit("Create", class: "btn btn-primary")} @@ -144,6 +142,39 @@ defmodule LogflareWeb.AccessTokensLive do """ end + attr :sources, :list + attr :endpoints, :list + attr :value, :string + attr :description, :string + attr :form, Phoenix.HTML.Form + + def scope_input(assigns) do + assigns = + assigns + |> assign_new(:title, fn + %{value: "private:admin"} -> "Admin" + %{value: value} -> String.capitalize(value) + end) + + ~H""" +
+ + +
+ """ + end + @default_create_form %{ "description" => "", "scopes" => [], @@ -155,6 +186,7 @@ defmodule LogflareWeb.AccessTokensLive do %{assigns: %{user: user}} = socket sources = Sources.list_sources_by_user(user) endpoints = Endpoints.list_endpoints_by(user_id: user.id) + team_context = struct(TeamContext, socket.assigns) socket = socket @@ -165,6 +197,7 @@ defmodule LogflareWeb.AccessTokensLive do |> assign(scopes_ingest_sources: %{}) |> assign(scopes_query_endpoints: %{}) |> assign(create_token_form: @default_create_form) + |> assign(:team_context, team_context) |> do_refresh() {:ok, socket} diff --git a/test/logflare/auth_test.exs b/test/logflare/auth_test.exs index a9905cbbf5..f89e5b20c0 100644 --- a/test/logflare/auth_test.exs +++ b/test/logflare/auth_test.exs @@ -89,6 +89,16 @@ defmodule Logflare.AuthTest do assert :ok = Auth.check_scopes(key, ~w(ingest:source:3)) end + test "check_scopes/2 private:admin scope ", %{user: user} do + {:ok, key} = Auth.create_access_token(user, %{scopes: "private:admin"}) + 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)) + assert :ok = Auth.check_scopes(key, ~w(private)) + assert :ok = Auth.check_scopes(key, ~w(private:admin)) + 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: ""}) @@ -147,4 +157,32 @@ defmodule Logflare.AuthTest do {:ok, key} = Auth.create_access_token(user_or_team_or_partner) key end + + describe "can_create_admin_token?/1" do + test "with private:admin token scope", %{user: user} do + {:ok, token} = Auth.create_access_token(user, %{scopes: "private:admin"}) + assert Auth.can_create_admin_token?(token) == true + end + + test "with private token scope", %{user: user} do + {:ok, token} = Auth.create_access_token(user, %{scopes: "private"}) + assert Auth.can_create_admin_token?(token) == false + end + + test "with no token scope", %{user: user} do + {:ok, token} = Auth.create_access_token(user, %{scopes: nil}) + assert Auth.can_create_admin_token?(token) == false + end + + test "with owner TeamContext", %{user: user, team: team} do + {:ok, team_context} = Logflare.Teams.TeamContext.resolve(team.id, user.email) + assert Auth.can_create_admin_token?(team_context) == true + end + + test "with invited team user TeamContext", %{team: team} do + team_user = insert(:team_user, team: team) + {:ok, team_context} = Logflare.Teams.TeamContext.resolve(team.id, team_user.email) + assert Auth.can_create_admin_token?(team_context) == false + end + end end diff --git a/test/logflare/teams/team_context_test.exs b/test/logflare/teams/team_context_test.exs index f5de6ae0d0..371d0889c2 100644 --- a/test/logflare/teams/team_context_test.exs +++ b/test/logflare/teams/team_context_test.exs @@ -160,4 +160,22 @@ defmodule Logflare.Teams.TeamContextTest do assert {:error, :not_authorized} = TeamContext.resolve(forbidden_team.id, user.email) end end + + describe "team_owner?/1" do + test "returns true for user accessing their own home team", %{user: user, team: team} do + {:ok, context} = TeamContext.resolve(team.id, user.email) + + assert context.team_user == nil + assert TeamContext.team_owner?(context) == true + end + + test "returns false for user accessing a team they are a member of", %{user: user} do + invited_team = insert(:team) + insert(:team_user, email: user.email, team: invited_team) + {:ok, context} = TeamContext.resolve(invited_team.id, user.email) + + assert context.team_user != nil + assert TeamContext.team_owner?(context) == false + end + end end diff --git a/test/logflare_web/controllers/api/access_tokens_controller_test.exs b/test/logflare_web/controllers/api/access_tokens_controller_test.exs index b8338a6b57..02c99c8784 100644 --- a/test/logflare_web/controllers/api/access_tokens_controller_test.exs +++ b/test/logflare_web/controllers/api/access_tokens_controller_test.exs @@ -74,6 +74,37 @@ defmodule LogflareWeb.Api.AccessTokensTest do |> json_response(401) end + test "users with private:admin scope token can create admin scope tokens", %{ + conn: conn, + user: user + } do + {:ok, admin_token} = Logflare.Auth.create_access_token(user, %{scopes: "private:admin"}) + + response = + conn + |> put_req_header("authorization", "Bearer #{admin_token.token}") + |> post("/api/access-tokens", %{scopes: "private:admin"}) + |> json_response(201) + + assert response["scopes"] =~ "private:admin" + assert response["token"] + end + + test "users with other scope token cannot create admin scope tokens", %{ + conn: conn, + user: user + } do + conn + |> add_access_token(user, "private") + |> post("/api/access-tokens", %{scopes: "private:admin"}) + |> json_response(401) + + conn + |> add_access_token(user, "ingest:source:1") + |> post("/api/access-tokens", %{scopes: "private:admin"}) + |> json_response(401) + end + test "must use private token", %{conn: conn, user: user} do conn |> add_access_token(user, "public")