diff --git a/lib/beacon/auth.ex b/lib/beacon/auth.ex new file mode 100644 index 000000000..272563a19 --- /dev/null +++ b/lib/beacon/auth.ex @@ -0,0 +1,432 @@ +defmodule Beacon.Auth do + @moduledoc """ + Role-based access control. + + 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 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. + + ## 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. + + ``` + defmodule MyApp.Auth do + @behaviour Beacon.Auth + + def actor_from_session(session) do + user = MyApp.Accounts.get_user_by_session_token(session["user_token"]) + + {user.id, user.email} + end + + 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 + ``` + + 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. With that token, it fetches the `%User{}` struct from the database + and returns the ID with the user's email as the label. + + `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: + + ``` + 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", "First Lastname"}) + {: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", "John Smith"}) + {: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.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_tuple() | nil + + @doc """ + 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 list_actors() :: [actor_tuple()] + + @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()] + + @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) 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 + :ok + else + _ -> {:error, :not_authorized} + 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()) :: actor_tuple() + def 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 owners. + """ + @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 + repo(site).one( + from ar in ActorRole, + join: r in Role, + on: ar.role_id == r.id, + where: r.site == ^site, + where: ar.actor_id == ^actor_id, + select: r + ) + end + + @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(map()) :: ActorRole.t() + def new_actor_role(attrs \\ %{}) do + struct(ActorRole, attrs) + 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. + + 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 """ + 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 """ + 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. + """ + @spec change_role(Role.t(), map()) :: Changeset.t() + def change_role(role, attrs \\ %{}) do + Role.changeset(role, attrs) + end + + @doc """ + Lookup a role by its name. + """ + @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 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 """ + 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() | :not_authorized} + 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 """ + 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() | :not_authorized} + 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. + + 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() | :not_authorized} + 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. + """ + @spec list_capabilities() :: [String.t()] + def list_capabilities do + ~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() :: [String.t()] + def default_role_capabilities do + ~w(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/auth/actor_role.ex b/lib/beacon/auth/actor_role.ex new file mode 100644 index 000000000..7e9331214 --- /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: String.t(), + role_id: Ecto.UUID.t(), + role: Beacon.Auth.Role.t(), + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + schema "beacon_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 new file mode 100644 index 000000000..9575f92eb --- /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. + """ + @behaviour Beacon.Auth + + def actor_from_session(_session), do: {"__beacon_default_owner__", "Default Owner"} + + def list_actors, do: [] + + def owners, do: [{"__beacon_default_owner__", "Default Owner"}] +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/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/config.ex b/lib/beacon/config.ex index 7435b8780..30148bdd1 100644 --- a/lib/beacon/config.ex +++ b/lib/beacon/config.ex @@ -200,6 +200,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(), @@ -218,7 +223,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 [ @@ -241,8 +247,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, @@ -266,7 +270,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()} @@ -287,6 +292,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. @@ -317,10 +323,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. @@ -336,6 +342,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( @@ -365,7 +373,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, @@ -415,7 +424,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 } """ @@ -459,6 +469,7 @@ defmodule Beacon.Config do extra_asset_fields = get_opt(opts, :extra_asset_fields, [{"image/*", [Beacon.MediaLibrary.AssetFields.AltText]}]) page_warming = get_opt(opts, :page_warming, {:shortest_paths, 10}) + auth_module = get_opt(opts, :auth_module, Beacon.Auth.Default) struct!( __MODULE__, @@ -471,7 +482,8 @@ defmodule Beacon.Config do assets: assets, default_meta_tags: default_meta_tags, extra_asset_fields: extra_asset_fields, - page_warming: page_warming + page_warming: page_warming, + auth_module: auth_module ) ) end diff --git a/lib/beacon/content.ex b/lib/beacon/content.ex index be5a193da..b42d2c2c8 100644 --- a/lib/beacon/content.ex +++ b/lib/beacon/content.ex @@ -22,12 +22,37 @@ 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", "First Lastname"}) + {:ok, %Page{}} + ``` + + 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.Content.create_page(%{"title" => "My New Page", ...},, actor: {"user-with-read-only-access", "John Smith"}) + {: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 use GenServer import Ecto.Query + import Beacon.Auth, only: [authorize: 3] import Beacon.Utils, only: [repo: 1, transact: 2] alias Beacon.Content.Component @@ -128,35 +153,37 @@ defmodule Beacon.Content do @doc """ Creates a layout. - ## Example - - iex> create_layout(%{title: "Home"}) - {:ok, %Layout{}} - + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @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 @@ -164,19 +191,17 @@ defmodule Beacon.Content do @doc """ Updates a layout. - ## Example - - iex> update_layout(layout, %{title: "New Home"}) - {:ok, %Layout{}} - + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @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 @@ -188,33 +213,41 @@ defmodule Beacon.Content do Event + snapshot This operation is serialized. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @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(layout.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. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @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_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() + |> publish_layout(opts) end defp validate_layout_template(changeset) do @@ -529,11 +562,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() @@ -550,10 +578,13 @@ defmodule Beacon.Content do It will insert a `created` event into the page timeline, and no snapshot is created. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @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} @@ -563,14 +594,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 @@ -579,15 +612,17 @@ defmodule Beacon.Content do end @doc """ - Creates a page. + Creates a page, raising an error if unsuccessful. - Raises an error if unsuccessful. + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @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 @@ -595,15 +630,12 @@ defmodule Beacon.Content do @doc """ Updates a page. - ## Example - - iex> update_page(page, %{title: "New Home"}) - {:ok, %Page{}} - + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @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 = @@ -615,13 +647,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 """ @@ -632,52 +666,65 @@ defmodule Beacon.Content do can keep editing the page as needed without impacting the published page. This operation is serialized. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @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. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @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_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() + |> 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. + + 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()]} - def publish_pages(pages) when is_list(pages) do + @spec publish_pages([Page.t()], keyword()) :: {:ok, [Page.t()]} + 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 = @@ -712,21 +759,26 @@ defmodule Beacon.Content do The page will be removed from your site and it will return error 404 for new requests. This operation is serialized. + + This function requires authorization. See ["Authorization Options"](#module-authorization-options) + in the module documentation. """ @doc type: :pages - @spec unpublish_page(Page.t()) :: {:ok, Page.t()} | {:error, Changeset.t()} - def unpublish_page(%Page{} = page) do - case Beacon.Config.fetch!(page.site).mode do - :live -> - GenServer.call(name(page.site), {:unpublish_page, page}) - - :testing -> - page - |> insert_unpublished_page() - |> tap(fn {:ok, page} -> clear_cache(page.site, page.id) end) - - :manual -> - insert_unpublished_page(page) + @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 -> + GenServer.call(name(page.site), {:unpublish_page, page}) + + :testing -> + page + |> insert_unpublished_page() + |> tap(fn {:ok, page} -> clear_cache(page.site, page.id) end) + + :manual -> + insert_unpublished_page(page) + end end end @@ -1111,15 +1163,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 """ @@ -1144,33 +1199,39 @@ 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. - """ @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 """ @@ -1181,25 +1242,31 @@ 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. + + 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 @@ -1207,19 +1274,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: {"some-user-id", "Some User"}) {: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 """ @@ -1278,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 @@ -1365,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 @@ -3091,36 +3182,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", "Some User"}) {: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} -> @@ -3136,18 +3238,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", "Some User"}) {: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(component.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 @@ -3321,17 +3430,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 @@ -3339,13 +3448,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 @@ -3353,11 +3466,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 @@ -3407,42 +3525,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", "Some User"}) {: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", "Some User"}) {: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 @@ -3451,10 +3587,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]) @@ -3463,10 +3602,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 @@ -3482,13 +3623,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 @@ -3698,29 +3843,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 @@ -3741,24 +3895,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 @@ -3797,11 +3961,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) @@ -3809,37 +3976,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(event_handler.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 @@ -3852,13 +4027,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 @@ -3880,44 +4060,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) + @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) + 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) + @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) + 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 @@ -3950,11 +4140,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} @@ -4004,53 +4198,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 @@ -4123,38 +4331,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", "Some User"}) {: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", "Some User"}) {: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 @@ -4182,41 +4401,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", "Some User"}) {: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() @@ -4236,16 +4471,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", "Some User"}) %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 @@ -4310,33 +4551,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", "Some User"}) {: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(info_handler.site, :delete_info_handler, opts) do + info_handler + |> repo(info_handler).delete() + |> tap(&maybe_broadcast_updated_content_event(&1, :info_handler)) + end end ## Utils 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 3a2961a97..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 @@ -64,7 +66,7 @@ defmodule Beacon.Loader.Worker do [] -> attrs |> Map.put(:site, site) - |> Content.create_component!() + |> Content.create_component!(auth: false) _ -> :skip @@ -81,8 +83,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 +104,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 +128,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 +352,8 @@ defmodule Beacon.Loader.Worker do """ } - |> Content.create_page!() - |> Content.publish_page() + |> Content.create_page!(auth: false) + |> Content.publish_page(auth: false) _ -> :skip @@ -364,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 [ diff --git a/lib/beacon/migration.ex b/lib/beacon/migration.ex index d9722ce07..40dfd501a 100644 --- a/lib/beacon/migration.ex +++ b/lib/beacon/migration.ex @@ -52,7 +52,7 @@ defmodule Beacon.Migration do """ @initial_version 1 - @current_version 3 + @current_version 4 @doc """ Upgrades Beacon database schemas. diff --git a/lib/beacon/migrations/v004.ex b/lib/beacon/migrations/v004.ex new file mode 100644 index 000000000..95a2ce566 --- /dev/null +++ b/lib/beacon/migrations/v004.ex @@ -0,0 +1,30 @@ +defmodule Beacon.Migrations.V004 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 + + create_if_not_exists table(:beacon_actors_roles, primary_key: false) do + add :id, :binary_id, primary_key: true + add :actor_id, :string, null: false + add :role_id, references(:beacon_roles, type: :binary_id), null: false + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:beacon_actors_roles, [:actor_id]) + end + + def down do + drop_if_exists table(:beacon_actors_roles) + drop_if_exists table(:beacon_roles) + end +end diff --git a/lib/beacon/test/fixtures.ex b/lib/beacon/test/fixtures.ex index 71aa8cc33..20373e185 100644 --- a/lib/beacon/test/fixtures.ex +++ b/lib/beacon/test/fixtures.ex @@ -170,7 +170,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 @@ -195,7 +195,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 @@ -223,7 +223,7 @@ defmodule Beacon.Test.Fixtures do <%= @inner_content %> """ }) - |> Content.create_layout!() + |> Content.create_layout!(auth: false) end @doc """ @@ -234,7 +234,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) @@ -271,7 +271,7 @@ defmodule Beacon.Test.Fixtures do """, format: :heex }) - |> Content.create_page!() + |> Content.create_page!(auth: false) end @doc """ @@ -287,7 +287,7 @@ defmodule Beacon.Test.Fixtures do {:ok, page} = attrs |> beacon_page_fixture() - |> Content.publish_page() + |> Content.publish_page(auth: false) page end @@ -326,7 +326,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 @@ -435,7 +435,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 @@ -459,7 +459,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 @@ -479,7 +479,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 @@ -547,7 +547,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 @@ -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: "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" => "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: "