From 54c92abaed628a5faeef6097bd249a978e7234c3 Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Thu, 23 Jan 2025 15:31:54 -0600 Subject: [PATCH 01/23] wip --- lib/beacon/auth.ex | 61 +++++++ lib/beacon/auth/default.ex | 12 ++ lib/beacon/auth/role.ex | 44 +++++ lib/beacon/config.ex | 32 ++-- lib/beacon/content.ex | 294 ++++++++++++++++++++++------------ lib/beacon/migrations/v003.ex | 19 +++ lib/beacon/test/fixtures.ex | 22 +-- 7 files changed, 363 insertions(+), 121 deletions(-) create mode 100644 lib/beacon/auth.ex create mode 100644 lib/beacon/auth/default.ex create mode 100644 lib/beacon/auth/role.ex create mode 100644 lib/beacon/migrations/v003.ex diff --git a/lib/beacon/auth.ex b/lib/beacon/auth.ex new file mode 100644 index 000000000..d83a25f39 --- /dev/null +++ b/lib/beacon/auth.ex @@ -0,0 +1,61 @@ +defmodule Beacon.Auth do + @moduledoc """ + Top-level functions for checking role-based access control. + + These functions are used by Beacon clients such as LiveAdmin, and may be necessary when adding + customizations to the client. + """ + import Beacon.Utils, only: [repo: 1] + import Ecto.Query + + alias Beacon.Auth.Role + alias Beacon.Config + + @doc """ + Parses the actor's identity from the session. + """ + @callback actor_from_session(session :: map()) :: actor :: any() + + @doc """ + Checks the role of a given actor. + + Warning: this function should always check for the most recent data, in case it has changed. + + ```elixir + # bad + def check_role(actor), do: actor.role + # good + def check_role(actor), do: MyApp.Repo.one(from u in Users, where: u.id == ^actor, select: u.role) + ``` + """ + @callback check_role(actor :: any()) :: role :: any() + + def authorize(site, action, opts) do + if Keyword.get(opts, :auth, true) do + do_authorize(site, opts[:actor], action) + else + :ok + end + end + + defp do_authorize(site, actor, action) do + role = get_role(site, actor) + + query = from r in Role, where: r.site == ^site, where: r.name == ^to_string(role) + + with %{} = role <- repo(site).one(query), + true <- to_string(action) in role.capabilities do + :ok + else + _ -> {:error, :not_authorized} + end + end + + # defp get_actor(site, session) do + # Config.fetch!(site).auth_module.actor_from_session(session) + # end + + defp get_role(site, actor) do + Config.fetch!(site).auth_module.check_role(actor) + end +end diff --git a/lib/beacon/auth/default.ex b/lib/beacon/auth/default.ex new file mode 100644 index 000000000..2547058dd --- /dev/null +++ b/lib/beacon/auth/default.ex @@ -0,0 +1,12 @@ +defmodule Beacon.Auth.Default do + @moduledoc """ + Default Auth logic when none is provided. + + All users will be considered as `:admin`. + """ + @behaviour Beacon.Auth + + def actor_from_session(_session), do: nil + + def check_role(_actor), do: :admin +end diff --git a/lib/beacon/auth/role.ex b/lib/beacon/auth/role.ex new file mode 100644 index 000000000..6c738af6a --- /dev/null +++ b/lib/beacon/auth/role.ex @@ -0,0 +1,44 @@ +defmodule Beacon.Auth.Role do + @moduledoc """ + Scopes roles to actions for Authz. + + > #### Do not create or edit roles manually {: .warning} + > + > Use the public functions in `Beacon.Auth` instead. + > The functions in that module guarantee that all dependencies + > are created correctly and all processes are updated. + > Manipulating data manually will most likely result + > in inconsistent behavior and crashes. + + """ + use Beacon.Schema + + import Ecto.Changeset + + @type t :: %__MODULE__{ + id: Ecto.UUID.t(), + site: Beacon.Types.Site.t(), + name: String.t(), + capabilities: [String.t()], + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + schema "beacon_roles" do + field :site, Beacon.Types.Site + field :name, :string + field :capabilities, {:array, :string} + + timestamps() + end + + @doc false + def changeset(%__MODULE__{} = role, attrs) do + fields = ~w(site name capabilities)a + + role + |> cast(attrs, fields) + |> validate_required(fields) + |> unique_constraint([:site, :name]) + end +end diff --git a/lib/beacon/config.ex b/lib/beacon/config.ex index 3963b907c..913256986 100644 --- a/lib/beacon/config.ex +++ b/lib/beacon/config.ex @@ -199,6 +199,11 @@ defmodule Beacon.Config do """ @type page_warming :: {:shortest_paths, integer()} | {:specify_paths, [String.t()]} | :none + @typedoc """ + A module that implements `Beacon.Auth`. + """ + @type auth_module :: module() + @type t :: %__MODULE__{ site: Beacon.Types.Site.t(), endpoint: endpoint(), @@ -217,7 +222,8 @@ defmodule Beacon.Config do extra_page_fields: extra_page_fields(), extra_asset_fields: extra_asset_fields(), default_meta_tags: default_meta_tags(), - page_warming: page_warming() + page_warming: page_warming(), + auth_module: auth_module() } @default_load_template [ @@ -240,8 +246,6 @@ defmodule Beacon.Config do router: nil, repo: nil, mode: :live, - # TODO: rename to `authorization_policy`, see https://github.com/BeaconCMS/beacon/pull/563 - # authorization_source: Beacon.Authorization.DefaultPolicy, css_compiler: Beacon.RuntimeCSS.TailwindCompiler, tailwind_config: nil, tailwind_css: nil, @@ -264,7 +268,8 @@ defmodule Beacon.Config do extra_page_fields: [], extra_asset_fields: [], default_meta_tags: [], - page_warming: {:shortest_paths, 10} + page_warming: {:shortest_paths, 10}, + auth_module: Beacon.Auth.Default @type option :: {:site, Beacon.Types.Site.t()} @@ -285,6 +290,7 @@ defmodule Beacon.Config do | {:extra_asset_fields, extra_asset_fields()} | {:default_meta_tags, default_meta_tags()} | {:page_warming, page_warming()} + | {:auth_module, auth_module()} @doc """ Build a new `%Beacon.Config{}` instance to hold the entire configuration for each site. @@ -315,10 +321,10 @@ defmodule Beacon.Config do Defaults to: - [ - {:heex, "HEEx (HTML)"}, - {:markdown, "Markdown (GitHub Flavored version)"} - ] + [ + heex: "HEEx (HTML)", + markdown: "Markdown (GitHub Flavored version)" + ] Note that the default config is merged with your config. @@ -334,6 +340,8 @@ defmodule Beacon.Config do * `:page_warming` - `t:page_warming/0` (optional). Defaults to `{:shortest_paths, 10}`. + * `:auth_module` - `t:auth_module/0` (optional). Defaults to `Beacon.Auth.Default`. + ## Example iex> Beacon.Config.new( @@ -363,7 +371,8 @@ defmodule Beacon.Config do notify_admin: fn page -> {:cont, MyApp.Admin.send_email(page)} end ] ], - page_warming: {:specify_paths, ["/", "/home", "/blog"]} + page_warming: {:specify_paths, ["/", "/home", "/blog"]}, + auth_module: MyApp.BeaconAuth ) %Beacon.Config{ site: :my_site, @@ -412,7 +421,8 @@ defmodule Beacon.Config do extra_page_fields: [], extra_asset_fields: [], default_meta_tags: [], - page_warming: {:specify_paths, ["/", "/home", "/blog"]} + page_warming: {:specify_paths, ["/", "/home", "/blog"]}, + auth_module: MyApp.BeaconAuth } """ @@ -455,6 +465,7 @@ defmodule Beacon.Config do extra_asset_fields = Keyword.get(opts, :extra_asset_fields, [{"image/*", [Beacon.MediaLibrary.AssetFields.AltText]}]) page_warming = Keyword.get(opts, :page_warming, {:shortest_paths, 10}) + auth_module = Keyword.get(opts, :auth_module, Beacon.Auth.Default) opts = opts @@ -467,6 +478,7 @@ defmodule Beacon.Config do |> Keyword.put(:default_meta_tags, default_meta_tags) |> Keyword.put(:extra_asset_fields, extra_asset_fields) |> Keyword.put(:page_warming, page_warming) + |> Keyword.put(:auth_module, auth_module) struct!(__MODULE__, opts) end diff --git a/lib/beacon/content.ex b/lib/beacon/content.ex index 67e597623..ef6ff14e6 100644 --- a/lib/beacon/content.ex +++ b/lib/beacon/content.ex @@ -28,6 +28,7 @@ defmodule Beacon.Content do use GenServer import Ecto.Query + import Beacon.Auth, only: [authorize: 3] import Beacon.Utils, only: [repo: 1, transact: 2] alias Beacon.Content.Component @@ -127,35 +128,47 @@ defmodule Beacon.Content do @doc """ Creates a layout. - ## Example + ## Options - iex> create_layout(%{title: "Home"}) + * `:actor` - the identity to check for authorization + * `:auth` - pass `false` to disable authorization (defaults to `true`) + + ## Examples + + iex> create_layout(%{title: "Home"}, auth: false) {:ok, %Layout{}} + iex> create_layout(%{title: "Home"}, actor: "user-id-with-read-only-access") + {:error, :not_authorized} """ @doc type: :layouts - @spec create_layout(map()) :: {:ok, Layout.t()} | {:error, Changeset.t()} - def create_layout(attrs) do + @spec create_layout(map(), keyword()) :: {:ok, Layout.t()} | {:error, Changeset.t() | :not_authorized} + def create_layout(attrs, opts \\ []) do changeset = Layout.changeset(%Layout{}, attrs) site = Changeset.get_field(changeset, :site) - transact(repo(site), fn -> - with {:ok, changeset} <- validate_layout_template(changeset), - {:ok, layout} <- repo(site).insert(changeset), - {:ok, _event} <- create_layout_event(layout, "created") do - {:ok, layout} - end - end) + with :ok <- authorize(site, :create_layout, opts) do + transact(repo(site), fn -> + with {:ok, changeset} <- validate_layout_template(changeset), + {:ok, layout} <- repo(site).insert(changeset), + {:ok, _event} <- create_layout_event(layout, "created") do + {:ok, layout} + end + end) + end end @doc """ - Creates a layout. + Creates a layout, raising an error if unsuccessful. + + See `create_layout/2` for options. """ @doc type: :layouts - @spec create_layout!(map()) :: Layout.t() - def create_layout!(attrs) do - case create_layout(attrs) do + @spec create_layout!(map(), keyword()) :: Layout.t() + def create_layout!(attrs, opts \\ []) do + case create_layout(attrs, opts) do {:ok, layout} -> layout + {:error, :not_authorized} -> raise "failed to create layout: not authorized" {:error, changeset} -> raise "failed to create layout, got: #{inspect(changeset.errors)}" end end @@ -163,19 +176,27 @@ defmodule Beacon.Content do @doc """ Updates a layout. - ## Example + ## Options + + * `:actor` - the identity to check for authorization + * `:auth` - pass `false` to disable authorization (defaults to `true`) - iex> update_layout(layout, %{title: "New Home"}) + ## Examples + + iex> update_layout(layout, %{title: "New Home"}, auth: false) {:ok, %Layout{}} + iex> update_layout(layout, %{title: "New Home"}, actor: "user-id-with-read-only-access") + {:error, :not_authorized} """ @doc type: :layouts - @spec update_layout(Layout.t(), map()) :: {:ok, Layout.t()} | {:error, Changeset.t()} - def update_layout(%Layout{} = layout, attrs) do + @spec update_layout(Layout.t(), map(), keyword()) :: {:ok, Layout.t()} | {:error, Changeset.t() | :not_authorized} + def update_layout(%Layout{} = layout, attrs, opts \\ []) do changeset = Layout.changeset(layout, attrs) site = Changeset.get_field(changeset, :site) - with {:ok, changeset} <- validate_layout_template(changeset) do + with :ok <- authorize(site, :update_layout, opts), + {:ok, changeset} <- validate_layout_template(changeset) do repo(site).update(changeset) end end @@ -187,33 +208,50 @@ defmodule Beacon.Content do Event + snapshot This operation is serialized. + + ## Options + + * `:actor` - the identity to check for authorization + * `:auth` - pass `false` to disable authorization (defaults to `true`) + + ## Examples + + iex> publish_layout(layout, auth: false) + {:ok, %Layout{}} + iex> publish_layout(layout, actor: "user-id-with-read-only-access") + {:error, :not_authorized} + """ @doc type: :layouts - @spec publish_layout(Layout.t()) :: {:ok, Layout.t()} | {:error, Changeset.t() | term()} - def publish_layout(%Layout{} = layout) do - case Beacon.Config.fetch!(layout.site).mode do - :live -> - GenServer.call(name(layout.site), {:publish_layout, layout}) - - :testing -> - layout - |> insert_published_layout() - |> tap(fn {:ok, layout} -> reset_published_layout(layout.site, layout.id) end) - - :manual -> - insert_published_layout(layout) + @spec publish_layout(Layout.t() | keyword()) :: {:ok, Layout.t()} | {:error, Changeset.t() | term()} + def publish_layout(%Layout{} = layout, opts \\ []) do + with :ok <- authorize(site, :publish_layout, opts) do + case Beacon.Config.fetch!(layout.site).mode do + :live -> + GenServer.call(name(layout.site), {:publish_layout, layout}) + + :testing -> + layout + |> insert_published_layout() + |> tap(fn {:ok, layout} -> reset_published_layout(layout.site, layout.id) end) + + :manual -> + insert_published_layout(layout) + end end end @doc """ Same as `publish_layout/2` but accepts a `site` and `layout_id` with which to lookup the layout. + + See `publish_layout/2` for accepted options. """ @doc type: :layouts - @spec publish_layout(Site.t(), UUID.t()) :: {:ok, Layout.t()} | any() - def publish_layout(site, layout_id) when is_atom(site) and is_binary(layout_id) do + @spec publish_layout(Site.t(), UUID.t(), keyword()) :: {:ok, Layout.t()} | {:error, Changeset.t() | term()} + def publish_layout(site, layout_id, opts \\ []) when is_atom(site) and is_binary(layout_id) do site |> get_layout(layout_id) - |> publish_layout() + |> publish_layout(opts) end defp validate_layout_template(changeset) do @@ -528,11 +566,6 @@ defmodule Beacon.Content do @doc """ Creates a new page that's not published. - ## Example - - iex> create_page(%{"title" => "My New Page"}) - {:ok, %Page{}} - `attrs` may contain the following keys: * `path` - String.t() @@ -549,10 +582,23 @@ defmodule Beacon.Content do It will insert a `created` event into the page timeline, and no snapshot is created. + + ## Options + + * `:actor` - the identity to check for authorization + * `:auth` - pass `false` to disable authorization (defaults to `true`) + + ## Examples + + iex> create_page(%{"title" => "My New Page"}, auth: false) + {:ok, %Page{}} + iex> create_page(%{"title" => "My New Page"}, actor: "user-id-with-read-only-access") + {:error, :not_authorized} + """ @doc type: :pages - @spec create_page(map()) :: {:ok, Page.t()} | {:error, Changeset.t()} - def create_page(attrs) when is_map(attrs) do + @spec create_page(map(), keyword()) :: {:ok, Page.t()} | {:error, Changeset.t() | :not_authorized} + def create_page(attrs, opts \\ []) when is_map(attrs) do attrs = Map.new(attrs, fn {key, val} when is_binary(key) -> {key, val} @@ -562,14 +608,16 @@ defmodule Beacon.Content do {:ok, site} = Beacon.Types.Site.cast(attrs["site"]) changeset = Page.create_changeset(%Page{}, maybe_put_default_meta_tags(site, attrs)) - transact(repo(site), fn -> - with {:ok, changeset} <- validate_page_template(changeset), - {:ok, page} <- repo(site).insert(changeset), - {:ok, _event} <- create_page_event(page, "created"), - %Page{} = page <- Lifecycle.Page.after_create_page(page) do - {:ok, page} - end - end) + with :ok <- authorize(site, :create_page, opts) do + transact(repo(site), fn -> + with {:ok, changeset} <- validate_page_template(changeset), + {:ok, page} <- repo(site).insert(changeset), + {:ok, _event} <- create_page_event(page, "created"), + %Page{} = page <- Lifecycle.Page.after_create_page(page) do + {:ok, page} + end + end) + end end defp maybe_put_default_meta_tags(site, attrs) do @@ -578,15 +626,16 @@ defmodule Beacon.Content do end @doc """ - Creates a page. + Creates a page, raising an error if unsuccessful. - Raises an error if unsuccessful. + See `create_page/2` for accepted options. """ @doc type: :pages - @spec create_page!(map()) :: Page.t() - def create_page!(attrs) do - case create_page(attrs) do + @spec create_page!(map(), keyword()) :: Page.t() + def create_page!(attrs, opts \\ []) do + case create_page(attrs, opts) do {:ok, page} -> page + {:error, :not_authorized} -> raise "failed to create page: not authorized" {:error, changeset} -> raise "failed to create page, got: #{inspect(changeset.errors)}" end end @@ -594,15 +643,22 @@ defmodule Beacon.Content do @doc """ Updates a page. - ## Example + ## Options + + * `:actor` - the identity to check for authorization + * `:auth` - pass `false` to disable authorization (defaults to `true`) - iex> update_page(page, %{title: "New Home"}) + ## Examples + + iex> update_page(page, %{title: "New Home"}, auth: false) {:ok, %Page{}} + iex> update_page(page, %{title: "New Home"}, actor: "user-id-with-read-only-access") + {:error, :not_authorized} """ @doc type: :pages - @spec update_page(Page.t(), map()) :: {:ok, Page.t()} | {:error, Changeset.t()} - def update_page(%Page{} = page, attrs) do + @spec update_page(Page.t(), map(), keyword()) :: {:ok, Page.t()} | {:error, Changeset.t() | :not_authorized} + def update_page(%Page{} = page, attrs, opts \\ []) do {ast, attrs} = Map.pop(attrs, "ast") attrs = @@ -614,13 +670,15 @@ defmodule Beacon.Content do changeset = Page.update_changeset(page, attrs) - transact(repo(page), fn -> - with {:ok, changeset} <- validate_page_template(changeset), - {:ok, page} <- repo(page.site).update(changeset), - %Page{} = page <- Lifecycle.Page.after_update_page(page) do - {:ok, page} - end - end) + with :ok <- authorize(page.site, :update_page, opts) do + transact(repo(page), fn -> + with {:ok, changeset} <- validate_page_template(changeset), + {:ok, page} <- repo(page.site).update(changeset), + %Page{} = page <- Lifecycle.Page.after_update_page(page) do + {:ok, page} + end + end) + end end @doc """ @@ -631,52 +689,73 @@ defmodule Beacon.Content do can keep editing the page as needed without impacting the published page. This operation is serialized. + + ## Options + + * `:actor` - the identity to check for authorization + * `:auth` - pass `false` to disable authorization (defaults to `true`) + + ## Examples + + iex> publish_page(page, auth: false) + {:ok, %Page{}} + iex> publish_page(page, actor: "user-id-with-read-only-access") + {:error, :not_authorized} + """ @doc type: :pages - @spec publish_page(Page.t()) :: {:ok, Page.t()} | {:error, Changeset.t() | term()} - def publish_page(%Page{} = page) do - case Beacon.Config.fetch!(page.site).mode do - :live -> - GenServer.call(name(page.site), {:publish_page, page}) - - :testing -> - page - |> insert_published_page() - |> tap(fn {:ok, page} -> reset_published_page(page.site, page.id) end) - - :manual -> - insert_published_page(page) + @spec publish_page(Page.t(), keyword()) :: {:ok, Page.t()} | {:error, Changeset.t() | term()} + def publish_page(%Page{} = page, opts \\ []) do + with :ok <- authorize(page.site, :publish_page, opts) do + case Beacon.Config.fetch!(page.site).mode do + :live -> + GenServer.call(name(page.site), {:publish_page, page}) + + :testing -> + page + |> insert_published_page() + |> tap(fn {:ok, page} -> reset_published_page(page.site, page.id) end) + + :manual -> + insert_published_page(page) + end end end @doc """ - Same as `publish_page/1` but accepts a `site` and `page_id` with which to lookup the page. + Same as `publish_page/2` but accepts a `site` and `page_id` with which to lookup the page. + + See `publish_page/2` for allowed options. """ @doc type: :pages - @spec publish_page(Site.t(), UUID.t()) :: {:ok, Page.t()} | {:error, Changeset.t()} - def publish_page(site, page_id) when is_atom(site) and is_binary(page_id) do + @spec publish_page(Site.t(), UUID.t(), keyword()) :: {:ok, Page.t()} | {:error, Changeset.t() | :not_authorized} + def publish_page(site, page_id, opts \\ []) when is_atom(site) and is_binary(page_id) do site |> get_page(page_id) - |> publish_page() + |> publish_page(opts) end # TODO: only publish if there were actual changes compared to the last snapshot @doc """ Publish multiple `pages`. - Similar to `publish_page/1` but defers loading dependent resources + Similar to `publish_page/2` but defers loading dependent resources as late as possible making the process faster. + + See `publish_page/2` for allowed options. """ @doc type: :pages @spec publish_pages([Page.t()]) :: {:ok, [Page.t()]} - def publish_pages(pages) when is_list(pages) do + def publish_pages(pages, opts \\ []) when is_list(pages) do publish = fn page -> - transact(repo(page), fn -> - with {:ok, event} <- create_page_event(page, "published"), - {:ok, _snapshot} <- create_page_snapshot(page, event) do - {:ok, page} - end - end) + with :ok <- authorize(page.site, :publish_page, opts) do + transact(repo(page), fn -> + with {:ok, event} <- create_page_event(page, "published"), + {:ok, _snapshot} <- create_page_snapshot(page, event) do + {:ok, page} + end + end) + end end pages = @@ -710,16 +789,31 @@ defmodule Beacon.Content do Note that page will be removed from your site and it will return error 404 for new requests. + + ## Options + + * `:actor` - the identity to check for authorization + * `:auth` - pass `false` to disable authorization (defaults to `true`) + + ## Examples + + iex> unpublish_page(page, auth: false) + {:ok, %Page{}} + iex> unpublish_page(page, actor: "user-id-with-read-only-access") + {:error, :not_authorized} + """ @doc type: :pages - @spec unpublish_page(Page.t()) :: {:ok, Page.t()} | {:error, Changeset.t()} - def unpublish_page(%Page{} = page) do - transact(repo(page), fn -> - with {:ok, _event} <- create_page_event(page, "unpublished") do - :ok = Beacon.PubSub.page_unpublished(page) - {:ok, page} - end - end) + @spec unpublish_page(Page.t(), keyword()) :: {:ok, Page.t()} | {:error, Changeset.t() | :not_authorized} + def unpublish_page(%Page{} = page, opts \\ []) do + with :ok <- authorize(page.site, :unpublish_page, opts) do + transact(repo(page), fn -> + with {:ok, _event} <- create_page_event(page, "unpublished") do + :ok = Beacon.PubSub.page_unpublished(page) + {:ok, page} + end + end) + end end @doc false diff --git a/lib/beacon/migrations/v003.ex b/lib/beacon/migrations/v003.ex new file mode 100644 index 000000000..d84c39204 --- /dev/null +++ b/lib/beacon/migrations/v003.ex @@ -0,0 +1,19 @@ +defmodule Beacon.Migrations.V003 do + @moduledoc false + use Ecto.Migration + + def up do + create_if_not_exists table(:beacon_roles, primary_key: false) do + add :id, :binary_id, primary_key: true + add :name, :text, null: false + add :site, :text, null: false + add :capabilities, {:array, :string} + + timestamps(type: :utc_datetime_usec) + end + end + + def down do + drop_if_exists table(:beacon_roles) + end +end diff --git a/lib/beacon/test/fixtures.ex b/lib/beacon/test/fixtures.ex index 94052742d..310f83f12 100644 --- a/lib/beacon/test/fixtures.ex +++ b/lib/beacon/test/fixtures.ex @@ -169,7 +169,7 @@ defmodule Beacon.Test.Fixtures do name: "sample_stylesheet", content: "body {cursor: zoom-in;}" }) - |> Content.create_stylesheet!() + |> Content.create_stylesheet!(auth: false) |> tap(&Loader.load_stylesheet_module(&1.site)) end @@ -194,7 +194,7 @@ defmodule Beacon.Test.Fixtures do template: ~S|<%= @project.name %>|, example: ~S|<.sample_component project={%{id: 1, name: "Beacon"}} />| }) - |> Content.create_component!() + |> Content.create_component!(auth: false) |> tap(&Loader.load_components_module(&1.site)) end @@ -222,7 +222,7 @@ defmodule Beacon.Test.Fixtures do <%= @inner_content %> """ }) - |> Content.create_layout!() + |> Content.create_layout!(auth: false) end @doc """ @@ -233,7 +233,7 @@ defmodule Beacon.Test.Fixtures do {:ok, layout} = attrs |> beacon_layout_fixture() - |> Content.publish_layout() + |> Content.publish_layout(auth: false) Loader.load_layout_module(layout.site, layout.id) @@ -270,7 +270,7 @@ defmodule Beacon.Test.Fixtures do """, format: :heex }) - |> Content.create_page!() + |> Content.create_page!(auth: false) end @doc """ @@ -286,7 +286,7 @@ defmodule Beacon.Test.Fixtures do {:ok, page} = attrs |> beacon_page_fixture() - |> Content.publish_page() + |> Content.publish_page(auth: false) page end @@ -325,7 +325,7 @@ defmodule Beacon.Test.Fixtures do |> String.upcase() """ }) - |> Content.create_snippet_helper!() + |> Content.create_snippet_helper!(auth: false) |> tap(&Loader.load_snippets_module(&1.site)) end @@ -434,7 +434,7 @@ defmodule Beacon.Test.Fixtures do name: "Event Handler #{System.unique_integer([:positive])}", code: "{:noreply, socket}" }) - |> Content.create_event_handler!() + |> Content.create_event_handler!(auth: false) |> tap(&Loader.load_event_handlers_module(&1.site)) end @@ -458,7 +458,7 @@ defmodule Beacon.Test.Fixtures do template: "Uh-oh!", layout_id: layout.id }) - |> Content.create_error_page!() + |> Content.create_error_page!(auth: false) |> tap(&Loader.load_error_page_module(&1.site)) end @@ -478,7 +478,7 @@ defmodule Beacon.Test.Fixtures do site: "my_site", path: "/foo/bar" }) - |> Content.create_live_data!() + |> Content.create_live_data!(auth: false) |> tap(&Loader.load_live_data_module(&1.site)) end @@ -546,7 +546,7 @@ defmodule Beacon.Test.Fixtures do msg: msg, code: code }) - |> Content.create_info_handler!() + |> Content.create_info_handler!(auth: false) |> tap(&Loader.load_info_handlers_module(&1.site)) end end From c3791a63c2a44357ac6e0bbb3492baa34c223ee5 Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Fri, 24 Jan 2025 11:29:17 -0600 Subject: [PATCH 02/23] authorize all content functions --- lib/beacon/content.ex | 778 +++++++++++++++++++++++++----------------- 1 file changed, 456 insertions(+), 322 deletions(-) diff --git a/lib/beacon/content.ex b/lib/beacon/content.ex index ef6ff14e6..a96f8dc73 100644 --- a/lib/beacon/content.ex +++ b/lib/beacon/content.ex @@ -22,6 +22,30 @@ defmodule Beacon.Content do * Layouts - applies to all pages used by the template. * Page - only applies to the specific page. + ## Authorization Options + + Many functions in this module require authorization by default. This is done via the `:actor` option: + + ``` + iex> Beacon.Content.create_page(%{"title" => "My New Page", ...}, actor: "some-identifying-id") + {:ok, %Page{}} + ``` + + Beacon will use your site's `Beacon.Config.auth_module/0` to determine the role for the given actor, + and prevent the function from running if the role should not have access: + + ``` + iex> Beacon.Content.create_page(%{"title" => "My New Page", ...},, actor: "user-with-read-only-access") + {:error, :not_authorized} + ``` + + To disable authorization for internal calls, pass the `auth: false` option: + + ``` + iex> Beacon.Content.create_page(%{"title" => "My New Page", ...}, auth: false) + {:ok, %Page{}} + ``` + """ @doc false @@ -128,18 +152,8 @@ defmodule Beacon.Content do @doc """ Creates a layout. - ## Options - - * `:actor` - the identity to check for authorization - * `:auth` - pass `false` to disable authorization (defaults to `true`) - - ## Examples - - iex> create_layout(%{title: "Home"}, auth: false) - {:ok, %Layout{}} - iex> create_layout(%{title: "Home"}, actor: "user-id-with-read-only-access") - {:error, :not_authorized} - + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :layouts @spec create_layout(map(), keyword()) :: {:ok, Layout.t()} | {:error, Changeset.t() | :not_authorized} @@ -176,18 +190,8 @@ defmodule Beacon.Content do @doc """ Updates a layout. - ## Options - - * `:actor` - the identity to check for authorization - * `:auth` - pass `false` to disable authorization (defaults to `true`) - - ## Examples - - iex> update_layout(layout, %{title: "New Home"}, auth: false) - {:ok, %Layout{}} - iex> update_layout(layout, %{title: "New Home"}, actor: "user-id-with-read-only-access") - {:error, :not_authorized} - + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :layouts @spec update_layout(Layout.t(), map(), keyword()) :: {:ok, Layout.t()} | {:error, Changeset.t() | :not_authorized} @@ -209,18 +213,8 @@ defmodule Beacon.Content do This operation is serialized. - ## Options - - * `:actor` - the identity to check for authorization - * `:auth` - pass `false` to disable authorization (defaults to `true`) - - ## Examples - - iex> publish_layout(layout, auth: false) - {:ok, %Layout{}} - iex> publish_layout(layout, actor: "user-id-with-read-only-access") - {:error, :not_authorized} - + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :layouts @spec publish_layout(Layout.t() | keyword()) :: {:ok, Layout.t()} | {:error, Changeset.t() | term()} @@ -244,7 +238,8 @@ defmodule Beacon.Content do @doc """ Same as `publish_layout/2` but accepts a `site` and `layout_id` with which to lookup the layout. - See `publish_layout/2` for accepted options. + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :layouts @spec publish_layout(Site.t(), UUID.t(), keyword()) :: {:ok, Layout.t()} | {:error, Changeset.t() | term()} @@ -583,18 +578,8 @@ defmodule Beacon.Content do It will insert a `created` event into the page timeline, and no snapshot is created. - ## Options - - * `:actor` - the identity to check for authorization - * `:auth` - pass `false` to disable authorization (defaults to `true`) - - ## Examples - - iex> create_page(%{"title" => "My New Page"}, auth: false) - {:ok, %Page{}} - iex> create_page(%{"title" => "My New Page"}, actor: "user-id-with-read-only-access") - {:error, :not_authorized} - + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :pages @spec create_page(map(), keyword()) :: {:ok, Page.t()} | {:error, Changeset.t() | :not_authorized} @@ -628,7 +613,8 @@ defmodule Beacon.Content do @doc """ Creates a page, raising an error if unsuccessful. - See `create_page/2` for accepted options. + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :pages @spec create_page!(map(), keyword()) :: Page.t() @@ -643,18 +629,8 @@ defmodule Beacon.Content do @doc """ Updates a page. - ## Options - - * `:actor` - the identity to check for authorization - * `:auth` - pass `false` to disable authorization (defaults to `true`) - - ## Examples - - iex> update_page(page, %{title: "New Home"}, auth: false) - {:ok, %Page{}} - iex> update_page(page, %{title: "New Home"}, actor: "user-id-with-read-only-access") - {:error, :not_authorized} - + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :pages @spec update_page(Page.t(), map(), keyword()) :: {:ok, Page.t()} | {:error, Changeset.t() | :not_authorized} @@ -690,18 +666,8 @@ defmodule Beacon.Content do This operation is serialized. - ## Options - - * `:actor` - the identity to check for authorization - * `:auth` - pass `false` to disable authorization (defaults to `true`) - - ## Examples - - iex> publish_page(page, auth: false) - {:ok, %Page{}} - iex> publish_page(page, actor: "user-id-with-read-only-access") - {:error, :not_authorized} - + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :pages @spec publish_page(Page.t(), keyword()) :: {:ok, Page.t()} | {:error, Changeset.t() | term()} @@ -725,7 +691,8 @@ defmodule Beacon.Content do @doc """ Same as `publish_page/2` but accepts a `site` and `page_id` with which to lookup the page. - See `publish_page/2` for allowed options. + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :pages @spec publish_page(Site.t(), UUID.t(), keyword()) :: {:ok, Page.t()} | {:error, Changeset.t() | :not_authorized} @@ -742,7 +709,8 @@ defmodule Beacon.Content do Similar to `publish_page/2` but defers loading dependent resources as late as possible making the process faster. - See `publish_page/2` for allowed options. + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :pages @spec publish_pages([Page.t()]) :: {:ok, [Page.t()]} @@ -790,18 +758,8 @@ defmodule Beacon.Content do Note that page will be removed from your site and it will return error 404 for new requests. - ## Options - - * `:actor` - the identity to check for authorization - * `:auth` - pass `false` to disable authorization (defaults to `true`) - - ## Examples - - iex> unpublish_page(page, auth: false) - {:ok, %Page{}} - iex> unpublish_page(page, actor: "user-id-with-read-only-access") - {:error, :not_authorized} - + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :pages @spec unpublish_page(Page.t(), keyword()) :: {:ok, Page.t()} | {:error, Changeset.t() | :not_authorized} @@ -1197,15 +1155,18 @@ defmodule Beacon.Content do Given a map of fields, stores this map as `:extra` fields in a `Page`. Any existing `:extra` data for that Page will be overwritten! + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :pages - @spec put_page_extra(Page.t(), map()) :: {:ok, Page.t()} | {:error, Changeset.t()} - def put_page_extra(%Page{} = page, attrs) when is_map(attrs) do - attrs = %{"extra" => attrs} - - page - |> Changeset.cast(attrs, [:extra]) - |> repo(page).update() + @spec put_page_extra(Page.t(), map(), keyword()) :: {:ok, Page.t()} | {:error, Changeset.t() | :not_authorized} + def put_page_extra(%Page{} = page, attrs, opts \\ []) when is_map(attrs) do + with :ok <- authorize(page.site, :update_page, opts) do + page + |> Changeset.cast(%{"extra" => attrs}, [:extra]) + |> repo(page).update() + end end @doc """ @@ -1247,16 +1208,20 @@ defmodule Beacon.Content do Note that escape characters must be preserved, so you should use `~S` to avoid issues. + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :stylesheets - @spec create_stylesheet(map()) :: {:ok, Stylesheet.t()} | {:error, Changeset.t()} - def create_stylesheet(attrs \\ %{}) do + @spec create_stylesheet(map(), keyword()) :: {:ok, Stylesheet.t()} | {:error, Changeset.t() | :not_authorized} + def create_stylesheet(attrs, opts \\ []) do changeset = Stylesheet.changeset(%Stylesheet{}, attrs) site = Changeset.get_field(changeset, :site) - changeset - |> repo(site).insert() - |> tap(&maybe_broadcast_updated_content_event(&1, :stylesheet)) + with :ok <- authorize(site, :create_stylesheet, opts) do + changeset + |> repo(site).insert() + |> tap(&maybe_broadcast_updated_content_event(&1, :stylesheet)) + end end @doc """ @@ -1280,12 +1245,16 @@ defmodule Beacon.Content do %Stylesheet{} Note that escape characters must be preserved, so you should use `~S` to avoid issues. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :stylesheets - @spec create_stylesheet!(map()) :: Stylesheet.t() - def create_stylesheet!(attrs \\ %{}) do - case create_stylesheet(attrs) do + @spec create_stylesheet!(map(), keyword()) :: Stylesheet.t() + def create_stylesheet!(attrs, opts \\ []) do + case create_stylesheet(attrs, opts) do {:ok, stylesheet} -> stylesheet + {:error, :not_authorized} -> raise "failed to create stylesheet: not authorized" {:error, changeset} -> raise "failed to create stylesheet, got: #{inspect(changeset.errors)}" end end @@ -1293,19 +1262,24 @@ defmodule Beacon.Content do @doc """ Updates a stylesheet. + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. + ## Example - iex> update_stylesheet(stylesheet, %{name: new_value}) + iex> update_stylesheet(stylesheet, %{name: new_value}, actor: "my-user-id) {:ok, %Stylesheet{}} """ @doc type: :stylesheets - @spec update_stylesheet(Stylesheet.t(), map()) :: {:ok, Stylesheet.t()} | {:error, Changeset.t()} - def update_stylesheet(%Stylesheet{} = stylesheet, attrs) do - stylesheet - |> Stylesheet.changeset(attrs) - |> repo(stylesheet).update() - |> tap(&maybe_broadcast_updated_content_event(&1, :stylesheet)) + @spec update_stylesheet(Stylesheet.t(), map(), keyword()) :: {:ok, Stylesheet.t()} | {:error, Changeset.t() | :not_authorized} + def update_stylesheet(%Stylesheet{} = stylesheet, attrs, opts \\ []) do + with :ok <- authorize(stylesheet.site, :update_stylesheet, opts) do + stylesheet + |> Stylesheet.changeset(attrs) + |> repo(stylesheet).update() + |> tap(&maybe_broadcast_updated_content_event(&1, :stylesheet)) + end end @doc """ @@ -3047,36 +3021,47 @@ defmodule Beacon.Content do Returns `{:ok, component}` if successful, otherwise `{:error, changeset}`. + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. + ## Example - iex> create_component(attrs) + iex> create_component(attrs, actor: "some-user-id") {:ok, %Component{}} """ - @spec create_component(map()) :: {:ok, Component.t()} | {:error, Changeset.t()} + @spec create_component(map(), keyword()) :: {:ok, Component.t()} | {:error, Changeset.t() | :not_authorized} @doc type: :components - def create_component(attrs \\ %{}) do + def create_component(attrs, opts \\ []) do changeset = Component.changeset(%Component{}, attrs) site = Changeset.get_field(changeset, :site) - changeset - |> validate_component_template() - |> repo(site).insert() - |> tap(&maybe_broadcast_updated_content_event(&1, :component)) + with :ok <- authorize(site, :create_component, opts) do + changeset + |> validate_component_template() + |> repo(site).insert() + |> tap(&maybe_broadcast_updated_content_event(&1, :component)) + end end @doc """ Creates a component, raising an error if unsuccessful. Returns the new component if successful, otherwise raises a `RuntimeError`. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :components - @spec create_component!(map()) :: Component.t() - def create_component!(attrs \\ %{}) do - case create_component(attrs) do + @spec create_component!(map(), keyword()) :: Component.t() + def create_component!(attrs, opts \\ []) do + case create_component(attrs, opts) do {:ok, component} -> component + {:error, :not_authorized} -> + raise "failed to create component: not authorized" + {:error, changeset} -> errors = Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> @@ -3092,18 +3077,25 @@ defmodule Beacon.Content do @doc """ Updates a component. - iex> update_component(component, %{name: "new_component"}) + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. + + ## Example + + iex> update_component(component, %{name: "new_component"}, actor: "some-user-id") {:ok, %Component{}} """ @doc type: :components - @spec update_component(Component.t(), map()) :: {:ok, Component.t()} | {:error, Changeset.t()} - def update_component(%Component{} = component, attrs) do - component - |> Component.changeset(attrs) - |> validate_component_template() - |> repo(component).update() - |> tap(&maybe_broadcast_updated_content_event(&1, :component)) + @spec update_component(Component.t(), map(), keyword()) :: {:ok, Component.t()} | {:error, Changeset.t() | :not_authorized} + def update_component(%Component{} = component, attrs, opts \\ []) do + with :ok <- authorize(site, :update_component, opts) do + component + |> Component.changeset(attrs) + |> validate_component_template() + |> repo(component).update() + |> tap(&maybe_broadcast_updated_content_event(&1, :component)) + end end defp validate_component_template(changeset) do @@ -3277,17 +3269,17 @@ defmodule Beacon.Content do @doc """ Creates a new component slot and returns the component with updated `:slots` association. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :components - @spec create_slot_for_component(Component.t(), %{name: binary()}) :: - {:ok, Component.t()} | {:error, Changeset.t()} - def create_slot_for_component(component, attrs) do - changeset = - component - |> Ecto.build_assoc(:slots) - |> ComponentSlot.changeset(attrs) - - with {:ok, %ComponentSlot{}} <- repo(component).insert(changeset), + @spec create_slot_for_component(Component.t(), %{name: binary()}, keyword()) :: + {:ok, Component.t()} | {:error, Changeset.t() | :not_authorized} + def create_slot_for_component(component, attrs, opts \\ []) do + with :ok <- authorize(component.site, :create_slot_for_component, opts), + changeset = component |> Ecto.build_assoc(:slots) |> ComponentSlot.changeset(attrs), + {:ok, %ComponentSlot{}} <- repo(component).insert(changeset), %Component{} = component <- repo(component).preload(component, [slots: [:attrs]], force: true) do {:ok, component} end @@ -3295,13 +3287,17 @@ defmodule Beacon.Content do @doc """ Updates a component slot and returns the component with updated `:slots` association. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :components - @spec update_slot_for_component(Component.t(), ComponentSlot.t(), map(), list(String.t())) :: {:ok, Component.t()} | {:error, Changeset.t()} - def update_slot_for_component(component, slot, attrs, component_slots_names) do - changeset = ComponentSlot.changeset(slot, attrs, component_slots_names) - - with {:ok, %ComponentSlot{}} <- repo(component).update(changeset), + @spec update_slot_for_component(Component.t(), ComponentSlot.t(), map(), list(String.t()), keyword()) :: + {:ok, Component.t()} | {:error, Changeset.t() | :not_authorized} + def update_slot_for_component(component, slot, attrs, component_slots_names, opts \\ []) do + with :ok <- authorize(component.site, :update_slot_for_component, opts), + changeset = ComponentSlot.changeset(slot, attrs, component_slots_names), + {:ok, %ComponentSlot{}} <- repo(component).update(changeset), %Component{} = component <- repo(component).preload(component, [slots: [:attrs]], force: true) do {:ok, component} end @@ -3309,11 +3305,16 @@ defmodule Beacon.Content do @doc """ Deletes a component slot and returns the component with updated slots association. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :components - @spec delete_slot_from_component(Component.t(), ComponentSlot.t()) :: {:ok, Component.t()} | {:error, Changeset.t()} - def delete_slot_from_component(component, slot) do - with {:ok, %ComponentSlot{}} <- repo(component).delete(slot), + @spec delete_slot_from_component(Component.t(), ComponentSlot.t(), keyword()) :: + {:ok, Component.t()} | {:error, Changeset.t() | :not_authorized} + def delete_slot_from_component(component, slot, opts \\ []) do + with :ok <- authorize(component.site, :delete_slot_from_component, opts), + {:ok, %ComponentSlot{}} <- repo(component).delete(slot), %Component{} = component <- repo(component).preload(component, [slots: [:attrs]], force: true) do {:ok, component} end @@ -3363,42 +3364,60 @@ defmodule Beacon.Content do @doc """ Creates a slot attr. + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. + ## Example - iex> create_slot_attr(site, attrs) + iex> create_slot_attr(site, attrs, [], actor: "some-user-id") {:ok, %ComponentSlotAttr{}} """ - @spec create_slot_attr(Site.t(), map(), list(String.t())) :: {:ok, ComponentSlotAttr.t()} | {:error, Changeset.t()} + @spec create_slot_attr(Site.t(), map(), list(String.t()), keyword()) :: + {:ok, ComponentSlotAttr.t()} | {:error, Changeset.t() | :not_authorized} @doc type: :components - def create_slot_attr(site, attrs, slot_attr_names) do - %ComponentSlotAttr{} - |> ComponentSlotAttr.changeset(attrs, slot_attr_names) - |> repo(site).insert() + def create_slot_attr(site, attrs, slot_attr_names, opts \\ []) do + with :ok <- authorize(site, :create_slot_attr, opts) do + %ComponentSlotAttr{} + |> ComponentSlotAttr.changeset(attrs, slot_attr_names) + |> repo(site).insert() + end end @doc """ Updates a slot attr. - iex> update_slot(slot_attr, %{name: "new_slot"}) + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. + + iex> update_slot(site, slot_attr, %{name: "new_slot"}, [], actor: "some-user-id") {:ok, %ComponentSlotAttr{}} """ @doc type: :components - @spec update_slot_attr(Site.t(), ComponentSlotAttr.t(), map(), list(String.t())) :: {:ok, ComponentAttr.t()} | {:error, Changeset.t()} - def update_slot_attr(site, %ComponentSlotAttr{} = slot_attr, attrs, slot_attr_names) do - slot_attr - |> ComponentSlotAttr.changeset(attrs, slot_attr_names) - |> repo(site).update() + @spec update_slot_attr(Site.t(), ComponentSlotAttr.t(), map(), list(String.t()), keyword()) :: + {:ok, ComponentAttr.t()} | {:error, Changeset.t() | :not_authorized} + def update_slot_attr(site, %ComponentSlotAttr{} = slot_attr, attrs, slot_attr_names, opts \\ []) do + with :ok <- authorize(site, :update_slot_attr, opts) do + slot_attr + |> ComponentSlotAttr.changeset(attrs, slot_attr_names) + |> repo(site).update() + end end @doc """ Deletes a slot attr. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :components - @spec delete_slot_attr(Site.t(), ComponentSlotAttr.t()) :: {:ok, ComponentSlotAttr.t()} | {:error, Changeset.t()} - def delete_slot_attr(site, slot_attr) do - repo(site).delete(slot_attr) + @spec delete_slot_attr(Site.t(), ComponentSlotAttr.t(), keyword()) :: + {:ok, ComponentSlotAttr.t()} | {:error, Changeset.t() | :not_authorized} + def delete_slot_attr(site, slot_attr, opts \\ []) do + with :ok <- authorize(site, :delete_slot_attr, opts) do + repo(site).delete(slot_attr) + end end # SNIPPETS @@ -3407,10 +3426,13 @@ defmodule Beacon.Content do Creates a snippet helper. Returns `{:ok, helper}` if successful, otherwise `{:error, changeset}` + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :snippets - @spec create_snippet_helper(map()) :: {:ok, Snippets.Helper.t()} | {:error, Changeset.t()} - def create_snippet_helper(attrs) do + @spec create_snippet_helper(map(), keyword()) :: {:ok, Snippets.Helper.t()} | {:error, Changeset.t() | :not_authorized} + def create_snippet_helper(attrs, opts \\ []) do changeset = %Snippets.Helper{} |> Changeset.cast(attrs, [:site, :name, :body]) @@ -3419,10 +3441,12 @@ defmodule Beacon.Content do site = Changeset.get_field(changeset, :site) - changeset - |> validate_snippet_helper() - |> repo(site).insert() - |> tap(&maybe_broadcast_updated_content_event(&1, :snippet_helper)) + with :ok <- authorize(site, :create_snippet_helper, opts) do + changeset + |> validate_snippet_helper() + |> repo(site).insert() + |> tap(&maybe_broadcast_updated_content_event(&1, :snippet_helper)) + end end defp validate_snippet_helper(changeset) do @@ -3438,13 +3462,17 @@ defmodule Beacon.Content do Creates a snippet helper, raising an error if unsuccessful. Returns the new helper if successful, otherwise raises a `RuntimeError`. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :snippets - @spec create_snippet_helper!(map()) :: Snippets.Helper.t() - def create_snippet_helper!(attrs) do - case create_snippet_helper(attrs) do + @spec create_snippet_helper!(map(), keyword()) :: Snippets.Helper.t() + def create_snippet_helper!(attrs, opts \\ []) do + case create_snippet_helper(attrs, opts) do {:ok, helper} -> helper - {:error, changeset} -> raise "failed to create snippet helper, got: #{inspect(changeset.errors)} " + {:error, :not_authorized} -> raise "failed to create snippet helper: not authorized" + {:error, changeset} -> raise "failed to create snippet helper, got: #{inspect(changeset.errors)}" end end @@ -3654,29 +3682,38 @@ defmodule Beacon.Content do @doc """ Creates a new error page. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :error_pages - @spec create_error_page(%{site: Site.t(), status: ErrorPage.error_status(), template: binary(), layout_id: Ecto.UUID.t()}) :: - {:ok, ErrorPage.t()} | {:error, Changeset.t()} - def create_error_page(attrs) do + @spec create_error_page(%{site: Site.t(), status: ErrorPage.error_status(), template: binary(), layout_id: Ecto.UUID.t()}, keyword()) :: + {:ok, ErrorPage.t()} | {:error, Changeset.t() | :not_authorized} + def create_error_page(attrs, opts \\ []) do changeset = ErrorPage.changeset(%ErrorPage{}, attrs) site = Changeset.get_field(changeset, :site) - changeset - |> validate_error_page() - |> repo(site).insert() - |> tap(&maybe_broadcast_updated_content_event(&1, :error_page)) + with :ok <- authorize(site, :create_error_page, opts) do + changeset + |> validate_error_page() + |> repo(site).insert() + |> tap(&maybe_broadcast_updated_content_event(&1, :error_page)) + end end @doc """ Creates a new error page, raising if the operation fails. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :error_pages - @spec create_error_page!(%{site: Site.t(), status: ErrorPage.error_status(), template: binary(), layout_id: Ecto.UUID.t()}) :: + @spec create_error_page!(%{site: Site.t(), status: ErrorPage.error_status(), template: binary(), layout_id: Ecto.UUID.t()}, keyword()) :: ErrorPage.t() - def create_error_page!(attrs) do - case create_error_page(attrs) do + def create_error_page!(attrs, opts \\ []) do + case create_error_page(attrs, opts) do {:ok, error_page} -> error_page + {:error, :not_authorized} -> raise "failed to create error page: not authorized" {:error, changeset} -> raise "failed to create error page, got: #{inspect(changeset.errors)}" end end @@ -3697,24 +3734,34 @@ defmodule Beacon.Content do @doc """ Updates an error page. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :error_pages - @spec update_error_page(ErrorPage.t(), map()) :: {:ok, ErrorPage.t()} | {:error, Changeset.t()} - def update_error_page(error_page, attrs) do - error_page - |> ErrorPage.changeset(attrs) - |> validate_error_page() - |> repo(error_page).update() - |> tap(&maybe_broadcast_updated_content_event(&1, :error_page)) + @spec update_error_page(ErrorPage.t(), map(), keyword()) :: {:ok, ErrorPage.t()} | {:error, Changeset.t() | :not_authorized} + def update_error_page(error_page, attrs, opts \\ []) do + with :ok <- authorize(error_page.site, :update_error_page, opts) do + error_page + |> ErrorPage.changeset(attrs) + |> validate_error_page() + |> repo(error_page).update() + |> tap(&maybe_broadcast_updated_content_event(&1, :error_page)) + end end @doc """ Deletes an error page. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :error_pages - @spec delete_error_page(ErrorPage.t()) :: {:ok, ErrorPage.t()} | {:error, Changeset.t()} - def delete_error_page(error_page) do - repo(error_page).delete(error_page) + @spec delete_error_page(ErrorPage.t(), keyword()) :: {:ok, ErrorPage.t()} | {:error, Changeset.t() | :not_authorized} + def delete_error_page(error_page, opts \\ []) do + with :ok <- authorize(error_page.site, :delete_error_page, opts) do + repo(error_page).delete(error_page) + end end defp validate_error_page(changeset) do @@ -3753,11 +3800,14 @@ defmodule Beacon.Content do @doc """ Creates a new event handler. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :event_handlers - @spec create_event_handler(%{name: binary(), code: binary(), site: Site.t()}) :: - {:ok, EventHandler.t()} | {:error, Changeset.t()} - def create_event_handler(attrs) do + @spec create_event_handler(%{name: binary(), code: binary(), site: Site.t()}, keyword()) :: + {:ok, EventHandler.t()} | {:error, Changeset.t() | :not_authorized} + def create_event_handler(attrs, opts \\ []) do changeset = %EventHandler{} |> EventHandler.changeset(attrs) @@ -3765,37 +3815,45 @@ defmodule Beacon.Content do site = Changeset.get_field(changeset, :site) - changeset - |> repo(site).insert() - |> tap(&maybe_broadcast_updated_content_event(&1, :event_handler)) + with :ok <- authorize(site, :create_event_handler, opts) do + changeset + |> repo(site).insert() + |> tap(&maybe_broadcast_updated_content_event(&1, :event_handler)) + end end @doc """ Creates an event handler, raising an error if unsuccessful. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :event_handlers - @spec create_event_handler!(map()) :: EventHandler.t() - def create_event_handler!(attrs \\ %{}) do - case create_event_handler(attrs) do - {:ok, event_handler} -> - event_handler - - {:error, changeset} -> - raise "failed to create event_handler: #{inspect(changeset.errors)}" + @spec create_event_handler!(map(), keyword()) :: EventHandler.t() + def create_event_handler!(attrs, opts \\ []) do + case create_event_handler(attrs, opts) do + {:ok, event_handler} -> event_handler + {:error, :not_authorized} -> raise "failed to create event_handler: not authorized" + {:error, changeset} -> raise "failed to create event_handler: #{inspect(changeset.errors)}" end end @doc """ Updates an event handler with the given attrs. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :event_handlers - @spec update_event_handler(EventHandler.t(), map()) :: {:ok, EventHandler.t()} | {:error, Changeset.t()} - def update_event_handler(event_handler, attrs) do - event_handler - |> EventHandler.changeset(attrs) - |> validate_event_handler() - |> repo(event_handler).update() - |> tap(&maybe_broadcast_updated_content_event(&1, :event_handler)) + @spec update_event_handler(EventHandler.t(), map(), keyword()) :: {:ok, EventHandler.t()} | {:error, Changeset.t() | :not_authorized} + def update_event_handler(event_handler, attrs, opts \\ []) do + with :ok <- authorize(site, :update_event_handler, opts) do + event_handler + |> EventHandler.changeset(attrs) + |> validate_event_handler() + |> repo(event_handler).update() + |> tap(&maybe_broadcast_updated_content_event(&1, :event_handler)) + end end defp validate_event_handler(changeset) do @@ -3808,13 +3866,18 @@ defmodule Beacon.Content do @doc """ Deletes an event handler. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :event_handlers - @spec delete_event_handler(EventHandler.t()) :: {:ok, EventHandler.t()} | {:error, Changeset.t()} - def delete_event_handler(event_handler) do - event_handler - |> repo(event_handler).delete() - |> tap(&maybe_broadcast_updated_content_event(&1, :event_handler)) + @spec delete_event_handler(EventHandler.t(), keyword()) :: {:ok, EventHandler.t()} | {:error, Changeset.t() | :not_authorized} + def delete_event_handler(event_handler, opts \\ []) do + with :ok <- authorize(event_handler.site, :delete_event_handler, opts) do + event_handler + |> repo(event_handler).delete() + |> tap(&maybe_broadcast_updated_content_event(&1, :event_handler)) + end end # PAGE VARIANTS @@ -3836,44 +3899,54 @@ defmodule Beacon.Content do @doc """ Creates a new page variant and returns the page with updated `:variants` association. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :page_variants - @spec create_variant_for_page(Page.t(), %{name: binary(), template: binary(), weight: integer()}) :: - {:ok, Page.t()} | {:error, Changeset.t()} - def create_variant_for_page(page, attrs) do - changeset = - page - |> Ecto.build_assoc(:variants) - |> PageVariant.changeset(attrs) - |> validate_variant(page) - - transact(repo(page), fn -> - with {:ok, %PageVariant{}} <- repo(page).insert(changeset), - %Page{} = page <- repo(page).preload(page, :variants, force: true), - %Page{} = page <- Lifecycle.Page.after_update_page(page) do - {:ok, page} - end - end) + @spec create_variant_for_page(Page.t(), %{name: binary(), template: binary(), weight: integer()}, keyword()) :: + {:ok, Page.t()} | {:error, Changeset.t() | :not_authorized} + def create_variant_for_page(page, attrs, opts \\ []) do + with :ok <- authorize(page.site, :create_variant_for_page, opts) do + changeset = + page + |> Ecto.build_assoc(:variants) + |> PageVariant.changeset(attrs) + |> validate_variant(page) + + transact(repo(page), fn -> + with {:ok, %PageVariant{}} <- repo(page).insert(changeset), + %Page{} = page <- repo(page).preload(page, :variants, force: true), + %Page{} = page <- Lifecycle.Page.after_update_page(page) do + {:ok, page} + end + end) + end end @doc """ Updates a page variant and returns the page with updated `:variants` association. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :page_variants - @spec update_variant_for_page(Page.t(), PageVariant.t(), map()) :: {:ok, Page.t()} | {:error, Changeset.t()} - def update_variant_for_page(page, variant, attrs) do - changeset = - variant - |> PageVariant.changeset(attrs) - |> validate_variant(page) - - transact(repo(page), fn -> - with {:ok, %PageVariant{}} <- repo(page).update(changeset), - %Page{} = page <- repo(page).preload(page, :variants, force: true), - %Page{} = page <- Lifecycle.Page.after_update_page(page) do - {:ok, page} - end - end) + @spec update_variant_for_page(Page.t(), PageVariant.t(), map(), keyword()) :: {:ok, Page.t()} | {:error, Changeset.t() | :not_authorized} + def update_variant_for_page(page, variant, attrs, opts \\ []) do + with :ok <- authorize(page.site, :update_variant_for_page, opts) do + changeset = + variant + |> PageVariant.changeset(attrs) + |> validate_variant(page) + + transact(repo(page), fn -> + with {:ok, %PageVariant{}} <- repo(page).update(changeset), + %Page{} = page <- repo(page).preload(page, :variants, force: true), + %Page{} = page <- Lifecycle.Page.after_update_page(page) do + {:ok, page} + end + end) + end end defp validate_variant(changeset, page) do @@ -3906,11 +3979,15 @@ defmodule Beacon.Content do @doc """ Deletes a page variant and returns the page with updated `:variants` association. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :page_variants - @spec delete_variant_from_page(Page.t(), PageVariant.t()) :: {:ok, Page.t()} | {:error, Changeset.t()} - def delete_variant_from_page(page, variant) do - with {:ok, %PageVariant{}} <- repo(page).delete(variant), + @spec delete_variant_from_page(Page.t(), PageVariant.t(), keyword()) :: {:ok, Page.t()} | {:error, Changeset.t() | :not_authorized} + def delete_variant_from_page(page, variant, opts \\ []) do + with :ok <- authorize(page.site, :delete_variant_from_page, opts), + {:ok, %PageVariant{}} <- repo(page).delete(variant), %Page{} = page <- repo(page).preload(page, :variants, force: true), %Page{} = page <- Lifecycle.Page.after_update_page(page) do {:ok, page} @@ -3960,53 +4037,67 @@ defmodule Beacon.Content do Creates a new LiveData for scoping live data to pages. Returns `{:ok, live_data}` if successful, otherwise `{:error, changeset}` + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :live_data - @spec create_live_data(map()) :: {:ok, LiveData.t()} | {:error, Changeset.t()} - def create_live_data(attrs) do + @spec create_live_data(map(), keyword()) :: {:ok, LiveData.t()} | {:error, Changeset.t() | :not_authorized} + def create_live_data(attrs, opts \\ []) do changeset = LiveData.changeset(%LiveData{}, attrs) site = Changeset.get_field(changeset, :site) - changeset - |> repo(site).insert() - |> tap(&maybe_broadcast_updated_content_event(&1, :live_data)) + with :ok <- authorize(site, :create_live_data, opts) do + changeset + |> repo(site).insert() + |> tap(&maybe_broadcast_updated_content_event(&1, :live_data)) + end end @doc """ Creates a new LiveData for scoping live data to pages, raising an error if unsuccessful. Returns the new LiveData if successful, otherwise raises a `RuntimeError`. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :live_data - @spec create_live_data!(map()) :: LiveData.t() - def create_live_data!(attrs) do - case create_live_data(attrs) do + @spec create_live_data!(map(), keyword()) :: LiveData.t() + def create_live_data!(attrs, opts \\ []) do + case create_live_data(attrs, opts) do {:ok, live_data} -> live_data + {:error, :not_authorized} -> raise "failed to create live data: not authorized" {:error, changeset} -> raise "failed to create live data, got: #{inspect(changeset.errors)}" end end @doc """ Creates a new LiveDataAssign. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :live_data - @spec create_assign_for_live_data(LiveData.t(), map()) :: {:ok, LiveData.t()} | {:error, Changeset.t()} - def create_assign_for_live_data(live_data, attrs) do - changeset = - live_data - |> Ecto.build_assoc(:assigns) - |> Map.put(:live_data, live_data) - |> LiveDataAssign.changeset(attrs) - |> validate_live_data_code() - - case repo(live_data).insert(changeset) do - {:ok, %LiveDataAssign{}} -> - live_data = repo(live_data).preload(live_data, :assigns, force: true) - maybe_broadcast_updated_content_event({:ok, live_data}, :live_data) - {:ok, live_data} - - {:error, changeset} -> - {:error, changeset} + @spec create_assign_for_live_data(LiveData.t(), map(), keyword()) :: {:ok, LiveData.t()} | {:error, Changeset.t() | :not_authorized} + def create_assign_for_live_data(live_data, attrs, opts \\ []) do + with :ok <- authorize(live_data.site, :create_assign_for_live_data, opts) do + changeset = + live_data + |> Ecto.build_assoc(:assigns) + |> Map.put(:live_data, live_data) + |> LiveDataAssign.changeset(attrs) + |> validate_live_data_code() + + case repo(live_data).insert(changeset) do + {:ok, %LiveDataAssign{}} -> + live_data = repo(live_data).preload(live_data, :assigns, force: true) + maybe_broadcast_updated_content_event({:ok, live_data}, :live_data) + {:ok, live_data} + + {:error, changeset} -> + {:error, changeset} + end end end @@ -4079,38 +4170,49 @@ defmodule Beacon.Content do @doc """ Updates LiveDataPath. - iex> update_live_data_path(live_data, "/foo/bar/:baz_id") + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. + + iex> update_live_data_path(live_data, "/foo/bar/:baz_id", actor: "some-user-id") {:ok, %LiveData{}} """ @doc type: :live_data - @spec update_live_data_path(LiveData.t(), String.t()) :: {:ok, LiveData.t()} | {:error, Changeset.t()} - def update_live_data_path(%LiveData{} = live_data, path) do - live_data - |> LiveData.path_changeset(%{path: path}) - |> repo(live_data).update() - |> tap(&maybe_broadcast_updated_content_event(&1, :live_data)) + @spec update_live_data_path(LiveData.t(), String.t(), keyword()) :: {:ok, LiveData.t()} | {:error, Changeset.t() | :not_authorized} + def update_live_data_path(%LiveData{} = live_data, path, opts \\ []) do + with :ok <- authorize(live_data.site, :update_live_data_path, opts) do + live_data + |> LiveData.path_changeset(%{path: path}) + |> repo(live_data).update() + |> tap(&maybe_broadcast_updated_content_event(&1, :live_data)) + end end @doc """ Updates LiveDataAssign. - iex> update_live_data_assign(live_data_assign, :my_site, %{code: "true"}) + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. + + iex> update_live_data_assign(live_data_assign, :my_site, %{code: "true"}, actor: "some-user-id") {:ok, %LiveDataAssign{}} """ @doc type: :live_data - @spec update_live_data_assign(LiveDataAssign.t(), Site.t(), map()) :: {:ok, LiveDataAssign.t()} | {:error, Changeset.t()} - def update_live_data_assign(%LiveDataAssign{} = live_data_assign, site, attrs) do - live_data_assign - |> repo(site).preload(:live_data) - |> LiveDataAssign.changeset(attrs) - |> validate_live_data_code() - |> repo(site).update() - |> tap(fn - {:ok, live_data_assign} -> maybe_broadcast_updated_content_event({:ok, live_data_assign.live_data}, :live_data) - _error -> :skip - end) + @spec update_live_data_assign(LiveDataAssign.t(), Site.t(), map(), keyword()) :: + {:ok, LiveDataAssign.t()} | {:error, Changeset.t() | :not_authorized} + def update_live_data_assign(%LiveDataAssign{} = live_data_assign, site, attrs, opts \\ []) do + with :ok <- authorize(site, :update_live_data_assign, opts) do + live_data_assign + |> repo(site).preload(:live_data) + |> LiveDataAssign.changeset(attrs) + |> validate_live_data_code() + |> repo(site).update() + |> tap(fn + {:ok, live_data_assign} -> maybe_broadcast_updated_content_event({:ok, live_data_assign.live_data}, :live_data) + _error -> :skip + end) + end end defp validate_live_data_code(changeset) do @@ -4138,41 +4240,57 @@ defmodule Beacon.Content do @doc """ Deletes LiveData. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :live_data - @spec delete_live_data(LiveData.t()) :: {:ok, LiveData.t()} | {:error, Changeset.t()} - def delete_live_data(live_data) do - repo(live_data).delete(live_data) + @spec delete_live_data(LiveData.t(), keyword()) :: {:ok, LiveData.t()} | {:error, Changeset.t() | :not_authorized} + def delete_live_data(live_data, opts \\ []) do + with :ok <- authorize(live_data.site, :delete_live_data, opts) do + repo(live_data).delete(live_data) + end end @doc """ Deletes LiveDataAssign. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :live_data - @spec delete_live_data_assign(LiveDataAssign.t(), Site.t()) :: {:ok, LiveDataAssign.t()} | {:error, Changeset.t()} - def delete_live_data_assign(live_data_assign, site) do - repo(site).delete(live_data_assign) + @spec delete_live_data_assign(LiveDataAssign.t(), Site.t(), keyword()) :: {:ok, LiveDataAssign.t()} | {:error, Changeset.t() | :not_authorized} + def delete_live_data_assign(live_data_assign, site, opts \\ []) do + with :ok <- authorize(site, :delete_live_data_assign, opts) do + repo(site).delete(live_data_assign) + end end @doc """ Creates a new info handler for creating shared handle_info callbacks. + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. + ## Example - iex> create_info_handler(%{site: "my_site", msg: "{:new_msg, arg}", code: "{:noreply, socket}"}) + iex> attrs = %{site: "my_site", msg: "{:new_msg, arg}", code: "{:noreply, socket}"} + iex> create_info_handler(attrs, actor: "some-user-id") {:ok, %InfoHandler{}} """ @doc type: :info_handlers - @spec create_info_handler(map()) :: {:ok, InfoHandler.t()} | {:error, Changeset.t()} - def create_info_handler(attrs) do + @spec create_info_handler(map(), keyword()) :: {:ok, InfoHandler.t()} | {:error, Changeset.t() | :not_authorized} + def create_info_handler(attrs, opts \\ []) do changeset = InfoHandler.changeset(%InfoHandler{}, attrs) site = Changeset.get_field(changeset, :site) - changeset - |> validate_info_handler() - |> repo(site).insert() - |> tap(&maybe_broadcast_updated_content_event(&1, :info_handler)) + with :ok <- authorize(site, :create_info_handler, opts) do + changeset + |> validate_info_handler() + |> repo(site).insert() + |> tap(&maybe_broadcast_updated_content_event(&1, :info_handler)) + end end @spec validate_info_handler(Changeset.t(), [String.t()]) :: Changeset.t() @@ -4192,16 +4310,22 @@ defmodule Beacon.Content do Returns the new info handler if successful, otherwise raises a `RuntimeError`. + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. + ## Example - iex> create_info_handler!(%{site: "my_site", msg: "{:new_msg, arg}", code: "{:noreply, socket}"}) + iex> attrs = %{site: "my_site", msg: "{:new_msg, arg}", code: "{:noreply, socket}"} + iex> create_info_handler!(attrs, actor: "some-user-id") %InfoHandler{} + """ @doc type: :info_handlers - @spec create_info_handler!(map()) :: InfoHandler.t() - def create_info_handler!(attrs \\ %{}) do - case create_info_handler(attrs) do + @spec create_info_handler!(map(), keyword()) :: InfoHandler.t() + def create_info_handler!(attrs, opts \\ []) do + case create_info_handler(attrs, opts) do {:ok, info_handler} -> info_handler + {:error, :not_authorized} -> raise "failed to create info handler: not authorized" {:error, changeset} -> raise "failed to create info handler, got: #{inspect(changeset.errors)}" end end @@ -4266,33 +4390,43 @@ defmodule Beacon.Content do @doc """ Updates a info handler. + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. + ## Example - iex> update_info_handler(info_handler, %{msg: "{:new_msg, arg}"}) + iex> update_info_handler(info_handler, %{msg: "{:new_msg, arg}"}, actor: "some-user-id") {:ok, %InfoHandler{}} """ @doc type: :info_handlers - @spec update_info_handler(InfoHandler.t(), map()) :: {:ok, InfoHandler.t()} - def update_info_handler(%InfoHandler{} = info_handler, attrs) do + @spec update_info_handler(InfoHandler.t(), map(), keyword()) :: {:ok, InfoHandler.t()} | {:error, Changeset.t() | :not_authorized} + def update_info_handler(%InfoHandler{} = info_handler, attrs, opts \\ []) do changeset = InfoHandler.changeset(info_handler, attrs) site = Changeset.get_field(changeset, :site) - changeset - |> validate_info_handler(["Phoenix.Component"]) - |> repo(site).update() - |> tap(&maybe_broadcast_updated_content_event(&1, :info_handler)) + with :ok <- authorize(site, :update_info_handler, opts) do + changeset + |> validate_info_handler(["Phoenix.Component"]) + |> repo(site).update() + |> tap(&maybe_broadcast_updated_content_event(&1, :info_handler)) + end end @doc """ Deletes info handler. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :info_handlers - @spec delete_info_handler(InfoHandler.t()) :: {:ok, InfoHandler.t()} | {:error, Changeset.t()} - def delete_info_handler(info_handler) do - info_handler - |> repo(info_handler).delete() - |> tap(&maybe_broadcast_updated_content_event(&1, :info_handler)) + @spec delete_info_handler(InfoHandler.t(), keyword()) :: {:ok, InfoHandler.t()} | {:error, Changeset.t() | :not_authorized} + def delete_info_handler(info_handler, opts \\ []) do + with :ok <- authorize(site, :delete_info_handler, opts) do + info_handler + |> repo(info_handler).delete() + |> tap(&maybe_broadcast_updated_content_event(&1, :info_handler)) + end end ## Utils From af8c42e55538ddaf1f54ecef3d0528e846e130e2 Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Fri, 24 Jan 2025 12:18:47 -0600 Subject: [PATCH 03/23] fix compile issues --- lib/beacon/content.ex | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/beacon/content.ex b/lib/beacon/content.ex index a96f8dc73..fec94af02 100644 --- a/lib/beacon/content.ex +++ b/lib/beacon/content.ex @@ -219,7 +219,7 @@ defmodule Beacon.Content do @doc type: :layouts @spec publish_layout(Layout.t() | keyword()) :: {:ok, Layout.t()} | {:error, Changeset.t() | term()} def publish_layout(%Layout{} = layout, opts \\ []) do - with :ok <- authorize(site, :publish_layout, opts) do + with :ok <- authorize(layout.site, :publish_layout, opts) do case Beacon.Config.fetch!(layout.site).mode do :live -> GenServer.call(name(layout.site), {:publish_layout, layout}) @@ -242,8 +242,8 @@ defmodule Beacon.Content do in the module documentation. """ @doc type: :layouts - @spec publish_layout(Site.t(), UUID.t(), keyword()) :: {:ok, Layout.t()} | {:error, Changeset.t() | term()} - def publish_layout(site, layout_id, opts \\ []) when is_atom(site) and is_binary(layout_id) do + @spec publish_layout_id(Site.t(), UUID.t(), keyword()) :: {:ok, Layout.t()} | {:error, Changeset.t() | term()} + def publish_layout_id(site, layout_id, opts \\ []) when is_atom(site) and is_binary(layout_id) do site |> get_layout(layout_id) |> publish_layout(opts) @@ -695,8 +695,8 @@ defmodule Beacon.Content do in the module documentation. """ @doc type: :pages - @spec publish_page(Site.t(), UUID.t(), keyword()) :: {:ok, Page.t()} | {:error, Changeset.t() | :not_authorized} - def publish_page(site, page_id, opts \\ []) when is_atom(site) and is_binary(page_id) do + @spec publish_page_id(Site.t(), UUID.t(), keyword()) :: {:ok, Page.t()} | {:error, Changeset.t() | :not_authorized} + def publish_page_id(site, page_id, opts \\ []) when is_atom(site) and is_binary(page_id) do site |> get_page(page_id) |> publish_page(opts) @@ -3089,7 +3089,7 @@ defmodule Beacon.Content do @doc type: :components @spec update_component(Component.t(), map(), keyword()) :: {:ok, Component.t()} | {:error, Changeset.t() | :not_authorized} def update_component(%Component{} = component, attrs, opts \\ []) do - with :ok <- authorize(site, :update_component, opts) do + with :ok <- authorize(component.site, :update_component, opts) do component |> Component.changeset(attrs) |> validate_component_template() @@ -3847,7 +3847,7 @@ defmodule Beacon.Content do @doc type: :event_handlers @spec update_event_handler(EventHandler.t(), map(), keyword()) :: {:ok, EventHandler.t()} | {:error, Changeset.t() | :not_authorized} def update_event_handler(event_handler, attrs, opts \\ []) do - with :ok <- authorize(site, :update_event_handler, opts) do + with :ok <- authorize(event_handler.site, :update_event_handler, opts) do event_handler |> EventHandler.changeset(attrs) |> validate_event_handler() @@ -4422,7 +4422,7 @@ defmodule Beacon.Content do @doc type: :info_handlers @spec delete_info_handler(InfoHandler.t(), keyword()) :: {:ok, InfoHandler.t()} | {:error, Changeset.t() | :not_authorized} def delete_info_handler(info_handler, opts \\ []) do - with :ok <- authorize(site, :delete_info_handler, opts) do + with :ok <- authorize(info_handler.site, :delete_info_handler, opts) do info_handler |> repo(info_handler).delete() |> tap(&maybe_broadcast_updated_content_event(&1, :info_handler)) From 492eea6a4e41cedc0f5bc911f558eb1e6940f3d1 Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Fri, 24 Jan 2025 12:27:00 -0600 Subject: [PATCH 04/23] bump migration current_version --- lib/beacon/migration.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/beacon/migration.ex b/lib/beacon/migration.ex index f2042dc11..d9722ce07 100644 --- a/lib/beacon/migration.ex +++ b/lib/beacon/migration.ex @@ -52,7 +52,7 @@ defmodule Beacon.Migration do """ @initial_version 1 - @current_version 2 + @current_version 3 @doc """ Upgrades Beacon database schemas. From a95da53cdeb8c0f7108531151aa5cdc6d15247d4 Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Fri, 24 Jan 2025 12:46:25 -0600 Subject: [PATCH 05/23] list capabilities --- lib/beacon/auth.ex | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/lib/beacon/auth.ex b/lib/beacon/auth.ex index d83a25f39..31f145a7c 100644 --- a/lib/beacon/auth.ex +++ b/lib/beacon/auth.ex @@ -58,4 +58,46 @@ defmodule Beacon.Auth do defp get_role(site, actor) do Config.fetch!(site).auth_module.check_role(actor) end + + defp list_capabilities do + [ + :create_layout, + :update_layout, + :publish_layout, + :create_page, + :update_page, + :publish_page, + :unpublish_page, + :update_page, + :create_stylesheet, + :update_stylesheet, + :create_component, + :update_component, + :create_slot_for_component, + :update_slot_for_component, + :delete_slot_from_component, + :create_slot_attr, + :update_slot_attr, + :delete_slot_attr, + :create_snippet_helper, + :create_error_page, + :update_error_page, + :delete_error_page, + :create_event_handler, + :update_event_handler, + :delete_event_handler, + :create_variant_for_page, + :update_variant_for_page, + :delete_variant_from_page, + :create_live_data, + :create_assign_for_live_data, + :update_live_data_path, + :update_live_data_assign, + :delete_live_data, + :delete_live_data_assign, + :create_info_handler, + :update_info_handler, + :delete_info_handler + ] + end end From 5b78c5dc1846797cbb38e6f66622687c0348f791 Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Thu, 6 Feb 2025 14:21:21 -0600 Subject: [PATCH 06/23] various fixes --- lib/beacon/auth.ex | 36 +++++++++++++-- lib/beacon/loader/worker.ex | 87 +++++++++++++++++++------------------ 2 files changed, 77 insertions(+), 46 deletions(-) diff --git a/lib/beacon/auth.ex b/lib/beacon/auth.ex index 31f145a7c..da78e4337 100644 --- a/lib/beacon/auth.ex +++ b/lib/beacon/auth.ex @@ -30,6 +30,10 @@ defmodule Beacon.Auth do """ @callback check_role(actor :: any()) :: role :: any() + @doc """ + Check if an action is allowed. + """ + @spec authorize(Site.t(), atom(), keyword()) :: :ok | {:error, :not_authorized} def authorize(site, action, opts) do if Keyword.get(opts, :auth, true) do do_authorize(site, opts[:actor], action) @@ -51,15 +55,39 @@ defmodule Beacon.Auth do end end - # defp get_actor(site, session) do - # Config.fetch!(site).auth_module.actor_from_session(session) - # end + @doc """ + Uses a site's `:auth_module` from `Beacon.Config` to find the actor for a given session. + """ + @spec get_actor(Site.t(), map()) :: any() + def get_actor(site, session) do + Config.fetch!(site).auth_module.actor_from_session(session) + end defp get_role(site, actor) do Config.fetch!(site).auth_module.check_role(actor) end - defp list_capabilities do + @doc """ + Creates a changeset with the given role and optional map of changes. + """ + @spec change_role(Role.t(), map()) :: Changeset.t() + def change_role(role, attrs \\ %{}) do + Role.changeset(role, attrs) + end + + @doc """ + Lists all roles available for a given site. + """ + @spec list_roles(Site.t()) :: [Role.t()] + def list_roles(site) do + repo(site).all(from r in Role, where: r.site == ^to_string(site)) + end + + @doc """ + Lists all possible capabilities a Beacon role can have. + """ + @spec list_capabilities() :: [:atom] + def list_capabilities do [ :create_layout, :update_layout, diff --git a/lib/beacon/loader/worker.ex b/lib/beacon/loader/worker.ex index 3a2961a97..d460f43c7 100644 --- a/lib/beacon/loader/worker.ex +++ b/lib/beacon/loader/worker.ex @@ -64,7 +64,7 @@ defmodule Beacon.Loader.Worker do [] -> attrs |> Map.put(:site, site) - |> Content.create_component!() + |> Content.create_component!(auth: false) _ -> :skip @@ -81,8 +81,8 @@ defmodule Beacon.Loader.Worker do nil -> Content.default_layout() |> Map.put(:site, site) - |> Content.create_layout!() - |> Content.publish_layout() + |> Content.create_layout!(auth: false) + |> Content.publish_layout(auth: false) _ -> :skip @@ -102,7 +102,7 @@ defmodule Beacon.Loader.Worker do attrs |> Map.put(:site, site) |> Map.put(:layout_id, default_layout.id) - |> Content.create_error_page!() + |> Content.create_error_page!(auth: false) _ -> :skip @@ -126,42 +126,45 @@ defmodule Beacon.Loader.Worker do populate = fn -> case Content.get_page_by(site, path: "/") do nil -> - Content.create_stylesheet(%{ - site: site, - name: "beacon-demo", - content: ~S""" - .beacon-demo-home { - background: rgb(50,163,252); - background: linear-gradient(145deg, rgba(50,163,252,1) 0%, rgba(99,102,241,1) 26%, rgba(138,55,214,1) 55%, rgba(100,37,181,1) 76%, rgba(31,41,55,1) 100%); - background-size: 400% 400%; - animation: beacon-demo-home-gradient 30s ease infinite; - height: 100vh; - font-family: "Plus Jakarta Sans", sans-serif; - } - - @keyframes beacon-demo-home-gradient { - 0% { - background-position: 0% 0%; - } - 50% { - background-position: 100% 100%; - } - 100% { - background-position: 0% 0%; - } - } - .beacon-demo-home-title { - background: linear-gradient( - to right, - rgb(186, 230, 253), - rgb(221, 214, 254) - ); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - text-shadow: 0 2px 4px rgba(255, 255, 255, 0.1); - } - """ - }) + Content.create_stylesheet( + %{ + site: site, + name: "beacon-demo", + content: ~S""" + .beacon-demo-home { + background: rgb(50,163,252); + background: linear-gradient(145deg, rgba(50,163,252,1) 0%, rgba(99,102,241,1) 26%, rgba(138,55,214,1) 55%, rgba(100,37,181,1) 76%, rgba(31,41,55,1) 100%); + background-size: 400% 400%; + animation: beacon-demo-home-gradient 30s ease infinite; + height: 100vh; + font-family: "Plus Jakarta Sans", sans-serif; + } + + @keyframes beacon-demo-home-gradient { + 0% { + background-position: 0% 0%; + } + 50% { + background-position: 100% 100%; + } + 100% { + background-position: 0% 0%; + } + } + .beacon-demo-home-title { + background: linear-gradient( + to right, + rgb(186, 230, 253), + rgb(221, 214, 254) + ); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + text-shadow: 0 2px 4px rgba(255, 255, 255, 0.1); + } + """ + }, + auth: false + ) %{ site: site, @@ -347,8 +350,8 @@ defmodule Beacon.Loader.Worker do """ } - |> Content.create_page!() - |> Content.publish_page() + |> Content.create_page!(auth: false) + |> Content.publish_page(auth: false) _ -> :skip From b9fe3a7a98a546e82f4d30911df693b29ad012eb Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Fri, 7 Feb 2025 16:51:15 -0600 Subject: [PATCH 07/23] improve docs and api --- lib/beacon/auth.ex | 149 ++++++++++++++++++++++++++++++++++++++++-- lib/beacon/content.ex | 2 +- 2 files changed, 146 insertions(+), 5 deletions(-) diff --git a/lib/beacon/auth.ex b/lib/beacon/auth.ex index da78e4337..d2115554f 100644 --- a/lib/beacon/auth.ex +++ b/lib/beacon/auth.ex @@ -1,15 +1,110 @@ defmodule Beacon.Auth do @moduledoc """ - Top-level functions for checking role-based access control. + Role-based access control. - These functions are used by Beacon clients such as LiveAdmin, and may be necessary when adding - customizations to the client. + Beacon's auth model uses actors, roles, and capabilities: + + ``` + [ACTOR] ---has-one--- [ROLE] ---has-many--- [CAPABILITIES] + ``` + + For example: + + ``` + user_1337 --- author --- create_page + |- update_page + |- publish_page + |- etc... + ``` + + To add auth to your Beacon application, there are two callbacks to implement: + + * `actor_from_session/1` - a function which receives the user's session data, and returns some unique identifier for that user + * `check_role/1` - a function which receives the identifier, and returns what role that user should have + + This allows you to integrate Beacon with any type of authentication system your app might require. + + ## Implementing the Beacon.Auth behaviour + + Let's take a look at the case where [`mix phx.gen.auth`](https://hexdocs.pm/phoenix/mix_phx_gen_auth.html) was used to register and sign in users. + + The first question is "How does my app determine the access level (role) of a given user?". + Some examples solutions might be: + + * Add a `role` column to the users table, and `:role` field on the User schema + * Calculate the role dynamically based on other fields such as `admin?` or `registered_at` + * Create a separate table to map each `user_id` to a `role` + + Regardless of which approach you choose, let's assume that logic is used in a function called + `determine_role/1`. Then we can create an auth module which implements `Beacon.Auth` behaviour: + + ``` + defmodule MyApp.Auth do + @behaviour Beacon.Auth + + def actor_from_session(session) do + Map.get(session, "user_token") + end + + def check_role(user_token) do + user_token + |> MyApp.Accounts.get_user_by_session_token() + |> determine_role() + end + + defp determine_role(user) do + ... + end + end + ``` + + In the above example, the `actor_from_session/1` function retrieves the `user_token` which is put + into the session when a user logs in. + + Then `check_role/1` will receive that token, look up the user, and then determine the role of that user. + + This module can then be provided to `Beacon.Config` as an `:auth_module` option: + + ``` + config :beacon, :my_site, + ... + auth_module: MyApp.Auth, + ... + ``` + + And now calls to Beacon can be authorized by passing the `:actor` option. Continue to the next + section for more details. + + ## Authorization Options + + Several functions in this module (and others) require authorization by default. This is done via the `:actor` option: + + ``` + iex> Beacon.Auth.create_role(%{"name" => "Power User", ...}, actor: "some-identifying-id") + {:ok, %Role{}} + ``` + + Beacon will use your site's `t:Beacon.Config.auth_module/0` to determine the role for the given actor, + and prevent the function from running if the role should not have access: + + ``` + iex> Beacon.Auth.create_role(%{"name" => "Power User", ...},, actor: "user-with-read-only-access") + {:error, :not_authorized} + ``` + + To disable authorization for internal calls, pass the `auth: false` option: + + ``` + iex> Beacon.Auth.create_role(%{"name" => "Power User", ...}, auth: false) + {:ok, %Role{}} + ``` """ import Beacon.Utils, only: [repo: 1] import Ecto.Query alias Beacon.Auth.Role alias Beacon.Config + alias Ecto.Changeset @doc """ Parses the actor's identity from the session. @@ -83,6 +178,49 @@ defmodule Beacon.Auth do repo(site).all(from r in Role, where: r.site == ^to_string(site)) end + @doc """ + + """ + @spec default_role_capabilities() :: [atom()] + def default_role_capabilities do + [] + end + + @doc """ + Create a new role. + """ + @spec create_role(map(), keyword()) :: {:ok, Role.t()} | {:error, Changeset.t()} + def create_role(attrs, opts \\ []) do + changeset = Role.changeset(%Role{}, attrs) + site = Changeset.get_field(changeset, :site) + + with :ok <- authorize(site, :create_role, opts) do + repo(site).insert(changeset) + end + end + + @doc """ + Update an existing role. + """ + @spec update_role(Role.t(), map(), keyword()) :: {:ok, Role.t()} | {:error, Changeset.t()} + def update_role(role, attrs, opts \\ []) do + with :ok <- authorize(role.site, :update_role, opts) do + role + |> Role.changeset(attrs) + |> repo(role).update() + end + end + + @doc """ + Delete an existing role. + """ + @spec delete_role(Role.t(), keyword()) :: {:ok, Role.t()} | {:error, Changeset.t()} + def delete_role(role, opts \\ []) do + with :ok <- authorize(role.site, :delete_role, opts) do + repo(role).delete(role) + end + end + @doc """ Lists all possible capabilities a Beacon role can have. """ @@ -125,7 +263,10 @@ defmodule Beacon.Auth do :delete_live_data_assign, :create_info_handler, :update_info_handler, - :delete_info_handler + :delete_info_handler, + :create_role, + :update_role, + :delete_role ] end end diff --git a/lib/beacon/content.ex b/lib/beacon/content.ex index 95a1efd3c..8dbbe9001 100644 --- a/lib/beacon/content.ex +++ b/lib/beacon/content.ex @@ -31,7 +31,7 @@ defmodule Beacon.Content do {:ok, %Page{}} ``` - Beacon will use your site's `Beacon.Config.auth_module/0` to determine the role for the given actor, + Beacon will use your site's `t:Beacon.Config.auth_module/0` to determine the role for the given actor, and prevent the function from running if the role should not have access: ``` From a190e7c9f88b9730112bc8d7b7934bea11c8a0e0 Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Mon, 10 Feb 2025 13:18:27 -0600 Subject: [PATCH 08/23] actors_roles --- lib/beacon/auth.ex | 80 +++++++++++++++++------------------ lib/beacon/auth/actor_role.ex | 28 ++++++++++++ lib/beacon/auth/default.ex | 6 +-- lib/beacon/migrations/v004.ex | 9 ++++ 4 files changed, 78 insertions(+), 45 deletions(-) create mode 100644 lib/beacon/auth/actor_role.ex diff --git a/lib/beacon/auth.ex b/lib/beacon/auth.ex index d2115554f..1f65add56 100644 --- a/lib/beacon/auth.ex +++ b/lib/beacon/auth.ex @@ -19,49 +19,39 @@ defmodule Beacon.Auth do To add auth to your Beacon application, there are two callbacks to implement: - * `actor_from_session/1` - a function which receives the user's session data, and returns some unique identifier for that user - * `check_role/1` - a function which receives the identifier, and returns what role that user should have + * `actor_from_session/1` - a function which receives the user's session data, and returns a tuple + containing a unique ID and a human-readable label + * `list_actors/0` - a function to return a list of actors, in the same format as above: `{id, label}` This allows you to integrate Beacon with any type of authentication system your app might require. ## Implementing the Beacon.Auth behaviour - Let's take a look at the case where [`mix phx.gen.auth`](https://hexdocs.pm/phoenix/mix_phx_gen_auth.html) was used to register and sign in users. - - The first question is "How does my app determine the access level (role) of a given user?". - Some examples solutions might be: - - * Add a `role` column to the users table, and `:role` field on the User schema - * Calculate the role dynamically based on other fields such as `admin?` or `registered_at` - * Create a separate table to map each `user_id` to a `role` - - Regardless of which approach you choose, let's assume that logic is used in a function called - `determine_role/1`. Then we can create an auth module which implements `Beacon.Auth` behaviour: + Let's take a look at the case where [`mix phx.gen.auth`](https://hexdocs.pm/phoenix/mix_phx_gen_auth.html) + was used to register and sign in users. ``` defmodule MyApp.Auth do @behaviour Beacon.Auth def actor_from_session(session) do - Map.get(session, "user_token") - end + user = MyApp.Accounts.get_user_by_session_token(session["user_token"]) - def check_role(user_token) do - user_token - |> MyApp.Accounts.get_user_by_session_token() - |> determine_role() + {user.id, user.email} end - defp determine_role(user) do - ... + def list_actors do + Repo.all(from u in MyApp.Accounts.User, select: {u.id, u.email}) end end ``` In the above example, the `actor_from_session/1` function retrieves the `user_token` which is put - into the session when a user logs in. + into the session when a user logs in. With that token, it fetches the `%User{}` struct from the database + and returns the ID with the user's email as the label. - Then `check_role/1` will receive that token, look up the user, and then determine the role of that user. + `list_actors/0` provides the database query for Beacon to find the actors in your app and return + them in the expected `{id, label}` format. This module can then be provided to `Beacon.Config` as an `:auth_module` option: @@ -102,28 +92,23 @@ defmodule Beacon.Auth do import Beacon.Utils, only: [repo: 1] import Ecto.Query + alias Beacon.Auth.ActorRole alias Beacon.Auth.Role alias Beacon.Config alias Ecto.Changeset + @type actor_tuple :: {id :: String.t(), label :: String.t()} + @doc """ Parses the actor's identity from the session. """ - @callback actor_from_session(session :: map()) :: actor :: any() + @callback actor_from_session(session :: map()) :: actor_tuple() | nil @doc """ - Checks the role of a given actor. - - Warning: this function should always check for the most recent data, in case it has changed. - - ```elixir - # bad - def check_role(actor), do: actor.role - # good - def check_role(actor), do: MyApp.Repo.one(from u in Users, where: u.id == ^actor, select: u.role) - ``` + Lists all actors for your beacon site, each in the form of a tuple containing a unique ID as well + as a human-readable label. """ - @callback check_role(actor :: any()) :: role :: any() + @callback list_actors() :: [actor_tuple()] @doc """ Check if an action is allowed. @@ -138,11 +123,7 @@ defmodule Beacon.Auth do end defp do_authorize(site, actor, action) do - role = get_role(site, actor) - - query = from r in Role, where: r.site == ^site, where: r.name == ^to_string(role) - - with %{} = role <- repo(site).one(query), + with %{} = role <- get_role(site, actor), true <- to_string(action) in role.capabilities do :ok else @@ -150,16 +131,31 @@ defmodule Beacon.Auth do end end + @doc """ + Uses a site's `:auth_module` from `Beacon.Config` to list all actors for a site. + """ + @spec list_actors(Site.t()) :: [actor_tuple()] + def list_actors(site) do + Config.fetch!(site).auth_module.list_actors() + end + @doc """ Uses a site's `:auth_module` from `Beacon.Config` to find the actor for a given session. """ - @spec get_actor(Site.t(), map()) :: any() + @spec get_actor(Site.t(), map()) :: actor_tuple() def get_actor(site, session) do Config.fetch!(site).auth_module.actor_from_session(session) end defp get_role(site, actor) do - Config.fetch!(site).auth_module.check_role(actor) + repo(site).one( + from ar in ActorRole, + join: r in Role, + on: ar.role_id == r.id, + where: ar.site == ^site, + where: ar.actor_id == ^actor, + select: r + ) end @doc """ diff --git a/lib/beacon/auth/actor_role.ex b/lib/beacon/auth/actor_role.ex new file mode 100644 index 000000000..cec4c4926 --- /dev/null +++ b/lib/beacon/auth/actor_role.ex @@ -0,0 +1,28 @@ +defmodule Beacon.Auth.ActorRole do + use Beacon.Schema + + import Ecto.Changeset + + @type t :: %__MODULE__{ + id: Ecto.UUID.t(), + actor_id: any(), + role_id: Ecto.UUID.t(), + role: Beacon.Auth.Role.t(), + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + schema "actors_roles" do + field :actor_id, :string + + belongs_to :role, Beacon.Auth.Role + + timestamps() + end + + def changeset(actor_role, attrs \\ %{}) do + actor_role + |> cast(attrs, [:actor_id, :role_id]) + |> validate_required([:actor_id, :role_id]) + end +end diff --git a/lib/beacon/auth/default.ex b/lib/beacon/auth/default.ex index 2547058dd..d84e3d81c 100644 --- a/lib/beacon/auth/default.ex +++ b/lib/beacon/auth/default.ex @@ -1,12 +1,12 @@ defmodule Beacon.Auth.Default do @moduledoc """ Default Auth logic when none is provided. - - All users will be considered as `:admin`. """ @behaviour Beacon.Auth + # TODO: setup default auth for site owner + def actor_from_session(_session), do: nil - def check_role(_actor), do: :admin + def list_actors, do: [] end diff --git a/lib/beacon/migrations/v004.ex b/lib/beacon/migrations/v004.ex index c67c041e7..e82d88827 100644 --- a/lib/beacon/migrations/v004.ex +++ b/lib/beacon/migrations/v004.ex @@ -11,9 +11,18 @@ defmodule Beacon.Migrations.V004 do timestamps(type: :utc_datetime_usec) end + + create_if_not_exists table(:beacon_actors_roles, primary_key: false) do + add :id, :binary_id, primary_key: true + add :actor_id, :binary_id, null: false + add :role_id, references(:beacon_roles), null: false + + timestamps(type: :utc_datetime_usec) + end end def down do + drop_if_exists table(:beacon_actors_roles) drop_if_exists table(:beacon_roles) end end From 222e91b706e55591449ca29f29e5f4be043603ca Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Fri, 14 Feb 2025 11:47:49 -0600 Subject: [PATCH 09/23] binary ID for db reference --- lib/beacon/migrations/v004.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/beacon/migrations/v004.ex b/lib/beacon/migrations/v004.ex index e82d88827..a97b00769 100644 --- a/lib/beacon/migrations/v004.ex +++ b/lib/beacon/migrations/v004.ex @@ -15,7 +15,7 @@ defmodule Beacon.Migrations.V004 do create_if_not_exists table(:beacon_actors_roles, primary_key: false) do add :id, :binary_id, primary_key: true add :actor_id, :binary_id, null: false - add :role_id, references(:beacon_roles), null: false + add :role_id, references(:beacon_roles, type: :binary_id), null: false timestamps(type: :utc_datetime_usec) end From 15b82f6969f2f6c7e154cfc14f59a27e0359c4e7 Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Tue, 18 Feb 2025 11:33:12 -0600 Subject: [PATCH 10/23] fix compile error --- lib/beacon/content.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/beacon/content.ex b/lib/beacon/content.ex index e8f01f760..5c198b0e8 100644 --- a/lib/beacon/content.ex +++ b/lib/beacon/content.ex @@ -714,7 +714,7 @@ defmodule Beacon.Content do in the module documentation. """ @doc type: :pages - @spec publish_pages([Page.t()]) :: {:ok, [Page.t()]} + @spec publish_pages([Page.t()], keyword()) :: {:ok, [Page.t()]} def publish_pages(pages, opts \\ []) when is_list(pages) do publish = fn page -> with :ok <- authorize(page.site, :publish_page, opts) do @@ -764,8 +764,8 @@ defmodule Beacon.Content do in the module documentation. """ @doc type: :pages - @spec unpublish_page(Page.t()) :: {:ok, Page.t()} | {:error, Changeset.t()} - def unpublish_page(%Page{} = page) do + @spec unpublish_page(Page.t(), keyword()) :: {:ok, Page.t()} | {:error, Changeset.t()} + def unpublish_page(%Page{} = page, opts \\ []) do with :ok <- authorize(page.site, :unpublish_page, opts) do case Beacon.Config.fetch!(page.site).mode do :live -> From 5a01e586def62369f4c1ffb87c2c8e3b3c5727cc Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Tue, 18 Feb 2025 16:08:20 -0600 Subject: [PATCH 11/23] wip --- lib/beacon/auth.ex | 30 ++++++++++++++++++++++++++---- lib/beacon/auth/default.ex | 6 +++--- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/lib/beacon/auth.ex b/lib/beacon/auth.ex index 1f65add56..6fa85f7a0 100644 --- a/lib/beacon/auth.ex +++ b/lib/beacon/auth.ex @@ -110,12 +110,25 @@ defmodule Beacon.Auth do """ @callback list_actors() :: [actor_tuple()] + @doc """ + Specifies the identity of the site owner who should always have unconditional access. + """ + @callback owner() :: actor_tuple() + @doc """ Check if an action is allowed. """ @spec authorize(Site.t(), atom(), keyword()) :: :ok | {:error, :not_authorized} def authorize(site, action, opts) do - if Keyword.get(opts, :auth, true) do + {owner_id, _label} = get_owner(site) + + owner? = + case opts[:actor] do + {^owner_id, _label} -> true + _otherwise -> false + end + + if not owner? and Keyword.get(opts, :auth, true) do do_authorize(site, opts[:actor], action) else :ok @@ -147,13 +160,21 @@ defmodule Beacon.Auth do Config.fetch!(site).auth_module.actor_from_session(session) end - defp get_role(site, actor) do + @doc """ + Uses a site's `:auth_module` from `Beacon.Config` to find the owner. + """ + @spec get_owner(Site.t()) :: actor_tuple() + def get_owner(site) do + Config.fetch!(site).auth_module.owner() + end + + defp get_role(site, {actor_id, _label}) do repo(site).one( from ar in ActorRole, join: r in Role, on: ar.role_id == r.id, where: ar.site == ^site, - where: ar.actor_id == ^actor, + where: ar.actor_id == ^actor_id, select: r ) end @@ -187,7 +208,8 @@ defmodule Beacon.Auth do """ @spec create_role(map(), keyword()) :: {:ok, Role.t()} | {:error, Changeset.t()} def create_role(attrs, opts \\ []) do - changeset = Role.changeset(%Role{}, attrs) + IO.inspect(attrs, label: "attrs") + changeset = Role.changeset(%Role{}, attrs) |> IO.inspect(label: "changeset", structs: false) site = Changeset.get_field(changeset, :site) with :ok <- authorize(site, :create_role, opts) do diff --git a/lib/beacon/auth/default.ex b/lib/beacon/auth/default.ex index d84e3d81c..6643c507f 100644 --- a/lib/beacon/auth/default.ex +++ b/lib/beacon/auth/default.ex @@ -4,9 +4,9 @@ defmodule Beacon.Auth.Default do """ @behaviour Beacon.Auth - # TODO: setup default auth for site owner - - def actor_from_session(_session), do: nil + def actor_from_session(_session), do: {:__beacon_default_owner__, "Default Owner"} def list_actors, do: [] + + def owner, do: {:__beacon_default_owner__, "Default Owner"} end From 34ea4c3654bf56ae7625078da726e39122bb3cf9 Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Wed, 19 Feb 2025 13:56:15 -0600 Subject: [PATCH 12/23] context fixes --- lib/beacon/auth.ex | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/beacon/auth.ex b/lib/beacon/auth.ex index 6fa85f7a0..347d0c976 100644 --- a/lib/beacon/auth.ex +++ b/lib/beacon/auth.ex @@ -208,8 +208,7 @@ defmodule Beacon.Auth do """ @spec create_role(map(), keyword()) :: {:ok, Role.t()} | {:error, Changeset.t()} def create_role(attrs, opts \\ []) do - IO.inspect(attrs, label: "attrs") - changeset = Role.changeset(%Role{}, attrs) |> IO.inspect(label: "changeset", structs: false) + changeset = Role.changeset(%Role{}, attrs) site = Changeset.get_field(changeset, :site) with :ok <- authorize(site, :create_role, opts) do @@ -252,7 +251,6 @@ defmodule Beacon.Auth do :update_page, :publish_page, :unpublish_page, - :update_page, :create_stylesheet, :update_stylesheet, :create_component, From f9f73e83c311ef1144afbbe6028cd4771c1692a3 Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Thu, 20 Feb 2025 21:35:44 -0600 Subject: [PATCH 13/23] use strings for id --- lib/beacon/auth.ex | 18 ++++++++++++++++++ lib/beacon/auth/actor_role.ex | 4 ++-- lib/beacon/auth/default.ex | 4 ++-- lib/beacon/migrations/v004.ex | 2 +- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/lib/beacon/auth.ex b/lib/beacon/auth.ex index 347d0c976..f3827f3d8 100644 --- a/lib/beacon/auth.ex +++ b/lib/beacon/auth.ex @@ -179,6 +179,24 @@ defmodule Beacon.Auth do ) end + @doc """ + Returns all ActorRoles for a list of Actor IDs. + + This can be helpful for fetching Role IDs in bulk with only one query. + + Accepts option `preload: :role` to include the full Role schema instead of just the ID. + """ + @spec get_actor_roles(Site.t(), [String.t()]) :: [ActorRole.t()] + def get_actor_roles(site, ids, opts \\ []) do + preload = opts[:preload] || [] + + repo(site).all( + from ar in ActorRole, + where: ar.actor_id in ^ids, + preload: ^preload + ) + end + @doc """ Creates a changeset with the given role and optional map of changes. """ diff --git a/lib/beacon/auth/actor_role.ex b/lib/beacon/auth/actor_role.ex index cec4c4926..7e9331214 100644 --- a/lib/beacon/auth/actor_role.ex +++ b/lib/beacon/auth/actor_role.ex @@ -5,14 +5,14 @@ defmodule Beacon.Auth.ActorRole do @type t :: %__MODULE__{ id: Ecto.UUID.t(), - actor_id: any(), + actor_id: String.t(), role_id: Ecto.UUID.t(), role: Beacon.Auth.Role.t(), inserted_at: DateTime.t(), updated_at: DateTime.t() } - schema "actors_roles" do + schema "beacon_actors_roles" do field :actor_id, :string belongs_to :role, Beacon.Auth.Role diff --git a/lib/beacon/auth/default.ex b/lib/beacon/auth/default.ex index 6643c507f..48f4dbae0 100644 --- a/lib/beacon/auth/default.ex +++ b/lib/beacon/auth/default.ex @@ -4,9 +4,9 @@ defmodule Beacon.Auth.Default do """ @behaviour Beacon.Auth - def actor_from_session(_session), do: {:__beacon_default_owner__, "Default Owner"} + def actor_from_session(_session), do: {"__beacon_default_owner__", "Default Owner"} def list_actors, do: [] - def owner, do: {:__beacon_default_owner__, "Default Owner"} + def owner, do: {"__beacon_default_owner__", "Default Owner"} end diff --git a/lib/beacon/migrations/v004.ex b/lib/beacon/migrations/v004.ex index a97b00769..91b67d891 100644 --- a/lib/beacon/migrations/v004.ex +++ b/lib/beacon/migrations/v004.ex @@ -14,7 +14,7 @@ defmodule Beacon.Migrations.V004 do create_if_not_exists table(:beacon_actors_roles, primary_key: false) do add :id, :binary_id, primary_key: true - add :actor_id, :binary_id, null: false + add :actor_id, :string, null: false add :role_id, references(:beacon_roles, type: :binary_id), null: false timestamps(type: :utc_datetime_usec) From 9814a77a6086fa0b06cbcc7104398f12e843008a Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Mon, 24 Feb 2025 14:30:10 -0600 Subject: [PATCH 14/23] make owners a list --- lib/beacon/auth.ex | 51 ++++++++++++++++++++++++++------------ lib/beacon/auth/default.ex | 2 +- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/lib/beacon/auth.ex b/lib/beacon/auth.ex index f3827f3d8..0f76b47ea 100644 --- a/lib/beacon/auth.ex +++ b/lib/beacon/auth.ex @@ -111,30 +111,33 @@ defmodule Beacon.Auth do @callback list_actors() :: [actor_tuple()] @doc """ - Specifies the identity of the site owner who should always have unconditional access. + Specifies the identities of site owners who should always have unconditional access. """ - @callback owner() :: actor_tuple() + @callback owners() :: [actor_tuple()] @doc """ Check if an action is allowed. """ @spec authorize(Site.t(), atom(), keyword()) :: :ok | {:error, :not_authorized} def authorize(site, action, opts) do - {owner_id, _label} = get_owner(site) - - owner? = - case opts[:actor] do - {^owner_id, _label} -> true - _otherwise -> false - end - - if not owner? and Keyword.get(opts, :auth, true) do + if Keyword.get(opts, :auth, true) and not owner?(site, opts[:actor]) do do_authorize(site, opts[:actor], action) else :ok end end + defp owner?(site, actor) do + site + |> get_owners() + |> Enum.any?(fn {owner_id, _} -> + case actor do + {^owner_id, _label} -> true + _otherwise -> false + end + end) + end + defp do_authorize(site, actor, action) do with %{} = role <- get_role(site, actor), true <- to_string(action) in role.capabilities do @@ -161,11 +164,11 @@ defmodule Beacon.Auth do end @doc """ - Uses a site's `:auth_module` from `Beacon.Config` to find the owner. + Uses a site's `:auth_module` from `Beacon.Config` to find the owners. """ - @spec get_owner(Site.t()) :: actor_tuple() - def get_owner(site) do - Config.fetch!(site).auth_module.owner() + @spec get_owners(Site.t()) :: [actor_tuple()] + def get_owners(site) do + Config.fetch!(site).auth_module.owners() end defp get_role(site, {actor_id, _label}) do @@ -173,12 +176,28 @@ defmodule Beacon.Auth do from ar in ActorRole, join: r in Role, on: ar.role_id == r.id, - where: ar.site == ^site, + where: r.site == ^site, where: ar.actor_id == ^actor_id, select: r ) end + @doc """ + A blank ActorRole struct. + """ + @spec new_actor_role() :: ActorRole.t() + def new_actor_role do + %ActorRole{} + end + + @doc """ + Creates a changeset for the given ActorRole. + """ + @spec change_actor_role(ActorRole.t(), map()) :: Changeset.t() + def change_actor_role(actor_role, attrs \\ %{}) do + ActorRole.changeset(actor_role, attrs) + end + @doc """ Returns all ActorRoles for a list of Actor IDs. diff --git a/lib/beacon/auth/default.ex b/lib/beacon/auth/default.ex index 48f4dbae0..9575f92eb 100644 --- a/lib/beacon/auth/default.ex +++ b/lib/beacon/auth/default.ex @@ -8,5 +8,5 @@ defmodule Beacon.Auth.Default do def list_actors, do: [] - def owner, do: {"__beacon_default_owner__", "Default Owner"} + def owners, do: [{"__beacon_default_owner__", "Default Owner"}] end From 2e5f075fc9b519759f7f070a1de69a45f383062c Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Mon, 24 Feb 2025 16:40:29 -0600 Subject: [PATCH 15/23] fix beacon tests to work with auth --- lib/beacon/auth.ex | 5 +- lib/beacon/content.ex | 111 +++--- lib/beacon/test/fixtures.ex | 2 +- test/beacon/content_test.exs | 488 ++++++++++++++---------- test/beacon/loader/page_test.exs | 2 +- test/beacon_web/live/page_live_test.exs | 8 +- test/support/auth_module.ex | 20 + test/test_helper.exs | 5 +- 8 files changed, 392 insertions(+), 249 deletions(-) create mode 100644 test/support/auth_module.ex diff --git a/lib/beacon/auth.ex b/lib/beacon/auth.ex index 0f76b47ea..aab68e671 100644 --- a/lib/beacon/auth.ex +++ b/lib/beacon/auth.ex @@ -319,7 +319,10 @@ defmodule Beacon.Auth do :delete_info_handler, :create_role, :update_role, - :delete_role + :delete_role, + :create_js_hook, + :update_js_hook, + :delete_js_hook ] end end diff --git a/lib/beacon/content.ex b/lib/beacon/content.ex index 5c198b0e8..3d90a33a3 100644 --- a/lib/beacon/content.ex +++ b/lib/beacon/content.ex @@ -27,7 +27,7 @@ defmodule Beacon.Content do Many functions in this module require authorization by default. This is done via the `:actor` option: ``` - iex> Beacon.Content.create_page(%{"title" => "My New Page", ...}, actor: "some-identifying-id") + iex> Beacon.Content.create_page(%{"title" => "My New Page", ...}, actor: {"some-identifying-id", "First Lastname"}) {:ok, %Page{}} ``` @@ -35,7 +35,7 @@ defmodule Beacon.Content do and prevent the function from running if the role should not have access: ``` - iex> Beacon.Content.create_page(%{"title" => "My New Page", ...},, actor: "user-with-read-only-access") + iex> Beacon.Content.create_page(%{"title" => "My New Page", ...},, actor: {"user-with-read-only-access", "John Smith"}) {:error, :not_authorized} ``` @@ -1199,25 +1199,27 @@ defmodule Beacon.Content do Returns `{:ok, stylesheet}` if successful, otherwise `{:error, changeset}`. + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. + ## Example iex> create_stylesheet(%{ - site: :my_site, - name: "override", - content: ~S| - @media (min-width: 768px) { - .md\:text-red-400 { - color: red; + site: :my_site, + name: "override", + content: ~S| + @media (min-width: 768px) { + .md\:text-red-400 { + color: red; + } } - } - | - }) + | + }, + actor: {"some-user-id", "Some User"} + ) {:ok, %Stylesheet{}} Note that escape characters must be preserved, so you should use `~S` to avoid issues. - - This function requires authorization. See ["Authorization Options"](#module-authorization-options) - in the module documentation. """ @doc type: :stylesheets @spec create_stylesheet(map(), keyword()) :: {:ok, Stylesheet.t()} | {:error, Changeset.t() | :not_authorized} @@ -1240,16 +1242,18 @@ defmodule Beacon.Content do ## Example iex> create_stylesheet!(%{ - site: :my_site, - name: "override", - content: ~S| - @media (min-width: 768px) { - .md\:text-red-400 { - color: red; + site: :my_site, + name: "override", + content: ~S| + @media (min-width: 768px) { + .md\:text-red-400 { + color: red; + } } - } - | - }) + | + }, + actor: {"some-user-id", "Some User"} + ) %Stylesheet{} Note that escape characters must be preserved, so you should use `~S` to avoid issues. @@ -1275,7 +1279,7 @@ defmodule Beacon.Content do ## Example - iex> update_stylesheet(stylesheet, %{name: new_value}, actor: "my-user-id) + iex> update_stylesheet(stylesheet, %{name: new_value}, actor: {"some-user-id", "Some User"}) {:ok, %Stylesheet{}} """ @@ -1346,32 +1350,41 @@ defmodule Beacon.Content do Returns `{:ok, js_hook}` if successful, otherwise `{:error, changeset}`. + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. + ## Example iex> code = "export const ConsoleLogHook = {mounted() {console.log(\"foo\")}}" - iex> create_js_hook(%{site: :my_site, name: "ConsoleLogHook", code: code}) + iex> create_js_hook(%{site: :my_site, name: "ConsoleLogHook", code: code}, actor: {"some-user-id", "Some User"}) {:ok, %JSHook{}} """ @doc type: :js_hooks - @spec create_js_hook(map()) :: {:ok, JSHook.t()} | {:error, Changeset.t()} - def create_js_hook(attrs) do + @spec create_js_hook(map(), keyword()) :: {:ok, JSHook.t()} | {:error, Changeset.t() | :not_authorized} + def create_js_hook(attrs, opts \\ []) do changeset = JSHook.changeset(%JSHook{}, attrs) site = Changeset.get_field(changeset, :site) - changeset - |> repo(site).insert() - |> tap(&maybe_broadcast_updated_content_event(&1, :js_hook)) + with :ok <- authorize(site, :create_js_hook, opts) do + changeset + |> repo(site).insert() + |> tap(&maybe_broadcast_updated_content_event(&1, :js_hook)) + end end @doc """ Creates a JS Hook, raising an error if unsuccessful. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :js_hooks - @spec create_js_hook!(map()) :: JSHook.t() - def create_js_hook!(attrs) do - case create_js_hook(attrs) do + @spec create_js_hook!(map(), keyword()) :: JSHook.t() + def create_js_hook!(attrs, opts \\ []) do + case create_js_hook(attrs, opts) do {:ok, js_hook} -> js_hook + {:error, :not_authorized} -> raise "failed to create JS Hook: not authorized" {:error, changeset} -> raise "failed to create JS Hook, got: #{inspect(changeset.errors)}" end end @@ -1433,25 +1446,35 @@ defmodule Beacon.Content do @doc """ Updates a JS Hook. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :js_hooks - @spec update_js_hook(JSHook.t(), map()) :: {:ok, JSHook.t()} | {:error, Changeset.t()} - def update_js_hook(js_hook, attrs) do - js_hook - |> JSHook.changeset(attrs) - |> repo(js_hook).update() - |> tap(&maybe_broadcast_updated_content_event(&1, :js_hook)) + @spec update_js_hook(JSHook.t(), map(), keyword()) :: {:ok, JSHook.t()} | {:error, Changeset.t() | :not_authorized} + def update_js_hook(js_hook, attrs, opts \\ []) do + with :ok <- authorize(js_hook.site, :update_js_hook, opts) do + js_hook + |> JSHook.changeset(attrs) + |> repo(js_hook).update() + |> tap(&maybe_broadcast_updated_content_event(&1, :js_hook)) + end end @doc """ Deletes a JS Hook. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :js_hooks - @spec delete_js_hook(JSHook.t()) :: {:ok, JSHook.t()} | {:error, Changeset.t()} - def delete_js_hook(js_hook) do - js_hook - |> repo(js_hook).delete() - |> tap(&maybe_broadcast_updated_content_event(&1, :js_hook)) + @spec delete_js_hook(JSHook.t(), keyword()) :: {:ok, JSHook.t()} | {:error, Changeset.t() | :not_authorized} + def delete_js_hook(js_hook, opts \\ []) do + with :ok <- authorize(js_hook.site, :delete_js_hook, opts) do + js_hook + |> repo(js_hook).delete() + |> tap(&maybe_broadcast_updated_content_event(&1, :js_hook)) + end end # COMPONENTS diff --git a/lib/beacon/test/fixtures.ex b/lib/beacon/test/fixtures.ex index 3feedb5e7..20373e185 100644 --- a/lib/beacon/test/fixtures.ex +++ b/lib/beacon/test/fixtures.ex @@ -576,7 +576,7 @@ defmodule Beacon.Test.Fixtures do } """ }) - |> Content.create_js_hook!() + |> Content.create_js_hook!(auth: false) |> tap(&Loader.load_runtime_js(&1.site)) end end diff --git a/test/beacon/content_test.exs b/test/beacon/content_test.exs index 0830a95df..1fe56ab3d 100644 --- a/test/beacon/content_test.exs +++ b/test/beacon/content_test.exs @@ -20,20 +20,27 @@ defmodule Beacon.ContentTest do alias Beacon.BeaconTest.Repo alias Ecto.Changeset + def admin_actor, do: hd(Beacon.BeaconTest.AuthModule.owners()) + + def fake_actor, do: {"123", "Fake"} + describe "layouts" do test "broadcasts published event" do %{site: site, id: id} = layout = beacon_layout_fixture(site: "booted") :ok = Beacon.PubSub.subscribe_to_layouts(site) - Content.publish_layout(layout) + Content.publish_layout(layout, auth: false) assert_receive {:layout_published, %{site: ^site, id: ^id}} end test "create layout should create a created event" do - Content.create_layout!(%{ - site: "my_site", - title: "test", - template: "

