Repository: cpjk/canary Branch: master Commit: 5efb8dca6012 Files: 27 Total size: 168.6 KB Directory structure: gitextract_dt_ospw4/ ├── .credo.exs ├── .formatter.exs ├── .github/ │ └── workflows/ │ └── elixir.yml ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config/ │ └── config.exs ├── docs/ │ ├── getting-started.md │ └── upgrade.md ├── lib/ │ ├── canary/ │ │ ├── default_handler.ex │ │ ├── error_handler.ex │ │ ├── hooks.ex │ │ ├── plugs.ex │ │ └── utils.ex │ └── canary.ex ├── mix.exs └── test/ ├── canary/ │ ├── default_handler_test.exs │ ├── hooks_test.exs │ ├── plugs_test.exs │ └── utils_test.exs ├── support/ │ ├── endpoint.ex │ ├── page_live.ex │ ├── post_live.ex │ └── router.ex └── test_helper.exs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .credo.exs ================================================ # This file contains the configuration for Credo and you are probably reading # this after creating it with `mix credo.gen.config`. # # If you find anything wrong or unclear in this file, please report an # issue on GitHub: https://github.com/rrrene/credo/issues # %{ # # You can have as many configs as you like in the `configs:` field. configs: [ %{ # # Run any config using `mix credo -C `. If no config name is given # "default" is used. name: "default", # # These are the files included in the analysis: files: %{ # # You can give explicit globs or simply directories. # In the latter case `**/*.{ex,exs}` will be used. included: ["lib/", "src/", "web/", "apps/"], excluded: [~r"/_build/", ~r"/deps/"] }, # # If you create your own checks, you must specify the source files for # them here, so they can be loaded by Credo before running the analysis. requires: [], # # Credo automatically checks for updates, like e.g. Hex does. # You can disable this behaviour below: check_for_updates: true, # # If you want to enforce a style guide and need a more traditional linting # experience, you can change `strict` to `true` below: strict: false, # # If you want to use uncolored output by default, you can change `color` # to `false` below: color: true, # # You can customize the parameters of any check by adding a second element # to the tuple. # # To disable a check put `false` as second element: # # {Credo.Check.Design.DuplicatedCode, false} # checks: [ {Credo.Check.Consistency.ExceptionNames}, {Credo.Check.Consistency.LineEndings}, {Credo.Check.Consistency.MultiAliasImportRequireUse}, {Credo.Check.Consistency.ParameterPatternMatching}, {Credo.Check.Consistency.SpaceAroundOperators}, {Credo.Check.Consistency.SpaceInParentheses}, {Credo.Check.Consistency.TabsOrSpaces}, # For some checks, like AliasUsage, you can only customize the priority # Priority values are: `low, normal, high, higher` {Credo.Check.Design.AliasUsage, priority: :low}, # For others you can set parameters # If you don't want the `setup` and `test` macro calls in ExUnit tests # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. {Credo.Check.Design.DuplicatedCode, excluded_macros: []}, # You can also customize the exit_status of each check. # If you don't want TODO comments to cause `mix credo` to fail, just # set this value to 0 (zero). {Credo.Check.Design.TagTODO, exit_status: 2}, {Credo.Check.Design.TagFIXME}, {Credo.Check.Readability.FunctionNames}, {Credo.Check.Readability.LargeNumbers}, {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 80}, {Credo.Check.Readability.ModuleAttributeNames}, {Credo.Check.Readability.ModuleDoc}, {Credo.Check.Readability.ModuleNames}, {Credo.Check.Readability.NoParenthesesWhenZeroArity}, {Credo.Check.Readability.ParenthesesInCondition}, {Credo.Check.Readability.PredicateFunctionNames}, {Credo.Check.Readability.PreferImplicitTry}, {Credo.Check.Readability.RedundantBlankLines}, {Credo.Check.Readability.Specs, false}, {Credo.Check.Readability.StringSigils}, {Credo.Check.Readability.TrailingBlankLine}, {Credo.Check.Readability.TrailingWhiteSpace}, {Credo.Check.Readability.VariableNames}, {Credo.Check.Refactor.DoubleBooleanNegation}, # {Credo.Check.Refactor.CaseTrivialMatches}, # deprecated in 0.4.0 {Credo.Check.Refactor.ABCSize}, {Credo.Check.Refactor.CondStatements}, {Credo.Check.Refactor.CyclomaticComplexity}, {Credo.Check.Refactor.FunctionArity}, {Credo.Check.Refactor.MatchInCondition}, {Credo.Check.Refactor.NegatedConditionsInUnless}, {Credo.Check.Refactor.NegatedConditionsWithElse}, {Credo.Check.Refactor.Nesting}, {Credo.Check.Refactor.PipeChainStart, false}, {Credo.Check.Refactor.UnlessWithElse}, {Credo.Check.Refactor.VariableRebinding}, {Credo.Check.Warning.BoolOperationOnSameValues}, {Credo.Check.Warning.IExPry}, {Credo.Check.Warning.IoInspect}, {Credo.Check.Warning.NameRedeclarationByAssignment}, {Credo.Check.Warning.NameRedeclarationByCase}, {Credo.Check.Warning.NameRedeclarationByDef}, {Credo.Check.Warning.NameRedeclarationByFn}, {Credo.Check.Warning.OperationOnSameValues}, {Credo.Check.Warning.OperationWithConstantResult}, {Credo.Check.Warning.UnusedEnumOperation}, {Credo.Check.Warning.UnusedFileOperation}, {Credo.Check.Warning.UnusedKeywordOperation}, {Credo.Check.Warning.UnusedListOperation}, {Credo.Check.Warning.UnusedPathOperation}, {Credo.Check.Warning.UnusedRegexOperation}, {Credo.Check.Warning.UnusedStringOperation}, {Credo.Check.Warning.UnusedTupleOperation}, # Custom checks can be created using `mix credo.gen.check`. # ] } ] } ================================================ FILE: .formatter.exs ================================================ [ import_deps: [:plug, :phoenix, :phoenix_live_view], inputs: [ "lib/**/*.ex", "config/*.exs", "test/**/*.exs", "mix.exs" ] ] ================================================ FILE: .github/workflows/elixir.yml ================================================ name: CI on: push: pull_request: branches: - master jobs: mix_test: name: mix test (OTP ${{matrix.otp}} | Elixir ${{matrix.elixir}}) strategy: matrix: include: - elixir: "1.18" otp: "27.2" - elixir: "1.14" otp: "25.3" runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v4 - name: Install Erlang and Elixir uses: erlef/setup-beam@v1 with: otp-version: ${{ matrix.otp }} elixir-version: ${{ matrix.elixir }} - name: Restore deps and _build cache uses: actions/cache@v4 with: path: | deps _build key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} restore-keys: | deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }} - name: Install dependencies run: mix deps.get --only test - name: Remove compiled application files run: mix clean - name: Compile & lint dependencies run: mix compile --warnings-as-errors env: MIX_ENV: test - name: Run tests run: mix test ================================================ FILE: .gitignore ================================================ /_build /deps erl_crash.dump *.ez tags /doc /cover *.beam .history .tool-version ================================================ FILE: .travis.yml ================================================ language: elixir elixir: - 1.8 - 1.7 - 1.6 - 1.5 - 1.4 otp_release: - 21.1 - 20.3 - 19.3 - 18.3 matrix: exclude: - elixir: 1.8 otp_release: 19.3 - elixir: 1.8 otp_release: 18.3 - elixir: 1.7 otp_release: 18.3 - elixir: 1.6 otp_release: 18.3 - elixir: 1.5 otp_release: 21.1 - elixir: 1.4 otp_release: 21.1 - elixir: 1.3 otp_release: 21.1 - elixir: 1.3 otp_release: 20.3 script: mix test && mix credo ================================================ FILE: CHANGELOG.md ================================================ ## Changelog ## v2.0.0-dev Canary 2.0.0 introduces authorization hooks for Phoenix LiveView. The Plug based authorization was refactored a bit to make the API cosistent. Please follow the [Upgrade guide to 2.0.0](docs/upgrade.md#upgrading-from-canary-1-2-0-to-2-0-0) for more details. * Enhancements * added support for authorization LiveView with `Canary.Hooks` * added `:error_handler` and ErrorHandler behaviour * added `:required` option, default to true * Dependency changes * Elixir ~> 1.14 is now required * Deprecations * The `:non_id_actions` option is deprecated and will be removed in Canary 2.1.0. Use separate `:authorize_resource` plug for `non_id_actions` and `:except` to exclude non_in_actions. * The `:persisted` option is deprecated and will be removed in Canary 2.1.0. Use `:required` instead. ## v1.2.0 * Enhancements * Add `required` opt ## v1.1.0 * Enhancements * Add `non_id_actions` opt ## v1.0 * Bug fixes * Do not clobber resources in the `Conn` on index action if they are of the same model ## v0.14.2 * Relax Ecto version requirements ## v0.14.1 * Bug fixes * Use Macro.underscore/1 instead of Mix.Utils.underscore/1 to avoid :mix dependency on production ## v0.14.0 * Enhancements * You can now tell Canary to search for a resource using a field other than the default `:id` by using the `:id_field` option. Note that the specified field must be able to uniquely identify any resource in the specified table. * Dependency changes * Elixir ~> 1.2 is now required * Ecto ~> 1.1 is now required ## v0.13.1 * Enhancements * If both an `:unauthorized_handler` and a `:not_found_handler` are specified for `load_and_authorize_resource`, and the request meets the criteria for both, the `:unauthorized_handler` will be called first. * Bug Fixes * If more than one handler are specified and the first handler halts the request, the second handler will be skipped. ## v0.13.0 * Enhancements * Canary can now be configured to call a user-defined function when a resource is not found. The function is specified and used in a similar manner to `:unauthorized_handler`. * Bug Fixes * Disabled protocol consolidation in order for tests to work on Elixir 1.2 ## v0.12.2 * Deprecations * Canary now looks for the current action in `conn.assigns.canary_action` rather than `conn.assigns.action` in order to avoid conflicts. The `action` key is deprecated. ## v0.12.0 * Enhancements * Canary can now be configured to call a user-defined function when authorization fails. Canary will pass the `Plug.Conn` for the request to the given function. The handler should accept a `Plug.Conn` as its only argument, and should return a `Plug.Conn`. * For example, to have Canary call `Helpers.handle_unauthorized/1`: ```elixir config :canary, unauthorized_handler: {Helpers, :handle_unauthorized} ``` * You can also specify the `:unauthorized_handler` on an individual basis by specifying the `:unauthorized_handler` `opt` in the plug call like so: ```elixir plug :load_and_authorize_resource Post, unauthorized_handler: {Helpers, :handle_unauthorized} ``` ## v0.11.0 * Enhancements * Resources can now be loaded on `:new` and `:create` actions, when `persisted: true` is specified in the plug call. This allows parent resources to be loaded when a child is created. For example, if a `Post` resource has multiple `Comment` children, you may want to load the parent `Post` when creating a new `Comment`. You can load the parent `Post` with a separate ```elixir plug :load_and_authorize_resource, model: Post, id_name: "post_id", persisted: true, only: [:create] ``` This will cause Canary to try to load the corresponding `Post` from the database when creating a `Comment` at the URL `/posts/:post_id/comments` ## v0.10.0 * Bug fix * Correctly checks `conn.assigns` for pre-existing resource * Deprecations * Canary now favours looking for the current action in `conn.assigns.canary_action` rather than `conn.assigns.action` in order to avoid conflicts. The `action` key is deprecated * Enhancements * The name of the id in `conn.params` can now be specified with the `id_name` opt ================================================ FILE: LICENSE ================================================ Copyright (c) 2016 Chris Kelly Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ Canary ====== [![Actions Status](https://github.com/cpjk/canary/workflows/CI/badge.svg)](https://github.com/runhyve/canary/actions?query=workflow%3ACI) [![Hex pm](https://img.shields.io/hexpm/v/canary.svg?style=flat)](https://hex.pm/packages/canary) An authorization library in Elixir for `Plug` and `Phoenix.LiveView` applications that restricts what resources the current user is allowed to access, and automatically load and assigns resources. Inspired by [CanCan](https://github.com/CanCanCommunity/cancancan) for Ruby on Rails. [Read the docs](https://hexdocs.pm/canary/2.0.0-dev/getting-started.html) # Canary 2.0.0 The `master` branch is for the development of Canary 2.0.0. Check out [branch 1.2.x](https://github.com/cpjk/canary/tree/1.2.x) if you are looking Canary 1 (only plug authentication). ## Installation For the latest master (2.0.0-dev): ```elixir defp deps do {:canary, github: "cpjk/canary"} end ``` For the latest release: ```elixir defp deps do {:canary, "~> 2.0.0-dev"} end ``` Then run `mix deps.get` to fetch the dependencies. ## Quick start Canary provides functions to be used as plugs or LiveView hooks to load and authorize resources: `load_resource`, `authorize_resource`, `authorize_controller`*, and `load_and_authorize_resource`. `load_resource` and `authorize_resource` can be used by themselves, while `load_and_authorize_resource` combines them both. *Available only in plug based authentication* In order to use Canary, you will need, at minimum: - A [Canada.Can protocol](https://github.com/jarednorman/canada) implementation (a good place would be `lib/abilities.ex`) - An Ecto record struct containing the user to authorize in `assigns.current_user` (the key can be customized - [see more](#overriding-the-default-user)). - Your Ecto repo specified in your `config/config.exs`: `config :canary, repo: YourApp.Repo` For the plugs just `import Canary.Plugs`. In a Phoenix app the best place would probably be inside `controller/0` in your `web/web.ex`, in order to make the functions available in all of your controllers. For the liveview hooks just `use Canary.Hooks`. In a Phoenix app the best place would probably be inside `live_view/0` in your `web/web.ex`, in order to make the functions available in all of your controllers. ### load_resource Loads the resource having the id given in `params["id"]` from the database using the given Ecto repo and model, and assigns the resource to `assigns.`, where `resource_name` is inferred from the model name. ### Conn Plugs example ```elixir plug :load_resource, model: Project.Post ``` Will load the `Project.Post` having the id given in `conn.params["id"]` through `YourApp.Repo`, and assign it to `conn.assigns.post`. ### LiveView Hooks example ```elixir mount_canary :load_resource, model: Project.Post ``` Will load the `Project.Post` having the id given in `params["id"]` through `YourApp.Repo`, and assign it to `socket.assigns.post` ### authorize_resource Checks whether or not the `current_user` for the request can perform the given action on the given resource and assigns the result (true/false) to `assigns.authorized`. It is up to you to decide what to do with the result. For Phoenix applications, Canary determines the action automatically. For non-Phoenix applications, or to override the action provided by Phoenix, simply ensure that `assigns.canary_action` contains an atom specifying the action. For the LiveView on `handle_params` it uses `socket.assigns.live_action` as action, on `handle_event` it uses the event name as action. In order to authorize resources, you must specify permissions by implementing the [Canada.Can protocol](https://github.com/jarednorman/canada) for your `User` model (Canada is included as a light weight dependency). ### load_and_authorize_resource Authorizes the resource and then loads it if authorization succeeds. Again, the resource is loaded into `assigns.`. In the following example, the `Post` with the same `user_id` as the `current_user` is only loaded if authorization succeeds. ## Usage Example Let's say you have a Phoenix application with a `Post` model, and you want to authorize the `current_user` for accessing `Post` resources. Let's suppose that you have a file named `lib/abilities.ex` that contains your Canada authorization rules like so: ```elixir defimpl Canada.Can, for: User do def can?(%User{ id: user_id }, action, %Post{ user_id: user_id }) when action in [:show], do: true def can?(%User{ id: user_id }, _, _), do: false end ``` ### Example for Conn Plugs In your `web/router.ex:` you have: ```elixir get "/posts/:id", PostController, :show delete "/posts/:id", PostController, :delete ``` To automatically load and authorize on the `Post` having the `id` given in the params, you would add the following plug to your `PostController`: ```elixir plug :load_and_authorize_resource, model: Post ``` In this case, on `GET /posts/12` authorization succeeds, and the `Post` specified by `conn.params["id]` will be loaded into `conn.assigns.post`. However, on `DELETE /posts/12`, authorization fails and the `Post` resource is not loaded. ### Example for LiveView Hooks In your `web/router.ex:` you have: ```elixir live "/posts/:id", PostLive, :show ``` and in your PostLive module `web/live/post_live.ex`: ```elixir defmodule MyAppWeb.PostLive do use MyAppWeb, :live_view def render(assigns) do ~H""" Post id: {@post.id} """ end def mount(_params, _session, socket), do: {:ok, socket} def handle_event("delete", _params, socket) do # Do the action {:noreply, update(socket, :temperature, &(&1 + 1))} end end ``` To automatically load and authorize on the `Post` having the `id` given in the params, you would add the following hook to your `PostLive`: ```elixir mount_hook :load_and_authorize_resource, model: Post ``` In this case, once opening `/posts/12` the `load_and_authorize_resource` on `handle_params` stage will be performed. The the `Post` specified by `params["id]` will be loaded into `socket.assigns.post`. However, when the `delete` event will be triggered, authorization fails and the `Post` resource is not loaded. Socket will be halted. ### Excluding actions To exclude an action from any of the plugs, pass the `:except` key, with a single action or list of actions. For example, Single action form: ```elixir plug :load_and_authorize_resource, model: Post, except: :show mount_canary :load_and_authorize_resource, model: Post, except: :show ``` List form: ```elixir plug :load_and_authorize_resource, model: Post, except: [:show, :create] mount_canary :load_and_authorize_resource, model: Post, except: [:show, :create] ``` ### Authorizing only specific actions To specify that a plug should be run only for a specific list of actions, pass the `:only` key, with a single action or list of actions. For example, Single action form: ```elixir plug :load_and_authorize_resource, model: Post, only: :show mount_canary :load_and_authorize_resource, model: Post, only: :show ``` List form: ```elixir plug :load_and_authorize_resource, model: Post, only: [:show, :create] mount_canary :load_and_authorize_resource, model: Post, only: [:show, :create] ``` > Note: Having both `:only` and `:except` in opts is invalid. Canary will raise `ArgumentError` "You can't use both :except and :only options" ### Overriding the default user Globally, the default key for finding the user to authorize can be set in your configuration as follows: ```elixir config :canary, current_user: :some_current_user ``` In this case, canary will look for the current user record in `assigns.some_current_user`. The current user key can also be overridden for individual plugs as follows: ```elixir plug :load_and_authorize_resource, model: Post, current_user: :current_admin mount_canary :load_and_authorize_resource, model: Post, current_user: :current_admin ``` ### Specifying resource_name To specify the name under which the loaded resource is stored, pass the `:as` flag in the plug declaration. For example, ```elixir plug :load_and_authorize_resource, model: Post, as: :new_post mount_canary :load_and_authorize_resource, model: Post, as: :new_post ``` will load the post into `assigns.new_post` ### Preloading associations Associations can be preloaded with `Repo.preload` by passing the `:preload` option with the name of the association: ```elixir plug :load_and_authorize_resource, model: Post, preload: :comments mount_canary :load_and_authorize_resource, model: Post, preload: :comments ``` ### Non-id actions To authorize actions where there is no loaded resource, the resource passed to the `Canada.Can` implementation should be the module name of the model rather than a struct. To authorize such actions use `authorize_resource` plug with `required: false` option ```elixir plug :authorize_resource, model: Post, only: [:index, :new, :create], required: false mount_canary :authorize_resource, model: Post, only: [:index, :new, :create], required: false ``` For example, when authorizing access to the `Post` resource, you should use ```elixir def can?(%User{}, :index, Post), do: true ``` instead of ```elixir def can?(%User{}, :index, %Post{}), do: true ``` > ### Deprecated {: .warning} > > The `:non_id_actions` is deprecated as of 2.0.0-dev and will be removed in Canary 2.1.0 > Please follow the [Upgrade guide to 2.0.0](docs/upgrade.md#upgrading-from-canary-1-2-0-to-2-0-0) for more details. ### Nested associations Sometimes you need to load and authorize a parent resource when you have a relationship between two resources and you are creating a new one or listing all the children of that parent. Depending on your authorization model you migth authorize against the parent resource or against the child. ```elixir defmodule MyAppWeb.CommentController do plug :load_and_authorize_resource, model: Post, id_name: "post_id", only: [:new_comment, :create_comment] # get /posts/:post_id/comments/new def new_comment(conn, _params) do # ... end # post /posts/:post_id/comments def new_comment(conn, _params) do # ... end end ``` It will authorize using `Canada.Can` with following arguments: 1. subject is `conn.assigns.current_user` 2. action is `:new_comment` or `:create_comment` 3. resource is `%Post{}` with `conn.params["post_id"]` Thanks to the `:requried` set to true by default this plug will call `not_found_handler` if the `Post` with given `post_id` does not exists. If for some reason you want to disable it, set `required: false` in opts. > ### Deprecated {: .warning} > > The `:persisted` is deprecated as of 2.0.0-dev and will be removed in Canary 2.1.0 > Please follow the [Upgrade guide to 2.0.0](docs/upgrade.md#upgrading-from-canary-1-2-0-to-2-0-0) for more details. ### Implementing Canada.Can for an anonymous user You may wish to define permissions for when there is no logged in current user (when `conn.assigns.current_user` is `nil`). In this case, you should implement `Canada.Can` for `nil` like so: ```elixir defimpl Canada.Can, for: Atom do # When the user is not logged in, all they can do is read Posts def can?(nil, :show, %Post{}), do: true def can?(nil, _, _), do: false end ``` ### Specifing database field You can tell Canary to search for a resource using a field other than the default `:id` by using the `:id_field` option. Note that the specified field must be able to uniquely identify any resource in the specified table. For example, if you want to access your posts using a string field called `slug`, you can use ```elixir plug :load_and_authorize_resource, model: Post, id_name: "slug", id_field: "slug" ``` to load and authorize the resource `Post` with the slug specified by `conn.params["slug"]` value. If you are using Phoenix, your `web/router.ex` should contain something like: ```elixir resources "/posts", PostController, param: "slug" ``` Then your URLs will look like: ``` /posts/my-new-post ``` instead of ``` /posts/1 ``` ### Handling unauthorized actions By default, when an action is unauthorized, Canary simply sets `conn.assigns.authorized` to `false`. However, you can configure a handler function to be called when authorization fails. Canary will pass the `Plug.Conn` to the given function. The handler should accept a `Plug.Conn` as its only argument, and should return a `Plug.Conn`. For example, to have Canary call `Helpers.handle_unauthorized/1`: ```elixir config :canary, unauthorized_handler: {Helpers, :handle_unauthorized} ``` ### Handling resource not found By default, when a resource is not found, Canary simply sets the resource in `conn.assigns` to `nil`. Like unauthorized action handling , you can configure a function to which Canary will pass the `conn` when a resource is not found: ```elixir config :canary, not_found_handler: {Helpers, :handle_not_found} ``` You can also specify handlers on an individual basis (which will override the corresponding configured handler, if any) by specifying the corresponding `opt` in the plug call: ```elixir plug :load_and_authorize_resource Post, unauthorized_handler: {Helpers, :handle_unauthorized}, not_found_handler: {Helpers, :handle_not_found} ``` Tip: If you would like the request handling to stop after the handler function exits, e.g. when redirecting, be sure to call `Plug.Conn.halt/1` within your handler like so: ```elixir def handle_unauthorized(conn) do conn |> put_flash(:error, "You can't access that page!") |> redirect(to: "/") |> halt end ``` Note: If both an `:unauthorized_handler` and a `:not_found_handler` are specified for `load_and_authorize_resource`, and the request meets the criteria for both, the `:unauthorized_handler` will be called first. ## License MIT License. Copyright 2016 Chris Kelly. ================================================ FILE: config/config.exs ================================================ # This file is responsible for configuring your application # and its dependencies with the aid of the Mix.Config module. import Config # This configuration is loaded before any dependency and is restricted # to this project. If another project depends on this project, this # file won't be loaded nor affect the parent project. For this reason, # if you want to provide default values for your application for third- # party users, it should be done in your mix.exs file. # Sample configuration: # # config :logger, :console, # level: :info, # format: "$date $time [$level] $metadata$message\n", # metadata: [:user_id] # It is also possible to import configuration files, relative to this # directory. For example, you can emulate configuration per environment # by uncommenting the line below and defining dev.exs, test.exs and such. # Configuration from the imported file will override the ones defined # here (which is why it is important to import them last). # # import_config "#{Mix.env}.exs" ================================================ FILE: docs/getting-started.md ================================================ # Getting Started This guide introduces **Canary**, an authorization library for **Elixir** applications using `Plug` and `Phoenix.LiveView`. It restricts resource access based on user permissions and automatically loads and assigns resources. Canary provides three primary functions to be used as *plugs* or *LiveView hooks* to manage resources: - `load_resource` - `authorize_resource` - `load_and_authorize_resource` ## Glossary ### Subject The key name used to fetch the subject from `assigns`. This subject is passed to `Canada.Can` to evaluate permissions. By default, it is `:current_user`. To configure this in your module: ```elixir config :canary, current_user: :user ``` You can override this setting per plug/mounted hook by specifying `:current_user`. ### Action For **Phoenix applications and Plug-based pages**, Canary determines the action automatically from `conn.private.phoenix_action`. In **non-Phoenix applications**, or when overriding Phoenix's default action behavior, set `conn.assigns.canary_action` with an atom specifying the action. For **LiveView**: - In `handle_params`, Canary uses `socket.assigns.live_action`. - In `handle_event`, Canary uses the `event_name` (converted from a string to an atom for consistency). Actions can be limited using `:only` or `:except` options; otherwise, they apply to all actions. ### Resource For `load_resource` and `load_and_authorize_resource`, Canary checks if the resource is already assigned. If not, it fetches the resource from the repository using: - `:id_name` from `params` (default: `"id"`). - `:id_field` in the struct (default: `:id`). By default, **a resource is required**. That means the resource must be present in `conn.assigns` or `socket.assigns`. It's fetched using the `:model` name, which can be overridden with the `:as` option. If it cannot be found, an error is handled. To make it optional, set `:required` to `false`. In this case, the resource module name is used instead of a loaded struct. You can also use `:preload` to preload associations. See `Ecto.Query.preload/3` for more details. For `authorize_resource`, the resource must be present in `conn.assigns` or `socket.assigns`. By default, it fetches the resource using the `:model` name, which can be overridden with the `:as` option. ### Load Resource Loads a resource from the database using the specified **Ecto repo** and model. It assigns the result to `assigns.`, where `resource_name` is inferred from the model. ### Authorize Resource Checks if the **subject** can perform a given action on a resource. The result (`true`/`false`) is assigned to `assigns.authorized`. The developer decides how to handle this result. ### Load and Authorize Resource A combination of **Load Resource** and **Authorize Resource** in a single function. ## Configuration To use Canary, you need to configure it in `config/config.exs`. All settings, except for `:repo`, can be overridden when using the plug or hook. ### Available Configuration Options | Name | Description | Example | | --- | --- | --- | | `:repo` | The Repo module used in your application. | `YourApp.Repo` | | `:current_user` | The key name used to fetch the user from assigns. This value will be used as the `subject` for `Canada.Can` to evaluate permissions. Defaults to `:current_user`. | `:current_member` | | `:error_handler` | A module that implements the `Canary.ErrorHandler` behavior. It is used to handle `:not_found` and `:unauthorized` errors. Defaults to `Canary.DefaultHandler`. | `YourApp.ErrorHandler` | ### Deprecated Options | Name | Description | Example | | --- | --- | --- | | `:not_found_handler` | A `{mod, fun}` tuple for handling not found errors. | `{YourApp.ErrorHandler, :handle_not_found}` | | `:unauthorized_handler` | A `{mod, fun}` tuple for handling unauthorized errors. | `{YourApp.ErrorHandler, :handle_unauthorized}` | > #### Info {: .info} > > The `:error_handler` option should be used instead of separate handlers for `:not_found` and `:unauthorized` errors. > Handlers can still be overridden using plug or `mount_canary` options. ### Example Configuration ```elixir config :canary, repo: YourApp.Repo, current_user: :current_user, error_handler: YourApp.ErrorHandler ``` ### Overriding configuration #### Authorize different subject Sometimes, you may need to perform authorization for a different subject. You can override `:current_user` by passing options to the plug or hook. ### Conn Plugs ```elixir import Canary.Plugs plug :load_and_authorize_resource, model: Team, current_user: :current_member ``` With this override, the authorization check will use `conn.assigns.current_member` as the subject. ### LiveView Hooks ```elixir use Canary.Hooks mount_canary :load_and_authorize_resource, on: :handle_event, current_user: :current_member, model: Team ``` With this override, the authorization check for the `:handle_event` stage hook will use `socket.assigns.current_member` as the subject. ### Different error handler If you want to override the global Canary error handler, you can override one of the functions: `:not_found_handler` or `:unauthorized_handler`. ### Conn Plugs ```elixir plug :load_and_authorize_resource, model: Team, current_user: :current_member, not_found_handler: {CustomErrorHandler, :custom_not_found_handler}, unauthorized_handler: {CustomErrorHandler, :custom_unauthorized_handler} ``` ### LiveView Hooks ```elixir use Canary.Hooks mount_canary :load_and_authorize_resource, model: Team, current_user: :current_member, only: [:special_action] unauthorized_handler: {CustomErrorHandler, :special_unauthorized_handler} ``` The error handler should implement the `Canary.ErrorHandler` behavior. Refer to the default implementation in `Canary.DefaultHandler`. ## Canary options Canary Plugs and Hooks use the same configuration options. ### Available Options | Name | Description | Example | | --- | --- | --- | | `:model` | The model module name used in your app. **Required** | `Post` | | `:only` | Specifies the actions for which the plug/hook is enabled. | `[:show, :edit, :update]` | | `:except` | Specifies the actions for which the plug/hook is disabled. | `[:delete]` | | `:current_user` | The key name used to fetch the user from assigns. This value will be used as the `subject` for `Canada.Can` to evaluate permissions. Defaults to `:current_user`. Applies only to `authorize_resource` or `load_and_authorize_resource`. | `:current_member` | | `:on` | Specifies the LiveView lifecycle stages where the hook should be attached. Defaults to `:handle_params`. **Available only in Canary.Hooks** | `[:handle_params, :handle_event]` | | `:as` | Specifies the key name under which the resource will be stored in assigns. | `:team_post` | | `:id_name` | Specifies the name of the ID in params. *Defaults to `"id"`*. | `:post_id` | | `:id_field` | Specifies the database field name used to search for the `id_name` value. *Defaults to `"id"`*. | `:post_id` | | `:required` | Determines if the resource is required. If not found, it triggers a not found error. *Defaults to `true`*. | `false` | | `:not_found_handler` | A `{mod, fun}` tuple that overrides the default error handler for not found errors. | `{YourApp.ErrorHandler, :custom_handle_not_found}` | | `:unauthorized_handler` | A `{mod, fun}` tuple that overrides the default error handler for unauthorized errors. | `{YourApp.ErrorHandler, :custom_handle_unauthorized}` | ### Deprecated Options | Name | Description | Example | | --- | --- | --- | | `:non_id_actions` | Additional actions for which Canary will authorize based on the model name. | `[:index, :new, :create]` | | `:persisted` | Forces the resource to always be loaded from the database. Defaults to `false`. **Available only in Canary.Plugs** | `true` | ### Examples ```elixir plug :load_and_authorize_resource, current_user: :current_member, model: Machine, preload: [:plan, :networks, :distribution, :job, ipv4: [:ip_pool], hypervisor: :region] plug :load_resource, model: Hypervisor, id_name: "hypervisor_id", only: [:new, :create], preload: [:region, :hypervisor_type, machines: [:networks, :plan, :distribution]], plug :load_and_authorize_resource, model: Hypervisor, preload: [ :region, :hypervisor_type, machines: {Hypervisors.preload_active_machines, [:plan, :distribution, :hypervisor, :networks]} ] mount_canary :authorize_resource, on: [:handle_params, :handle_event], current_user: :current_member, model: Machine, only: [:index, :new], required: false mount_canary :load_and_authorize_resource, on: [:handle_event], current_user: :current_member, model: Machine, only: [:start, :stop, :restart, :poweroff] ``` ## Plug and Hooks `Canary.Plugs` and `Canary.Hooks` should work the same way in most cases, providing a unified approach to authorization for both Plug-based controllers and LiveView. - **Shared Functionality:** Both Plugs and Hooks allow for resource loading and authorization using similar configuration options. This ensures consistency across different parts of your application. - **Differences:** - `Canary.Plugs` is designed for use in traditional Phoenix controllers and pipelines. - `Canary.Hooks` is specifically built for LiveView and integrates with lifecycle events such as `:handle_params` and `:handle_event`. - **Configuration Compatibility:** Most options, such as `:model`, `:current_user`, `:only`, `:except`, and error handlers, function identically in both Plugs and Hooks. However, `Canary.Hooks` includes the `:on` option, allowing you to specify which LiveView lifecycle stage the authorization should run on. By keeping their behavior aligned, Canary ensures a seamless developer experience, whether you're working with traditional controller-based actions or real-time LiveView interactions. ### Authorize Resource The `authorize_resource` function checks whether the subject, typically stored in `assigns` under `:current_user`, is authorized to access a given resource. If the `:current_user` is not authorized, it sets `assigns.authorized` to `false` and calls the `handle_unauthorized/1` function from the `:error_handler` module configured in `config.exs` or the `:unauthorized_handler` specified in the options. #### Authorization Logic The authorization check is performed using the `can?/3` function from the `Canada.Can` protocol implemeted for `subject`: ```elixir can?(subject, action, resource) ``` where: 1. **Subject** – The entity being authorized, typically fetched from `assigns.current_user`. - By default, Canary looks for `:current_user`. - This key can be overridden via the `opts` or globally in `Application.get_env(:canary, :current_user, :current_user)`. 2. **Action** – The current action being performed. 3. **Resource** – The resource being accessed. - If the resource is already loaded, it is taken from `assigns`. - If the resource is not loaded and not required, the model name is used instead. #### Example Usage ```elixir # Replace `plug` with `mount_canary` for LiveView Hooks plug :authorize_resource, current_user: :current_member, model: Event, as: :public_event ``` In this example: 1. The `authorize_resource` function checks whether `:current_member` (instead of the default `:current_user`) is authorized to access the `Event` resource. 2. The resource is expected to be available in `assigns.public_event`. 3. If the user is unauthorized, `assigns.authorized` is set to false, and the `unauthorized_handler` is triggered. ### Load Resource The `load_resource` function fetches a resource based on an ID provided in `params` and assigns it to `assigns`. By default, it uses the `"id"` key from `params` and retrieves the resource from the database using the `:id` field of the model specified in `opts[:model]`. The loaded resource is stored under `assigns` using a key derived from the model module name. #### Customizing the Load Behavior You can modify the default behavior with the following options: - **`:id_name`** – Override the default `"id"` param key. - **`:id_field`** – Change the field used to query the resource in the database. - **`:as`** – Override the default `assigns` key where the resource is stored. - **`:required`** - When set to `false` it will assign `nil` instad calling the `not_found_handler`. #### Example Usage ```elixir # Replace `plug` with `mount_canary` for LiveView Hooks plug :load_resource, model: Event, as: :public_event, id_name: "uuid", id_field: :uuid, required: false ``` In this example: 1. `load_resource` fetches the `"uuid"` from `params`. 2. It queries `Event` using the `:uuid` field in the database. 3. The result is assigned to `assigns.public_event`. 4. If no matching `Event` is found, `assigns.public_event` will be set to `nil`. To trigger the `not_found_handler` when the resource is missing, ensure the `:required` flag is **not explicitly set to** `false` (it defaults to `true`). ### Load and Authorize Resource The `load_and_authorize_resource` function combines two operations: 1. **Loading the Resource** – Fetches the resource based on an ID from `params` and assigns it to `assigns`, similar to `load_resource`. 2. **Authorizing the Resource** – Checks whether the subject (by default, `:current_user`) is authorized to access the resource, using `authorize_resource`. This function ensures that resources are both retrieved and access-controlled within a single step. > #### Error handler order {: .info} > > If both `:unauthorized_handler` and `:not_found_handler` are specified for `load_and_authorize_resource`, and the request meets the criteria for both, the `:unauthorized_handler` will be called first. ## Non-ID Actions For actions that do not require loading a specific resource (such as `:index`, `:new`, and `:create`), use `:authorize_resource` instead of `:load_resource` or `:load_and_authorize_resource`. Ensure that these functions are limited to actions where resource loading is necessary. By default, the `:required` option is set to `true`, meaning that if the resource cannot be found in the repository, the `not_found_handler` will be called. Setting `:required` to `false` allows the resource to be assigned as `nil`, in which case the model module name will be used as the resource when calling `can?/3`. ### Example Usage ```elixir plug :authorize_resource, model: Post, only: [:index, :new, :create], required: false plug :load_and_authorize_resource, model: Post, except: [:index, :create, :new] ``` ### Loading All Resources in `:index` Action If you need to load multiple resources for the `:index` action, you can either use a plug or load the resources directly within the `index/2` controller action. #### Option 1: Using a Plug ```elixir plug :load_all_resources when action in [:index] defp load_all_resources(conn, _opts) do assign(conn, :posts, Posts.list_posts()) end ``` #### Option 2: Loading Directly in the Controller Action ```elixir def index(conn, _params) do posts = Posts.list_posts() render(conn, "index.html", posts: posts) end ``` ## Nested Resources Sometimes, you need to load and authorize a parent resource when dealing with nested relationships—such as when creating a child resource or listing all children of a parent. With the default `:required` set to true, if the parent resource is not found, the `not_found_handler` will be called. ### Example Usage When loading and authorizing a `Post` resource that `has_many` `Comment` resources: ```elixir # Load and authorize the parent (Post) plug :load_and_authorize_resource, model: Post, id_name: "post_id", only: [:create_comment] # Authorize action the child (Comment) plug :authorize_resource, model: Comment, only: [:create_comment, :save_comment], required: false ``` #### Explanation 1. The first plug loads and authorizes the parent `Post` resource using the `post_id` from `params` in the URL (`/posts/:post_id/comments`). - The `:required` option ensures that if the Post is missing, the `not_found_handler` is called. 2. The second plug authorizes actions on the child `Comment` resource. - Since this is a **non-ID action**, `authorize_resource` is used. - The `Comment` module name is passed as the resource to `can?/3` since no specific `Comment` does not exists yet. This approach ensures that authorization is enforced correctly in nested resource scenarios. ## Defining Permissions To perform authorization checks, you need to implement the [`Canada.Can` protocol](https://github.com/jarednorman/canada) for each subject that requires permission validation. By default, Canary uses `:current_user` from Plug or LiveView assigns as the subject. ### Example: Defining Permissions for an Authenticated User Assume your application has a `User` module for authentication. You can define permissions in `lib/abilities/user.ex`: ```elixir defimpl Canada.Can, for: User do # Super admin can do everything def can?(%User{role: "superadmin"}, _action, _resource), do: true # Post owner can view and modify their own posts def can?(%User{id: user_id}, action, %Post{user_id: user_id}) when action in [:show, :edit, :update], do: true # Deny all other actions by default def can?(%User{id: user_id}, _, _), do: false end ``` ### Handling Anonymous Users If the subject (`:current_user` in assigns) is `nil`, and the authorization check is performed then `can/3` will be performed against `Atom`. For anonymous users, define permissions, for example: `lib/abilities/anonymous.ex`: ```elixir defimpl Canada.Can, for: Atom do # Allow anonymous users to register def can?(nil, :new, User), do: true def can?(nil, :create, User), do: true def can?(nil, :confirm, User), do: true # Allow anonymous users to create sessions def can?(nil, :new, Session), do: true def can?(nil, :create, Session), do: true # Deny all other actions def can?(_, _action, _model), do: false end ``` Defining permissions for `Atom` and `nil` subjects is optional. If your application enforces authentication using a plug like `:require_authenticated_user` in the router pipeline, this may not be necessary. ## Error handling ### Handling Unauthorized Actions By default, when subject is unauthorized to access an action, Canary sets `assigns.authorized` to `false`. However, you can configure a custom handler function to be called when authorization fails. Canary will pass the `Plug.Conn` or `Phoenix.LiveView.Socket` to the specified function, which should accept `conn` or `socket` as its only argument and return a `Plug.Conn` or tuple `{:halt, socket}`. The error handler should implement the `Canary.ErrorHandler` behavior. Refer to the default implementation in `Canary.DefaultHandler`. For example, to have Canary call `ErrorHandler.handle_unauthorized/1`: ```elixir config :canary, error_handler: ErrorHandler ``` > #### LiveView Hook handlers > > In LiveView, the error handler should return `{:halt, socket}`. > For `handle_params`, it should also perform a redirect. ### Handling Resource Not Found By default, when a resource is not found, Canary sets the resource in `assigns` to `nil`. Similar to unauthorized action handling, you can configure a function that Canary will call when a resource is missing. This function will receive the `conn` (for Plugs) or `socket` (for LiveView). ```elixir config :canary, error_handler: ErrorHandler ``` ### Overriding Handlers Per Action You can specify custom handlers per action using `opts` in the `plug` or `mount_canary` call. These handlers will override any globally configured error handlers. ### Conn Plugs ```elixir plug :load_and_authorize_resource Post, unauthorized_handler: {Helpers, :handle_unauthorized}, not_found_handler: {Helpers, :handle_not_found} ``` > **Tip:** If you want to stop request handling after the handler function executes (e.g., for a redirect), > be sure to call `Plug.Conn.halt/1` within your handler: ```elixir def handle_unauthorized(conn) do conn |> put_flash(:error, "You can't access that page!") |> redirect(to: "/") |> halt() end ``` ### LiveView Hooks ```elixir mount_canary :load_and_authorize_resource Post, unauthorized_handler: {Helpers, :handle_unauthorized}, not_found_handler: {Helpers, :handle_not_found} ``` > **Tip:** If you want to stop request handling after the handler function executes (e.g., for a redirect), > be sure to call `Plug.Conn.halt/1` within your handler: ```elixir def handle_unauthorized(socket) do {:halt, Phoenix.LiveView.redirect(socket, to: "/")} end ``` > #### Error handler order {: .info} > > If both an `:unauthorized_handler` and a `:not_found_handler` are specified for `load_and_authorize_resource`, > and the request meets the criteria for both, the `:unauthorized_handler` will be called first. ================================================ FILE: docs/upgrade.md ================================================ # Upgrade guides ## Upgrading from Canary 1.2.0 to 2.0.0 ### Update Your Non-ID Actions > Since 2.0.0, the `:persisted` and `:non_id_actions` options have been deprecated and will be removed in Canary 2.1.0. You need to update plug calls. Using `:authorize_resource` for actions where there is no actual load action is more explicit. Let's assume you currently have the following plug call: ```elixir plug :load_and_authorize_resource, model: Network, non_id_actions: [:index, :create, :new], preload: [:hypervisor] ``` Now let's break it apart: ```elixir plug :authorize_resource, model: Network, only: [:index, :create, :new], required: false plug :load_and_authorize_resource, model: Network, except: [:index, :create, :new], preload: [:hypervisor] ``` For non-ID actions, there is a separate plug for authorization. The `required: false` option marks the resource as optional during authorization checks, and the model module name is used. Essentially, this is how :non_id_actions worked. For actions other than `:index`, `:create`, and `:new`, it will load and authorize resources as usual. To load all resources in the `:index` action, you can set up a plug or add the load function directly in `index/2`. ```elixir # using a plug plug :load_all_resources when action in [:index] defp load_all_resources(conn, _opts) do assign(:networks, Hypervisors.list_hypervisor_networks(hypervisor)) end # directly in the controller action def index(conn, _params) do networks = Hypervisors.list_hypervisor_networks(hypervisor) render(conn, "index.html", networks: networks) end ``` ### Remove `:persisted` option With the [update non-id action](#update-your-non-id-actions) the `:persisted` is no longer required. ================================================ FILE: lib/canary/default_handler.ex ================================================ defmodule Canary.DefaultHandler do @moduledoc """ The fallback Canary handler. This module is used primarily as a backwards compatibility for the `:not_found_handler` and `:unauthorized_handler`. It uses old configuration values to determine how to handle the error. If you are using `Canary` only with `Plug` based authorization then you can still use the `:not_found_handler` and `:unauthorized_handler` configuration values. Otherwise, you should implement the `Canary.ErrorHandler` behaviour in your own module. """ @moduledoc since: "2.0.0" @behaviour Canary.ErrorHandler @doc """ The default handler for when a resource is not found. For Plug based authorization it will use the global `:not_found_handler` or return the conn. For LiveView base authorization it will halt socket. """ @impl true def not_found_handler(%Plug.Conn{} = conn) do case Application.get_env(:canary, :not_found_handler) do {mod, fun} -> apply(mod, fun, [conn]) _ -> conn end end def not_found_handler(%Phoenix.LiveView.Socket{} = socket) do {:halt, Phoenix.LiveView.redirect(socket, to: "/")} end @doc """ The default handler for when a resource is not authorized. For Plug based authorization it will use the global `:unauthorized_handler` or return the conn. For LiveView base authorization it will halt socket. """ @impl true def unauthorized_handler(%Plug.Conn{} = conn) do case Application.get_env(:canary, :unauthorized_handler) do {mod, fun} -> apply(mod, fun, [conn]) _ -> conn end end def unauthorized_handler(%Phoenix.LiveView.Socket{} = socket) do {:halt, Phoenix.LiveView.redirect(socket, to: "/")} end end ================================================ FILE: lib/canary/error_handler.ex ================================================ defmodule Canary.ErrorHandler do @moduledoc """ Specifies the behavior for handling errors in Canary. """ @moduledoc since: "2.0.0" @doc """ Handles the case where a resource is not found. """ @callback not_found_handler(Plug.Conn.t) :: Plug.Conn.t @callback not_found_handler(Phoenix.LiveView.Socket.t) :: {:halt, Phoenix.LiveView.Socket.t} @doc """ Handles the case where a resource is not authorized. """ @callback unauthorized_handler(Plug.Conn.t) :: Plug.Conn.t @callback unauthorized_handler(Phoenix.LiveView.Socket.t) :: {:halt, Phoenix.LiveView.Socket.t} end ================================================ FILE: lib/canary/hooks.ex ================================================ if Code.ensure_loaded?(Phoenix.LiveView) do defmodule Canary.Hooks do @moduledoc """ Hooks functions for loading and authorizing resources for the LiveView events. If you want to authorize `handle_params` and `handle_event` LiveView callbacks you can use `mount_canary` macro to attach the hooks. You can think about the `mount_canary` as something similar to `plug` but for LiveView events. For `handle_params` it uses `socket.assigns.live_action` as `:action`. For `handle_event` it uses the event name as `:action`. > Note that the `event_name` is a string - but in Canary it's converted to an atom for consistency. `Canary.Hooks` and `Canary.Plugs` are separate modules but they share the same API. You can define plugs for standard pages and hooks for LiveView events with the same opts. For the authorization actions, when the `:required` is false (by default it's true) it might be nil. Then the `Canada.Can` implementation should be the module name of the model rather than a struct. ## Example ```elixir use Canary.Hooks mount_canary :load_and_authorize_resource, on: [:handle_params, :handle_event], model: Post, only: [:show, :edit, :update] mount_canary :authorize_resource, on: [:handle_event], model: Post, only: [:my_event], required: false # ... def handle_params(params, _uri, socket) do # resource is already loaded and authorized post = socket.assigns.post end def handle_event("my_event", _unsigned_params, socket) do # Only admin is allowed to perform my_event end ``` `lib/abilities/user.ex`: ```elixir defimpl Canada.Can, for: User do def can?(%User{} = user, :my_event, Post), do: user.role == "admin" def can?(%User{id: id}, _, %Post{user_id: user_id}), do: id == user_id end ``` """ # Copyright 2025 Piotr Baj @moduledoc since: "2.0.0" import Canary.Utils import Canada.Can, only: [can?: 3] import Phoenix.LiveView, only: [attach_hook: 4] import Phoenix.Component, only: [assign: 3] alias Phoenix.LiveView.Socket @doc false defmacro __using__(_opts) do quote do import Canary.Hooks Module.register_attribute(__MODULE__, :canary_hooks, accumulate: true) # Register the Canary.Hooks.__before_compile__/1 callback to be called before Phoenix.LiveView. hooks = Module.delete_attribute(__MODULE__, :before_compile) @before_compile Canary.Hooks Enum.each(hooks, fn {mod, fun} -> @before_compile {mod, fun} end) end end @doc false defmacro __before_compile__(env) do stages = Module.get_attribute(env.module, :canary_hooks, []) |> Enum.reverse() wrapped_hooks = Enum.with_index(stages, &wrap_hooks/2) mount_attach = attach_hooks_on_mount(env, stages) [wrapped_hooks, mount_attach] end defp wrap_hooks({stage, hook, opts}, id) do name = hook_name(stage, hook, id) quote do def unquote(name)(hook_arg_1, hook_arg_2, %Socket{} = socket) do metadata = %{hook: unquote(hook), stage: unquote(stage), opts: unquote(opts)} handle_hook(metadata, [hook_arg_1, hook_arg_2, socket]) end end end defp attach_hooks_on_mount(env, stages) do hooks = Enum.with_index(stages) |> Enum.map(fn {{stage, hook, _opts}, id} -> name = hook_name(stage, hook, id) {name, stage} end) quote bind_quoted: [module: env.module, hooks: hooks] do on_mount {Canary.Hooks, {:initialize, module, hooks}} end end defp hook_name(stage, hook, id) do String.to_atom("#{stage}_#{hook}_#{id}") end @doc false def handle_hook(metadata, [hook_arg_1, hook_arg_2, socket]) do %{hook: hook, stage: stage, opts: opts} = metadata case hook do :load_resource -> load_resource(stage, hook_arg_1, hook_arg_2, socket, opts) :authorize_resource -> authorize_resource(stage, hook_arg_1, hook_arg_2, socket, opts) :load_and_authorize_resource -> load_and_authorize_resource(stage, hook_arg_1, hook_arg_2, socket, opts) _ -> IO.warn( "Invalid type #{inspect(hook)} for Canary hook call. Please review defined hooks with mount_canary/2.", module: __MODULE__ ) {:cont, socket} end end @doc """ Mount canary authorization hooks on the current module. It creates a wrapper function to handle_params and handle_event, and attaches the hooks to the Live View. ## Example ``` mount_canary :load_and_authorize_resource, model: Post, only: [:edit: :update] ``` """ defmacro mount_canary(type, opts) do stages = get_stages(opts) if Enum.empty?(stages), do: IO.warn("mount_canary called with empty :on stages", module: __CALLER__.module, file: __CALLER__.file, line: __CALLER__.line ) Enum.reduce(stages, [], fn stage, acc -> [put_canary_hook(__CALLER__.module, stage, type, opts) | acc] end) |> Enum.reverse() end defp put_canary_hook(module, stage, type, opts) do quote do Module.put_attribute( unquote(module), :canary_hooks, {unquote(stage), unquote(type), unquote(opts)} ) end end @doc false def on_mount({:initialize, mod, stages}, _params, _session, %Socket{} = socket) do socket = Enum.reduce(stages, socket, fn {name, stage}, socket -> fun = Function.capture(mod, name, 3) attach_hook(socket, name, stage, fun) end) {:cont, socket} end def on_mount(_, :not_mounted_at_router, _session, socket), do: {:cont, socket} @doc """ Authorize the `:current_user` for the ginve resource. If the `:current_user` is not authorized it will halt the socket. For the authorization check, it uses the `can?/3` function from the `Canada.Can` module - `can?(subject, action, resource)` where: 1. The subject is the `:current_user` from the socket assigns. The `:current_user` key can be changed in the `opts` or in the `Application.get_env(:canary, :current_user, :current_user)`. By default it's `:current_user`. 2. The action for `handle_params` is `socket.assigns.live_action`, for `handle_event` it uses the event name. 3. The resource is the loaded resource from the socket assigns or the model name if the resource is not loaded and not required. Required opts: * `:model` - Specifies the module name of the model to load resources from * `:on` - Specifies the LiveView lifecycle stages to attach the hook. Default :handle_params Optional opts: * `:only` - Specifies which actions to authorize * `:except` - Specifies which actions for which to skip authorization > For `handle_params` it uses `socket.assigns.live_action` as `:action`. > For `handle_event` it uses the event name as `:action`. * `:as` - Specifies the `resource_name` to get from assigns * `:current_user` - Specifies the key in the socket assigns to get the current user * `:required` - Specifies if the resource is required, when it's not assigned in socket it will halt the socket * `:unauthorized_handler` - Specify a handler function to be called if the action is unauthorized Example: ```elixir mount_canary :authorize_resource, model: Post, only: [:show, :edit, :update] current_user: :current_user mount_canary :authorize_resource, model: Post, as: :custom_resource_name, except: [:new, :create], unauthorized_handler: {ErrorHandler, :unauthorized_handler} ``` """ def authorize_resource(:handle_params, _params, _uri, %Socket{} = socket, opts) do action = socket.assigns.live_action do_authorize_resource(action, socket, opts) end def authorize_resource(:handle_event, event_name, _unsigned_params, %Socket{} = socket, opts) do action = String.to_atom(event_name) do_authorize_resource(action, socket, opts) end @doc """ Loads the resource and assigns it to the socket. When resource is required it will halt the socket if the resource is not found. `load_resource` wrapper for attached hook functions, similar to the `load_resource/2` plug but for LiveView events on `:handle_params` and `:handle_event` stages. Required opts: * `:model` - Specifies the module name of the model to load resources from * `:on` - Specifies the LiveView lifecycle stages to attach the hook. Default :handle_params Optional opts: * `:only` - Specifies which actions to authorize * `:except` - Specifies which actions for which to skip authorization > For `handle_params` it uses `socket.assigns.live_action` as `:action`. > For `handle_event` it uses the event name as `:action`. * `:as` - Specifies the `resource_name` to use in assigns * `:preload` - Specifies association(s) to preload * `:id_name` - Specifies the name of the id in `params`, defaults to "id" * `:id_field` - Specifies the name of the ID field in the database for searching :id_name value, defaults to "id". * `:required` - Specifies if the resource is required, when it's not found it will halt the socket * `:not_found_handler` - Specify a handler function to be called if the resource is not found Example: ```elixir mount_canary :load_resource, model: Post, only: [:show, :edit, :update], preload: [:comments] mount_canary :load_resource, on: [:handle_params, :handle_event] model: Post, as: :custom_name, except: [:new, :create], preload: [:comments], not_found_handler: {ErrorHandler, :not_found_handler} ``` """ def load_resource(:handle_params, params, _uri, %Socket{} = socket, opts) do action = socket.assigns.live_action do_load_resource(action, socket, params, opts) end def load_resource(:handle_event, event_name, unsigned_params, %Socket{} = socket, opts) do action = String.to_atom(event_name) do_load_resource(action, socket, unsigned_params, opts) end @doc """ Loads and autorize resource and assigns it to the socket. When resource is required it will halt the socket if the resource is not found. If the user is not authorized it will halt the socket. It combines `load_resource` and `authorize_resource` functions. Required opts: * `:model` - Specifies the module name of the model to load resources from * `:on` - Specifies the LiveView lifecycle stages to attach the hook. Default :handle_params Optional opts: * `:only` - Specifies which actions to authorize * `:except` - Specifies which actions for which to skip authorization > For `handle_params` it uses `socket.assigns.live_action` as `:action`. > For `handle_event` it uses the event name as `:action`. * `:as` - Specifies the `resource_name` to use in assigns * `:current_user` - Specifies the key in the socket assigns to get the current user * `:preload` - Specifies association(s) to preload * `:id_name` - Specifies the name of the id in `params`, defaults to "id" * `:id_field` - Specifies the name of the ID field in the database for searching :id_name value, defaults to "id". * `:required` - Specifies if the resource is required, when it's not found it will halt the socket, default true * `:not_found_handler` - Specify a handler function to be called if the resource is not found * `:unauthorized_handler` - Specify a handler function to be called if the action is unauthorized Example: ```elixir mount_canary :load_and_authorize_resource, model: Comments, id_name: :post_id, id_field: :post_id, only: [:comments] mount_canary :load_and_authorize_resource, model: Post, as: :custom_name, except: [:new, :create], preload: [:comments], error_handler: CustomErrorHandler ``` """ def load_and_authorize_resource(:handle_params, params, _uri, %Socket{} = socket, opts) do action = socket.assigns.live_action do_load_and_authorize_resource(action, params, socket, opts) end def load_and_authorize_resource( :handle_event, event_name, unsigned_params, %Socket{} = socket, opts ) do action = String.to_atom(event_name) do_load_and_authorize_resource(action, unsigned_params, socket, opts) end defp do_load_resource(action, socket, params, opts) do if action_valid?(action, opts) do assign(socket, :canary_action, action) |> load_resource(params, opts) |> verify_resource(opts) else {:cont, socket} end end defp do_load_and_authorize_resource(action, params, socket, opts) do if action_valid?(action, opts) do assign(socket, :canary_action, action) |> load_resource(params, opts) |> check_authorization(action, opts) |> verify_authorized_resource(opts) else {:cont, socket} end end defp do_authorize_resource(action, socket, opts) do if action_valid?(action, opts) do assign(socket, :canary_action, action) |> check_authorization(action, opts) |> verify_authorized_resource(opts) else {:cont, socket} end end # Check if the resource is already loaded in the socket assigns # If not we need to load and assign it defp load_resource(%Socket{} = socket, params, opts) do resource = case fetch_resource(socket, opts) do {:ok, resource} -> resource _ -> repo_get_resource(params, opts) end action = socket.assigns.canary_action assign(socket, get_resource_name(action, opts), resource) end # Fetch the resource from the socket assigns or nil defp fetch_resource(%Socket{} = socket, opts) do action = socket.assigns.canary_action case Map.get(socket.assigns, get_resource_name(action, opts), nil) do resource when is_struct(resource) -> if resource.__struct__ == opts[:model] do {:ok, resource} else nil end _ -> nil end end # Load the resource from the repo defp repo_get_resource(params, opts) do repo = Application.get_env(:canary, :repo) field_name = Keyword.get(opts, :id_field, "id") get_map_args = %{String.to_atom(field_name) => get_resource_id(params, opts)} repo.get_by(opts[:model], get_map_args) |> preload_if_needed(repo, opts) end # Perform the authorization check defp check_authorization(%Socket{} = socket, action, opts) do current_user_name = opts[:current_user] || Application.get_env(:canary, :current_user, :current_user) current_user = Map.fetch(socket.assigns, current_user_name) resource = fetch_resoruce_or_model(socket, opts) case {current_user, resource} do {{:ok, _current_user}, nil} -> assign(socket, :authorized, false) {{:ok, current_user}, _} -> assign(socket, :authorized, can?(current_user, action, resource)) _ -> assign(socket, :authorized, false) end end # Fetch resource form assigns or model name if empty and not required defp fetch_resoruce_or_model(%Socket{} = socket, opts) do case fetch_resource(socket, opts) do {:ok, resource} -> resource _ -> if required?(opts) do nil else opts[:model] end end end # Verify if subject is authorized to perform action on resource defp verify_authorized_resource(%Socket{} = socket, opts) do authorized = Map.get(socket.assigns, :authorized, false) if authorized do verify_resource(socket, opts) else apply_error_handler(socket, :unauthorized_handler, opts) end end # Verify if the resource is loaded and if it is required defp verify_resource(%Socket{} = socket, opts) do is_required = required?(opts) resource = fetch_resource(socket, opts) if is_nil(resource) && is_required do apply_error_handler(socket, :not_found_handler, opts) else {:cont, socket} end end defp get_stages(opts) do Keyword.get(opts, :on, :handle_params) |> validate_stages() end defp validate_stages(stage) when is_atom(stage), do: validate_stages([stage]) defp validate_stages(stages) when is_list(stages) do allowed_satges = [:handle_params, :handle_event] Enum.filter(stages, &Enum.member?(allowed_satges, &1)) end end end ================================================ FILE: lib/canary/plugs.ex ================================================ defmodule Canary.Plugs do import Canary.Utils import Canada.Can, only: [can?: 3] import Ecto.Query @moduledoc """ Plug functions for loading and authorizing resources for the current request. The plugs all store data in conn.assigns (in Phoenix applications, keys in conn.assigns can be accessed with `@key_name` in templates) In order to use the plug functions, you must `import Canary.Plugs`. You must also specify the Ecto repo to use in your configuration: ``` config :canary, repo: Project.Repo ``` If you wish, you may also specify the key where Canary will look for the current user record to authorize against: ``` config :canary, current_user: :some_current_user ``` You can specify a error handler module (in this case, `Helpers`) to be called when an action is unauthorized like so: ```elixir config :canary, error_handler: Helpers ``` Module should implement the `Canary.ErrorHandler` behaviour. Canary will pass the `conn` to the handler function. """ @doc """ Load the given resource. Load the resource with id given by `conn.params["id"]` (or `conn.params[opts[:id_name]]` if `opts[:id_name]` is specified) and ecto model given by `opts[:model]` into `conn.assigns.resource_name`. `resource_name` is either inferred from the model name or specified in the plug declaration with the `:as` key. To infer the `resource_name`, the most specific(right most) name in the model's module name will be used, converted to underscore case. For example, `load_resource model: Some.Project.BlogPost` will load the resource into `conn.assigns.blog_post` If the resource cannot be fetched, `conn.assigns.resource_name` is set to nil. By default, when the action is `:index`, all records from the specified model will be loaded. This can be overridden to fetch a single record from the database by using the `:persisted` key. Currently, `:new` and `:create` actions are ignored, and `conn.assigns.resource_name` will be set to nil for these actions. This can be overridden to fetch a single record from the database by using the `:persisted` key. The `:persisted` key can override how a resource is loaded and can be useful when dealing with nested resources. Required opts: * `:model` - Specifies the module name of the model to load resources from Optional opts: * `:as` - Specifies the `resource_name` to use * `:only` - Specifies which actions to authorize * `:except` - Specifies which actions for which to skip authorization * `:preload` - Specifies association(s) to preload * `:id_name` - Specifies the name of the id in `conn.params`, defaults to "id" * `:id_field` - Specifies the name of the ID field in the database for searching :id_name value, defaults to "id". * `:persisted` - Specifies the resource should always be loaded from the database, defaults to false * `:required` - Same as `:persisted` but with not found handler - even for :index, :new or :create action * `:not_found_handler` - Specify a handler function to be called if the resource is not found Examples: ``` plug :load_resource, model: Post plug :load_resource, model: User, preload: :posts, as: :the_user plug :load_resource, model: User, only: [:index, :show], preload: :posts, as: :person plug :load_resource, model: User, except: [:destroy] plug :load_resource, model: Post, id_name: "post_id", only: [:new, :create], persisted: true plug :load_resource, model: Post, id_name: "slug", id_field: "slug", only: [:show], persisted: true ``` """ @spec load_resource(Plug.Conn.t(), Plug.opts()) :: Plug.Conn.t() def load_resource(conn, opts) do action = get_action(conn) validate_opts(opts) if action_valid?(action, opts) do conn |> do_load_resource(opts) |> handle_not_found(opts) else conn end end defp do_load_resource(conn, opts) do action = get_action(conn) is_persisted = persisted?(opts) validate_opts(opts) loaded_resource = cond do is_persisted -> fetch_resource(conn, opts) action == :index -> fetch_all(conn, opts) action in [:new, :create] -> nil true -> fetch_resource(conn, opts) end Plug.Conn.assign(conn, get_resource_name(action, opts), loaded_resource) end @doc """ Authorize the current user against the calling controller. In order to use this function, 1) `conn.assigns[Application.get_env(:canary, :current_user, :current_user)]` must be an ecto struct representing the current user 2) `conn.private` must be a map (this should not be a problem unless you explicitly modified it) authorize_controller checks for the name of the current controller in one of the following places 1) :phoenix_controller in conn.private 2) :canary_controller in conn.assigns In case you are not using phoenix, make sure you set the controller name in the conn.assigns Note that in case neither of `:phoenix_controller` or `:canary_controller` are found the requested authorization won't necessarily fail, rather it will trigger a `.can?` function with a `nil` controller If authorization succeeds, sets `conn.assigns.authorized` to true. If authorization fails, sets `conn.assigns.authorized` to false. Optional opts: * `:only` - Specifies which actions to authorize * `:except` - Specifies which actions for which to skip authorization * `:unauthorized_handler` - Specify a handler function to be called if the action is unauthorized Examples: ``` plug :authorize_controller plug :authorize_controller, only: [:index, :show] plug :authorize_controller, except: [:destroy] ``` """ @spec authorize_controller(Plug.Conn.t(), Plug.opts()) :: Plug.Conn.t() def authorize_controller(conn, opts) do action = get_action(conn) validate_opts(opts) if action_valid?(action, opts) do do_authorize_controller(conn, opts) |> handle_unauthorized(opts) else conn end end defp do_authorize_controller(conn, opts) do controller = conn.assigns[:canary_controller] || conn.private[:phoenix_controller] current_user_name = opts[:current_user] || Application.get_env(:canary, :current_user, :current_user) current_user = Map.fetch!(conn.assigns, current_user_name) action = get_action(conn) Plug.Conn.assign(conn, :authorized, can?(current_user, action, controller)) end @doc """ Authorize the current user for the given resource. In order to use this function, 1) `conn.assigns[Application.get_env(:canary, :current_user, :current_user)]` must be an ecto struct representing the current user 2) `conn.private` must be a map (this should not be a problem unless you explicitly modified it) If authorization succeeds, sets `conn.assigns.authorized` to true. If authorization fails, sets `conn.assigns.authorized` to false. For the `:index`, `:new`, and `:create` actions, the resource in the `Canada.Can` implementation should be the module name of the model rather than a struct. A struct should be used instead of the module name only if the `:persisted` key is used and you want to override the default authorization behavior. This can be useful when dealing with nested resources. For example: use ``` def can?(%User{}, :index, Post), do: true ``` instead of ``` def can?(%User{}, :index, %Post{}), do: true ``` or use ``` def can?(%User{id: user_id}, :index, %Post{user_id: user_id}), do: true ``` if you are dealing with a nested resource, such as, "/post/post_id/comments" You can specify additional actions for which Canary will authorize based on the model name, by passing the `non_id_actions` opt to the plug. For example, ```elixir plug :authorize_resource, model: Post, non_id_actions: [:find_by_name] ``` Required opts: * `:model` - Specifies the module name of the model to authorize access to Optional opts: * `:only` - Specifies which actions to authorize * `:except` - Specifies which actions for which to skip authorization * `:preload` - Specifies association(s) to preload * `:id_name` - Specifies the name of the id in `conn.params`, defaults to "id" * `:id_field` - Specifies the name of the ID field in the database for searching :id_name value, defaults to "id". * `:persisted` - Specifies the resource should always be loaded from the database, defaults to false * `:unauthorized_handler` - Specify a handler function to be called if the action is unauthorized Examples: ``` plug :authorize_resource, model: Post plug :authorize_resource, model: User, preload: :posts plug :authorize_resource, model: User, only: [:index, :show], preload: :posts plug :load_resource, model: Post, id_name: "post_id", only: [:index], persisted: true, preload: :comments plug :load_resource, model: Post, id_name: "slug", id_field: "slug", only: [:show], persisted: true ``` """ @spec authorize_resource(Plug.Conn.t(), Plug.opts()) :: Plug.Conn.t() def authorize_resource(conn, opts) do action = get_action(conn) if action_valid?(action, opts) do do_authorize_resource(conn, opts) |> handle_unauthorized(opts) else conn end end defp do_authorize_resource(conn, opts) do current_user_name = opts[:current_user] || Application.get_env(:canary, :current_user, :current_user) current_user = Map.fetch!(conn.assigns, current_user_name) action = get_action(conn) is_persisted = persisted?(opts) non_id_actions = if opts[:non_id_actions] do Enum.concat([:index, :new, :create], opts[:non_id_actions]) else [:index, :new, :create] end resource = cond do is_persisted -> fetch_resource(conn, opts) action in non_id_actions -> opts[:model] true -> fetch_resource(conn, opts) end Plug.Conn.assign(conn, :authorized, can?(current_user, action, resource)) end @doc """ Authorize the given resource and then load it if authorization succeeds. If the resource cannot be loaded or authorization fails, conn.assigns.resource_name is set to nil. The result of the authorization (true/false) is assigned to conn.assigns.authorized. Also, see the documentation for load_resource/2 and authorize_resource/2. Required opts: * `:model` - Specifies the module name of the model to load resources from Optional opts: * `:as` - Specifies the `resource_name` to use * `:only` - Specifies which actions to authorize * `:except` - Specifies which actions for which to skip authorization * `:preload` - Specifies association(s) to preload * `:id_name` - Specifies the name of the id in `conn.params`, defaults to "id" * `:id_field` - Specifies the name of the ID field in the database for searching :id_name value, defaults to "id". * `:unauthorized_handler` - Specify a handler function to be called if the action is unauthorized * `:not_found_handler` - Specify a handler function to be called if the resource is not found Note: If both an `:unauthorized_handler` and a `:not_found_handler` are specified for `load_and_authorize_resource`, and the request meets the criteria for both, the `:unauthorized_handler` will be called first. Examples: ``` plug :load_and_authorize_resource, model: Post plug :load_and_authorize_resource, model: User, preload: :posts, as: :the_user plug :load_and_authorize_resource, model: User, only: [:index, :show], preload: :posts, as: :person plug :load_and_authorize_resource, model: User, except: [:destroy] plug :load_and_authorize_resource, model: Post, id_name: "slug", id_field: "slug", only: [:show], persisted: true ``` """ def load_and_authorize_resource(conn, opts) do action = get_action(conn) if action_valid?(action, opts) do do_load_and_authorize_resource(conn, opts) else conn end end defp do_load_and_authorize_resource(conn, opts) do conn |> do_load_resource(opts) |> authorize_resource(opts) |> maybe_handle_not_found(opts) |> purge_resource_if_unauthorized(opts) end # Only try to handle 404 if the response has not been sent during authorization handling defp maybe_handle_not_found(%{state: :sent} = conn, _opts), do: conn defp maybe_handle_not_found(conn, opts), do: handle_not_found(conn, opts) defp purge_resource_if_unauthorized(%{assigns: %{authorized: true}} = conn, _opts), do: conn defp purge_resource_if_unauthorized(%{assigns: %{authorized: false}} = conn, opts) do action = get_action(conn) Plug.Conn.assign(conn, get_resource_name(action, opts), nil) end defp fetch_resource(conn, opts) do repo = Application.get_env(:canary, :repo) action = get_action(conn) field_name = Keyword.get(opts, :id_field, "id") get_map_args = %{String.to_atom(field_name) => get_resource_id(conn, opts)} case Map.fetch(conn.assigns, get_resource_name(action, opts)) do :error -> repo.get_by(opts[:model], get_map_args) |> preload_if_needed(repo, opts) {:ok, nil} -> repo.get_by(opts[:model], get_map_args) |> preload_if_needed(repo, opts) {:ok, resource} -> if resource.__struct__ == opts[:model] do # A resource of the type passed as opts[:model] is already loaded; do not clobber it resource else opts[:model] |> repo.get_by(get_map_args) |> preload_if_needed(repo, opts) end end end defp fetch_all(conn, opts) do repo = Application.get_env(:canary, :repo) action = get_action(conn) resource_name = get_resource_name(action, opts) # check if a resource is already loaded at the key case Map.fetch(conn.assigns, resource_name) do :error -> from(m in opts[:model]) |> select([m], m) |> repo.all |> preload_if_needed(repo, opts) {:ok, resources} -> if Enum.at(resources, 0).__struct__ == opts[:model] do resources else from(m in opts[:model]) |> select([m], m) |> repo.all |> preload_if_needed(repo, opts) end end end defp get_action(conn) do case Map.fetch(conn.assigns, :canary_action) do {:ok, action} -> action _ -> conn.private.phoenix_action end end defp handle_unauthorized(%{assigns: %{authorized: true}} = conn, _opts), do: conn defp handle_unauthorized(%{assigns: %{authorized: false}} = conn, opts), do: apply_error_handler(conn, :unauthorized_handler, opts) defp handle_not_found(conn, opts) do action = get_action(conn) if apply_handle_not_found?(action, conn.assigns, opts) do apply_error_handler(conn, :not_found_handler, opts) else conn end end end ================================================ FILE: lib/canary/utils.ex ================================================ defmodule Canary.Utils do @moduledoc """ Common utils functions for `Canary.Plugs` and `Canary.Hooks` """ @doc """ Get the resource id from the connection params iex> Canary.Utils.get_resource_id(%{"id" => "9"}, []) "9" iex> Canary.Utils.get_resource_id(%Plug.Conn{params: %{"custom_id" => "1"}}, id_name: "custom_id") "1" iex> Canary.Utils.get_resource_id(%{"user_id" => "7"}, id_name: "user_id") "7" iex> Canary.Utils.get_resource_id(%{"other_id" => "9"}, id_name: "id") nil """ @moduledoc since: "2.0.0" @spec get_resource_id(Plug.Conn.t(), Keyword.t()) :: String.t() | nil def get_resource_id(%Plug.Conn{params: params}, opts) do get_resource_id(params, opts) end @spec get_resource_id(map(), Keyword.t()) :: String.t() | nil def get_resource_id(params, opts) when is_map(params) do case opts[:id_name] do nil -> params["id"] id_name -> params[id_name] end end @doc """ Preload associations if needed """ @spec preload_if_needed(nil, Ecto.Repo.t(), Keyword.t()) :: nil def preload_if_needed(nil, _repo, _opts), do: nil @spec preload_if_needed([Ecto.Schema.t()], Ecto.Repo.t(), Keyword.t()) :: [Ecto.Schema.t()] def preload_if_needed(records, repo, opts) do case opts[:preload] do nil -> records models -> repo.preload(records, models) end end @doc ~S""" Check if an action is valid based on the options. iex> Canary.Utils.action_valid?(:index, only: [:index, :show]) true iex> Canary.Utils.action_valid?(:index, except: :index) false iex> Canary.Utils.action_valid?(:show, except: :index, only: :show) ** (ArgumentError) You can't use both :except and :only options """ @spec action_valid?(atom, Keyword.t()) :: boolean def action_valid?(action, opts) do cond do Keyword.has_key?(opts, :except) && Keyword.has_key?(opts, :only) -> raise ArgumentError, "You can't use both :except and :only options" Keyword.has_key?(opts, :except) -> !action_exempt?(action, opts) Keyword.has_key?(opts, :only) -> action_included?(action, opts) true -> true end end defp action_exempt?(action, opts) do if is_list(opts[:except]) && action in opts[:except] do true else action == opts[:except] end end defp action_included?(action, opts) do if is_list(opts[:only]) && action in opts[:only] do true else action == opts[:only] end end @doc """ Check if a key is present in a keyword list """ @spec required?(Keyword.t()) :: boolean def required?(opts) do !!Keyword.get(opts, :required, true) end @doc """ Apply the error handler to the connection or socket """ @spec apply_error_handler(Plug.Conn.t(), atom, Keyword.t()) :: Plug.Conn.t() @spec apply_error_handler(Phoenix.LiveView.Socket.t(), atom, Keyword.t()) :: {:halt, Phoenix.LiveView.Socket.t()} def apply_error_handler(conn_or_socket, handler_key, opts) do get_handler(handler_key, opts) |> apply([conn_or_socket]) end defp get_handler(handler_key, opts) do mod_or_mod_fun = Keyword.get(opts, handler_key) || Application.get_env(:canary, :error_handler, Canary.DefaultHandler) case mod_or_mod_fun do {mod, fun} -> Function.capture(mod, fun, 1) mod when is_atom(mod) -> Function.capture(mod, handler_key, 1) _ -> raise ArgumentError, " Invalid error handler, expected a module or a tuple with a module and a function, got: #{inspect(mod_or_mod_fun)}" end end @doc """ Get the resource name from the options, covert it to atom and pluralize it if needed - only for :index action. If the `:as` option is provided, it will be used as the resource name. iex> Canary.Utils.get_resource_name(:show, model: MyApp.Post) :post iex> Canary.Utils.get_resource_name(:index, model: MyApp.Post) :posts iex> Canary.Utils.get_resource_name(:index, model: MyApp.Post, as: :my_posts) :my_posts """ def get_resource_name(action, opts) do case opts[:as] do nil -> opts[:model] |> Module.split() |> List.last() |> Macro.underscore() |> pluralize_if_needed(action, opts) |> String.to_atom() as -> as end end def persisted?(opts) do !!Keyword.get(opts, :persisted, false) || !!Keyword.get(opts, :required, false) end defp pluralize_if_needed(name, action, opts) do if action in [:index] and not persisted?(opts) do name <> "s" else name end end @doc """ Returns the :non_id_actions option if it is present """ def non_id_actions(opts) do if opts[:non_id_actions] do Enum.concat([:index, :new, :create], opts[:non_id_actions]) else [:index, :new, :create] end end @doc """ Check if the not_found handler should be applied for given action, assigns and options """ @spec apply_handle_not_found?(action :: atom(), assigns :: map(), opts :: Keyword.t()) :: boolean() def apply_handle_not_found?(action, assigns, opts) do non_id_actions = non_id_actions(opts) is_required = required?(opts) resource_name = Map.get(assigns, get_resource_name(action, opts)) if is_nil(resource_name) and (is_required or action not in non_id_actions) do true else false end end @doc false def validate_opts(opts) do opts |> warn_deprecated_opts() end defp warn_deprecated_opts(opts) do if Keyword.has_key?(opts, :persisted) do IO.warn("The `:persisted` option is deprecated and will be removed in Canary 2.1.0. Use `:required` instead. Check the documentation for more information.") end if Keyword.has_key?(opts, :non_id_actions) do IO.warn("The `:non_id_actions` option is deprecated and will be removed in Canary 2.1.0. Use separate :authorize_resource plug for non_id_actions and `:except` to exclude non_in_actions. Check the documentation for more information.") end opts end end ================================================ FILE: lib/canary.ex ================================================ defmodule Canary do @moduledoc """ An authorization library in Elixir for `Plug` and `Phoenix.LiveView` applications that restricts what resources the current user is allowed to access, and automatically load and assigns resources. `load_resource/2` and `authorize_resource/2` can be used by themselves, while `load_and_authorize_resource/2` combines them both. The plug functions are defined in `Canary.Plugs` In order to use `Canary` authorization in standard pages with plug, just `import Canary.Plugs` and use plugs, for example: ```elixir defmodule MyAppWeb.PostController do use MyAppWeb, :controller import Canary.Plugs plug :load_and_authorize_resource, model: Post, current_user: :current_user, only: [:show, :edit, :update] end ``` The LiveView hooks are defined in `Canary.Hooks` In order to use `Canary` authorization in LiveView, just `use Canary.Hooks` and mount hooks, for example: ```elixir defmodule MyAppWeb.PostLive do use MyAppWeb, :live_view use Canary.Hooks mount_canary :load_and_authorize_resource, on: [:handle_params, :handle_event], current_user: :current_user, model: Post, only: [:show, :edit, :update] end ``` This will attach hooks to the LiveView module with `Phoenix.LiveView.attach_hook/4`. In the example above hooks will be attached to `handle_params` and `handle_event` stages of the LiveView lifecycle. Please read the documentation for `Canary.Plugs` and `Canary.Hooks` for more information. """ end ================================================ FILE: mix.exs ================================================ defmodule Canary.Mixfile do use Mix.Project def project do [ app: :canary, version: "2.0.0-dev", elixir: "~> 1.14", package: package(), description: """ An authorization library to restrict what resources the current user is allowed to access, and load those resources for you. """, build_embedded: Mix.env() == :prod, start_permanent: Mix.env() == :prod, deps: deps(), consolidate_protocols: false, elixirc_paths: elixirc_paths(Mix.env()), test_options: [docs: true], test_coverage: [summary: [threshold: 85], ignore_modules: coverage_ignore_modules()], docs: [ extras: [ "docs/getting-started.md", "README.md", "CHANGELOG.md", "docs/upgrade.md", ], groups_for_modules: [ "Error Handler": [Canary.ErrorHandler, Canary.DefaultHandler], ] ] ] end defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] def application do [extra_applications: [:logger]] end defp package do [ maintainers: ["Chris Kelly"], licenses: ["MIT"], links: %{"GitHub" => "https://github.com/cpjk/canary"} ] end defp deps do [ {:ecto, ">= 1.1.0"}, {:canada, "~> 2.0.0"}, {:plug, "~> 1.10"}, {:ex_doc, "~> 0.7", only: :dev}, {:earmark, ">= 0.0.0", only: :dev}, {:mock, ">= 0.0.0", only: :test}, {:credo, "~> 1.0", only: [:dev, :test]}, {:phoenix, "~> 1.6", optional: true}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", optional: true}, {:floki, ">= 0.30.0", only: :test} ] end defp coverage_ignore_modules do [ ~r/Canary\.HooksHelper\..*/ ] end end ================================================ FILE: test/canary/default_handler_test.exs ================================================ defmodule CustomHandlers do def not_found_handler(conn) do conn |> Plug.Conn.assign(:legacy_error_handler, true) end def unauthorized_handler(conn) do conn |> Plug.Conn.assign(:legacy_error_handler, true) end end defmodule DefaultHandlerTest do use ExUnit.Case, async: false import Plug.Adapters.Test.Conn, only: [conn: 4] describe "not_found_handler/1" do test "calls the global :not_found_handler for plug based authorization" do Application.put_env(:canary, :not_found_handler, {CustomHandlers, :not_found_handler}) params = %{"id" => "30"} conn = conn( %Plug.Conn{private: %{phoenix_action: :show}}, :get, "/posts/30", params ) |> Canary.DefaultHandler.not_found_handler() assert conn.assigns[:legacy_error_handler] == true assert conn.assigns[:post] == nil end test "returns conn when error_handler is not defined" do Application.put_env(:canary, :not_found_handler, nil) params = %{"id" => "30"} conn = conn( %Plug.Conn{private: %{phoenix_action: :show}}, :get, "/posts/30", params ) |> Canary.DefaultHandler.not_found_handler() assert conn.assigns[:post] == nil refute conn.assigns[:legacy_error_handler] == true end test "halts the socket for liveview based authorization" do assert {:halt, socket} = %Phoenix.LiveView.Socket{assigns: %{}} |> Canary.DefaultHandler.not_found_handler() assert {:redirect, %{to: "/"}} = socket.redirected end end describe "unauthorized_handler/1" do test "calls the global :not_found_handler for plug based authorization" do Application.put_env(:canary, :unauthorized_handler, {CustomHandlers, :unauthorized_handler}) params = %{"id" => "30"} conn = conn( %Plug.Conn{private: %{phoenix_action: :show}}, :get, "/posts/30", params ) |> Canary.DefaultHandler.unauthorized_handler() assert conn.assigns[:legacy_error_handler] == true assert conn.assigns[:post] == nil end test "returns conn when error_handler is not defined" do Application.put_env(:canary, :unauthorized_handler, nil) params = %{"id" => "30"} conn = conn( %Plug.Conn{private: %{phoenix_action: :show}}, :get, "/posts/30", params ) |> Canary.DefaultHandler.unauthorized_handler() assert conn.assigns[:post] == nil refute conn.assigns[:legacy_error_handler] == true end test "halts the socket for liveview based authorization" do assert {:halt, socket} = %Phoenix.LiveView.Socket{assigns: %{}} |> Canary.DefaultHandler.unauthorized_handler() assert {:redirect, %{to: "/"}} = socket.redirected end end end ================================================ FILE: test/canary/hooks_test.exs ================================================ defmodule Canary.HooksTest do use ExUnit.Case, async: true import Phoenix.ConnTest import Phoenix.LiveViewTest alias Canary.HooksHelper.{PageLive, PostLive} @endpoint Canary.HooksHelper.Endpoint setup_all do Application.put_env(:canary, Canary.HooksHelper.Endpoint, live_view: [signing_salt: "eTh8jeshoe2Bie4e"], secret_key_base: String.duplicate("57689", 50) ) Application.put_env(:canary, :repo, Repo) start_supervised!(Canary.HooksHelper.Endpoint) |> Process.link() conn = Plug.Test.init_test_session(build_conn(), %{}) |> Plug.Conn.assign(:current_user, %User{id: 1}) {:ok, conn: conn} end describe "handle_hook/2" do test "load_resource hook on handle_params loads resource when is available" do uri = "http://localhost/post" metadata = %{hook: :load_resource, stage: :handle_params, opts: [model: Post, required: false]} params = %{} assert {:cont, socket} = Canary.Hooks.handle_hook(metadata, [params, uri, build_socket()]) assert socket.assigns.post == nil params = %{"id" => "1"} assert {:cont, socket} = Canary.Hooks.handle_hook(metadata, [params, uri, build_socket()]) assert socket.assigns.post == %Post{id: 1} end test "load_resource accepts already assigned resource when it matches" do uri = "http://localhost/post" metadata = %{hook: :load_resource, stage: :handle_params, opts: [model: Post]} params = %{"id" => "2"} socket = build_socket() |> put_assigns(%{post: %Post{id: 2}}) assert {:cont, socket} = Canary.Hooks.handle_hook(metadata, [params, uri, socket]) assert socket.assigns.post == %Post{id: 2} socket = build_socket() |> put_assigns(%{post: %User{id: 1}}) assert {:cont, socket} = Canary.Hooks.handle_hook(metadata, [params, uri, socket]) assert socket.assigns.post == %Post{id: 2, user_id: 2} end test "load_resource hook on handle_params assigns nil when resource is not available" do uri = "http://localhost/post" params = %{"id" => "13"} metadata = %{ hook: :load_resource, stage: :handle_params, opts: [model: Post] } assert {:halt, socket} = Canary.Hooks.handle_hook(metadata, [params, uri, build_socket()]) assert socket.assigns.post == nil end test "load_resource hook on handle_event" do metadata = %{ hook: :load_resource, stage: :handle_event, opts: [model: Post] } params = %{"id" => "1"} assert {:cont, socket} = Canary.Hooks.handle_hook(metadata, ["my_event", params, build_socket()]) assert socket.assigns.post == %Post{id: 1} params = %{"id" => "13"} assert {:halt, socket} = Canary.Hooks.handle_hook(metadata, ["my_event", params, build_socket()]) assert socket.assigns.post == nil end test "authorize_resource hook on handle_params" do uri = "http://localhost/post" metadata = %{hook: :authorize_resource, stage: :handle_params, opts: [model: Post, required: false]} params = %{} socket = build_socket() |> put_assigns(%{post: %Post{id: 1}, current_user: %User{id: 1}}) assert {:cont, socket} = Canary.Hooks.handle_hook(metadata, [params, uri, socket]) assert socket.assigns.authorized == true socket = build_socket(:delete) |> put_assigns(%{post: %Post{id: 1}, current_user: %User{id: 1}}) assert {:halt, socket} = Canary.Hooks.handle_hook(metadata, [params, uri, socket]) assert socket.assigns.authorized == false socket = build_socket(:create) |> put_assigns(%{current_user: %User{id: 1}}) assert {:cont, socket} = Canary.Hooks.handle_hook(metadata, [params, uri, socket]) assert socket.assigns.authorized == true end test "authorize_resource hook on handle_event" do metadata = %{hook: :authorize_resource, stage: :handle_event, opts: [model: Post]} params = %{} socket = build_socket() |> put_assigns(%{post: %Post{id: 1}, current_user: %User{id: 1}}) assert {:cont, socket} = Canary.Hooks.handle_hook(metadata, ["create", params, socket]) assert socket.assigns.authorized == true socket = build_socket(:delete) |> put_assigns(%{post: %Post{id: 1}, current_user: %User{id: 1}}) assert {:halt, socket} = Canary.Hooks.handle_hook(metadata, ["delete", params, socket]) assert socket.assigns.authorized == false end test "load_and_authorize_resource on handle_params" do uri = "http://localhost/post" metadata = %{ hook: :load_and_authorize_resource, stage: :handle_params, opts: [model: Post, preload: :user] } params = %{"id" => "1"} socket = build_socket(:edit) |> put_assigns(%{current_user: %User{id: 1}}) assert {:cont, socket} = Canary.Hooks.handle_hook(metadata, [params, uri, socket]) assert socket.assigns.post == %Post{id: 1} |> Repo.preload(:user) assert socket.assigns.authorized == true socket = build_socket() |> put_assigns(%{current_user: %User{id: 1}}) params = %{"id" => "13"} assert {:halt, socket} = Canary.Hooks.handle_hook(metadata, [params, uri, socket]) assert socket.assigns.post == nil assert socket.assigns.authorized == false end test "load_and_authorize_resource on handle_event" do metadata = %{ hook: :load_and_authorize_resource, stage: :handle_event, opts: [model: Post, preload: :user] } params = %{"id" => "1"} socket = build_socket() |> put_assigns(%{current_user: %User{id: 1}}) assert {:cont, socket} = Canary.Hooks.handle_hook(metadata, ["edit", params, socket]) assert socket.assigns.post == %Post{id: 1} |> Repo.preload(:user) assert socket.assigns.authorized == true socket = build_socket() |> put_assigns(%{current_user: %User{id: 1}}) params = %{"id" => "13"} assert {:halt, socket} = Canary.Hooks.handle_hook(metadata, ["update", params, socket]) assert socket.assigns.post == nil assert socket.assigns.authorized == false end test "accepts :id_field to override the default id field" do uri = "http://localhost/post" metadata = %{hook: :load_resource, stage: :handle_params, opts: [model: Post, id_field: "slug"]} params = %{"id" => "slug1"} assert {:cont, socket} = Canary.Hooks.handle_hook(metadata, [params, uri, build_socket()]) assert socket.assigns.post == %Post{id: 1, slug: "slug1"} metadata = %{hook: :load_resource, stage: :handle_params, opts: [model: Post]} params = %{"id" => "1"} assert {:cont, socket} = Canary.Hooks.handle_hook(metadata, [params, uri, build_socket()]) assert socket.assigns.post == %Post{id: 1} end test "accepts :id_name to override the default id field" do uri = "http://localhost/post" metadata = %{hook: :load_resource, stage: :handle_params, opts: [model: Post, id_name: "blog_post_id"]} params = %{"blog_post_id" => "2"} assert {:cont, socket} = Canary.Hooks.handle_hook(metadata, [params, uri, build_socket()]) assert socket.assigns.post == Repo.get(Post, 2) params = %{"id" => "1"} assert {:halt, socket} = Canary.Hooks.handle_hook(metadata, [params, uri, build_socket()]) assert socket.assigns.post == nil end test "accepts :preload to preload the resource" do uri = "http://localhost/post" metadata = %{hook: :load_resource, stage: :handle_params, opts: [model: Post, preload: :user]} params = %{"id" => "1"} assert {:cont, socket} = Canary.Hooks.handle_hook(metadata, [params, uri, build_socket()]) assert socket.assigns.post == %Post{id: 1} |> Repo.preload(:user) metadata = %{hook: :load_resource, stage: :handle_params, opts: [model: Post]} params = %{"id" => "1"} assert {:cont, socket} = Canary.Hooks.handle_hook(metadata, [params, uri, build_socket()]) assert socket.assigns.post != %Post{id: 1} |> Repo.preload(:user) end test "accepts :as to override the default assign name" do uri = "http://localhost/post" metadata = %{hook: :load_resource, stage: :handle_params, opts: [model: Post, as: :my_post]} params = %{"id" => "1"} assert {:cont, socket} = Canary.Hooks.handle_hook(metadata, [params, uri, build_socket()]) assert socket.assigns.my_post == %Post{id: 1} end test "accepts :current_user to override the default subject assign name" do uri = "http://localhost/post" metadata = %{ hook: :authorize_resource, stage: :handle_params, opts: [model: Post, current_user: :my_user] } params = %{} socket = build_socket() |> put_assigns(%{post: %Post{id: 1}, current_user: %User{id: 1}}) assert {:halt, socket} = Canary.Hooks.handle_hook(metadata, [params, uri, socket]) assert socket.assigns.authorized == false end test "emits a warning when the hook is not defined" do metadata = %{hook: :invalid_hook, stage: :handle_params, opts: [model: Post]} assert ExUnit.CaptureIO.capture_io(:stderr, fn -> {:cont, socket} = Canary.Hooks.handle_hook(metadata, [%{}, "http://localhost/post", build_socket()]) assert_raise KeyError, ~r/key :post not found in/, fn -> Map.fetch!(socket.assigns, :post) end end) =~ "Invalid type :invalid_hook for Canary hook call. Please review defined hooks with mount_canary/2" end end describe "integration for :load_resource" do test "it loads the resource correctly", %{conn: conn} do {:ok, lv, _html} = live(conn, "/post/1") assert %{post: %Post{id: 1}} = PostLive.fetch_assigns(lv) end test "it halt the socket when the resource is required", %{conn: conn} do assert {:error, {:redirect, %{to: "/"}}} = live(conn, "/post/13/edit") assert {:error, {:redirect, %{to: "/"}}} = live(conn, "/post/15/update") end end describe "integration for on_mount/4" do test "it attaches defined hooks to the socket", %{conn: conn} do {:ok, lv, _html} = live(conn, "/page") {:ok, lifecycle} = PageLive.fetch_lifecycle(lv) expected_handle_params = [ %{ id: :handle_params_load_resource_0, function: &PageLive.handle_params_load_resource_0/3, stage: :handle_params }, %{ id: :handle_params_load_and_authorize_resource_1, function: &PageLive.handle_params_load_and_authorize_resource_1/3, stage: :handle_params } ] assert Enum.all?(expected_handle_params, &(&1 in lifecycle.handle_params)), "Expected Enum: #{inspect(lifecycle.handle_params)} \n to include: #{inspect(expected_handle_params)}" expected_handle_events = [ %{ id: :handle_event_load_and_authorize_resource_2, function: &PageLive.handle_event_load_and_authorize_resource_2/3, stage: :handle_event } ] assert Enum.all?(expected_handle_events, &(&1 in lifecycle.handle_event)), "Expected Enum: #{inspect(lifecycle.handle_event)} \n to include: #{inspect(expected_handle_events)}" end end describe "mount_canary/2" do defmodule TestLive do use Phoenix.LiveView use Canary.Hooks mount_canary(:load_resource, model: Post ) mount_canary(:load_and_authorize_resource, on: [:handle_params, :handle_event], model: User, only: [:show] ) def render(assigns) do ~H"""
Test
""" end def mount(_params, _session, socket) do {:ok, socket} end end test "defines wrapper function for events" do expected_fun = [ {:handle_params_load_resource_0, 3}, {:handle_params_load_and_authorize_resource_1, 3}, {:handle_event_load_and_authorize_resource_2, 3} ] assert Enum.all?(expected_fun, &(&1 in TestLive.__info__(:functions))) end test "adds on_mount hook for attaching event hooks" do %{lifecycle: %{mount: mount}} = TestLive.__live__() expected_mount = %{ function: &Canary.Hooks.on_mount/4, id: {Canary.Hooks, {:initialize, TestLive, [ handle_params_load_resource_0: :handle_params, handle_params_load_and_authorize_resource_1: :handle_params, handle_event_load_and_authorize_resource_2: :handle_event ]}}, stage: :mount } assert Enum.any?(mount, &(&1 == expected_mount)) end test "emits a warning when no valid stage is provided" do assert ExUnit.CaptureIO.capture_io(:stderr, fn -> defmodule InvalidLive do use Phoenix.LiveView use Canary.Hooks mount_canary(:load_resource, model: Post, on: [:invalid_stage] ) def mount(_params, _session, socket) do {:ok, socket} end end end) =~ "mount_canary called with empty :on stages" end end defp build_socket(action \\ :show) do %Phoenix.LiveView.Socket{assigns: %{__changed__: %{}, live_action: action}} end defp put_assigns(socket, assigns) do %{socket | assigns: Map.merge(socket.assigns, assigns)} end end ================================================ FILE: test/canary/plugs_test.exs ================================================ defmodule Canary.PlugsTest do import Canary.Plugs import Plug.Adapters.Test.Conn, only: [conn: 4] use ExUnit.Case, async: true @moduletag timeout: 100_000_000 Application.put_env(:canary, :repo, Repo) Application.delete_env(:canary, :error_handler) test "it loads the resource correctly" do opts = [model: Post] # when the resource with the id can be fetched params = %{"id" => "1"} conn = conn(%Plug.Conn{private: %{phoenix_action: :show}}, :get, "/posts/1", params) expected = %{conn | assigns: Map.put(conn.assigns, :post, %Post{id: 1})} assert load_resource(conn, opts) == expected # when a resource of the desired type is already present in conn.assigns # it does not clobber the old resource params = %{"id" => "1"} conn = conn( %Plug.Conn{private: %{phoenix_action: :show}, assigns: %{post: %Post{id: 2}}}, :get, "/posts/1", params ) expected = Plug.Conn.assign(conn, :post, %Post{id: 2}) assert load_resource(conn, opts) == expected # when a resource of the desired type is already present in conn.assigns and the action is :index # it does not clobber the old resource params = %{} conn = conn( %Plug.Conn{private: %{phoenix_action: :index}, assigns: %{posts: [%Post{id: 2}]}}, :get, "/posts", params ) expected = Plug.Conn.assign(conn, :posts, [%Post{id: 2}]) assert load_resource(conn, opts) == expected # when a resource of a different type is already present in conn.assigns # it replaces that resource with the desired resource params = %{"id" => "1"} conn = conn( %Plug.Conn{private: %{phoenix_action: :show}, assigns: %{post: %User{id: 2}}}, :get, "/posts/1", params ) expected = Plug.Conn.assign(conn, :post, %Post{id: 1}) assert load_resource(conn, opts) == expected # when a resource of a different type is already present in conn.assigns and the action is :index # it replaces that resource with the desired resource params = %{} conn = conn( %Plug.Conn{private: %{phoenix_action: :index}, assigns: %{posts: [%User{id: 2}]}}, :get, "/posts", params ) expected = Plug.Conn.assign(conn, :posts, [%Post{id: 1}, %Post{id: 2, user_id: 2}]) assert load_resource(conn, opts) == expected # when the resource with the id cannot be fetched params = %{"id" => "3"} conn = conn(%Plug.Conn{private: %{phoenix_action: :show}}, :get, "/posts/3", params) expected = Plug.Conn.assign(conn, :post, nil) assert load_resource(conn, opts) == expected # when the action is "index" params = %{} conn = conn(%Plug.Conn{private: %{phoenix_action: :index}}, :get, "/posts", params) expected = Plug.Conn.assign(conn, :posts, [%Post{id: 1}, %Post{id: 2, user_id: 2}]) assert load_resource(conn, opts) == expected # when the action is "new" params = %{} conn = conn(%Plug.Conn{private: %{phoenix_action: :new}}, :get, "/posts/new", params) expected = Plug.Conn.assign(conn, :post, nil) assert load_resource(conn, opts) == expected # when the action is "create" params = %{} conn = conn(%Plug.Conn{private: %{phoenix_action: :create}}, :post, "/posts/create", params) expected = Plug.Conn.assign(conn, :post, nil) assert load_resource(conn, opts) == expected end test "it loads the resource correctly with opts[:id_name] specified" do opts = [model: Post, id_name: "post_id"] # when id param is correct params = %{"post_id" => 1} conn = conn(%Plug.Conn{private: %{phoenix_action: :show}}, :get, "/posts/1", params) expected = Plug.Conn.assign(conn, :post, %Post{id: 1}) assert load_resource(conn, opts) == expected end test "it loads the resource correctly with opts[:id_field] specified" do opts = [model: Post, id_name: "slug", id_field: "slug"] # when slug param is correct params = %{"slug" => "slug1"} conn = conn(%Plug.Conn{private: %{phoenix_action: :show}}, :get, "/posts/slug1", params) expected = Plug.Conn.assign(conn, :post, %Post{id: 1, slug: "slug1"}) assert load_resource(conn, opts) == expected end test "it loads the resource correctly with opts[:persisted] specified on :index action" do opts = [model: User, id_name: "user_id", persisted: true] params = %{"user_id" => 1} conn = conn(%Plug.Conn{private: %{phoenix_action: :index}}, :get, "/users/1/posts", params) expected = Plug.Conn.assign(conn, :user, %User{id: 1}) assert load_resource(conn, opts) == expected end test "it loads the resource correctly with opts[:persisted] specified on :new action" do opts = [model: User, id_name: "user_id", persisted: true] params = %{"user_id" => 1} conn = conn(%Plug.Conn{private: %{phoenix_action: :new}}, :get, "/users/1/posts/new", params) expected = Plug.Conn.assign(conn, :user, %User{id: 1}) assert load_resource(conn, opts) == expected end test "it loads the resource correctly with opts[:persisted] specified on :create action" do opts = [model: User, id_name: "user_id", persisted: true] params = %{"user_id" => "1"} conn = conn(%Plug.Conn{private: %{phoenix_action: :create}}, :post, "/users/1/posts", params) expected = Plug.Conn.assign(conn, :user, %User{id: 1}) assert load_resource(conn, opts) == expected end test "it calls the specified action when not_found with opts[:required] specified on :new action" do opts = [model: Post, not_found_handler: {Helpers, :not_found_handler}, required: true] params = %{"id" => "3"} conn = conn( %Plug.Conn{assigns: %{post: nil}, private: %{phoenix_action: :new}}, :get, "/posts/3/new", params ) expected = Helpers.not_found_handler(conn) assert load_resource(conn, opts) == expected end test "it calls the specified action when not_found with opts[:required] specified on :create action" do opts = [model: Post, not_found_handler: {Helpers, :not_found_handler}, required: true] params = %{"id" => "3"} conn = conn( %Plug.Conn{assigns: %{post: nil}, private: %{phoenix_action: :index}}, :post, "/posts/3/new", params ) expected = Helpers.not_found_handler(conn) assert load_resource(conn, opts) == expected end test "it calls the specified action when not_found with opts[:required] specified on :index action" do opts = [model: Post, not_found_handler: {Helpers, :not_found_handler}, required: true] params = %{"id" => "3"} conn = conn( %Plug.Conn{assigns: %{post: nil}, private: %{phoenix_action: :index}}, :get, "/posts/3", params ) expected = Helpers.not_found_handler(conn) assert load_resource(conn, opts) == expected end test "it authorizes the resource correctly" do opts = [model: Post] # when the action is "new" params = %{} conn = conn( %Plug.Conn{ private: %{phoenix_action: :new}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/new", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected # when the action is "create" params = %{} conn = conn( %Plug.Conn{ private: %{phoenix_action: :create}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/create", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected # when the action is "index" params = %{} conn = conn( %Plug.Conn{ private: %{phoenix_action: :index}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected # when the action is a phoenix action params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/1", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected # when the current user can access the given resource # and the action is specified in conn.assigns.canary_action params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{}, assigns: %{current_user: %User{id: 1}, canary_action: :show} }, :get, "/posts/1", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected # when both conn.assigns.canary_action and conn.private.phoenix_action are defined # it uses conn.assigns.canary_action for authorization params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 1}, canary_action: :unauthorized} }, :get, "/posts/1", params ) expected = Plug.Conn.assign(conn, :authorized, false) assert authorize_resource(conn, opts) == expected # when the current user cannot access the given resource params = %{"id" => "2"} conn = conn( %Plug.Conn{ private: %{}, assigns: %{current_user: %User{id: 1}, canary_action: :show} }, :get, "/posts/2", params ) expected = Plug.Conn.assign(conn, :authorized, false) assert authorize_resource(conn, opts) == expected # when the resource of the desired type already exists in conn.assigns, # it authorizes for that resource params = %{"id" => "2"} conn = conn( %Plug.Conn{ private: %{}, assigns: %{current_user: %User{id: 1}, canary_action: :show, post: %Post{user_id: 1}} }, :get, "/posts/2", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected # when the resource of a different type already exists in conn.assigns, # it authorizes for the desired resource params = %{"id" => "2"} conn = conn( %Plug.Conn{ private: %{}, assigns: %{current_user: %User{id: 1}, canary_action: :show, post: %User{}} }, :get, "/posts/2", params ) expected = Plug.Conn.assign(conn, :authorized, false) assert authorize_resource(conn, opts) == expected # when current_user is nil params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{}, assigns: %{current_user: nil, canary_action: :create} }, :post, "/posts", params ) expected = Plug.Conn.assign(conn, :authorized, false) assert authorize_resource(conn, opts) == expected end test "it authorizes the resource correctly when using :id_field option" do opts = [model: Post, id_field: "slug", id_name: "slug"] # when the action is "new" params = %{} conn = conn( %Plug.Conn{ private: %{phoenix_action: :new}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/new", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected # when the action is "create" params = %{} conn = conn( %Plug.Conn{ private: %{phoenix_action: :create}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/create", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected # when the action is "index" params = %{} conn = conn( %Plug.Conn{ private: %{phoenix_action: :index}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected # when the action is a phoenix action params = %{"slug" => "slug1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/slug1", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected # when the current user can access the given resource # and the action is specified in conn.assigns.canary_action params = %{"slug" => "slug1"} conn = conn( %Plug.Conn{ private: %{}, assigns: %{current_user: %User{id: 1}, canary_action: :show} }, :get, "/posts/slug1", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected # when both conn.assigns.canary_action and conn.private.phoenix_action are defined # it uses conn.assigns.canary_action for authorization params = %{"slug" => "slug1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 1}, canary_action: :unauthorized} }, :get, "/posts/slug1", params ) expected = Plug.Conn.assign(conn, :authorized, false) assert authorize_resource(conn, opts) == expected # when the current user cannot access the given resource params = %{"slug" => "slug2"} conn = conn( %Plug.Conn{ private: %{}, assigns: %{current_user: %User{id: 1}, canary_action: :show} }, :get, "/posts/slug2", params ) expected = Plug.Conn.assign(conn, :authorized, false) assert authorize_resource(conn, opts) == expected # when the resource of the desired type already exists in conn.assigns, # it authorizes for that resource params = %{"slug" => "slug2"} conn = conn( %Plug.Conn{ private: %{}, assigns: %{current_user: %User{id: 1}, canary_action: :show, post: %Post{user_id: 1}} }, :get, "/posts/slug2", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected # when the resource of a different type already exists in conn.assigns, # it authorizes for the desired resource params = %{"slug" => "slug2"} conn = conn( %Plug.Conn{ private: %{}, assigns: %{current_user: %User{id: 1}, canary_action: :show, post: %User{}} }, :get, "/posts/slug2", params ) expected = Plug.Conn.assign(conn, :authorized, false) assert authorize_resource(conn, opts) == expected # when current_user is nil params = %{"slug" => "slug1"} conn = conn( %Plug.Conn{ private: %{}, assigns: %{current_user: nil, canary_action: :create} }, :post, "/posts", params ) expected = Plug.Conn.assign(conn, :authorized, false) assert authorize_resource(conn, opts) == expected end test "it authorizes the resource correctly with opts[:persisted] specified on :index action" do opts = [model: Post, id_name: "post_id", persisted: true] params = %{"post_id" => 2} conn = conn( %Plug.Conn{ private: %{phoenix_action: :index}, assigns: %{current_user: %User{id: 2}} }, :get, "/posts/post_id/comments", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected end test "it authorizes the resource correctly with opts[:persisted] specified on :new action" do opts = [model: Post, id_name: "post_id", persisted: true] params = %{"post_id" => 2} conn = conn( %Plug.Conn{ private: %{phoenix_action: :new}, assigns: %{current_user: %User{id: 2}} }, :get, "/posts/post_id/comments/new", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected end test "it authorizes the resource correctly with opts[:persisted] specified on :create action" do opts = [model: Post, id_name: "post_id", persisted: true] params = %{"post_id" => "2"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :create}, assigns: %{current_user: %User{id: 2}} }, :post, "/posts/post_id/comments", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected end test "it loads and authorizes the resource correctly" do opts = [model: Post] # when the current user can access the given resource # and the resource can be loaded params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/1", params ) expected = conn |> Plug.Conn.assign(:authorized, true) |> Plug.Conn.assign(:post, %Post{id: 1, user_id: 1}) assert load_and_authorize_resource(conn, opts) == expected # when the current user cannot access the given resource params = %{"id" => "2"} conn = conn( %Plug.Conn{ private: %{}, assigns: %{current_user: %User{id: 1}, canary_action: :show} }, :get, "/posts/2", params ) expected = conn |> Plug.Conn.assign(:authorized, false) |> Plug.Conn.assign(:post, nil) assert load_and_authorize_resource(conn, opts) == expected # when a resource of the desired type is already present in conn.assigns # it does not load a new resource params = %{"id" => "2"} conn = conn( %Plug.Conn{ private: %{}, assigns: %{current_user: %User{id: 1}, canary_action: :show, post: %Post{user_id: 1}} }, :get, "/posts/2", params ) expected = conn |> Plug.Conn.assign(:authorized, true) |> Plug.Conn.assign(:post, %Post{user_id: 1}) assert load_and_authorize_resource(conn, opts) == expected # when a resource of the a different type is already present in conn.assigns # it loads and authorizes for the desired resource params = %{"id" => "2"} conn = conn( %Plug.Conn{ private: %{}, assigns: %{current_user: %User{id: 1}, canary_action: :show, post: %User{id: 1}} }, :get, "/posts/2", params ) expected = conn |> Plug.Conn.assign(:authorized, false) |> Plug.Conn.assign(:post, nil) assert load_and_authorize_resource(conn, opts) == expected # when the given resource cannot be loaded params = %{"id" => "3"} conn = conn( %Plug.Conn{ private: %{}, assigns: %{current_user: %User{id: 1}, canary_action: :show} }, :get, "/posts/1", params ) expected = conn |> Plug.Conn.assign(:authorized, false) |> Plug.Conn.assign(:post, nil) assert load_and_authorize_resource(conn, opts) == expected end test "it loads and authorizes the resource correctly when using :id_field option" do opts = [model: Post, id_field: "slug", id_name: "slug"] # when the current user can access the given resource # and the resource can be loaded params = %{"slug" => "slug1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/slug1", params ) expected = conn |> Plug.Conn.assign(:authorized, true) |> Plug.Conn.assign(:post, %Post{id: 1, slug: "slug1", user_id: 1}) assert load_and_authorize_resource(conn, opts) == expected # when the current user cannot access the given resource params = %{"slug" => "slug2"} conn = conn( %Plug.Conn{ private: %{}, assigns: %{current_user: %User{id: 1}, canary_action: :show} }, :get, "/posts/slug2", params ) expected = conn |> Plug.Conn.assign(:authorized, false) |> Plug.Conn.assign(:post, nil) assert load_and_authorize_resource(conn, opts) == expected # when a resource of the desired type is already present in conn.assigns # it does not load a new resource params = %{"slug" => "slug2"} conn = conn( %Plug.Conn{ private: %{}, assigns: %{current_user: %User{id: 1}, canary_action: :show, post: %Post{user_id: 1}} }, :get, "/posts/slug2", params ) expected = conn |> Plug.Conn.assign(:authorized, true) |> Plug.Conn.assign(:post, %Post{user_id: 1}) assert load_and_authorize_resource(conn, opts) == expected # when a resource of the a different type is already present in conn.assigns # it loads and authorizes for the desired resource params = %{"slug" => "slug2"} conn = conn( %Plug.Conn{ private: %{}, assigns: %{current_user: %User{id: 1}, canary_action: :show, post: %User{id: 1}} }, :get, "/posts/slug2", params ) expected = conn |> Plug.Conn.assign(:authorized, false) |> Plug.Conn.assign(:post, nil) assert load_and_authorize_resource(conn, opts) == expected # when the given resource cannot be loaded params = %{"slug" => "slug3"} conn = conn( %Plug.Conn{ private: %{}, assigns: %{current_user: %User{id: 1}, canary_action: :show} }, :get, "/posts/slug3", params ) expected = conn |> Plug.Conn.assign(:authorized, false) |> Plug.Conn.assign(:post, nil) assert load_and_authorize_resource(conn, opts) == expected end test "it loads and authorizes the resource correctly with opts[:persisted] specified on :index action" do opts = [model: Post, id_name: "post_id", persisted: true] params = %{"post_id" => 2} conn = conn( %Plug.Conn{ private: %{phoenix_action: :index}, assigns: %{current_user: %User{id: 2}} }, :get, "/posts/2/comments", params ) expected = conn |> Plug.Conn.assign(:authorized, true) |> Plug.Conn.assign(:post, %Post{id: 2, user_id: 2}) assert load_and_authorize_resource(conn, opts) == expected end test "it loads and authorizes the resource correctly with opts[:persisted] specified on :new action" do opts = [model: Post, id_name: "post_id", persisted: true] params = %{"post_id" => 2} conn = conn( %Plug.Conn{ private: %{phoenix_action: :new}, assigns: %{current_user: %User{id: 2}} }, :get, "/posts/2/comments/new", params ) expected = conn |> Plug.Conn.assign(:authorized, true) |> Plug.Conn.assign(:post, %Post{id: 2, user_id: 2}) assert load_and_authorize_resource(conn, opts) == expected end test "it loads and authorizes the resource correctly with opts[:persisted] specified on :create action" do opts = [model: Post, id_name: "post_id", persisted: true] params = %{"post_id" => "2"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :create}, assigns: %{current_user: %User{id: 2}} }, :create, "/posts/2/comments", params ) expected = conn |> Plug.Conn.assign(:authorized, true) |> Plug.Conn.assign(:post, %Post{id: 2, user_id: 2}) assert load_and_authorize_resource(conn, opts) == expected end test "it only loads the resource when the action is in opts[:only]" do # when the action is in opts[:only] opts = [model: Post, only: :show] params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/1", params ) expected = Plug.Conn.assign(conn, :post, %Post{id: 1}) assert load_resource(conn, opts) == expected # when the action is not opts[:only] opts = [model: Post, only: :other] params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/1", params ) expected = conn assert load_resource(conn, opts) == expected end test "it only authorizes actions in opts[:only]" do # when the action is in opts[:only] opts = [model: Post, only: :show] params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/1", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected # when the action is not opts[:only] opts = [model: Post, only: :other] params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/1", params ) expected = conn assert authorize_resource(conn, opts) == expected end test "it only loads and authorizes the resource for actions in opts[:only]" do # when the action is in opts[:only] opts = [model: Post, only: :show] params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/1", params ) expected = conn |> Plug.Conn.assign(:authorized, true) |> Plug.Conn.assign(:post, %Post{id: 1, user_id: 1}) assert load_and_authorize_resource(conn, opts) == expected # when the action is not opts[:only] opts = [model: Post, only: :other] params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/1", params ) expected = conn assert load_and_authorize_resource(conn, opts) == expected end test "it raises when both opts[:only] and opts[:except] are specified" do # when the plug is load_resource opts = [model: Post, only: :show, except: :index] params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/1", params ) expected = conn assert_raise ArgumentError, fn -> load_resource(conn, opts) == expected end # when the plug is authorize_resource opts = [model: Post, only: :show, except: :index] params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/1", params ) expected = conn assert_raise ArgumentError, fn -> authorize_resource(conn, opts) == expected end # when the plug is load_and_authorize_resource opts = [model: Post, only: :show, except: :index] params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/1", params ) expected = conn assert_raise ArgumentError, fn -> load_and_authorize_resource(conn, opts) == expected end end test "it correctly skips authorization for exempt actions" do # when the action is exempt opts = [model: Post, except: :show] params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/1", params ) expected = conn assert authorize_resource(conn, opts) == expected # when the action is not exempt opts = [model: Post] expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected end test "it correctly skips loading resources for exempt actions" do # when the action is exempt opts = [model: Post, except: :show] params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/1", params ) expected = conn assert load_resource(conn, opts) == expected # when the action is not exempt opts = [model: Post] expected = Plug.Conn.assign(conn, :post, %Post{id: 1, user_id: 1}) assert load_resource(conn, opts) == expected end test "it correctly skips load_and_authorize_resource for exempt actions" do # when the action is exempt opts = [model: Post, except: :show] params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/1", params ) expected = conn assert load_and_authorize_resource(conn, opts) == expected # when the action is not exempt opts = [model: Post] expected = conn |> Plug.Conn.assign(:authorized, true) |> Plug.Conn.assign(:post, %Post{id: 1, user_id: 1}) assert load_and_authorize_resource(conn, opts) == expected end test "it loads the resource into a key specified by the :as option" do opts = [model: Post, as: :some_key] # when the resource with the id can be fetched params = %{"id" => "1"} conn = conn(%Plug.Conn{private: %{phoenix_action: :show}}, :get, "/posts/1", params) expected = Plug.Conn.assign(conn, :some_key, %Post{id: 1}) assert load_resource(conn, opts) == expected end test "it authorizes the resource correctly when the :as key is specified" do opts = [model: Post, as: :some_key] # when the action is "new" params = %{} conn = conn( %Plug.Conn{ private: %{phoenix_action: :new}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/new", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected # need to check that it works for authorization as well, and for load_and_authorize_resource end test "it loads and authorizes the resource correctly when the :as key is specified" do opts = [model: Post, as: :some_key] # when the current user can access the given resource # and the resource can be loaded params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/1", params ) expected = conn |> Plug.Conn.assign(:authorized, true) |> Plug.Conn.assign(:some_key, %Post{id: 1, user_id: 1}) assert load_and_authorize_resource(conn, opts) == expected end test "when the :as key is not specified, it loads the resource into a key inferred from the model name" do opts = [model: Post] # when the resource with the id can be fetched params = %{"id" => "1"} conn = conn(%Plug.Conn{private: %{phoenix_action: :show}}, :get, "/posts/1", params) expected = Plug.Conn.assign(conn, :post, %Post{id: 1}) assert load_resource(conn, opts) == expected end test "when unauthorized, it calls the specified action" do opts = [model: Post, unauthorized_handler: {Helpers, :unauthorized_handler}] params = %{"id" => "1"} conn = conn( %Plug.Conn{assigns: %{current_user: %User{id: 2}}, private: %{phoenix_action: :show}}, :get, "/posts/1", params ) expected = conn |> Plug.Conn.assign(:authorized, false) |> Helpers.unauthorized_handler() assert authorize_resource(conn, opts) == expected end test "when not_found, it calls the specified action" do opts = [model: Post, not_found_handler: {Helpers, :not_found_handler}] params = %{"id" => "3"} conn = conn( %Plug.Conn{assigns: %{post: nil}, private: %{phoenix_action: :show}}, :get, "/posts/3", params ) expected = Helpers.not_found_handler(conn) assert load_resource(conn, opts) == expected end test "when unauthorized and resource not found, it calls the specified authorization handler first" do opts = [ model: Post, not_found_handler: {Helpers, :not_found_handler}, unauthorized_handler: {Helpers, :unauthorized_handler} ] params = %{"id" => "3"} conn = conn( %Plug.Conn{assigns: %{current_user: %User{id: 2}}, private: %{phoenix_action: :show}}, :get, "/posts/3", params ) expected = conn |> Plug.Conn.assign(:authorized, false) |> Plug.Conn.assign(:post, nil) |> Helpers.unauthorized_handler() assert load_and_authorize_resource(conn, opts) == expected end test "when the authorization handler does not halt the request, it calls the not found handler if specified" do opts = [ model: Post, not_found_handler: {Helpers, :not_found_handler}, unauthorized_handler: {Helpers, :non_halting_unauthorized_handler} ] params = %{"id" => "3"} conn = conn( %Plug.Conn{assigns: %{current_user: %User{id: 2}}, private: %{phoenix_action: :show}}, :get, "/posts/3", params ) expected = conn |> Plug.Conn.assign(:authorized, false) |> Plug.Conn.assign(:post, nil) |> Helpers.non_halting_unauthorized_handler() |> Helpers.not_found_handler() assert load_and_authorize_resource(conn, opts) == expected end defmodule UnauthorizedHandlerConfigured do use ExUnit.Case, async: false test "when unauthorized, it calls the configured action" do Application.put_env(:canary, :unauthorized_handler, {Helpers, :unauthorized_handler}) opts = [model: Post] params = %{"id" => "1"} conn = conn( %Plug.Conn{assigns: %{current_user: %User{id: 2}}, private: %{phoenix_action: :show}}, :get, "/posts/1", params ) expected = conn |> Plug.Conn.assign(:authorized, false) |> Helpers.unauthorized_handler() assert authorize_resource(conn, opts) == expected end test "when unauthorized and resource not found, it calls the configured authorization handler first" do Application.put_env(:canary, :unauthorized_handler, {Helpers, :unauthorized_handler}) opts = [model: Post] params = %{"id" => "3"} conn = conn( %Plug.Conn{assigns: %{current_user: %User{id: 2}}, private: %{phoenix_action: :show}}, :get, "/posts/3", params ) expected = conn |> Plug.Conn.assign(:authorized, false) |> Plug.Conn.assign(:post, nil) |> Helpers.unauthorized_handler() assert load_and_authorize_resource(conn, opts) == expected end end defmodule UnauthorizedHandlerConfiguredAndSpecified do use ExUnit.Case, async: false test "when unauthorized, it calls the opt-specified action rather than the configured action" do # should not be called Application.put_env(:canary, :unauthorized_handler, {Helpers, :does_not_exist}) opts = [model: Post, unauthorized_handler: {Helpers, :unauthorized_handler}] params = %{"id" => "1"} conn = conn( %Plug.Conn{assigns: %{current_user: %User{id: 2}}, private: %{phoenix_action: :show}}, :get, "/posts/1", params ) expected = conn |> Helpers.unauthorized_handler() |> Plug.Conn.assign(:authorized, false) assert authorize_resource(conn, opts) == expected end end defmodule NotFoundHandlerConfigured do use ExUnit.Case, async: false test "when not_found, it calls the configured action" do Application.put_env(:canary, :error_handler, Helpers) opts = [model: Post] params = %{"id" => "4"} conn = conn(%Plug.Conn{private: %{phoenix_action: :show}}, :get, "/posts/4", params) expected = conn |> Helpers.not_found_handler() |> Plug.Conn.assign(:post, nil) assert load_resource(conn, opts) == expected end end defmodule NotFoundHandlerConfiguredAndSpecified do use ExUnit.Case, async: false test "when not_found, it calls the opt-specified action rather than the configured action" do # should not be called Application.put_env(:canary, :not_found_handler, {Helpers, :does_not_exist}) opts = [model: Post, not_found_handler: {Helpers, :not_found_handler}] params = %{"id" => "4"} conn = conn(%Plug.Conn{private: %{phoenix_action: :show}}, :get, "/posts/4", params) expected = conn |> Helpers.not_found_handler() |> Plug.Conn.assign(:post, nil) assert load_resource(conn, opts) == expected end end defmodule CurrentUser do use ExUnit.Case, async: true defmodule ApplicationConfig do use ExUnit.Case, async: false import Mock test_with_mock "it uses the current_user name configured", Application, [:passthrough], get_env: fn _, _, _ -> :current_admin end do # when the user configured with opts opts = [model: Post, except: :show] params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_admin: %User{id: 1}} }, :get, "/posts/1", params ) expected = conn assert authorize_resource(conn, opts) == expected end end test "it uses the current_user name in options" do # when the user configured with opts opts = [model: Post, current_user: :user] params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{user: %User{id: 1}, authorized: true} }, :get, "/posts/1", params ) expected = conn assert authorize_resource(conn, opts) == expected end test "it throws an error when the wrong current_user name is used" do # when the user configured with opts opts = [model: Post, current_user: :configured_current_user] params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{user: %User{id: 1}, authorized: true} }, :get, "/posts/1", params ) assert_raise KeyError, ~r/^key :configured_current_user not found in: %{/, fn -> authorize_resource(conn, opts) end end end defmodule Preload do use ExUnit.Case, async: true test "it loads the resource correctly when the :preload key is specified" do opts = [model: Post, preload: :user] # when the resource with the id can be fetched and the association exists params = %{"id" => "2"} conn = conn(%Plug.Conn{private: %{phoenix_action: :show}}, :get, "/posts/1", params) expected = Plug.Conn.assign(conn, :post, %Post{id: 2, user_id: 2, user: %User{id: 2}}) assert load_resource(conn, opts) == expected # when the resource with the id can be fetched and the association does not exist params = %{"id" => "1"} conn = conn(%Plug.Conn{private: %{phoenix_action: :show}}, :get, "/posts/1", params) expected = Plug.Conn.assign(conn, :post, %Post{id: 1, user_id: 1, user: %User{id: 1}}) assert load_resource(conn, opts) == expected # when the resource with the id cannot be fetched params = %{"id" => "3"} conn = conn(%Plug.Conn{private: %{phoenix_action: :show}}, :get, "/posts/3", params) expected = Plug.Conn.assign(conn, :post, nil) assert load_resource(conn, opts) == expected # when the action is "index" params = %{} conn = conn(%Plug.Conn{private: %{phoenix_action: :index}}, :get, "/posts", params) expected = Plug.Conn.assign(conn, :posts, [ %Post{id: 1}, %Post{id: 2, user_id: 2, user: %User{id: 2}} ]) assert load_resource(conn, opts) == expected # when the action is "new" params = %{} conn = conn(%Plug.Conn{private: %{phoenix_action: :new}}, :get, "/posts/new", params) expected = Plug.Conn.assign(conn, :post, nil) assert load_resource(conn, opts) == expected end test "it authorizes the resource correctly when the :preload key is specified" do opts = [model: Post, preload: :user] # when the action is "edit" params = %{"id" => "2"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :edit}, assigns: %{current_user: %User{id: 2}} }, :get, "/posts/edit/2", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected # when the action is "index" params = %{} conn = conn( %Plug.Conn{ private: %{phoenix_action: :index}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected end test "it loads and authorizes the resource correctly when the :preload key is specified" do opts = [model: Post, preload: :user] # when the current user can access the given resource # and the resource can be loaded and the association exists params = %{"id" => "2"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 2}} }, :get, "/posts/2", params ) expected = conn |> Plug.Conn.assign(:authorized, true) |> Plug.Conn.assign(:post, %Post{id: 2, user_id: 2, user: %User{id: 2}}) assert load_and_authorize_resource(conn, opts) == expected # when the current user can access the given resource # and the resource can be loaded and the association does not exist params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/1", params ) expected = conn |> Plug.Conn.assign(:authorized, true) |> Plug.Conn.assign(:post, %Post{id: 1, user_id: 1, user: %User{id: 1}}) assert load_and_authorize_resource(conn, opts) == expected # when the action is "edit" params = %{"id" => "2"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :edit}, assigns: %{current_user: %User{id: 2}} }, :get, "/posts/edit/2", params ) expected = conn |> Plug.Conn.assign(:authorized, true) |> Plug.Conn.assign(:post, %Post{id: 2, user_id: 2, user: %User{id: 2}}) assert load_and_authorize_resource(conn, opts) == expected end end defmodule NonIdActions do use ExUnit.Case, async: true test "it throws an error when the non_id_actions is not a list" do # when opts[:non_id_actions] is set but not as a list opts = [model: Post, non_id_actions: :other_action] params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :other_action}, assigns: %{current_user: %User{id: 1}, authorized: true} }, :get, "/posts/other-action", params ) assert_raise Protocol.UndefinedError, ~r/protocol Enumerable not implemented for /, fn -> authorize_resource(conn, opts) end end test "it authorizes the resource correctly when non_id_actions is a list" do # when opts[:non_id_actions] is set as a list opts = [model: Post, non_id_actions: [:other_action]] params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :other_action}, assigns: %{current_user: %User{id: 1}, authorized: true} }, :get, "/posts/other-action", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_resource(conn, opts) == expected end end test "it authorizes the controller correctly" do opts = [model: Post] # when the action is "new" params = %{} conn = conn( %Plug.Conn{ private: %{phoenix_action: :new, phoenix_controller: Myproject.SampleController}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/new", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_controller(conn, opts) == expected # when the action is "create" params = %{} conn = conn( %Plug.Conn{ private: %{phoenix_action: :create, phoenix_controller: Myproject.SampleController}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/create", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_controller(conn, opts) == expected # when the action is "index" params = %{} conn = conn( %Plug.Conn{ private: %{phoenix_action: :index, phoenix_controller: Myproject.SampleController}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_controller(conn, opts) == expected # when the action is a phoenix action params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show, phoenix_controller: Myproject.SampleController}, assigns: %{current_user: %User{id: 1}} }, :get, "/posts/1", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_controller(conn, opts) == expected # when the current user can access the given resource # and the action and controller are specified in conn.assigns.canary_action params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{}, assigns: %{ current_user: %User{id: 1}, canary_action: :show, canary_controller: Myproject.SampleController } }, :get, "/posts/1", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_controller(conn, opts) == expected # when both conn.assigns.canary_action and conn.private.phoenix_action are defined # it uses conn.assigns.canary_action for authorization params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_action: :show, phoenix_controller: Myproject.SampleController}, assigns: %{current_user: %User{id: 1}, canary_action: :unauthorized} }, :get, "/posts/1", params ) expected = Plug.Conn.assign(conn, :authorized, false) assert authorize_controller(conn, opts) == expected # when the current user cannot access the given action params = %{"id" => "2"} conn = conn( %Plug.Conn{ private: %{phoenix_controller: Myproject.SampleController}, assigns: %{current_user: %User{id: 1}, canary_action: :someaction} }, :get, "/posts/2", params ) expected = Plug.Conn.assign(conn, :authorized, false) assert authorize_controller(conn, opts) == expected # when current_user is nil params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_controller: Myproject.SampleController}, assigns: %{current_user: nil, canary_action: :create} }, :post, "/posts", params ) expected = Plug.Conn.assign(conn, :authorized, false) assert authorize_controller(conn, opts) == expected # when an action is restricted on a controller params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_controller: Myproject.PartialAccessController}, assigns: %{current_user: %User{id: 1}, canary_action: :new} }, :post, "/posts", params ) expected = Plug.Conn.assign(conn, :authorized, false) assert authorize_controller(conn, opts) == expected # when an action is authorized on a controller params = %{"id" => "1"} conn = conn( %Plug.Conn{ private: %{phoenix_controller: Myproject.PartialAccessController}, assigns: %{current_user: %User{id: 1}, canary_action: :show} }, :post, "/posts", params ) expected = Plug.Conn.assign(conn, :authorized, true) assert authorize_controller(conn, opts) == expected end end ================================================ FILE: test/canary/utils_test.exs ================================================ defmodule UtilsTest do import Canary.Utils use ExUnit.Case, async: true describe "get_resource_id/2" do test "returns the id from the params" do assert get_resource_id(%{"id" => "9"}, []) == "9" assert get_resource_id(%{"user_id" => "7"}, id_name: "user_id") == "7" end test "returns the id form conn.params" do conn = %Plug.Conn{params: %{"id" => "9"}} assert get_resource_id(conn, []) == "9" conn = %Plug.Conn{params: %{"custom_id" => "1"}} assert get_resource_id(conn, id_name: "custom_id") == "1" end test "returns nil if the id is not found" do assert get_resource_id(%{"other_id" => "9"}, id_name: "id") == nil conn = %Plug.Conn{params: %{"other_id" => "9"}} assert get_resource_id(conn, id_name: "id") == nil end end describe "action_valid?/2" do test "returns true if the action is valid" do assert action_valid?(:index, only: [:index, :show]) == true assert action_valid?(:show, except: :index) == true end test "returns false if the action is not valid" do assert action_valid?(:index, except: :index) == false assert action_valid?(:edit, only: [:index, :show]) == false end test "raise when both :only and :except are provided" do assert_raise ArgumentError, fn -> action_valid?(:index, only: [:index], except: :index) end end end test "required?/1 returns true if the resource is required" do assert required?(required: true) == true assert required?(required: false) == false assert required?([]) == true end describe "apply_error_handler/3" do defmodule CustomErrorHandler do @behaviour Canary.ErrorHandler def not_found_handler(%Plug.Conn{} = conn) do %{conn | assigns: %{ok_custom_not_found_handler: true}} end def unauthorized_handler(%Plug.Conn{} = conn) do %{conn | assigns: %{ok_custom_unauthorized_handler: true}} end def custom_handler(%Plug.Conn{} = conn) do %{conn | assigns: %{ok_custom_handler: true}} end end test "raises if the error_handler is undefined" do assert_raise UndefinedFunctionError, ~r/function UnknownCustomErrorHandler.wrong_function\/1 is undefined/, fn -> apply_error_handler(%Plug.Conn{}, :unauthorized_handler, [ unauthorized_handler: {UnknownCustomErrorHandler, :wrong_function} ]) end assert_raise UndefinedFunctionError, ~r/function OtherErrorHandler.custom_function\/1 is undefined/, fn -> apply_error_handler(%Plug.Conn{}, :unauthorized_handler, [ unauthorized_handler: {OtherErrorHandler, :custom_function} ]) end end test "raises if the error_handler is not a module" do Application.put_env(:canary, :error_handler, 42) assert_raise ArgumentError, ~r/Invalid error handler, expected a module or a tuple with a module and a function/, fn -> apply_error_handler(%Plug.Conn{}, :not_found_handler, []) end end test "allows overriding the error handler" do Application.put_env(:canary, :error_handler, CustomErrorHandler) conn = apply_error_handler(%Plug.Conn{}, :not_found_handler, []) assert conn.assigns[:ok_custom_not_found_handler] == true conn = apply_error_handler(%Plug.Conn{}, :unauthorized_handler, []) assert conn.assigns[:ok_custom_unauthorized_handler] == true conn = apply_error_handler(%Plug.Conn{}, :unauthorized_handler, [unauthorized_handler: {Canary.DefaultHandler, :unauthorized_handler}]) assert conn.assigns[:ok_custom_unauthorized_handler] == nil conn = apply_error_handler(%Plug.Conn{}, :not_found_handler, [ not_found_handler: {CustomErrorHandler, :custom_handler} ]) assert conn.assigns[:ok_custom_handler] == true end end end ================================================ FILE: test/support/endpoint.ex ================================================ defmodule Canary.HooksHelper.Endpoint do use Phoenix.Endpoint, otp_app: :canary socket "/live", Phoenix.LiveView.Socket plug Canary.HooksHelper.Router end ================================================ FILE: test/support/page_live.ex ================================================ defmodule Canary.HooksHelper.PageLive do use Phoenix.LiveView use Canary.Hooks mount_canary :load_resource, model: Post, required: false mount_canary :load_and_authorize_resource, on: [:handle_params, :handle_event], model: User, only: [:show], required: false def render(assigns) do ~H"""
Page
""" end def mount(_params, _session, socket) do {:ok, socket} end ## test helpers def handle_call({:run, func}, _, socket), do: func.(socket) def run(lv, func) do GenServer.call(lv.pid, {:run, func}) end def fetch_lifecycle(lv) do run(lv, fn socket -> {:reply, Map.fetch(socket.private, :lifecycle), socket} end) end end ================================================ FILE: test/support/post_live.ex ================================================ defmodule Canary.HooksHelper.PostLive do use Phoenix.LiveView use Canary.Hooks mount_canary :load_resource, model: Post, only: [:show] mount_canary :load_resource, model: Post, only: [:edit, :update] def render(assigns) do ~H"""
Post
""" end def mount(_params, _session, socket) do {:ok, socket} end ## test helpers def handle_call({:run, func}, _, socket), do: func.(socket) def run(lv, func) do GenServer.call(lv.pid, {:run, func}) end def fetch_assigns(lv) do run(lv, fn socket -> {:reply, socket.assigns, socket} end) end def fetch_socket(lv) do run(lv, fn socket -> {:reply, socket, socket} end) end end ================================================ FILE: test/support/router.ex ================================================ defmodule Canary.HooksHelper.Router do use Phoenix.Router import Plug.Conn import Phoenix.LiveView.Router pipeline :browser do plug :fetch_session plug :accepts, ["html"] plug :fetch_live_flash end scope "/" do pipe_through :browser live "/page", Canary.HooksHelper.PageLive live "/post", Canary.HooksHelper.PostLive live "/post/:id", Canary.HooksHelper.PostLive, :show live "/post/:id/edit", Canary.HooksHelper.PostLive, :edit live "/post/:id/update", Canary.HooksHelper.PostLive, :update end end ================================================ FILE: test/test_helper.exs ================================================ ExUnit.start() defmodule User do defstruct id: 1 end defmodule Post do use Ecto.Schema schema "posts" do # :defaults not working so define own field with default value belongs_to(:user, :integer, define_field: false) field(:user_id, :integer, default: 1) field(:slug, :string) end end defmodule Repo do def get(User, 1), do: %User{} def get(User, _id), do: nil def get(Post, 1), do: %Post{id: 1} def get(Post, 2), do: %Post{id: 2, user_id: 2} def get(Post, _), do: nil def all(_), do: [%Post{id: 1}, %Post{id: 2, user_id: 2}] def preload(%Post{id: post_id, user_id: user_id}, :user) do %Post{id: post_id, user_id: user_id, user: %User{id: user_id}} end #def preload(%Post{id: 2, user_id: 2}, :user), do: %Post{id: 2, user_id: 2, user: %User{id: 2}} def preload([%Post{id: 1}, %Post{id: 2, user_id: 2}], :user), do: [%Post{id: 1}, %Post{id: 2, user_id: 2, user: %User{id: 2}}] def preload(resources, _), do: resources def get_by(User, %{id: "1"}), do: %User{} def get_by(User, _), do: nil def get_by(Post, %{id: "1"}), do: %Post{id: 1} def get_by(Post, %{id: "2"}), do: %Post{id: 2, user_id: 2} def get_by(Post, %{id: _}), do: nil def get_by(Post, %{slug: "slug1"}), do: %Post{id: 1, slug: "slug1"} def get_by(Post, %{slug: "slug2"}), do: %Post{id: 2, slug: "slug2", user_id: 2} def get_by(Post, %{slug: _}), do: nil end defimpl Canada.Can, for: User do def can?(%User{}, action, Myproject.PartialAccessController) when action in [:index, :show], do: true def can?(%User{}, action, Myproject.PartialAccessController) when action in [:new, :create, :update, :delete], do: false def can?(%User{}, :index, Myproject.SampleController), do: true def can?(%User{id: _user_id}, action, Myproject.SampleController) when action in [:index, :show, :new, :create, :update, :delete], do: true def can?(%User{id: user_id}, action, %Post{user_id: user_id}) when action in [:index, :show, :new, :create], do: true def can?(%User{}, :index, Post), do: true def can?(%User{}, action, Post) when action in [:new, :create, :other_action], do: true def can?(%User{id: user_id}, action, %Post{user: %User{id: user_id}}) when action in [:edit, :update], do: true def can?(%User{}, _, _), do: false end defimpl Canada.Can, for: Atom do def can?(nil, :create, Post), do: false def can?(nil, :create, Myproject.SampleController), do: false end defmodule Helpers do def unauthorized_handler(conn) do conn |> Plug.Conn.resp(403, "I'm sorry Dave. I'm afraid I can't do that.") |> Plug.Conn.send_resp() end def not_found_handler(conn) do conn |> Map.put(:not_found_handler_called, true) |> Plug.Conn.resp(404, "Resource not found.") |> Plug.Conn.send_resp() end def non_halting_unauthorized_handler(conn) do conn end end defmodule ErrorHandler do @behaviour Canary.ErrorHandler def not_found_handler(%Plug.Conn{} = conn) do Helpers.not_found_handler(conn) end def not_found_handler(%Phoenix.LiveView.Socket{} = socket) do {:halt, Phoenix.LiveView.redirect(socket, to: "/")} end def unauthorized_handler(%Plug.Conn{} = conn) do Helpers.unauthorized_handler(conn) end def unauthorized_handler(%Phoenix.LiveView.Socket{} = socket) do {:halt, Phoenix.LiveView.redirect(socket, to: "/")} end end