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: "`" end @@ -94,7 +101,7 @@ defmodule Beacon.ContentTest do layout = beacon_layout_fixture() assert {:error, %Ecto.Changeset{errors: [template: {"invalid", [compilation_error: compilation_error]}]}} = - Content.update_layout(layout, %{template: "`" end @@ -126,14 +133,14 @@ defmodule Beacon.ContentTest do test "broadcasts published event" do %{site: site, id: id} = page = beacon_page_fixture(site: "booted") :ok = Beacon.PubSub.subscribe_to_pages(site) - Content.publish_page(page) + Content.publish_page(page, auth: false) assert_receive {:page_published, %{site: ^site, id: ^id}} end test "broadcasts unpublished event" do %{site: site, id: id, path: path} = page = beacon_published_page_fixture(site: "booted") :ok = Beacon.PubSub.subscribe_to_pages(site) - assert {:ok, _} = Content.unpublish_page(page) + assert {:ok, _} = Content.unpublish_page(page, auth: false) assert_receive {:page_unpublished, %{site: ^site, id: ^id, path: ^path}} end @@ -149,7 +156,7 @@ defmodule Beacon.ContentTest do layout = beacon_layout_fixture() assert {:error, %Ecto.Changeset{errors: [template: {"invalid", [compilation_error: compilation_error]}]}} = - Content.create_page(%{site: :my_site, path: "/", title: "home", layout_id: layout.id, template: "`" end @@ -158,32 +165,38 @@ defmodule Beacon.ContentTest do page = beacon_page_fixture() assert {:error, %Ecto.Changeset{errors: [template: {"invalid", [compilation_error: compilation_error]}]}} = - Content.update_page(page, %{template: "`" end test "create page should create a created event" do - Content.create_page!(%{ - site: "my_site", - path: "/", - title: "home", - template: "

page

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

page

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

page

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

page

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

page

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

page

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

page

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

page updated

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

page

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

page updated

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

page

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

page

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

page

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

page

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

page

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

page

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

page

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

page

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

page

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

page

", + layout_id: layout.id, + raw_schema: [nil] + }, + auth: false + ) end end @@ -469,7 +504,7 @@ defmodule Beacon.ContentTest do test "update broadcasts updated content event" do %{site: site} = stylesheet = beacon_stylesheet_fixture(site: "booted") :ok = Beacon.PubSub.subscribe_to_content(site) - Content.update_stylesheet(stylesheet, %{body: "/* test */"}) + Content.update_stylesheet(stylesheet, %{body: "/* test */"}, auth: false) assert_receive {:content_updated, :stylesheet, %{site: ^site}} end end @@ -488,7 +523,7 @@ defmodule Beacon.ContentTest do """ } - assert {:ok, js_hook} = Content.create_js_hook(attrs) + assert {:ok, js_hook} = Content.create_js_hook(attrs, auth: false) assert %JSHook{name: "FooHook"} = js_hook end @@ -510,14 +545,14 @@ defmodule Beacon.ContentTest do js_hook = beacon_js_hook_fixture() attrs = %{name: "Changed", code: "export const Changed = { mounted() {} }"} - assert {:ok, updated_js_hook} = Content.update_js_hook(js_hook, attrs) + assert {:ok, updated_js_hook} = Content.update_js_hook(js_hook, attrs, auth: false) assert %JSHook{name: "Changed", code: "export const Changed = { mounted() {} }"} = updated_js_hook end test "delete_js_hook/1" do %{id: id} = js_hook = beacon_js_hook_fixture() - assert {:ok, %{id: ^id}} = Content.delete_js_hook(js_hook) + assert {:ok, %{id: ^id}} = Content.delete_js_hook(js_hook, auth: false) end end @@ -525,13 +560,13 @@ defmodule Beacon.ContentTest do test "create_snippet_helper/1" do attrs = %{site: :my_site, name: "foo_snippet", body: "page title is {{ page.title }}"} - assert {:ok, _snippet_helper} = Content.create_snippet_helper(attrs) + assert {:ok, _snippet_helper} = Content.create_snippet_helper(attrs, auth: false) end test "create_snippet_helper should validate invalid body" do attrs = %{site: :my_site, name: "foo_snippet", body: "page title is {{ page.title"} - assert {:error, %Ecto.Changeset{errors: [body: {err, []}], valid?: false}} = Content.create_snippet_helper(attrs) + assert {:error, %Ecto.Changeset{errors: [body: {err, []}], valid?: false}} = Content.create_snippet_helper(attrs, auth: false) assert err =~ "Reason: expected end of string, line: 1" end @@ -588,7 +623,16 @@ defmodule Beacon.ContentTest do page = beacon_page_fixture(%{format: :heex}) attrs = %{name: "Foo", weight: 3, template: "
Bar
"} - assert {:ok, %Page{variants: [variant]}} = Content.create_variant_for_page(page, attrs) + assert {:ok, %Page{variants: [variant]}} = Content.create_variant_for_page(page, attrs, auth: false) + assert %PageVariant{name: "Foo", weight: 3, template: "
Bar
"} = variant + end + + test "create variant auth" do + page = beacon_page_fixture(%{format: :heex}) + attrs = %{name: "Foo", weight: 3, template: "
Bar
"} + + assert {:error, :not_authorized} = Content.create_variant_for_page(page, attrs, actor: fake_actor()) + assert {:ok, %Page{variants: [variant]}} = Content.create_variant_for_page(page, attrs, actor: admin_actor()) assert %PageVariant{name: "Foo", weight: 3, template: "
Bar
"} = variant end @@ -596,7 +640,7 @@ defmodule Beacon.ContentTest do page = beacon_page_fixture(site: :lifecycle_test) attrs = %{name: "Foo", weight: 3, template: "
Bar
"} - {:ok, %Page{}} = Content.create_variant_for_page(page, attrs) + {:ok, %Page{}} = Content.create_variant_for_page(page, attrs, auth: false) assert_receive :lifecycle_after_update_page end @@ -606,7 +650,7 @@ defmodule Beacon.ContentTest do attrs = %{name: "Changed Name", weight: 99, template: "
invalid"} assert {:error, %Ecto.Changeset{errors: [template: {"invalid", [compilation_error: error]}], valid?: false}} = - Content.create_variant_for_page(page, attrs) + Content.create_variant_for_page(page, attrs, auth: false) assert error =~ "unmatched closing tag" end @@ -616,7 +660,7 @@ defmodule Beacon.ContentTest do variant = beacon_page_variant_fixture(%{page: page}) attrs = %{name: "Changed Name", weight: 99, template: "
changed
"} - assert {:ok, %Page{variants: [updated_variant]}} = Content.update_variant_for_page(page, variant, attrs) + assert {:ok, %Page{variants: [updated_variant]}} = Content.update_variant_for_page(page, variant, attrs, auth: false) assert %PageVariant{name: "Changed Name", weight: 99, template: "
changed
"} = updated_variant end @@ -624,7 +668,7 @@ defmodule Beacon.ContentTest do page = beacon_page_fixture(site: :lifecycle_test) variant = beacon_page_variant_fixture(%{page: page}) - {:ok, %Page{}} = Content.update_variant_for_page(page, variant, %{name: "Changed"}) + {:ok, %Page{}} = Content.update_variant_for_page(page, variant, %{name: "Changed"}, auth: false) assert_receive :lifecycle_after_update_page end @@ -635,7 +679,7 @@ defmodule Beacon.ContentTest do attrs = %{name: "Changed Name", weight: 99, template: "
invalid"} assert {:error, %Ecto.Changeset{errors: [template: {"invalid", [compilation_error: error]}], valid?: false}} = - Content.update_variant_for_page(page, variant, attrs) + Content.update_variant_for_page(page, variant, attrs, auth: false) assert error =~ "unmatched closing tag" end @@ -646,9 +690,9 @@ defmodule Beacon.ContentTest do variant_2 = beacon_page_variant_fixture(%{page: page, weight: 0}) assert {:error, %Ecto.Changeset{errors: [weight: {"total weights cannot exceed 100", []}], valid?: false}} = - Content.update_variant_for_page(page, variant_2, %{weight: 2}) + Content.update_variant_for_page(page, variant_2, %{weight: 2}, auth: false) - assert {:ok, %Page{}} = Content.update_variant_for_page(page, variant_2, %{weight: 1}) + assert {:ok, %Page{}} = Content.update_variant_for_page(page, variant_2, %{weight: 1}, auth: false) end test "update variant should not validate total weight if unchanged" do @@ -656,7 +700,7 @@ defmodule Beacon.ContentTest do variant_1 = beacon_page_variant_fixture(%{page: page, weight: 99}) _variant_2 = beacon_page_variant_fixture(%{page: page, weight: 98}) - assert {:ok, %Page{}} = Content.update_variant_for_page(page, variant_1, %{name: "Foo"}) + assert {:ok, %Page{}} = Content.update_variant_for_page(page, variant_1, %{name: "Foo"}, auth: false) end test "delete variant OK" do @@ -664,18 +708,26 @@ defmodule Beacon.ContentTest do variant_1 = beacon_page_variant_fixture(%{page: page}) variant_2 = beacon_page_variant_fixture(%{page: page}) - assert {:ok, %Page{variants: [^variant_2]}} = Content.delete_variant_from_page(page, variant_1) - assert {:ok, %Page{variants: []}} = Content.delete_variant_from_page(page, variant_2) + assert {:ok, %Page{variants: [^variant_2]}} = Content.delete_variant_from_page(page, variant_1, auth: false) + assert {:ok, %Page{variants: []}} = Content.delete_variant_from_page(page, variant_2, auth: false) end test "delete triggers after_update_page lifecycle" do page = beacon_page_fixture(site: :lifecycle_test) variant = beacon_page_variant_fixture(%{page: page}) - {:ok, %Page{}} = Content.delete_variant_from_page(page, variant) + {:ok, %Page{}} = Content.delete_variant_from_page(page, variant, auth: false) assert_receive :lifecycle_after_update_page end + + test "delete variant auth" do + page = beacon_page_fixture(%{format: :heex}) + variant = beacon_page_variant_fixture(%{page: page}) + + assert {:error, :not_authorized} = Content.delete_variant_from_page(page, variant, actor: fake_actor()) + assert {:ok, %Page{variants: []}} = Content.delete_variant_from_page(page, variant, actor: admin_actor()) + end end describe "event_handlers" do @@ -690,18 +742,26 @@ defmodule Beacon.ContentTest do test "create event handler OK" do attrs = %{name: "Foo", code: "{:noreply, socket}", site: :my_site} - assert {:ok, event_handler} = Content.create_event_handler(attrs) + assert {:ok, event_handler} = Content.create_event_handler(attrs, auth: false) + assert %EventHandler{name: "Foo", code: "{:noreply, socket}"} = event_handler + end + + test "create event handler auth" do + attrs = %{name: "Foo", code: "{:noreply, socket}", site: :my_site} + + assert {:error, :not_authorized} = Content.create_event_handler(attrs, actor: fake_actor()) + assert {:ok, event_handler} = Content.create_event_handler(attrs, actor: admin_actor()) assert %EventHandler{name: "Foo", code: "{:noreply, socket}"} = event_handler end test "create validates elixir code" do attrs = %{name: "test", code: "[1)", site: :my_site} - assert {:error, %{errors: [error]}} = Content.create_event_handler(attrs) + assert {:error, %{errors: [error]}} = Content.create_event_handler(attrs, auth: false) {:code, {_, [compilation_error: compilation_error]}} = error assert compilation_error =~ "unexpected token: )" attrs = %{name: "test", code: "if true, do false", site: :my_site} - assert {:error, %{errors: [error]}} = Content.create_event_handler(attrs) + assert {:error, %{errors: [error]}} = Content.create_event_handler(attrs, auth: false) {:code, {_, [compilation_error: compilation_error]}} = error assert compilation_error =~ "unexpected reserved word: do" @@ -712,14 +772,14 @@ defmodule Beacon.ContentTest do | attrs = %{name: "test", code: code, site: :my_site} - assert {:ok, _} = Content.create_event_handler(attrs) + assert {:ok, _} = Content.create_event_handler(attrs, auth: false) end test "update event handler OK" do event_handler = beacon_event_handler_fixture() attrs = %{name: "Changed Name", code: "{:noreply, assign(socket, foo: :bar)}"} - assert {:ok, updated_event_handler} = Content.update_event_handler(event_handler, attrs) + assert {:ok, updated_event_handler} = Content.update_event_handler(event_handler, attrs, auth: false) assert %EventHandler{name: "Changed Name", code: "{:noreply, assign(socket, foo: :bar)}"} = updated_event_handler end @@ -727,12 +787,12 @@ defmodule Beacon.ContentTest do event_handler = beacon_event_handler_fixture() attrs = %{code: "[1)"} - assert {:error, %{errors: [error]}} = Content.update_event_handler(event_handler, attrs) + assert {:error, %{errors: [error]}} = Content.update_event_handler(event_handler, attrs, auth: false) {:code, {_, [compilation_error: compilation_error]}} = error assert compilation_error =~ "unexpected token: )" attrs = %{code: "if true, do false"} - assert {:error, %{errors: [error]}} = Content.update_event_handler(event_handler, attrs) + assert {:error, %{errors: [error]}} = Content.update_event_handler(event_handler, attrs, auth: false) {:code, {_, [compilation_error: compilation_error]}} = error assert compilation_error =~ "unexpected reserved word: do" @@ -743,13 +803,13 @@ defmodule Beacon.ContentTest do | attrs = %{code: code} - assert {:ok, _} = Content.update_event_handler(event_handler, attrs) + assert {:ok, _} = Content.update_event_handler(event_handler, attrs, auth: false) end test "delete event handler OK" do %{id: id} = event_handler = beacon_event_handler_fixture() - assert {:ok, %{id: ^id}} = Content.delete_event_handler(event_handler) + assert {:ok, %{id: ^id}} = Content.delete_event_handler(event_handler, auth: false) end end @@ -763,7 +823,7 @@ defmodule Beacon.ContentTest do test "update broadcasts updated content event" do %{site: site} = error_page = beacon_error_page_fixture(site: "booted") :ok = Beacon.PubSub.subscribe_to_content(site) - Content.update_error_page(error_page, %{template: "test"}) + Content.update_error_page(error_page, %{template: "test"}, auth: false) assert_receive {:content_updated, :error_page, %{site: ^site}} end @@ -778,7 +838,7 @@ defmodule Beacon.ContentTest do %{id: layout_id} = beacon_layout_fixture() attrs = %{site: :my_site, status: 400, template: "Oops!", layout_id: layout_id} - assert {:ok, %ErrorPage{} = error_page} = Content.create_error_page(attrs) + assert {:ok, %ErrorPage{} = error_page} = Content.create_error_page(attrs, auth: false) assert %{site: :my_site, status: 400, template: "Oops!", layout_id: ^layout_id} = error_page end @@ -787,7 +847,7 @@ defmodule Beacon.ContentTest do attrs = %{site: :my_site, status: 400, template: "
invalid", layout_id: layout_id} assert {:error, %Ecto.Changeset{errors: [template: {"invalid", [compilation_error: error]}], valid?: false}} = - Content.create_error_page(attrs) + Content.create_error_page(attrs, auth: false) assert error =~ "unmatched closing tag" end @@ -796,13 +856,13 @@ defmodule Beacon.ContentTest do error_page = beacon_error_page_fixture() bad_attrs = %{site: error_page.site, status: error_page.status, template: "Error", layout_id: beacon_layout_fixture().id} - assert {:error, %Changeset{errors: errors}} = Content.create_error_page(bad_attrs) + assert {:error, %Changeset{errors: errors}} = Content.create_error_page(bad_attrs, auth: false) assert [{:status, {"has already been taken", [constraint: :unique, constraint_name: "beacon_error_pages_status_site_index"]}}] = errors end test "update_error_page/2" do error_page = beacon_error_page_fixture() - assert {:ok, %ErrorPage{template: "Changed"}} = Content.update_error_page(error_page, %{template: "Changed"}) + assert {:ok, %ErrorPage{template: "Changed"}} = Content.update_error_page(error_page, %{template: "Changed"}, auth: false) end test "update_error_page should validate invalid templates" do @@ -811,14 +871,14 @@ defmodule Beacon.ContentTest do attrs = %{template: "
invalid"} assert {:error, %Ecto.Changeset{errors: [template: {"invalid", [compilation_error: error]}], valid?: false}} = - Content.update_error_page(error_page, attrs) + Content.update_error_page(error_page, attrs, auth: false) assert error =~ "unmatched closing tag" end test "delete_error_page/1" do error_page = beacon_error_page_fixture() - assert {:ok, %ErrorPage{__meta__: %{state: :deleted}}} = Content.delete_error_page(error_page) + assert {:ok, %ErrorPage{__meta__: %{state: :deleted}}} = Content.delete_error_page(error_page, auth: false) end end @@ -832,13 +892,13 @@ defmodule Beacon.ContentTest do test "update broadcasts updated content event" do %{site: site} = component = beacon_component_fixture(site: "booted") :ok = Beacon.PubSub.subscribe_to_content(site) - Content.update_component(component, %{template: "
test
"}) + Content.update_component(component, %{template: "
test
"}, auth: false) assert_receive {:content_updated, :component, %{site: ^site}} end test "validate template heex on create" do assert {:error, %Ecto.Changeset{errors: [template: {"invalid", [compilation_error: compilation_error]}]}} = - Content.create_component(%{site: :my_site, name: "test", template: "`" end @@ -847,17 +907,17 @@ defmodule Beacon.ContentTest do component = beacon_component_fixture() assert {:error, %Ecto.Changeset{errors: [template: {"invalid", [compilation_error: compilation_error]}]}} = - Content.update_component(component, %{template: "`" end test "validate name format as valid function name" do assert {:error, %Ecto.Changeset{errors: [name: {"can only contain lowercase letters, numbers, and underscores", _}]}} = - Content.create_component(%{site: :my_site, name: "my component", template: "test", example: "test"}) + Content.create_component(%{site: :my_site, name: "my component", template: "test", example: "test"}, auth: false) assert {:error, %Ecto.Changeset{errors: [name: {"can only contain lowercase letters, numbers, and underscores", _}]}} = - Content.create_component(%{site: :my_site, name: "my_component$", template: "test", example: "test"}) + Content.create_component(%{site: :my_site, name: "my_component$", template: "test", example: "test"}, auth: false) end test "validate allowed attrs opts" do @@ -868,15 +928,18 @@ defmodule Beacon.ContentTest do valid?: false } } = - Content.create_component(%{ - site: :my_site, - name: "my_component", - template: "test", - example: "test", - attrs: [ - %{name: "name", type: "string", opts: [required: true, other: nil]} - ] - }) + Content.create_component( + %{ + site: :my_site, + name: "my_component", + template: "test", + example: "test", + attrs: [ + %{name: "name", type: "string", opts: [required: true, other: nil]} + ] + }, + auth: false + ) end test "validate allowed slot opts" do @@ -887,15 +950,42 @@ defmodule Beacon.ContentTest do valid?: false } } = - Content.create_component(%{ - site: :my_site, - name: "my_component", - template: "test", - example: "test", - slots: [ - %{name: "inner_block", opts: [default: nil]} - ] - }) + Content.create_component( + %{ + site: :my_site, + name: "my_component", + template: "test", + example: "test", + slots: [ + %{name: "inner_block", opts: [default: nil]} + ] + }, + auth: false + ) + end + + test "auth for create" do + assert {:error, :not_authorized} = + Content.create_component( + %{ + site: :my_site, + name: "my_component", + template: "test", + example: "test" + }, + actor: {"123", "Fake Actor"} + ) + + assert {:ok, %Component{}} = + Content.create_component( + %{ + site: :my_site, + name: "my_component", + template: "test", + example: "test" + }, + actor: admin_actor() + ) end test "list components" do @@ -932,7 +1022,7 @@ defmodule Beacon.ContentTest do test "update_component" do component = beacon_component_fixture(name: "new_component", template: "old_body") - assert {:ok, %Component{template: "new_body"}} = Content.update_component(component, %{template: "new_body"}) + assert {:ok, %Component{template: "new_body"}} = Content.update_component(component, %{template: "new_body"}, auth: false) end end @@ -946,14 +1036,14 @@ defmodule Beacon.ContentTest do test "create_live_data/1" do attrs = %{site: :my_site, path: "/foo/:bar"} - assert {:ok, %LiveData{} = live_data} = Content.create_live_data(attrs) + assert {:ok, %LiveData{} = live_data} = Content.create_live_data(attrs, auth: false) assert %{site: :my_site, path: "/foo/:bar"} = live_data end test "create_live_data/1 for root path" do attrs = %{site: :my_site, path: "/"} - assert {:ok, %LiveData{} = live_data} = Content.create_live_data(attrs) + assert {:ok, %LiveData{} = live_data} = Content.create_live_data(attrs, auth: false) assert %{site: :my_site, path: "/"} = live_data end @@ -961,7 +1051,7 @@ defmodule Beacon.ContentTest do live_data = beacon_live_data_fixture() attrs = %{key: "product_id", format: :elixir, value: "123"} - assert {:ok, %LiveData{assigns: [assign]}} = Content.create_assign_for_live_data(live_data, attrs) + assert {:ok, %LiveData{assigns: [assign]}} = Content.create_assign_for_live_data(live_data, attrs, auth: false) assert %{key: "product_id", format: :elixir, value: "123"} = assign end @@ -971,7 +1061,7 @@ defmodule Beacon.ContentTest do for invalid_key <- invalid_keys do attrs = %{key: to_string(invalid_key), format: :text, value: "foo"} - assert {:error, %{errors: [error]}} = Content.create_assign_for_live_data(live_data, attrs) + assert {:error, %{errors: [error]}} = Content.create_assign_for_live_data(live_data, attrs, auth: false) assert {:key, {"is reserved", _}} = error end end @@ -980,12 +1070,12 @@ defmodule Beacon.ContentTest do live_data = beacon_live_data_fixture() attrs = %{key: "foo", value: "[1)", format: :elixir} - assert {:error, %{errors: [error]}} = Content.create_assign_for_live_data(live_data, attrs) + assert {:error, %{errors: [error]}} = Content.create_assign_for_live_data(live_data, attrs, auth: false) {:value, {_, [compilation_error: compilation_error]}} = error assert compilation_error =~ "unexpected token: )" attrs = %{key: "foo", value: "if true, do false", format: :elixir} - assert {:error, %{errors: [error]}} = Content.create_assign_for_live_data(live_data, attrs) + assert {:error, %{errors: [error]}} = Content.create_assign_for_live_data(live_data, attrs, auth: false) {:value, {_, [compilation_error: compilation_error]}} = error assert compilation_error =~ "unexpected reserved word: do" @@ -995,7 +1085,7 @@ defmodule Beacon.ContentTest do | attrs = %{key: "foo", value: code, format: :elixir} - assert {:ok, _} = Content.create_assign_for_live_data(live_data, attrs) + assert {:ok, _} = Content.create_assign_for_live_data(live_data, attrs, auth: false) end test "get_live_data/2" do @@ -1045,7 +1135,7 @@ defmodule Beacon.ContentTest do test "update_live_data_path/2" do live_data = beacon_live_data_fixture(site: :my_site, path: "/foo") - assert {:ok, result} = Content.update_live_data_path(live_data, "/foo/:bar_id") + assert {:ok, result} = Content.update_live_data_path(live_data, "/foo/:bar_id", auth: false) assert result.id == live_data.id assert result.path == "/foo/:bar_id" end @@ -1055,7 +1145,7 @@ defmodule Beacon.ContentTest do live_data_assign = beacon_live_data_assign_fixture(live_data: live_data) attrs = %{key: "wins", value: "1337", format: :elixir} - assert {:ok, updated_assign} = Content.update_live_data_assign(live_data_assign, live_data.site, attrs) + assert {:ok, updated_assign} = Content.update_live_data_assign(live_data_assign, live_data.site, attrs, auth: false) assert updated_assign.id == live_data_assign.id assert updated_assign.key == "wins" @@ -1068,12 +1158,12 @@ defmodule Beacon.ContentTest do live_data_assign = beacon_live_data_assign_fixture(live_data: live_data) attrs = %{value: "[1)", format: :elixir} - assert {:error, %{errors: [error]}} = Content.update_live_data_assign(live_data_assign, live_data.site, attrs) + assert {:error, %{errors: [error]}} = Content.update_live_data_assign(live_data_assign, live_data.site, attrs, auth: false) {:value, {_, [compilation_error: compilation_error]}} = error assert compilation_error =~ "unexpected token: )" attrs = %{value: "if true, do false", format: :elixir} - assert {:error, %{errors: [error]}} = Content.update_live_data_assign(live_data_assign, live_data.site, attrs) + assert {:error, %{errors: [error]}} = Content.update_live_data_assign(live_data_assign, live_data.site, attrs, auth: false) {:value, {_, [compilation_error: compilation_error]}} = error assert compilation_error =~ "unexpected reserved word: do" @@ -1083,14 +1173,14 @@ defmodule Beacon.ContentTest do | attrs = %{value: code, format: :elixir} - assert {:ok, _} = Content.update_live_data_assign(live_data_assign, live_data.site, attrs) + assert {:ok, _} = Content.update_live_data_assign(live_data_assign, live_data.site, attrs, auth: false) end test "delete_live_data/1" do live_data = beacon_live_data_fixture() assert [%{}] = Content.live_data_for_site(live_data.site) - assert {:ok, _} = Content.delete_live_data(live_data) + assert {:ok, _} = Content.delete_live_data(live_data, auth: false) assert [] = Content.live_data_for_site(live_data.site) end @@ -1099,7 +1189,7 @@ defmodule Beacon.ContentTest do live_data_assign = beacon_live_data_assign_fixture(live_data: live_data) Repo.preload(live_data, :assigns) - assert {:ok, _} = Content.delete_live_data_assign(live_data_assign, live_data.site) + assert {:ok, _} = Content.delete_live_data_assign(live_data_assign, live_data.site, auth: false) assert %{assigns: []} = Repo.preload(live_data, :assigns) end end @@ -1126,17 +1216,20 @@ defmodule Beacon.ContentTest do test "success: create_info_handler/1", %{msg: msg, code: code} do attrs = %{site: :my_site, msg: msg, code: code} - assert {:ok, %InfoHandler{} = info_handler} = Content.create_info_handler(attrs) + assert {:ok, %InfoHandler{} = info_handler} = Content.create_info_handler(attrs, auth: false) assert %InfoHandler{site: :my_site, msg: ^msg, code: ^code} = info_handler end test "error: create_info_handler/1 validates code", %{msg: msg} do assert {:error, %{errors: [error]}} = - Content.create_info_handler(%{ - site: :my_site, - msg: msg, - code: ":no_reply, socket" - }) + Content.create_info_handler( + %{ + site: :my_site, + msg: msg, + code: ":no_reply, socket" + }, + auth: false + ) {:code, {"invalid", [compilation_error: compilation_error]}} = error @@ -1146,11 +1239,14 @@ defmodule Beacon.ContentTest do end test "success: create_info_handler!/1", %{msg: msg, code: code} do - Content.create_info_handler!(%{ - site: :my_site, - msg: msg, - code: code - }) + Content.create_info_handler!( + %{ + site: :my_site, + msg: msg, + code: code + }, + auth: false + ) assert %InfoHandler{site: :my_site, msg: ^msg, code: ^code} = Repo.one(InfoHandler) end @@ -1200,7 +1296,7 @@ defmodule Beacon.ContentTest do refute info_handler.code == code refute info_handler.msg == msg - assert {:ok, %InfoHandler{} = info_handler_from_db} = Content.update_info_handler(info_handler, attrs) + assert {:ok, %InfoHandler{} = info_handler_from_db} = Content.update_info_handler(info_handler, attrs, auth: false) assert %InfoHandler{code: ^code, msg: ^msg} = info_handler_from_db end @@ -1225,7 +1321,7 @@ defmodule Beacon.ContentTest do refute info_handler.code == code refute info_handler.msg == msg - {:error, %{errors: [error]}} = Content.update_info_handler(info_handler, attrs) + {:error, %{errors: [error]}} = Content.update_info_handler(info_handler, attrs, auth: false) {:code, {"invalid", [compilation_error: compilation_error]}} = error @@ -1238,7 +1334,7 @@ defmodule Beacon.ContentTest do assert Repo.one(InfoHandler) - assert {:ok, %InfoHandler{site: ^site, msg: ^msg, code: ^code}} = Content.delete_info_handler(info_handler) + assert {:ok, %InfoHandler{site: ^site, msg: ^msg, code: ^code}} = Content.delete_info_handler(info_handler, auth: false) refute Repo.one(InfoHandler) end end diff --git a/test/beacon/loader/page_test.exs b/test/beacon/loader/page_test.exs index 94d30e820..70d747322 100644 --- a/test/beacon/loader/page_test.exs +++ b/test/beacon/loader/page_test.exs @@ -85,9 +85,9 @@ defmodule Beacon.Loader.PageTest do test "render all templates" do page = beacon_published_page_fixture(path: "/1") - Beacon.Content.create_variant_for_page(page, %{name: "variant_a", weight: 1, template: "
variant_a
"}) - Beacon.Content.create_variant_for_page(page, %{name: "variant_b", weight: 2, template: "
variant_b
"}) - Beacon.Content.publish_page(page) + Beacon.Content.create_variant_for_page(page, %{name: "variant_a", weight: 1, template: "
variant_a
"}, auth: false) + Beacon.Content.create_variant_for_page(page, %{name: "variant_b", weight: 2, template: "
variant_b
"}, auth: false) + Beacon.Content.publish_page(page, auth: false) module = Loader.fetch_page_module(page.site, page.id) assert [ diff --git a/test/beacon_web/live/page_live_test.exs b/test/beacon_web/live/page_live_test.exs index 291f37a62..02a8fbfab 100644 --- a/test/beacon_web/live/page_live_test.exs +++ b/test/beacon_web/live/page_live_test.exs @@ -120,7 +120,7 @@ defmodule Beacon.Web.Live.PageLiveTest do """ }) - Content.publish_page(page_home) + Content.publish_page(page_home, auth: false) _page_without_meta_tags = beacon_published_page_fixture( @@ -276,8 +276,8 @@ defmodule Beacon.Web.Live.PageLiveTest do end test "update resource links on layout publish", %{conn: conn, layout: layout} do - {:ok, layout} = Content.update_layout(layout, %{"resource_links" => [%{"rel" => "stylesheet", "href" => "color.css"}]}) - {:ok, layout} = Content.publish_layout(layout) + {:ok, layout} = Content.update_layout(layout, %{"resource_links" => [%{"rel" => "stylesheet", "href" => "color.css"}]}, auth: false) + {:ok, layout} = Content.publish_layout(layout, auth: false) Beacon.Loader.load_layout_module(layout.site, layout.id) {:ok, _view, html} = live(conn, "/home/hello") assert html =~ ~S|| @@ -385,7 +385,7 @@ defmodule Beacon.Web.Live.PageLiveTest do assert html =~ "component_test_v1" - Content.update_component(component, %{template: "component_test_v2"}) + Content.update_component(component, %{template: "component_test_v2"}, auth: false) Beacon.Loader.load_components_module(component.site) {:ok, _view, html} = live(conn, "/component_test") diff --git a/test/support/auth_module.ex b/test/support/auth_module.ex new file mode 100644 index 000000000..e862ced6c --- /dev/null +++ b/test/support/auth_module.ex @@ -0,0 +1,20 @@ +defmodule Beacon.BeaconTest.AuthModule do + @behaviour Beacon.Auth + + def actor_from_session(_session) do + {"1-1-1", "Test User"} + end + + def list_actors do + [ + {"1-2-3", "Owner 1"}, + {"4-5-6", "Owner 2"}, + {"3-3-3", "User 1"}, + {"4-4-4", "User 2"} + ] + end + + def owners do + [{"1-2-3", "Owner 1"}, {"4-5-6", "Owner 2"}] + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index a84cf9fc5..cecfbf9e4 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -16,6 +16,7 @@ Supervisor.start_link( endpoint: Beacon.BeaconTest.Endpoint, router: Beacon.BeaconTest.Router, repo: Beacon.BeaconTest.Repo, + auth_module: Beacon.BeaconTest.AuthModule, tailwind_config: Path.join([File.cwd!(), "test", "support", "tailwind.config.templates.js"]), tailwind_css: Path.join([File.cwd!(), "test", "support", "tailwind.custom.css"]), live_socket_path: "/custom_live", @@ -141,13 +142,13 @@ Supervisor.start_link( ], after_publish_page: [ maybe_publish_page: fn page -> - {:ok, page} = Beacon.Content.update_page(page, %{title: "updated after publish page"}) + {:ok, page} = Beacon.Content.update_page(page, %{title: "updated after publish page"}, auth: false) {:cont, page} end ], after_unpublish_page: [ maybe_unpublish_page: fn page -> - {:ok, page} = Beacon.Content.update_page(page, %{title: "updated after unpublish page"}) + {:ok, page} = Beacon.Content.update_page(page, %{title: "updated after unpublish page"}, auth: false) {:cont, page} end ]