layout

" - }) + Content.create_layout!( + %{ + site: "my_site", + title: "test", + template: "

layout

" + }, + auth: false + ) assert %LayoutEvent{event: :created} = Repo.one(LayoutEvent) end @@ -41,23 +48,23 @@ defmodule Beacon.ContentTest do test "publish layout should create a published event" do layout = beacon_layout_fixture() - assert {:ok, %Layout{}} = Content.publish_layout(layout) + assert {:ok, %Layout{}} = Content.publish_layout(layout, auth: false) assert [_created, %LayoutEvent{event: :published}] = Repo.all(LayoutEvent) end test "publish layout should create a snapshot" do layout = beacon_layout_fixture(title: "snapshot test") - assert {:ok, %Layout{}} = Content.publish_layout(layout) + assert {:ok, %Layout{}} = Content.publish_layout(layout, auth: false) assert %LayoutSnapshot{layout: %Layout{title: "snapshot test"}} = Repo.one(LayoutSnapshot) end test "list published layouts" do # publish layout_a twice layout_a = beacon_layout_fixture(title: "layout_a v1") - {:ok, layout_a} = Content.publish_layout(layout_a) - {:ok, layout_a} = Content.update_layout(layout_a, %{"title" => "layout_a v2"}) - {:ok, _layout_a} = Content.publish_layout(layout_a) + {:ok, layout_a} = Content.publish_layout(layout_a, auth: false) + {:ok, layout_a} = Content.update_layout(layout_a, %{"title" => "layout_a v2"}, auth: false) + {:ok, _layout_a} = Content.publish_layout(layout_a, auth: false) # do not publish layout_b _layout_b = beacon_layout_fixture(title: "layout_b v1") @@ -67,7 +74,7 @@ defmodule Beacon.ContentTest do test "list_layout_events" do layout = beacon_layout_fixture() - Content.publish_layout(layout) + Content.publish_layout(layout, auth: false) assert [ %LayoutEvent{event: :published, snapshot: %LayoutSnapshot{}}, @@ -79,13 +86,13 @@ defmodule Beacon.ContentTest do layout = beacon_layout_fixture() assert %LayoutEvent{event: :created} = Content.get_latest_layout_event(layout.site, layout.id) - Content.publish_layout(layout) + Content.publish_layout(layout, auth: false) assert %LayoutEvent{event: :published} = Content.get_latest_layout_event(layout.site, layout.id) end test "validate body heex on create" do assert {:error, %Ecto.Changeset{errors: [template: {"invalid", [compilation_error: compilation_error]}]}} = - Content.create_layout(%{site: :my_site, title: "test", template: "`" end @@ -94,7 +101,7 @@ defmodule Beacon.ContentTest do layout = beacon_layout_fixture() assert {:error, %Ecto.Changeset{errors: [template: {"invalid", [compilation_error: compilation_error]}]}} = - Content.update_layout(layout, %{template: "`" end @@ -126,14 +133,14 @@ defmodule Beacon.ContentTest do test "broadcasts published event" do %{site: site, id: id} = page = beacon_page_fixture(site: "booted") :ok = Beacon.PubSub.subscribe_to_pages(site) - Content.publish_page(page) + Content.publish_page(page, auth: false) assert_receive {:page_published, %{site: ^site, id: ^id}} end test "broadcasts unpublished event" do %{site: site, id: id, path: path} = page = beacon_published_page_fixture(site: "booted") :ok = Beacon.PubSub.subscribe_to_pages(site) - assert {:ok, _} = Content.unpublish_page(page) + assert {:ok, _} = Content.unpublish_page(page, auth: false) assert_receive {:page_unpublished, %{site: ^site, id: ^id, path: ^path}} end @@ -149,7 +156,7 @@ defmodule Beacon.ContentTest do layout = beacon_layout_fixture() assert {:error, %Ecto.Changeset{errors: [template: {"invalid", [compilation_error: compilation_error]}]}} = - Content.create_page(%{site: :my_site, path: "/", title: "home", layout_id: layout.id, template: "`" end @@ -158,32 +165,38 @@ defmodule Beacon.ContentTest do page = beacon_page_fixture() assert {:error, %Ecto.Changeset{errors: [template: {"invalid", [compilation_error: compilation_error]}]}} = - Content.update_page(page, %{template: "`" end test "create page should create a created event" do - Content.create_page!(%{ - site: "my_site", - path: "/", - title: "home", - template: "

page

", - layout_id: beacon_layout_fixture().id - }) + Content.create_page!( + %{ + site: "my_site", + path: "/", + title: "home", + template: "

page

", + layout_id: beacon_layout_fixture().id + }, + auth: false + ) assert %PageEvent{event: :created} = Repo.one(PageEvent) end test "create page includes default meta tags" do page = - Content.create_page!(%{ - site: "default_meta_tags_test", - path: "/", - title: "home", - template: "

page

", - layout_id: beacon_layout_fixture().id - }) + Content.create_page!( + %{ + site: "default_meta_tags_test", + path: "/", + title: "home", + template: "

page

", + layout_id: beacon_layout_fixture().id + }, + auth: false + ) assert page.meta_tags == [%{"name" => "foo", "content" => "bar"}] end @@ -192,7 +205,7 @@ defmodule Beacon.ContentTest do page = beacon_page_fixture() assert {:error, %Ecto.Changeset{errors: [template: {"invalid", [compilation_error: compilation_error]}], valid?: false}} = - Content.update_page(page, %{"template" => "
invalid"}) + Content.update_page(page, %{"template" => "
invalid"}, auth: false) assert compilation_error =~ "unmatched closing tag" end @@ -200,14 +213,14 @@ defmodule Beacon.ContentTest do test "publish page creates a published event" do page = beacon_page_fixture() - assert {:ok, %Page{}} = Content.publish_page(page) + assert {:ok, %Page{}} = Content.publish_page(page, auth: false) assert [_created, %PageEvent{event: :published}] = Repo.all(PageEvent) end test "publish page creates a snapshot" do page = beacon_page_fixture(title: "snapshot test") - assert {:ok, %Page{}} = Content.publish_page(page) + assert {:ok, %Page{}} = Content.publish_page(page, auth: false) assert %PageSnapshot{page: %Page{title: "snapshot test"}} = Repo.one(PageSnapshot) end @@ -219,14 +232,14 @@ defmodule Beacon.ContentTest do test "list_published_pages" do # publish page_a twice page_a = beacon_page_fixture(path: "/a", title: "page_a v1") - {:ok, page_a} = Content.publish_page(page_a) - {:ok, page_a} = Content.update_page(page_a, %{"title" => "page_a v2"}) - {:ok, _page_a} = Content.publish_page(page_a) + {:ok, page_a} = Content.publish_page(page_a, auth: false) + {:ok, page_a} = Content.update_page(page_a, %{"title" => "page_a v2"}, auth: false) + {:ok, _page_a} = Content.publish_page(page_a, auth: false) # publish and unpublish page_b page_b = beacon_page_fixture(path: "/b", title: "page_b v1") - {:ok, page_b} = Content.publish_page(page_b) - {:ok, _page_b} = Content.unpublish_page(page_b) + {:ok, page_b} = Content.publish_page(page_b, auth: false) + {:ok, _page_b} = Content.unpublish_page(page_b, auth: false) # do not publish page_c _page_c = beacon_page_fixture(path: "/c", title: "page_c v1") @@ -241,7 +254,7 @@ defmodule Beacon.ContentTest do assert Content.list_published_pages(:my_site) == [] - {:ok, _page} = Content.publish_page(page) + {:ok, _page} = Content.publish_page(page, auth: false) Repo.query!("UPDATE beacon_page_events SET inserted_at = '2020-01-01'", []) Repo.query!("UPDATE beacon_page_snapshots SET inserted_at = '2020-01-01'", []) @@ -251,9 +264,9 @@ defmodule Beacon.ContentTest do test "list_published_pages query latest snapshot" do # publish page_a twice page_a = beacon_page_fixture(path: "/a", title: "page_a v1") - {:ok, page_a} = Content.publish_page(page_a) - {:ok, page_a} = Content.update_page(page_a, %{"title" => "page_a v2"}) - {:ok, _page_a} = Content.publish_page(page_a) + {:ok, page_a} = Content.publish_page(page_a, auth: false) + {:ok, page_a} = Content.update_page(page_a, %{"title" => "page_a v2"}, auth: false) + {:ok, _page_a} = Content.publish_page(page_a, auth: false) assert [%Page{title: "page_a v2"}] = Content.list_published_pages(:my_site, query: "page_a") end @@ -319,8 +332,8 @@ defmodule Beacon.ContentTest do test "list_page_events" do page = beacon_page_fixture() - Content.publish_page(page) - Content.unpublish_page(page) + Content.publish_page(page, auth: false) + Content.unpublish_page(page, auth: false) assert [ %PageEvent{event: :unpublished, snapshot: nil}, @@ -333,26 +346,29 @@ defmodule Beacon.ContentTest do page = beacon_page_fixture() assert %PageEvent{event: :created} = Content.get_latest_page_event(page.site, page.id) - Content.publish_page(page) + Content.publish_page(page, auth: false) assert %PageEvent{event: :published} = Content.get_latest_page_event(page.site, page.id) - Content.unpublish_page(page) + Content.unpublish_page(page, auth: false) assert %PageEvent{event: :unpublished} = Content.get_latest_page_event(page.site, page.id) - Content.publish_page(page) + Content.publish_page(page, auth: false) assert %PageEvent{event: :published} = Content.get_latest_page_event(page.site, page.id) end test "lifecycle after_create_page" do layout = beacon_layout_fixture(site: :lifecycle_test) - Content.create_page!(%{ - site: "lifecycle_test", - path: "/", - title: "home", - template: "

page

", - layout_id: layout.id - }) + Content.create_page!( + %{ + site: "lifecycle_test", + path: "/", + title: "home", + template: "

page

", + layout_id: layout.id + }, + auth: false + ) assert_receive :lifecycle_after_create_page end @@ -361,15 +377,18 @@ defmodule Beacon.ContentTest do layout = beacon_layout_fixture(site: :lifecycle_test) page = - Content.create_page!(%{ - site: "lifecycle_test", - path: "/", - title: "home", - template: "

page

", - layout_id: layout.id - }) - - Content.update_page(page, %{template: "

page updated

"}) + Content.create_page!( + %{ + site: "lifecycle_test", + path: "/", + title: "home", + template: "

page

", + layout_id: layout.id + }, + auth: false + ) + + Content.update_page(page, %{template: "

page updated

"}, auth: false) assert_receive :lifecycle_after_create_page assert_receive :lifecycle_after_update_page @@ -379,15 +398,18 @@ defmodule Beacon.ContentTest do layout = beacon_layout_fixture(site: :lifecycle_test) page = - Content.create_page!(%{ - site: "lifecycle_test", - path: "/", - title: "home", - template: "

page

", - layout_id: layout.id - }) - - Content.publish_page(page) + Content.create_page!( + %{ + site: "lifecycle_test", + path: "/", + title: "home", + template: "

page

", + layout_id: layout.id + }, + auth: false + ) + + Content.publish_page(page, auth: false) assert %{title: "updated after publish page"} = Beacon.Content.get_page(page.site, page.id) end @@ -396,15 +418,18 @@ defmodule Beacon.ContentTest do layout = beacon_layout_fixture(site: :lifecycle_test) page = - Content.create_page!(%{ - site: "lifecycle_test", - path: "/", - title: "home", - template: "

page

", - layout_id: layout.id - }) - - Content.unpublish_page(page) + Content.create_page!( + %{ + site: "lifecycle_test", + path: "/", + title: "home", + template: "

page

", + layout_id: layout.id + }, + auth: false + ) + + Content.unpublish_page(page, auth: false) assert %{title: "updated after unpublish page"} = Beacon.Content.get_page(page.site, page.id) end @@ -413,30 +438,37 @@ defmodule Beacon.ContentTest do layout = beacon_layout_fixture(site: :raw_schema_test) assert %Page{raw_schema: [%{"foo" => "bar"}]} = - Content.create_page!(%{ - site: "my_site", - path: "/", - title: "home", - template: "

page

", - layout_id: layout.id, - raw_schema: [%{"foo" => "bar"}] - }) + Content.create_page!( + %{ + site: "my_site", + path: "/", + title: "home", + template: "

