Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions docs/docs.logflare.com/docs/concepts/access-tokens/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
37 changes: 35 additions & 2 deletions lib/logflare/auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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) ->
Expand Down Expand Up @@ -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
6 changes: 6 additions & 0 deletions lib/logflare/teams/team_context.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 21 additions & 5 deletions lib/logflare_web/controllers/api/access_token_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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]],
Expand Down
87 changes: 60 additions & 27 deletions lib/logflare_web/live/access_tokens_live.ex
Original file line number Diff line number Diff line change
@@ -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>
Expand Down Expand Up @@ -38,32 +61,7 @@ defmodule LogflareWeb.AccessTokensLive do

<div class="form-group ">
<label name="scopes" class="tw-mr-3">Scope</label>
<%= 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 %>
<div class="form-check tw-mr-2">
<input class="form-check-input" type="checkbox" name="scopes_main[]" id={["scopes", "main", value]} value={value} checked={value in @create_token_form["scopes_main"]} />
<label class="form-check-label tw-px-1" for={["scopes", "main", value]}>
{String.capitalize(value)}
<small class="form-text text-muted">{description}</small>
<select :for={input_n <- 0..2} :if={value == "ingest" and value in @create_token_form["scopes_main"]} id={["scopes", "ingest", input_n]} name="scopes_ingest[]" class="mt-1 form-control form-control-sm">
<option hidden value="">Ingest into a specific source...</option>
<option :for={source <- @sources} selected={"ingest:source:#{source.id}" == Enum.at(@create_token_form["scopes_ingest"], input_n)} value={"ingest:source:#{source.id}"} }>Ingest into {source.name} only</option>
</select>
<select :for={input_n <- 0..2} :if={value == "query" and value in @create_token_form["scopes_main"]} id={["scopes", "query", input_n]} name="scopes_query[]" class="mt-1 form-control form-control-sm">
<option hidden value="">Query a specific endpoint...</option>
<option :for={endpoint <- @endpoints} value={"query:endpoint:#{endpoint.id}"} selected={"query:endpoint:#{endpoint.id}" == Enum.at(@create_token_form["scopes_query"], input_n)}>Query {endpoint.name} only</option>
</select>
</label>
</div>
<% end %>
<.scope_input :for={{value, description} <- @scopes} endpoints={@endpoints} sources={@sources} value={value} description={description} form={@create_token_form} />
</div>
<button type="button" class="btn btn-secondary" phx-click="toggle-create-form" phx-value-show="false">Cancel</button>
{submit("Create", class: "btn btn-primary")}
Expand Down Expand Up @@ -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"""
<div class="form-check tw-mr-2">
<input class="form-check-input" type="checkbox" name="scopes_main[]" id={["scopes", "main", @value]} value={@value} checked={@value in @form["scopes_main"]} />
<label class="form-check-label tw-px-1" for={["scopes", "main", @value]}>
{@title}
<small class="form-text text-muted">{@description}</small>
<select :for={input_n <- 0..2} :if={@value == "ingest" and @value in @form["scopes_main"]} id={["scopes", "ingest", input_n]} name="scopes_ingest[]" class="mt-1 form-control form-control-sm">
<option hidden value="">Ingest into a specific source...</option>
<option :for={source <- @sources} selected={"ingest:source:#{source.id}" == Enum.at(@form["scopes_ingest"], input_n)} value={"ingest:source:#{source.id}"} }>Ingest into {source.name} only</option>
</select>
<select :for={input_n <- 0..2} :if={@value == "query" and @value in @form["scopes_main"]} id={["scopes", "query", input_n]} name="scopes_query[]" class="mt-1 form-control form-control-sm">
<option hidden value="">Query a specific endpoint...</option>
<option :for={endpoint <- @endpoints} value={"query:endpoint:#{endpoint.id}"} selected={"query:endpoint:#{endpoint.id}" == Enum.at(@form["scopes_query"], input_n)}>Query {endpoint.name} only</option>
</select>
</label>
</div>
"""
end

@default_create_form %{
"description" => "",
"scopes" => [],
Expand All @@ -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
Expand All @@ -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}
Expand Down
38 changes: 38 additions & 0 deletions test/logflare/auth_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""})
Expand Down Expand Up @@ -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
18 changes: 18 additions & 0 deletions test/logflare/teams/team_context_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading