diff --git a/config/dev.exs b/config/dev.exs index ce3c273..d4a672c 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -4,15 +4,13 @@ port = System.get_env("PORT") || "7070" config :mailgun_logger, MailgunLoggerWeb.Endpoint, url: [ - host: System.get_env("HOST", "0.0.0.0"), - scheme: "https", + host: System.get_env("HOST", "localhost"), + scheme: "http", port: port ], - https: [ + http: [ + ip: {0, 0, 0, 0}, port: port, - cipher_suite: :strong, - keyfile: "priv/cert/selfsigned_key.pem", - certfile: "priv/cert/selfsigned.pem" ], live_reload: [ web_console_logger: true, diff --git a/config/runtime.exs b/config/runtime.exs index 662f03e..c1b10de 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -3,32 +3,34 @@ import Config config :mailgun_logger, store_messages: System.get_env("ML_STORE_MESSAGES", "false") |> String.downcase() == "true" -config :mailgun_logger, MailgunLoggerWeb.Endpoint, - server: true, - secret_key_base: System.get_env("SECRET_KEY_BASE") +if config_env() != :test do + config :mailgun_logger, MailgunLoggerWeb.Endpoint, + server: true, + secret_key_base: System.get_env("SECRET_KEY_BASE") -config :mailgun_logger, MailgunLogger.Repo, - username: System.get_env("ML_DB_USER"), - password: System.get_env("ML_DB_PASSWORD"), - database: System.get_env("ML_DB_NAME"), - hostname: System.get_env("ML_DB_HOST"), - port: System.get_env("ML_DB_PORT") || 5432 + config :mailgun_logger, MailgunLogger.Repo, + username: System.get_env("ML_DB_USER"), + password: System.get_env("ML_DB_PASSWORD"), + database: System.get_env("ML_DB_NAME"), + hostname: System.get_env("ML_DB_HOST"), + port: System.get_env("ML_DB_PORT") || 5432 -config :mailgun_logger, MailgunLogger.Mailer, - adapter: Bamboo.MailgunAdapter, - api_key: System.get_env("MAILGUN_API_KEY"), - domain: System.get_env("MAILGUN_DOMAIN"), - from: System.get_env("MAILGUN_FROM") || "no-reply@jackjoe.be" + config :mailgun_logger, MailgunLogger.Mailer, + adapter: Bamboo.MailgunAdapter, + api_key: System.get_env("MAILGUN_API_KEY"), + domain: System.get_env("MAILGUN_DOMAIN"), + from: System.get_env("MAILGUN_FROM") || "no-reply@jackjoe.be" -config :ex_aws, - access_key_id: System.get_env("AWS_ACCESS_KEY_ID"), - secret_access_key: System.get_env("AWS_SECRET_ACCESS_KEY"), - region: System.get_env("AWS_REGION"), - bucket: System.get_env("AWS_BUCKET"), - raw_path: System.get_env("RAW_PATH"), - s3: [ - scheme: System.get_env("AWS_SCHEME"), - port: System.get_env("AWS_PORT"), + config :ex_aws, + access_key_id: System.get_env("AWS_ACCESS_KEY_ID"), + secret_access_key: System.get_env("AWS_SECRET_ACCESS_KEY"), region: System.get_env("AWS_REGION"), - host: System.get_env("AWS_HOST") - ] + bucket: System.get_env("AWS_BUCKET"), + raw_path: System.get_env("RAW_PATH"), + s3: [ + scheme: System.get_env("AWS_SCHEME"), + port: System.get_env("AWS_PORT"), + region: System.get_env("AWS_REGION"), + host: System.get_env("AWS_HOST") + ] +end diff --git a/config/test.exs b/config/test.exs index 09d1fdc..79290b5 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,20 +1,18 @@ import Config -# We don't run a server during test. If one is required, -# you can enable the server option below. config :mailgun_logger, MailgunLoggerWeb.Endpoint, http: [port: 4001], server: false -# Quantum cron schedule config :mailgun_logger, MailgunLogger.Scheduler, jobs: [] config :logger, level: :warning -config :mailgun_logger, MailgunLogger.Repo, pool: Ecto.Adapters.SQL.Sandbox config :mailgun_logger, MailgunLogger.Repo, - username: System.get_env("ML_DB_USER") || "travis", + username: System.get_env("ML_DB_USER") || "max", password: System.get_env("ML_DB_PASSWORD") || "", - database: System.get_env("ML_DB_NAME") || "mailgun_logger_ci_test", - hostname: System.get_env("ML_DB_HOST") || "localhost" + database: System.get_env("ML_DB_NAME") || "mailgun_logger_test", + hostname: System.get_env("ML_DB_HOST") || "localhost", + port: String.to_integer(System.get_env("ML_DB_PORT") || "5432"), + pool: Ecto.Adapters.SQL.Sandbox diff --git a/lib/mailgun_logger/roles/roles.ex b/lib/mailgun_logger/roles/roles.ex index 8a873bb..3a2f8fb 100644 --- a/lib/mailgun_logger/roles/roles.ex +++ b/lib/mailgun_logger/roles/roles.ex @@ -5,6 +5,7 @@ defmodule MailgunLogger.Roles do alias MailgunLogger.User alias MailgunLogger.Repo + @member_role "member" @superuser_role "superuser" @admin_role "admin" @@ -12,7 +13,9 @@ defmodule MailgunLogger.Roles do @default_actions ~w() - @admin_actions ~w(do_stuff) ++ @default_actions + @member_actions ~w(view_events view_event_details edit_own_profile) ++ @default_actions + + @admin_actions ~w(view_stats manage_accounts manage_users) ++ @member_actions @superuser_actions ~w() ++ @admin_actions @@ -55,6 +58,11 @@ defmodule MailgunLogger.Roles do Enum.any?(roles, &can?(&1.name, action)) end + for action <- @member_actions do + action = String.to_atom(action) + def can?(@member_role, unquote(action)), do: true + end + for action <- @admin_actions do action = String.to_atom(action) def can?(@admin_role, unquote(action)), do: true @@ -67,6 +75,7 @@ defmodule MailgunLogger.Roles do def can?(_, _), do: false + def is?(%User{roles: roles}, :member), do: is(roles, "member") def is?(%User{roles: roles}, :superuser), do: is(roles, "superuser") def is?(%User{roles: roles}, :admin), do: is(roles, "admin") def is?(_, _), do: raise("Roles.is/2 requires roles to be preloaded") @@ -75,6 +84,7 @@ defmodule MailgunLogger.Roles do def abilities(%User{roles: []}), do: [] def abilities(%User{roles: roles}), do: hd(roles) |> abilities() + def abilities(%Role{name: "member"}), do: @member_actions def abilities(%Role{name: "admin"}), do: @admin_actions def abilities(%Role{name: "superuser"}), do: @superuser_actions diff --git a/lib/mailgun_logger/seeder.ex b/lib/mailgun_logger/seeder.ex index 6fe0588..44011f8 100644 --- a/lib/mailgun_logger/seeder.ex +++ b/lib/mailgun_logger/seeder.ex @@ -6,7 +6,11 @@ defmodule MailgunLogger.Seeder do # alias MailgunLogger.UserRole - @roles [%Role{name: "superuser"}, %Role{name: "admin"}] + @roles [ + %Role{name: "superuser"}, + %Role{name: "admin"}, + %Role{name: "member"} + ] def run do Enum.each(@roles, &insert_if_new(&1)) diff --git a/lib/mailgun_logger/users/users.ex b/lib/mailgun_logger/users/users.ex index ae6902e..460ab6d 100644 --- a/lib/mailgun_logger/users/users.ex +++ b/lib/mailgun_logger/users/users.ex @@ -3,7 +3,7 @@ defmodule MailgunLogger.Users do alias MailgunLogger.Repo alias MailgunLogger.User - + alias MailgunLogger.Roles @type ecto_user() :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} @type maybe_user() :: User.t() | nil @@ -146,18 +146,56 @@ defmodule MailgunLogger.Users do @spec create_user(map) :: ecto_user() def create_user(params) do + roles = get_roles_from_params(params) + %User{} |> User.changeset(params) + |> Ecto.Changeset.put_assoc(:roles, roles) |> Repo.insert() end @spec update_user(User.t(), map) :: ecto_user() def update_user(user, params) do + roles = get_roles_from_params(params) + user |> User.update_changeset(params) + |> Ecto.Changeset.put_assoc(:roles, roles) |> Repo.update() end + @spec update_user(User.t(), User.t(), map) :: ecto_user() + def update_user(actor, user, params) do + roles = get_roles_from_params(params) + changeset = User.update_changeset(user, params) + + if self_downgrade?(actor, user, roles) do + changeset + |> Ecto.Changeset.add_error(:roles, "You cannot remove your own user management access.") + |> then(&{:error, &1}) + else + changeset + |> Ecto.Changeset.put_assoc(:roles, roles) + |> Repo.update() + end + end + + defp self_downgrade?(actor, user, roles) do + actor.id == user.id and not keeps_user_management_access?(roles) + end + + defp keeps_user_management_access?(roles) do + Enum.any?(roles, &Roles.can?(&1.name, :manage_users)) + end + + defp get_roles_from_params(params) do + params + |> Map.get("role_ids", []) + |> Enum.reject(&(&1 in [nil, ""])) + |> Enum.map(&String.to_integer/1) + |> Roles.get_roles_by_id() + end + @spec delete_user(User.t()) :: ecto_user() def delete_user(%User{} = user) do Repo.delete(user) diff --git a/lib/mailgun_logger_web/controllers/profile_controller.ex b/lib/mailgun_logger_web/controllers/profile_controller.ex index e2c5f4d..e59616d 100644 --- a/lib/mailgun_logger_web/controllers/profile_controller.ex +++ b/lib/mailgun_logger_web/controllers/profile_controller.ex @@ -6,7 +6,7 @@ defmodule MailgunLoggerWeb.ProfileController do def edit(conn, _) do user = conn.assigns.current_user - changeset = User.changeset(user) + changeset = User.update_changeset(user) conn |> put_view(MailgunLoggerWeb.UserView) diff --git a/lib/mailgun_logger_web/controllers/user_controller.ex b/lib/mailgun_logger_web/controllers/user_controller.ex index 1128c69..5e8fe1c 100644 --- a/lib/mailgun_logger_web/controllers/user_controller.ex +++ b/lib/mailgun_logger_web/controllers/user_controller.ex @@ -3,6 +3,7 @@ defmodule MailgunLoggerWeb.UserController do alias MailgunLogger.Users alias MailgunLogger.User + alias MailgunLogger.Roles def index(conn, _) do users = Users.list_users() @@ -11,31 +12,39 @@ defmodule MailgunLoggerWeb.UserController do def new(conn, _) do changeset = User.changeset(%User{}) - render(conn, :new, changeset: changeset) + roles = Roles.list_roles() + render(conn, :new, changeset: changeset, roles: roles) end def create(conn, %{"user" => params}) do case Users.create_user(params) do - {:ok, _} -> redirect(conn, to: Routes.user_path(conn, :index)) - {:error, changeset} -> render(conn, :new, changeset: changeset) + {:ok, _} -> + redirect(conn, to: Routes.user_path(conn, :index)) + + {:error, changeset} -> + roles = Roles.list_roles() + render(conn, :new, changeset: changeset, roles: roles) end end def edit(conn, %{"id" => id}) do user = Users.get_user!(id) - changeset = User.changeset(user) - render(conn, :edit, changeset: changeset, user: user) + changeset = User.update_changeset(user) + roles = Roles.list_roles() + render(conn, :edit, changeset: changeset, user: user, roles: roles) end def update(conn, %{"id" => id, "user" => params}) do user = Users.get_user!(id) + current_user = conn.assigns.current_user - case Users.update_user(user, params) do + case Users.update_user(current_user, user, params) do {:ok, _} -> redirect(conn, to: Routes.user_path(conn, :index)) {:error, changeset} -> - render(conn, :edit, changeset: changeset, user: user) + roles = Roles.list_roles() + render(conn, :edit, changeset: changeset, user: user, roles: roles) end end diff --git a/lib/mailgun_logger_web/plugs/authorize.ex b/lib/mailgun_logger_web/plugs/authorize.ex new file mode 100644 index 0000000..a69117b --- /dev/null +++ b/lib/mailgun_logger_web/plugs/authorize.ex @@ -0,0 +1,28 @@ +defmodule MailgunLoggerWeb.Plugs.Authorize do + import Plug.Conn + import Phoenix.Controller + + alias MailgunLogger.Roles + alias MailgunLoggerWeb.Router.Helpers, as: Routes + + @moduledoc """ + Plug that checks whether the current user has the required permission. + """ + + @spec init(atom()) :: atom() + def init(permission), do: permission + + @spec call(Plug.Conn.t(), atom()) :: Plug.Conn.t() + def call(conn, permission) do + user = conn.assigns.current_user + + if Roles.can?(user, permission) do + conn + else + conn + |> put_flash(:error, "You are not authorized to access this page.") + |> redirect(to: Routes.event_path(conn, :index)) + |> halt() + end + end +end diff --git a/lib/mailgun_logger_web/plugs/setup_check.ex b/lib/mailgun_logger_web/plugs/setup_check.ex index fa942c6..b008cbd 100644 --- a/lib/mailgun_logger_web/plugs/setup_check.ex +++ b/lib/mailgun_logger_web/plugs/setup_check.ex @@ -1,4 +1,5 @@ defmodule MailgunLoggerWeb.Plugs.SetupCheck do + import Plug.Conn import Phoenix.Controller alias MailgunLoggerWeb.Router.Helpers, as: Routes @@ -15,7 +16,10 @@ defmodule MailgunLoggerWeb.Plugs.SetupCheck do def call(conn, _) do case Users.any_users?() do true -> conn - false -> redirect(conn, to: Routes.setup_path(conn, :index)) + false -> + conn + |> redirect(to: Routes.setup_path(conn, :index)) + |> halt() end end end diff --git a/lib/mailgun_logger_web/router.ex b/lib/mailgun_logger_web/router.ex index 7297982..44a75c5 100644 --- a/lib/mailgun_logger_web/router.ex +++ b/lib/mailgun_logger_web/router.ex @@ -23,6 +23,30 @@ defmodule MailgunLoggerWeb.Router do plug(MailgunLoggerWeb.Plugs.Auth) end + pipeline :can_view_events do + plug(MailgunLoggerWeb.Plugs.Authorize, :view_events) + end + + pipeline :can_view_event_details do + plug(MailgunLoggerWeb.Plugs.Authorize, :view_event_details) + end + + pipeline :can_edit_own_profile do + plug(MailgunLoggerWeb.Plugs.Authorize, :edit_own_profile) + end + + pipeline :can_view_stats do + plug(MailgunLoggerWeb.Plugs.Authorize, :view_stats) + end + + pipeline :can_manage_accounts do + plug(MailgunLoggerWeb.Plugs.Authorize, :manage_accounts) + end + + pipeline :can_manage_users do + plug(MailgunLoggerWeb.Plugs.Authorize, :manage_users) + end + # Always except in prod if Application.compile_env(:mailgun_logger, :env) == :dev do forward("/sent_emails", Bamboo.SentEmailViewerPlug) @@ -66,25 +90,54 @@ defmodule MailgunLoggerWeb.Router do scope "/setup", MailgunLoggerWeb do pipe_through(:browser) get("/", SetupController, :index) + get("/non-affiliation", PageController, :non_affiliation) post("/", SetupController, :create_root) end scope "/", MailgunLoggerWeb do - pipe_through([:browser, :auth]) + pipe_through([:browser, :auth, :can_view_events]) - get("/logout", AuthController, :logout) + get("/", PageController, :index) + get("/events", EventController, :index) + end + + scope "/", MailgunLoggerWeb do + pipe_through([:browser, :auth, :can_view_event_details]) - resources("/events", EventController, only: [:index, :show]) + get("/events/:id", EventController, :show) get("/events/:id/stored_message", EventController, :stored_message) - resources("/accounts", AccountController, except: [:show]) + end + + scope "/", MailgunLoggerWeb do + pipe_through([:browser, :auth, :can_edit_own_profile]) + get("/profile", ProfileController, :edit) put("/profile", ProfileController, :update) - resources("/users", UserController, except: [:show]) + end + + scope "/", MailgunLoggerWeb do + pipe_through([:browser, :auth]) + + get("/logout", AuthController, :logout) + end + + scope "/", MailgunLoggerWeb do + pipe_through([:browser, :auth, :can_view_stats]) - get("/", PageController, :index) - post("/trigger-run", PageController, :trigger_run) get("/stats", PageController, :stats) get("/graphs", PageController, :graphs) - get("/non-affiliation", PageController, :non_affiliation) + post("/trigger-run", PageController, :trigger_run) + end + + scope "/", MailgunLoggerWeb do + pipe_through([:browser, :auth, :can_manage_accounts]) + + resources("/accounts", AccountController, except: [:show]) + end + + scope "/", MailgunLoggerWeb do + pipe_through([:browser, :auth, :can_manage_users]) + + resources("/users", UserController, except: [:show]) end end diff --git a/lib/mailgun_logger_web/templates/layout/app.html.heex b/lib/mailgun_logger_web/templates/layout/app.html.heex index 6a78307..fda2b06 100644 --- a/lib/mailgun_logger_web/templates/layout/app.html.heex +++ b/lib/mailgun_logger_web/templates/layout/app.html.heex @@ -15,7 +15,8 @@ var html = document.documentElement; var theme = html.getAttribute("data-theme"); if (theme === "system") { - html.setAttribute("data-theme", + html.setAttribute( + "data-theme", window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark" ); } @@ -38,19 +39,51 @@