page

", + layout_id: layout.id, + raw_schema: [%{"foo" => "bar"}] + }, + auth: false + ) end test "update raw_schema" do layout = beacon_layout_fixture(site: :raw_schema_test) page = - Content.create_page!(%{ - site: "my_site", - path: "/", - title: "home", - template: "

page

", - layout_id: layout.id, - raw_schema: [%{"foo" => "bar"}] - }) - - assert {:ok, %Page{raw_schema: [%{"@type" => "BlogPosting"}]}} = Content.update_page(page, %{"raw_schema" => [%{"@type" => "BlogPosting"}]}) + Content.create_page!( + %{ + site: "my_site", + path: "/", + title: "home", + template: "

page

", + layout_id: layout.id, + raw_schema: [%{"foo" => "bar"}] + }, + auth: false + ) + + assert {:ok, %Page{raw_schema: [%{"@type" => "BlogPosting"}]}} = + Content.update_page(page, %{"raw_schema" => [%{"@type" => "BlogPosting"}]}, auth: false) end test "validate raw_schema" do @@ -448,14 +480,17 @@ defmodule Beacon.ContentTest do raw_schema: {"expected a list of map or a map, got: [nil]", [type: Beacon.Types.JsonArrayMap, validation: :cast]} ] }} = - Content.create_page(%{ - site: "my_site", - path: "/", - title: "home", - template: "

page

", - layout_id: layout.id, - raw_schema: [nil] - }) + Content.create_page( + %{ + site: "my_site", + path: "/", + title: "home", + template: "

page

", + layout_id: layout.id, + raw_schema: [nil] + }, + auth: false + ) end end @@ -469,7 +504,7 @@ defmodule Beacon.ContentTest do test "update broadcasts updated content event" do %{site: site} = stylesheet = beacon_stylesheet_fixture(site: "booted") :ok = Beacon.PubSub.subscribe_to_content(site) - Content.update_stylesheet(stylesheet, %{body: "/* test */"}) + Content.update_stylesheet(stylesheet, %{body: "/* test */"}, auth: false) assert_receive {:content_updated, :stylesheet, %{site: ^site}} end end @@ -488,7 +523,7 @@ defmodule Beacon.ContentTest do """ } - assert {:ok, js_hook} = Content.create_js_hook(attrs) + assert {:ok, js_hook} = Content.create_js_hook(attrs, auth: false) assert %JSHook{name: "FooHook"} = js_hook end @@ -510,14 +545,14 @@ defmodule Beacon.ContentTest do js_hook = beacon_js_hook_fixture() attrs = %{name: "Changed", code: "export const Changed = { mounted() {} }"} - assert {:ok, updated_js_hook} = Content.update_js_hook(js_hook, attrs) + assert {:ok, updated_js_hook} = Content.update_js_hook(js_hook, attrs, auth: false) assert %JSHook{name: "Changed", code: "export const Changed = { mounted() {} }"} = updated_js_hook end test "delete_js_hook/1" do %{id: id} = js_hook = beacon_js_hook_fixture() - assert {:ok, %{id: ^id}} = Content.delete_js_hook(js_hook) + assert {:ok, %{id: ^id}} = Content.delete_js_hook(js_hook, auth: false) end end @@ -525,13 +560,13 @@ defmodule Beacon.ContentTest do test "create_snippet_helper/1" do attrs = %{site: :my_site, name: "foo_snippet", body: "page title is {{ page.title }}"} - assert {:ok, _snippet_helper} = Content.create_snippet_helper(attrs) + assert {:ok, _snippet_helper} = Content.create_snippet_helper(attrs, auth: false) end test "create_snippet_helper should validate invalid body" do attrs = %{site: :my_site, name: "foo_snippet", body: "page title is {{ page.title"} - assert {:error, %Ecto.Changeset{errors: [body: {err, []}], valid?: false}} = Content.create_snippet_helper(attrs) + assert {:error, %Ecto.Changeset{errors: [body: {err, []}], valid?: false}} = Content.create_snippet_helper(attrs, auth: false) assert err =~ "Reason: expected end of string, line: 1" end @@ -588,7 +623,16 @@ defmodule Beacon.ContentTest do page = beacon_page_fixture(%{format: :heex}) attrs = %{name: "Foo", weight: 3, template: "
Bar
"} - assert {:ok, %Page{variants: [variant]}} = Content.create_variant_for_page(page, attrs) + assert {:ok, %Page{variants: [variant]}} = Content.create_variant_for_page(page, attrs, auth: false) + assert %PageVariant{name: "Foo", weight: 3, template: "
Bar
"} = variant + end + + test "create variant auth" do + page = beacon_page_fixture(%{format: :heex}) + attrs = %{name: "Foo", weight: 3, template: "
Bar
"} + + assert {:error, :not_authorized} = Content.create_variant_for_page(page, attrs, actor: fake_actor()) + assert {:ok, %Page{variants: [variant]}} = Content.create_variant_for_page(page, attrs, actor: admin_actor()) assert %PageVariant{name: "Foo", weight: 3, template: "
Bar
"} = variant end @@ -596,7 +640,7 @@ defmodule Beacon.ContentTest do page = beacon_page_fixture(site: :lifecycle_test) attrs = %{name: "Foo", weight: 3, template: "
Bar
"} - {:ok, %Page{}} = Content.create_variant_for_page(page, attrs) + {:ok, %Page{}} = Content.create_variant_for_page(page, attrs, auth: false) assert_receive :lifecycle_after_update_page end @@ -606,7 +650,7 @@ defmodule Beacon.ContentTest do attrs = %{name: "Changed Name", weight: 99, template: "
invalid"} assert {:error, %Ecto.Changeset{errors: [template: {"invalid", [compilation_error: error]}], valid?: false}} = - Content.create_variant_for_page(page, attrs) + Content.create_variant_for_page(page, attrs, auth: false) assert error =~ "unmatched closing tag" end @@ -616,7 +660,7 @@ defmodule Beacon.ContentTest do variant = beacon_page_variant_fixture(%{page: page}) attrs = %{name: "Changed Name", weight: 99, template: "
changed
"} - assert {:ok, %Page{variants: [updated_variant]}} = Content.update_variant_for_page(page, variant, attrs) + assert {:ok, %Page{variants: [updated_variant]}} = Content.update_variant_for_page(page, variant, attrs, auth: false) assert %PageVariant{name: "Changed Name", weight: 99, template: "
changed
"} = updated_variant end @@ -624,7 +668,7 @@ defmodule Beacon.ContentTest do page = beacon_page_fixture(site: :lifecycle_test) variant = beacon_page_variant_fixture(%{page: page}) - {:ok, %Page{}} = Content.update_variant_for_page(page, variant, %{name: "Changed"}) + {:ok, %Page{}} = Content.update_variant_for_page(page, variant, %{name: "Changed"}, auth: false) assert_receive :lifecycle_after_update_page end @@ -635,7 +679,7 @@ defmodule Beacon.ContentTest do attrs = %{name: "Changed Name", weight: 99, template: "
invalid"} assert {:error, %Ecto.Changeset{errors: [template: {"invalid", [compilation_error: error]}], valid?: false}} = - Content.update_variant_for_page(page, variant, attrs) + Content.update_variant_for_page(page, variant, attrs, auth: false) assert error =~ "unmatched closing tag" end @@ -646,9 +690,9 @@ defmodule Beacon.ContentTest do variant_2 = beacon_page_variant_fixture(%{page: page, weight: 0}) assert {:error, %Ecto.Changeset{errors: [weight: {"total weights cannot exceed 100", []}], valid?: false}} = - Content.update_variant_for_page(page, variant_2, %{weight: 2}) + Content.update_variant_for_page(page, variant_2, %{weight: 2}, auth: false) - assert {:ok, %Page{}} = Content.update_variant_for_page(page, variant_2, %{weight: 1}) + assert {:ok, %Page{}} = Content.update_variant_for_page(page, variant_2, %{weight: 1}, auth: false) end test "update variant should not validate total weight if unchanged" do @@ -656,7 +700,7 @@ defmodule Beacon.ContentTest do variant_1 = beacon_page_variant_fixture(%{page: page, weight: 99}) _variant_2 = beacon_page_variant_fixture(%{page: page, weight: 98}) - assert {:ok, %Page{}} = Content.update_variant_for_page(page, variant_1, %{name: "Foo"}) + assert {:ok, %Page{}} = Content.update_variant_for_page(page, variant_1, %{name: "Foo"}, auth: false) end test "delete variant OK" do @@ -664,18 +708,26 @@ defmodule Beacon.ContentTest do variant_1 = beacon_page_variant_fixture(%{page: page}) variant_2 = beacon_page_variant_fixture(%{page: page}) - assert {:ok, %Page{variants: [^variant_2]}} = Content.delete_variant_from_page(page, variant_1) - assert {:ok, %Page{variants: []}} = Content.delete_variant_from_page(page, variant_2) + assert {:ok, %Page{variants: [^variant_2]}} = Content.delete_variant_from_page(page, variant_1, auth: false) + assert {:ok, %Page{variants: []}} = Content.delete_variant_from_page(page, variant_2, auth: false) end test "delete triggers after_update_page lifecycle" do page = beacon_page_fixture(site: :lifecycle_test) variant = beacon_page_variant_fixture(%{page: page}) - {:ok, %Page{}} = Content.delete_variant_from_page(page, variant) + {:ok, %Page{}} = Content.delete_variant_from_page(page, variant, auth: false) assert_receive :lifecycle_after_update_page end + + test "delete variant auth" do + page = beacon_page_fixture(%{format: :heex}) + variant = beacon_page_variant_fixture(%{page: page}) + + assert {:error, :not_authorized} = Content.delete_variant_from_page(page, variant, actor: fake_actor()) + assert {:ok, %Page{variants: []}} = Content.delete_variant_from_page(page, variant, actor: admin_actor()) + end end describe "event_handlers" do @@ -690,18 +742,26 @@ defmodule Beacon.ContentTest do test "create event handler OK" do attrs = %{name: "Foo", code: "{:noreply, socket}", site: :my_site} - assert {:ok, event_handler} = Content.create_event_handler(attrs) + assert {:ok, event_handler} = Content.create_event_handler(attrs, auth: false) + assert %EventHandler{name: "Foo", code: "{:noreply, socket}"} = event_handler + end + + test "create event handler auth" do + attrs = %{name: "Foo", code: "{:noreply, socket}", site: :my_site} + + assert {:error, :not_authorized} = Content.create_event_handler(attrs, actor: fake_actor()) + assert {:ok, event_handler} = Content.create_event_handler(attrs, actor: admin_actor()) assert %EventHandler{name: "Foo", code: "{:noreply, socket}"} = event_handler end test "create validates elixir code" do attrs = %{name: "test", code: "[1)", site: :my_site} - assert {:error, %{errors: [error]}} = Content.create_event_handler(attrs) + assert {:error, %{errors: [error]}} = Content.create_event_handler(attrs, auth: false) {:code, {_, [compilation_error: compilation_error]}} = error assert compilation_error =~ "unexpected token: )" attrs = %{name: "test", code: "if true, do false", site: :my_site} - assert {:error, %{errors: [error]}} = Content.create_event_handler(attrs) + assert {:error, %{errors: [error]}} = Content.create_event_handler(attrs, auth: false) {:code, {_, [compilation_error: compilation_error]}} = error assert compilation_error =~ "unexpected reserved word: do" @@ -712,14 +772,14 @@ defmodule Beacon.ContentTest do | attrs = %{name: "test", code: code, site: :my_site} - assert {:ok, _} = Content.create_event_handler(attrs) + assert {:ok, _} = Content.create_event_handler(attrs, auth: false) end test "update event handler OK" do event_handler = beacon_event_handler_fixture() attrs = %{name: "Changed Name", code: "{:noreply, assign(socket, foo: :bar)}"} - assert {:ok, updated_event_handler} = Content.update_event_handler(event_handler, attrs) + assert {:ok, updated_event_handler} = Content.update_event_handler(event_handler, attrs, auth: false) assert %EventHandler{name: "Changed Name", code: "{:noreply, assign(socket, foo: :bar)}"} = updated_event_handler end @@ -727,12 +787,12 @@ defmodule Beacon.ContentTest do event_handler = beacon_event_handler_fixture() attrs = %{code: "[1)"} - assert {:error, %{errors: [error]}} = Content.update_event_handler(event_handler, attrs) + assert {:error, %{errors: [error]}} = Content.update_event_handler(event_handler, attrs, auth: false) {:code, {_, [compilation_error: compilation_error]}} = error assert compilation_error =~ "unexpected token: )" attrs = %{code: "if true, do false"} - assert {:error, %{errors: [error]}} = Content.update_event_handler(event_handler, attrs) + assert {:error, %{errors: [error]}} = Content.update_event_handler(event_handler, attrs, auth: false) {:code, {_, [compilation_error: compilation_error]}} = error assert compilation_error =~ "unexpected reserved word: do" @@ -743,13 +803,13 @@ defmodule Beacon.ContentTest do | attrs = %{code: code} - assert {:ok, _} = Content.update_event_handler(event_handler, attrs) + assert {:ok, _} = Content.update_event_handler(event_handler, attrs, auth: false) end test "delete event handler OK" do %{id: id} = event_handler = beacon_event_handler_fixture() - assert {:ok, %{id: ^id}} = Content.delete_event_handler(event_handler) + assert {:ok, %{id: ^id}} = Content.delete_event_handler(event_handler, auth: false) end end @@ -763,7 +823,7 @@ defmodule Beacon.ContentTest do test "update broadcasts updated content event" do %{site: site} = error_page = beacon_error_page_fixture(site: "booted") :ok = Beacon.PubSub.subscribe_to_content(site) - Content.update_error_page(error_page, %{template: "test"}) + Content.update_error_page(error_page, %{template: "test"}, auth: false) assert_receive {:content_updated, :error_page, %{site: ^site}} end @@ -778,7 +838,7 @@ defmodule Beacon.ContentTest do %{id: layout_id} = beacon_layout_fixture() attrs = %{site: :my_site, status: 400, template: "Oops!", layout_id: layout_id} - assert {:ok, %ErrorPage{} = error_page} = Content.create_error_page(attrs) + assert {:ok, %ErrorPage{} = error_page} = Content.create_error_page(attrs, auth: false) assert %{site: :my_site, status: 400, template: "Oops!", layout_id: ^layout_id} = error_page end @@ -787,7 +847,7 @@ defmodule Beacon.ContentTest do attrs = %{site: :my_site, status: 400, template: "
invalid", layout_id: layout_id} assert {:error, %Ecto.Changeset{errors: [template: {"invalid", [compilation_error: error]}], valid?: false}} = - Content.create_error_page(attrs) + Content.create_error_page(attrs, auth: false) assert error =~ "unmatched closing tag" end @@ -796,13 +856,13 @@ defmodule Beacon.ContentTest do error_page = beacon_error_page_fixture() bad_attrs = %{site: error_page.site, status: error_page.status, template: "Error", layout_id: beacon_layout_fixture().id} - assert {:error, %Changeset{errors: errors}} = Content.create_error_page(bad_attrs) + assert {:error, %Changeset{errors: errors}} = Content.create_error_page(bad_attrs, auth: false) assert [{:status, {"has already been taken", [constraint: :unique, constraint_name: "beacon_error_pages_status_site_index"]}}] = errors end test "update_error_page/2" do error_page = beacon_error_page_fixture() - assert {:ok, %ErrorPage{template: "Changed"}} = Content.update_error_page(error_page, %{template: "Changed"}) + assert {:ok, %ErrorPage{template: "Changed"}} = Content.update_error_page(error_page, %{template: "Changed"}, auth: false) end test "update_error_page should validate invalid templates" do @@ -811,14 +871,14 @@ defmodule Beacon.ContentTest do attrs = %{template: "
invalid"} assert {:error, %Ecto.Changeset{errors: [template: {"invalid", [compilation_error: error]}], valid?: false}} = - Content.update_error_page(error_page, attrs) + Content.update_error_page(error_page, attrs, auth: false) assert error =~ "unmatched closing tag" end test "delete_error_page/1" do error_page = beacon_error_page_fixture() - assert {:ok, %ErrorPage{__meta__: %{state: :deleted}}} = Content.delete_error_page(error_page) + assert {:ok, %ErrorPage{__meta__: %{state: :deleted}}} = Content.delete_error_page(error_page, auth: false) end end @@ -832,13 +892,13 @@ defmodule Beacon.ContentTest do test "update broadcasts updated content event" do %{site: site} = component = beacon_component_fixture(site: "booted") :ok = Beacon.PubSub.subscribe_to_content(site) - Content.update_component(component, %{template: "
test
"}) + Content.update_component(component, %{template: "
test
"}, auth: false) assert_receive {:content_updated, :component, %{site: ^site}} end test "validate template heex on create" do assert {:error, %Ecto.Changeset{errors: [template: {"invalid", [compilation_error: compilation_error]}]}} = - Content.create_component(%{site: :my_site, name: "test", template: "`" end @@ -847,17 +907,17 @@ defmodule Beacon.ContentTest do component = beacon_component_fixture() assert {:error, %Ecto.Changeset{errors: [template: {"invalid", [compilation_error: compilation_error]}]}} = - Content.update_component(component, %{template: "`" end test "validate name format as valid function name" do assert {:error, %Ecto.Changeset{errors: [name: {"can only contain lowercase letters, numbers, and underscores", _}]}} = - Content.create_component(%{site: :my_site, name: "my component", template: "test", example: "test"}) + Content.create_component(%{site: :my_site, name: "my component", template: "test", example: "test"}, auth: false) assert {:error, %Ecto.Changeset{errors: [name: {"can only contain lowercase letters, numbers, and underscores", _}]}} = - Content.create_component(%{site: :my_site, name: "my_component$", template: "test", example: "test"}) + Content.create_component(%{site: :my_site, name: "my_component$", template: "test", example: "test"}, auth: false) end test "validate allowed attrs opts" do @@ -868,15 +928,18 @@ defmodule Beacon.ContentTest do valid?: false } } = - Content.create_component(%{ - site: :my_site, - name: "my_component", - template: "test", - example: "test", - attrs: [ - %{name: "name", type: "string", opts: [required: true, other: nil]} - ] - }) + Content.create_component( + %{ + site: :my_site, + name: "my_component", + template: "test", + example: "test", + attrs: [ + %{name: "name", type: "string", opts: [required: true, other: nil]} + ] + }, + auth: false + ) end test "validate allowed slot opts" do @@ -887,15 +950,42 @@ defmodule Beacon.ContentTest do valid?: false } } = - Content.create_component(%{ - site: :my_site, - name: "my_component", - template: "test", - example: "test", - slots: [ - %{name: "inner_block", opts: [default: nil]} - ] - }) + Content.create_component( + %{ + site: :my_site, + name: "my_component", + template: "test", + example: "test", + slots: [ + %{name: "inner_block", opts: [default: nil]} + ] + }, + auth: false + ) + end + + test "auth for create" do + assert {:error, :not_authorized} = + Content.create_component( + %{ + site: :my_site, + name: "my_component", + template: "test", + example: "test" + }, + actor: {"123", "Fake Actor"} + ) + + assert {:ok, %Component{}} = + Content.create_component( + %{ + site: :my_site, + name: "my_component", + template: "test", + example: "test" + }, + actor: admin_actor() + ) end test "list components" do @@ -932,7 +1022,7 @@ defmodule Beacon.ContentTest do test "update_component" do component = beacon_component_fixture(name: "new_component", template: "old_body") - assert {:ok, %Component{template: "new_body"}} = Content.update_component(component, %{template: "new_body"}) + assert {:ok, %Component{template: "new_body"}} = Content.update_component(component, %{template: "new_body"}, auth: false) end end @@ -946,14 +1036,14 @@ defmodule Beacon.ContentTest do test "create_live_data/1" do attrs = %{site: :my_site, path: "/foo/:bar"} - assert {:ok, %LiveData{} = live_data} = Content.create_live_data(attrs) + assert {:ok, %LiveData{} = live_data} = Content.create_live_data(attrs, auth: false) assert %{site: :my_site, path: "/foo/:bar"} = live_data end test "create_live_data/1 for root path" do attrs = %{site: :my_site, path: "/"} - assert {:ok, %LiveData{} = live_data} = Content.create_live_data(attrs) + assert {:ok, %LiveData{} = live_data} = Content.create_live_data(attrs, auth: false) assert %{site: :my_site, path: "/"} = live_data end @@ -961,7 +1051,7 @@ defmodule Beacon.ContentTest do live_data = beacon_live_data_fixture() attrs = %{key: "product_id", format: :elixir, value: "123"} - assert {:ok, %LiveData{assigns: [assign]}} = Content.create_assign_for_live_data(live_data, attrs) + assert {:ok, %LiveData{assigns: [assign]}} = Content.create_assign_for_live_data(live_data, attrs, auth: false) assert %{key: "product_id", format: :elixir, value: "123"} = assign end @@ -971,7 +1061,7 @@ defmodule Beacon.ContentTest do for invalid_key <- invalid_keys do attrs = %{key: to_string(invalid_key), format: :text, value: "foo"} - assert {:error, %{errors: [error]}} = Content.create_assign_for_live_data(live_data, attrs) + assert {:error, %{errors: [error]}} = Content.create_assign_for_live_data(live_data, attrs, auth: false) assert {:key, {"is reserved", _}} = error end end @@ -980,12 +1070,12 @@ defmodule Beacon.ContentTest do live_data = beacon_live_data_fixture() attrs = %{key: "foo", value: "[1)", format: :elixir} - assert {:error, %{errors: [error]}} = Content.create_assign_for_live_data(live_data, attrs) + assert {:error, %{errors: [error]}} = Content.create_assign_for_live_data(live_data, attrs, auth: false) {:value, {_, [compilation_error: compilation_error]}} = error assert compilation_error =~ "unexpected token: )" attrs = %{key: "foo", value: "if true, do false", format: :elixir} - assert {:error, %{errors: [error]}} = Content.create_assign_for_live_data(live_data, attrs) + assert {:error, %{errors: [error]}} = Content.create_assign_for_live_data(live_data, attrs, auth: false) {:value, {_, [compilation_error: compilation_error]}} = error assert compilation_error =~ "unexpected reserved word: do" @@ -995,7 +1085,7 @@ defmodule Beacon.ContentTest do | attrs = %{key: "foo", value: code, format: :elixir} - assert {:ok, _} = Content.create_assign_for_live_data(live_data, attrs) + assert {:ok, _} = Content.create_assign_for_live_data(live_data, attrs, auth: false) end test "get_live_data/2" do @@ -1045,7 +1135,7 @@ defmodule Beacon.ContentTest do test "update_live_data_path/2" do live_data = beacon_live_data_fixture(site: :my_site, path: "/foo") - assert {:ok, result} = Content.update_live_data_path(live_data, "/foo/:bar_id") + assert {:ok, result} = Content.update_live_data_path(live_data, "/foo/:bar_id", auth: false) assert result.id == live_data.id assert result.path == "/foo/:bar_id" end @@ -1055,7 +1145,7 @@ defmodule Beacon.ContentTest do live_data_assign = beacon_live_data_assign_fixture(live_data: live_data) attrs = %{key: "wins", value: "1337", format: :elixir} - assert {:ok, updated_assign} = Content.update_live_data_assign(live_data_assign, live_data.site, attrs) + assert {:ok, updated_assign} = Content.update_live_data_assign(live_data_assign, live_data.site, attrs, auth: false) assert updated_assign.id == live_data_assign.id assert updated_assign.key == "wins" @@ -1068,12 +1158,12 @@ defmodule Beacon.ContentTest do live_data_assign = beacon_live_data_assign_fixture(live_data: live_data) attrs = %{value: "[1)", format: :elixir} - assert {:error, %{errors: [error]}} = Content.update_live_data_assign(live_data_assign, live_data.site, attrs) + assert {:error, %{errors: [error]}} = Content.update_live_data_assign(live_data_assign, live_data.site, attrs, auth: false) {:value, {_, [compilation_error: compilation_error]}} = error assert compilation_error =~ "unexpected token: )" attrs = %{value: "if true, do false", format: :elixir} - assert {:error, %{errors: [error]}} = Content.update_live_data_assign(live_data_assign, live_data.site, attrs) + assert {:error, %{errors: [error]}} = Content.update_live_data_assign(live_data_assign, live_data.site, attrs, auth: false) {:value, {_, [compilation_error: compilation_error]}} = error assert compilation_error =~ "unexpected reserved word: do" @@ -1083,14 +1173,14 @@ defmodule Beacon.ContentTest do | attrs = %{value: code, format: :elixir} - assert {:ok, _} = Content.update_live_data_assign(live_data_assign, live_data.site, attrs) + assert {:ok, _} = Content.update_live_data_assign(live_data_assign, live_data.site, attrs, auth: false) end test "delete_live_data/1" do live_data = beacon_live_data_fixture() assert [%{}] = Content.live_data_for_site(live_data.site) - assert {:ok, _} = Content.delete_live_data(live_data) + assert {:ok, _} = Content.delete_live_data(live_data, auth: false) assert [] = Content.live_data_for_site(live_data.site) end @@ -1099,7 +1189,7 @@ defmodule Beacon.ContentTest do live_data_assign = beacon_live_data_assign_fixture(live_data: live_data) Repo.preload(live_data, :assigns) - assert {:ok, _} = Content.delete_live_data_assign(live_data_assign, live_data.site) + assert {:ok, _} = Content.delete_live_data_assign(live_data_assign, live_data.site, auth: false) assert %{assigns: []} = Repo.preload(live_data, :assigns) end end @@ -1126,17 +1216,20 @@ defmodule Beacon.ContentTest do test "success: create_info_handler/1", %{msg: msg, code: code} do attrs = %{site: :my_site, msg: msg, code: code} - assert {:ok, %InfoHandler{} = info_handler} = Content.create_info_handler(attrs) + assert {:ok, %InfoHandler{} = info_handler} = Content.create_info_handler(attrs, auth: false) assert %InfoHandler{site: :my_site, msg: ^msg, code: ^code} = info_handler end test "error: create_info_handler/1 validates code", %{msg: msg} do assert {:error, %{errors: [error]}} = - Content.create_info_handler(%{ - site: :my_site, - msg: msg, - code: ":no_reply, socket" - }) + Content.create_info_handler( + %{ + site: :my_site, + msg: msg, + code: ":no_reply, socket" + }, + auth: false + ) {:code, {"invalid", [compilation_error: compilation_error]}} = error @@ -1146,11 +1239,14 @@ defmodule Beacon.ContentTest do end test "success: create_info_handler!/1", %{msg: msg, code: code} do - Content.create_info_handler!(%{ - site: :my_site, - msg: msg, - code: code - }) + Content.create_info_handler!( + %{ + site: :my_site, + msg: msg, + code: code + }, + auth: false + ) assert %InfoHandler{site: :my_site, msg: ^msg, code: ^code} = Repo.one(InfoHandler) end @@ -1200,7 +1296,7 @@ defmodule Beacon.ContentTest do refute info_handler.code == code refute info_handler.msg == msg - assert {:ok, %InfoHandler{} = info_handler_from_db} = Content.update_info_handler(info_handler, attrs) + assert {:ok, %InfoHandler{} = info_handler_from_db} = Content.update_info_handler(info_handler, attrs, auth: false) assert %InfoHandler{code: ^code, msg: ^msg} = info_handler_from_db end @@ -1225,7 +1321,7 @@ defmodule Beacon.ContentTest do refute info_handler.code == code refute info_handler.msg == msg - {:error, %{errors: [error]}} = Content.update_info_handler(info_handler, attrs) + {:error, %{errors: [error]}} = Content.update_info_handler(info_handler, attrs, auth: false) {:code, {"invalid", [compilation_error: compilation_error]}} = error @@ -1238,7 +1334,7 @@ defmodule Beacon.ContentTest do assert Repo.one(InfoHandler) - assert {:ok, %InfoHandler{site: ^site, msg: ^msg, code: ^code}} = Content.delete_info_handler(info_handler) + assert {:ok, %InfoHandler{site: ^site, msg: ^msg, code: ^code}} = Content.delete_info_handler(info_handler, auth: false) refute Repo.one(InfoHandler) end end diff --git a/test/beacon/loader/page_test.exs b/test/beacon/loader/page_test.exs index 94d30e820..551e3f39c 100644 --- a/test/beacon/loader/page_test.exs +++ b/test/beacon/loader/page_test.exs @@ -87,7 +87,7 @@ defmodule Beacon.Loader.PageTest do page = beacon_published_page_fixture(path: "/1") Beacon.Content.create_variant_for_page(page, %{name: "variant_a", weight: 1, template: "
variant_a
"}) Beacon.Content.create_variant_for_page(page, %{name: "variant_b", weight: 2, template: "
variant_b
"}) - Beacon.Content.publish_page(page) + Beacon.Content.publish_page(page, auth: false) module = Loader.fetch_page_module(page.site, page.id) assert [ diff --git a/test/beacon_web/live/page_live_test.exs b/test/beacon_web/live/page_live_test.exs index 291f37a62..02a8fbfab 100644 --- a/test/beacon_web/live/page_live_test.exs +++ b/test/beacon_web/live/page_live_test.exs @@ -120,7 +120,7 @@ defmodule Beacon.Web.Live.PageLiveTest do """ }) - Content.publish_page(page_home) + Content.publish_page(page_home, auth: false) _page_without_meta_tags = beacon_published_page_fixture( @@ -276,8 +276,8 @@ defmodule Beacon.Web.Live.PageLiveTest do end test "update resource links on layout publish", %{conn: conn, layout: layout} do - {:ok, layout} = Content.update_layout(layout, %{"resource_links" => [%{"rel" => "stylesheet", "href" => "color.css"}]}) - {:ok, layout} = Content.publish_layout(layout) + {:ok, layout} = Content.update_layout(layout, %{"resource_links" => [%{"rel" => "stylesheet", "href" => "color.css"}]}, auth: false) + {:ok, layout} = Content.publish_layout(layout, auth: false) Beacon.Loader.load_layout_module(layout.site, layout.id) {:ok, _view, html} = live(conn, "/home/hello") assert html =~ ~S|| @@ -385,7 +385,7 @@ defmodule Beacon.Web.Live.PageLiveTest do assert html =~ "component_test_v1" - Content.update_component(component, %{template: "component_test_v2"}) + Content.update_component(component, %{template: "component_test_v2"}, auth: false) Beacon.Loader.load_components_module(component.site) {:ok, _view, html} = live(conn, "/component_test") diff --git a/test/support/auth_module.ex b/test/support/auth_module.ex new file mode 100644 index 000000000..6923bf428 --- /dev/null +++ b/test/support/auth_module.ex @@ -0,0 +1,20 @@ +defmodule Beacon.BeaconTest.AuthModule do + @behaviour Beacon.Auth + + def actor_from_session(_session) do + {"1-1-1", "Test User"} + end + + def list_actors() do + [ + {"1-2-3", "Owner 1"}, + {"4-5-6", "Owner 2"}, + {"3-3-3", "User 1"}, + {"4-4-4", "User 2"} + ] + end + + def owners() do + [{"1-2-3", "Owner 1"}, {"4-5-6", "Owner 2"}] + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index a84cf9fc5..cecfbf9e4 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -16,6 +16,7 @@ Supervisor.start_link( endpoint: Beacon.BeaconTest.Endpoint, router: Beacon.BeaconTest.Router, repo: Beacon.BeaconTest.Repo, + auth_module: Beacon.BeaconTest.AuthModule, tailwind_config: Path.join([File.cwd!(), "test", "support", "tailwind.config.templates.js"]), tailwind_css: Path.join([File.cwd!(), "test", "support", "tailwind.custom.css"]), live_socket_path: "/custom_live", @@ -141,13 +142,13 @@ Supervisor.start_link( ], after_publish_page: [ maybe_publish_page: fn page -> - {:ok, page} = Beacon.Content.update_page(page, %{title: "updated after publish page"}) + {:ok, page} = Beacon.Content.update_page(page, %{title: "updated after publish page"}, auth: false) {:cont, page} end ], after_unpublish_page: [ maybe_unpublish_page: fn page -> - {:ok, page} = Beacon.Content.update_page(page, %{title: "updated after unpublish page"}) + {:ok, page} = Beacon.Content.update_page(page, %{title: "updated after unpublish page"}, auth: false) {:cont, page} end ] From 9e68f140d693c6d25b3131977f910efbe23ee51f Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Mon, 24 Feb 2025 16:44:42 -0600 Subject: [PATCH 16/23] resolve credo issue in beacon test auth module --- test/support/auth_module.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/support/auth_module.ex b/test/support/auth_module.ex index 6923bf428..e862ced6c 100644 --- a/test/support/auth_module.ex +++ b/test/support/auth_module.ex @@ -5,7 +5,7 @@ defmodule Beacon.BeaconTest.AuthModule do {"1-1-1", "Test User"} end - def list_actors() do + def list_actors do [ {"1-2-3", "Owner 1"}, {"4-5-6", "Owner 2"}, @@ -14,7 +14,7 @@ defmodule Beacon.BeaconTest.AuthModule do ] end - def owners() do + def owners do [{"1-2-3", "Owner 1"}, {"4-5-6", "Owner 2"}] end end From 11646802b3b777c801eaa1ee996097da4f949111 Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Mon, 24 Feb 2025 16:51:03 -0600 Subject: [PATCH 17/23] fix page_test --- test/beacon/loader/page_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/beacon/loader/page_test.exs b/test/beacon/loader/page_test.exs index 551e3f39c..70d747322 100644 --- a/test/beacon/loader/page_test.exs +++ b/test/beacon/loader/page_test.exs @@ -85,8 +85,8 @@ defmodule Beacon.Loader.PageTest do test "render all templates" do page = beacon_published_page_fixture(path: "/1") - Beacon.Content.create_variant_for_page(page, %{name: "variant_a", weight: 1, template: "
variant_a
"}) - Beacon.Content.create_variant_for_page(page, %{name: "variant_b", weight: 2, template: "
variant_b
"}) + Beacon.Content.create_variant_for_page(page, %{name: "variant_a", weight: 1, template: "
variant_a
"}, auth: false) + Beacon.Content.create_variant_for_page(page, %{name: "variant_b", weight: 2, template: "
variant_b
"}, auth: false) Beacon.Content.publish_page(page, auth: false) module = Loader.fetch_page_module(page.site, page.id) From 156637632788d61833a1890817e9a69978fd99ae Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Tue, 25 Feb 2025 11:51:58 -0600 Subject: [PATCH 18/23] update content docs --- lib/beacon/content.ex | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/beacon/content.ex b/lib/beacon/content.ex index 3d90a33a3..b42d2c2c8 100644 --- a/lib/beacon/content.ex +++ b/lib/beacon/content.ex @@ -3187,7 +3187,7 @@ defmodule Beacon.Content do ## Example - iex> create_component(attrs, actor: "some-user-id") + iex> create_component(attrs, actor: {"some-user-id", "Some User"}) {:ok, %Component{}} """ @@ -3243,7 +3243,7 @@ defmodule Beacon.Content do ## Example - iex> update_component(component, %{name: "new_component"}, actor: "some-user-id") + iex> update_component(component, %{name: "new_component"}, actor: {"some-user-id", "Some User"}) {:ok, %Component{}} """ @@ -3530,7 +3530,7 @@ defmodule Beacon.Content do ## Example - iex> create_slot_attr(site, attrs, [], actor: "some-user-id") + iex> create_slot_attr(site, attrs, [], actor: {"some-user-id", "Some User"}) {:ok, %ComponentSlotAttr{}} """ @@ -3551,7 +3551,7 @@ defmodule Beacon.Content do This function requires authorization. See ["Authorization Options"](#module-authorization-options) in the module documentation. - iex> update_slot(site, slot_attr, %{name: "new_slot"}, [], actor: "some-user-id") + iex> update_slot(site, slot_attr, %{name: "new_slot"}, [], actor: {"some-user-id", "Some User"}) {:ok, %ComponentSlotAttr{}} """ @@ -4334,7 +4334,7 @@ defmodule Beacon.Content do This function requires authorization. See ["Authorization Options"](#module-authorization-options) in the module documentation. - iex> update_live_data_path(live_data, "/foo/bar/:baz_id", actor: "some-user-id") + iex> update_live_data_path(live_data, "/foo/bar/:baz_id", actor: {"some-user-id", "Some User"}) {:ok, %LiveData{}} """ @@ -4355,7 +4355,7 @@ defmodule Beacon.Content do This function requires authorization. See ["Authorization Options"](#module-authorization-options) in the module documentation. - iex> update_live_data_assign(live_data_assign, :my_site, %{code: "true"}, actor: "some-user-id") + iex> update_live_data_assign(live_data_assign, :my_site, %{code: "true"}, actor: {"some-user-id", "Some User"}) {:ok, %LiveDataAssign{}} """ @@ -4436,7 +4436,7 @@ defmodule Beacon.Content do ## Example iex> attrs = %{site: "my_site", msg: "{:new_msg, arg}", code: "{:noreply, socket}"} - iex> create_info_handler(attrs, actor: "some-user-id") + iex> create_info_handler(attrs, actor: {"some-user-id", "Some User"}) {:ok, %InfoHandler{}} """ @@ -4477,7 +4477,7 @@ defmodule Beacon.Content do ## Example iex> attrs = %{site: "my_site", msg: "{:new_msg, arg}", code: "{:noreply, socket}"} - iex> create_info_handler!(attrs, actor: "some-user-id") + iex> create_info_handler!(attrs, actor: {"some-user-id", "Some User"}) %InfoHandler{} """ @@ -4556,7 +4556,7 @@ defmodule Beacon.Content do ## Example - iex> update_info_handler(info_handler, %{msg: "{:new_msg, arg}"}, actor: "some-user-id") + iex> update_info_handler(info_handler, %{msg: "{:new_msg, arg}"}, actor: {"some-user-id", "Some User"}) {:ok, %InfoHandler{}} """ From 63dbcd617e9eaf0cb457d3ad2e4f9e23760c384c Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Tue, 25 Feb 2025 15:50:19 -0600 Subject: [PATCH 19/23] some data fixes --- lib/beacon/auth.ex | 41 ++++++++++++++++++++++++++++++++--- lib/beacon/migrations/v004.ex | 2 ++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/lib/beacon/auth.ex b/lib/beacon/auth.ex index aab68e671..ef8f87c24 100644 --- a/lib/beacon/auth.ex +++ b/lib/beacon/auth.ex @@ -184,10 +184,14 @@ defmodule Beacon.Auth do @doc """ A blank ActorRole struct. + + Optionally provide initial attrs if needed. + + Does not validate, insert the struct, or perform any database operation. """ - @spec new_actor_role() :: ActorRole.t() - def new_actor_role do - %ActorRole{} + @spec new_actor_role(map()) :: ActorRole.t() + def new_actor_role(attrs \\ %{}) do + struct(ActorRole, attrs) end @doc """ @@ -216,6 +220,27 @@ defmodule Beacon.Auth do ) end + @doc """ + Grants an actor the given Role, removing any previous Role. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. + """ + @spec set_role_for_actor(String.t(), Role.t(), keyword()) :: + {:ok, ActorRole.t()} | {:error, Changeset.t() | :not_authorized} + def set_role_for_actor(actor_id, role, opts \\ []) do + site = role.site + + with :ok <- authorize(site, :set_role_for_actor, opts) do + new_actor_role() + |> change_actor_role(%{actor_id: actor_id, role_id: role.id}) + |> repo(site).insert( + on_conflict: {:replace_all_except, [:id, :inserted_at]}, + conflict_target: [:actor_id] + ) + end + end + @doc """ Creates a changeset with the given role and optional map of changes. """ @@ -242,6 +267,9 @@ defmodule Beacon.Auth do @doc """ Create a new role. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @spec create_role(map(), keyword()) :: {:ok, Role.t()} | {:error, Changeset.t()} def create_role(attrs, opts \\ []) do @@ -255,6 +283,9 @@ defmodule Beacon.Auth do @doc """ Update an existing role. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @spec update_role(Role.t(), map(), keyword()) :: {:ok, Role.t()} | {:error, Changeset.t()} def update_role(role, attrs, opts \\ []) do @@ -267,6 +298,9 @@ defmodule Beacon.Auth do @doc """ Delete an existing role. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @spec delete_role(Role.t(), keyword()) :: {:ok, Role.t()} | {:error, Changeset.t()} def delete_role(role, opts \\ []) do @@ -320,6 +354,7 @@ defmodule Beacon.Auth do :create_role, :update_role, :delete_role, + :set_role_for_actor, :create_js_hook, :update_js_hook, :delete_js_hook diff --git a/lib/beacon/migrations/v004.ex b/lib/beacon/migrations/v004.ex index 91b67d891..95a2ce566 100644 --- a/lib/beacon/migrations/v004.ex +++ b/lib/beacon/migrations/v004.ex @@ -19,6 +19,8 @@ defmodule Beacon.Migrations.V004 do timestamps(type: :utc_datetime_usec) end + + create unique_index(:beacon_actors_roles, [:actor_id]) end def down do From f6fea4f2159d19125cd4b0ba1180fa41835e7a08 Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Tue, 25 Feb 2025 16:16:44 -0600 Subject: [PATCH 20/23] improve auth docs --- lib/beacon/auth.ex | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/beacon/auth.ex b/lib/beacon/auth.ex index ef8f87c24..8fbe5cd7f 100644 --- a/lib/beacon/auth.ex +++ b/lib/beacon/auth.ex @@ -22,6 +22,8 @@ defmodule Beacon.Auth do * `actor_from_session/1` - a function which receives the user's session data, and returns a tuple containing a unique ID and a human-readable label * `list_actors/0` - a function to return a list of actors, in the same format as above: `{id, label}` + * `owners/0` - a function to return a list of actor tuples which will be given full access to the site, + bypassing authorization checks entirely This allows you to integrate Beacon with any type of authentication system your app might require. @@ -43,6 +45,10 @@ defmodule Beacon.Auth do def list_actors do Repo.all(from u in MyApp.Accounts.User, select: {u.id, u.email}) end + + def owners do + [{"123-456", "it_admin@example.com"}] + end end ``` @@ -53,6 +59,9 @@ defmodule Beacon.Auth do `list_actors/0` provides the database query for Beacon to find the actors in your app and return them in the expected `{id, label}` format. + `owners/0` designates the actors who can bypass authorization and perform initial setup before any + roles have been granted. + This module can then be provided to `Beacon.Config` as an `:auth_module` option: ``` @@ -70,7 +79,7 @@ defmodule Beacon.Auth do Several functions in this module (and others) require authorization by default. This is done via the `:actor` option: ``` - iex> Beacon.Auth.create_role(%{"name" => "Power User", ...}, actor: "some-identifying-id") + iex> Beacon.Auth.create_role(%{"name" => "Power User", ...}, actor: {"some-identifying-id", "First Lastname"}) {:ok, %Role{}} ``` @@ -78,7 +87,7 @@ defmodule Beacon.Auth do and prevent the function from running if the role should not have access: ``` - iex> Beacon.Auth.create_role(%{"name" => "Power User", ...},, actor: "user-with-read-only-access") + iex> Beacon.Auth.create_role(%{"name" => "Power User", ...},, actor: {"user-with-read-only-access", "John Smith"}) {:error, :not_authorized} ``` @@ -112,6 +121,9 @@ defmodule Beacon.Auth do @doc """ Specifies the identities of site owners who should always have unconditional access. + + This is especially useful when initially setting up auth for your site, before any roles have + been granted. """ @callback owners() :: [actor_tuple()] From 8c801b2ba141f570f1da993b42313109e8390f25 Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Wed, 26 Feb 2025 12:29:12 -0600 Subject: [PATCH 21/23] populate default roles --- lib/beacon/auth.ex | 59 ++++++++++++++++++++++++++++++------- lib/beacon/boot.ex | 1 + lib/beacon/loader.ex | 4 +++ lib/beacon/loader/worker.ex | 24 +++++++++++++-- 4 files changed, 75 insertions(+), 13 deletions(-) diff --git a/lib/beacon/auth.ex b/lib/beacon/auth.ex index 8fbe5cd7f..b999ef699 100644 --- a/lib/beacon/auth.ex +++ b/lib/beacon/auth.ex @@ -262,19 +262,23 @@ defmodule Beacon.Auth do end @doc """ - Lists all roles available for a given site. + Lookup a role by its name. """ - @spec list_roles(Site.t()) :: [Role.t()] - def list_roles(site) do - repo(site).all(from r in Role, where: r.site == ^to_string(site)) + @spec get_role_by_name(Site.t(), String.t()) :: Role.t() | nil + def get_role_by_name(site, name) do + repo(site).one( + from r in Role, + where: r.site == ^to_string(site), + where: r.name == ^name + ) end @doc """ - + Lists all roles available for a given site. """ - @spec default_role_capabilities() :: [atom()] - def default_role_capabilities do - [] + @spec list_roles(Site.t()) :: [Role.t()] + def list_roles(site) do + repo(site).all(from r in Role, where: r.site == ^to_string(site)) end @doc """ @@ -283,7 +287,7 @@ defmodule Beacon.Auth do This function requires authorization. See ["Authorization Options"](#module-authorization-options) in the module documentation. """ - @spec create_role(map(), keyword()) :: {:ok, Role.t()} | {:error, Changeset.t()} + @spec create_role(map(), keyword()) :: {:ok, Role.t()} | {:error, Changeset.t() | :not_authorized} def create_role(attrs, opts \\ []) do changeset = Role.changeset(%Role{}, attrs) site = Changeset.get_field(changeset, :site) @@ -293,13 +297,28 @@ defmodule Beacon.Auth do end end + @doc """ + Create a new role, raising an error if unsuccessful. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. + """ + @spec create_role!(map(), keyword()) :: Role.t() + def create_role!(attrs, opts \\ []) do + case create_role(attrs, opts) do + {:ok, role} -> role + {:error, :not_authorized} -> raise "failed to create role: not authorized" + {:error, changeset} -> raise "failed to create role, got: #{inspect(changeset.errors)}" + end + end + @doc """ Update an existing role. This function requires authorization. See ["Authorization Options"](#module-authorization-options) in the module documentation. """ - @spec update_role(Role.t(), map(), keyword()) :: {:ok, Role.t()} | {:error, Changeset.t()} + @spec update_role(Role.t(), map(), keyword()) :: {:ok, Role.t()} | {:error, Changeset.t() | :not_authorized} def update_role(role, attrs, opts \\ []) do with :ok <- authorize(role.site, :update_role, opts) do role @@ -314,7 +333,7 @@ defmodule Beacon.Auth do This function requires authorization. See ["Authorization Options"](#module-authorization-options) in the module documentation. """ - @spec delete_role(Role.t(), keyword()) :: {:ok, Role.t()} | {:error, Changeset.t()} + @spec delete_role(Role.t(), keyword()) :: {:ok, Role.t()} | {:error, Changeset.t() | :not_authorized} def delete_role(role, opts \\ []) do with :ok <- authorize(role.site, :delete_role, opts) do repo(role).delete(role) @@ -372,4 +391,22 @@ defmodule Beacon.Auth do :delete_js_hook ] end + + @doc """ + The default capabilities for a new role that is created. + """ + @spec default_role_capabilities() :: [atom()] + def default_role_capabilities do + [:create_page, :update_page, :publish_page, :unpublish_page] + end + + @doc false + # Returns the list of roles that are loaded by default into new sites. + @spec default_roles() :: [map()] + def default_roles do + [ + %{name: "Administrator", capabilities: Enum.map(list_capabilities(), &to_string/1)}, + %{name: "Page Editor", capabilities: Enum.map(default_role_capabilities(), &to_string/1)} + ] + end end diff --git a/lib/beacon/boot.ex b/lib/beacon/boot.ex index 15973cb81..6b7085902 100644 --- a/lib/beacon/boot.ex +++ b/lib/beacon/boot.ex @@ -31,6 +31,7 @@ defmodule Beacon.Boot do Beacon.Loader.populate_default_layouts(site) Beacon.Loader.populate_default_error_pages(site) Beacon.Loader.populate_default_home_page(site) + Beacon.Loader.populate_default_roles(site) %{mode: :live} = Beacon.Config.update_value(site, :mode, :live) diff --git a/lib/beacon/loader.ex b/lib/beacon/loader.ex index 63dd850ca..f991db0e5 100644 --- a/lib/beacon/loader.ex +++ b/lib/beacon/loader.ex @@ -127,6 +127,10 @@ defmodule Beacon.Loader do GenServer.call(worker(site), :populate_default_home_page, @timeout) end + def populate_default_roles(site) do + GenServer.call(worker(site), :populate_default_roles, @timeout) + end + def load_runtime_js(site) do call_worker(site, :load_runtime_js, {:load_runtime_js, [site]}, timeout: :timer.minutes(2)) end diff --git a/lib/beacon/loader/worker.ex b/lib/beacon/loader/worker.ex index d460f43c7..8aa8b7238 100644 --- a/lib/beacon/loader/worker.ex +++ b/lib/beacon/loader/worker.ex @@ -1,12 +1,14 @@ defmodule Beacon.Loader.Worker do @moduledoc false - use GenServer, restart: :transient - require Logger + + alias Beacon.Auth alias Beacon.Compiler alias Beacon.Content alias Beacon.Loader + require Logger + def start_link(config) do GenServer.start_link(__MODULE__, config, name: name(config.site)) end @@ -367,6 +369,24 @@ defmodule Beacon.Loader.Worker do end end + def handle_call(:populate_default_roles, _from, config) do + %{site: site} = config + + for attrs <- Auth.default_roles() do + case Auth.get_role_by_name(site, attrs.name) do + nil -> + attrs + |> Map.put(:site, site) + |> Auth.create_role!(auth: false) + + %{} -> + :skip + end + end + + stop(:ok, config) + end + # todo: remove def handle_call(request, _from, config) when request in [ From 63ca3eb5185eec0e03bddb204af25ecf0796e525 Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Wed, 26 Feb 2025 13:23:19 -0600 Subject: [PATCH 22/23] remove role from actor --- lib/beacon/auth.ex | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/beacon/auth.ex b/lib/beacon/auth.ex index b999ef699..235cb5740 100644 --- a/lib/beacon/auth.ex +++ b/lib/beacon/auth.ex @@ -253,6 +253,25 @@ defmodule Beacon.Auth do end end + @doc """ + Removes any Role previously granted to an actor on a given Beacon site. + + Returns `:ok` regardless of the result. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. + """ + @spec remove_role_from_actor(Site.t(), String.t(), keyword()) :: :ok | {:error, :not_authorized} + def remove_role_from_actor(site, actor_id, opts \\ []) do + with :ok <- authorize(site, :remove_role_from_actor, opts) do + if actor_role = repo(site).one(from ar in ActorRole, where: ar.actor_id == ^actor_id) do + repo(site).delete!(actor_role) + end + + :ok + end + end + @doc """ Creates a changeset with the given role and optional map of changes. """ From b642f20ff35afbcc441af40b90a287d31f6abfdb Mon Sep 17 00:00:00 2001 From: APB9785 <74077809+APB9785@users.noreply.github.com> Date: Wed, 26 Feb 2025 16:47:34 -0600 Subject: [PATCH 23/23] use strings for capabilities lists --- lib/beacon/auth.ex | 97 +++++++++++++++++++++++----------------------- 1 file changed, 49 insertions(+), 48 deletions(-) diff --git a/lib/beacon/auth.ex b/lib/beacon/auth.ex index 235cb5740..272563a19 100644 --- a/lib/beacon/auth.ex +++ b/lib/beacon/auth.ex @@ -362,61 +362,62 @@ defmodule Beacon.Auth do @doc """ Lists all possible capabilities a Beacon role can have. """ - @spec list_capabilities() :: [:atom] + @spec list_capabilities() :: [String.t()] def list_capabilities do - [ - :create_layout, - :update_layout, - :publish_layout, - :create_page, - :update_page, - :publish_page, - :unpublish_page, - :create_stylesheet, - :update_stylesheet, - :create_component, - :update_component, - :create_slot_for_component, - :update_slot_for_component, - :delete_slot_from_component, - :create_slot_attr, - :update_slot_attr, - :delete_slot_attr, - :create_snippet_helper, - :create_error_page, - :update_error_page, - :delete_error_page, - :create_event_handler, - :update_event_handler, - :delete_event_handler, - :create_variant_for_page, - :update_variant_for_page, - :delete_variant_from_page, - :create_live_data, - :create_assign_for_live_data, - :update_live_data_path, - :update_live_data_assign, - :delete_live_data, - :delete_live_data_assign, - :create_info_handler, - :update_info_handler, - :delete_info_handler, - :create_role, - :update_role, - :delete_role, - :set_role_for_actor, - :create_js_hook, - :update_js_hook, - :delete_js_hook - ] + ~w( + create_layout + update_layout + publish_layout + create_page + update_page + publish_page + unpublish_page + create_stylesheet + update_stylesheet + create_component + update_component + create_slot_for_component + update_slot_for_component + delete_slot_from_component + create_slot_attr + update_slot_attr + delete_slot_attr + create_snippet_helper + create_error_page + update_error_page + delete_error_page + create_event_handler + update_event_handler + delete_event_handler + create_variant_for_page + update_variant_for_page + delete_variant_from_page + create_live_data + create_assign_for_live_data + update_live_data_path + update_live_data_assign + delete_live_data + delete_live_data_assign + create_info_handler + update_info_handler + delete_info_handler + create_role + update_role + delete_role + set_role_for_actor + remove_role_from_actor + create_js_hook + update_js_hook + delete_js_hook + ) end @doc """ The default capabilities for a new role that is created. """ - @spec default_role_capabilities() :: [atom()] + @spec default_role_capabilities() :: [String.t()] def default_role_capabilities do - [:create_page, :update_page, :publish_page, :unpublish_page] + ~w(create_page update_page publish_page unpublish_page) end @doc false