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 <name>`. 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
======
[](https://github.com/runhyve/canary/actions?query=workflow%3ACI)
[](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.<resource_name>`, where `resource_name` is inferred from the model name.
<!-- tabs-open -->
### 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`
<!-- tabs-close -->
### 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.<resource_name>`.
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}
<button phx-click="delete">Delete</button>
"""
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.<resource_name>`, 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.
<!-- tabs-open -->
### 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.
<!-- tabs-close -->
### 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`.
<!-- tabs-open -->
### 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}
```
<!-- tabs-close -->
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.
<!-- tabs-open -->
### 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
```
<!-- tabs-close -->
> #### 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"""
<div>Test</div>
"""
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"""
<div>Page</div>
"""
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"""
<div>Post</div>
"""
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
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
SYMBOL INDEX (112 symbols across 15 files)
FILE: lib/canary.ex
class Canary (line 1) | defmodule Canary
FILE: lib/canary/default_handler.ex
class Canary.DefaultHandler (line 1) | defmodule Canary.DefaultHandler
method not_found_handler (line 23) | def not_found_handler(%Plug.Conn{} = conn) do
method not_found_handler (line 29) | def not_found_handler(%Phoenix.LiveView.Socket{} = socket) do
method unauthorized_handler (line 40) | def unauthorized_handler(%Plug.Conn{} = conn) do
method unauthorized_handler (line 46) | def unauthorized_handler(%Phoenix.LiveView.Socket{} = socket) do
FILE: lib/canary/error_handler.ex
class Canary.ErrorHandler (line 1) | defmodule Canary.ErrorHandler
FILE: lib/canary/plugs.ex
class Canary.Plugs (line 1) | defmodule Canary.Plugs
method load_resource (line 91) | def load_resource(conn, opts) do
method do_load_resource (line 104) | defp do_load_resource(conn, opts) do
method authorize_controller (line 165) | def authorize_controller(conn, opts) do
method do_authorize_controller (line 176) | defp do_authorize_controller(conn, opts) do
method authorize_resource (line 263) | def authorize_resource(conn, opts) do
method do_authorize_resource (line 273) | defp do_authorize_resource(conn, opts) do
method load_and_authorize_resource (line 347) | def load_and_authorize_resource(conn, opts) do
method do_load_and_authorize_resource (line 357) | defp do_load_and_authorize_resource(conn, opts) do
method maybe_handle_not_found (line 366) | defp maybe_handle_not_found(%{state: :sent} = conn, _opts), do: conn
method maybe_handle_not_found (line 367) | defp maybe_handle_not_found(conn, opts), do: handle_not_found(conn, opts)
method purge_resource_if_unauthorized (line 369) | defp purge_resource_if_unauthorized(%{assigns: %{authorized: true}} = ...
method purge_resource_if_unauthorized (line 372) | defp purge_resource_if_unauthorized(%{assigns: %{authorized: false}} =...
method fetch_resource (line 377) | defp fetch_resource(conn, opts) do
method fetch_all (line 405) | defp fetch_all(conn, opts) do
method get_action (line 424) | defp get_action(conn) do
method handle_unauthorized (line 431) | defp handle_unauthorized(%{assigns: %{authorized: true}} = conn, _opts),
method handle_unauthorized (line 434) | defp handle_unauthorized(%{assigns: %{authorized: false}} = conn, opts),
method handle_not_found (line 437) | defp handle_not_found(conn, opts) do
FILE: lib/canary/utils.ex
class Canary.Utils (line 1) | defmodule Canary.Utils
method get_resource_id (line 24) | def get_resource_id(%Plug.Conn{params: params}, opts) do
method preload_if_needed (line 43) | def preload_if_needed(nil, _repo, _opts), do: nil
method preload_if_needed (line 46) | def preload_if_needed(records, repo, opts) do
method action_valid? (line 69) | def action_valid?(action, opts) do
method action_exempt? (line 85) | defp action_exempt?(action, opts) do
method action_included? (line 93) | defp action_included?(action, opts) do
method required? (line 105) | def required?(opts) do
method apply_error_handler (line 115) | def apply_error_handler(conn_or_socket, handler_key, opts) do
method get_handler (line 120) | defp get_handler(handler_key, opts) do
method get_resource_name (line 154) | def get_resource_name(action, opts) do
method persisted? (line 169) | def persisted?(opts) do
method pluralize_if_needed (line 173) | defp pluralize_if_needed(name, action, opts) do
method non_id_actions (line 184) | def non_id_actions(opts) do
method apply_handle_not_found? (line 197) | def apply_handle_not_found?(action, assigns, opts) do
method validate_opts (line 211) | def validate_opts(opts) do
method warn_deprecated_opts (line 216) | defp warn_deprecated_opts(opts) do
FILE: mix.exs
class Canary.Mixfile (line 1) | defmodule Canary.Mixfile
method project (line 4) | def project do
method elixirc_paths (line 35) | defp elixirc_paths(:test), do: ["lib", "test/support"]
method elixirc_paths (line 36) | defp elixirc_paths(_), do: ["lib"]
method application (line 38) | def application do
method package (line 42) | defp package do
method deps (line 50) | defp deps do
method coverage_ignore_modules (line 65) | defp coverage_ignore_modules do
FILE: test/canary/default_handler_test.exs
class CustomHandlers (line 1) | defmodule CustomHandlers
method not_found_handler (line 2) | def not_found_handler(conn) do
method unauthorized_handler (line 7) | def unauthorized_handler(conn) do
class DefaultHandlerTest (line 13) | defmodule DefaultHandlerTest
FILE: test/canary/hooks_test.exs
class Canary.HooksTest (line 1) | defmodule Canary.HooksTest
method build_socket (line 457) | defp build_socket(action \\ :show) do
method put_assigns (line 461) | defp put_assigns(socket, assigns) do
FILE: test/canary/plugs_test.exs
class Canary.PlugsTest (line 1) | defmodule Canary.PlugsTest
class UnauthorizedHandlerConfigured (line 1390) | defmodule UnauthorizedHandlerConfigured
class UnauthorizedHandlerConfiguredAndSpecified (line 1439) | defmodule UnauthorizedHandlerConfiguredAndSpecified
class NotFoundHandlerConfigured (line 1466) | defmodule NotFoundHandlerConfigured
class NotFoundHandlerConfiguredAndSpecified (line 1485) | defmodule NotFoundHandlerConfiguredAndSpecified
class CurrentUser (line 1505) | defmodule CurrentUser
class ApplicationConfig (line 1508) | defmodule ApplicationConfig
class Preload (line 1578) | defmodule Preload
class NonIdActions (line 1735) | defmodule NonIdActions
FILE: test/canary/utils_test.exs
class UtilsTest (line 1) | defmodule UtilsTest
FILE: test/support/endpoint.ex
class Canary.HooksHelper.Endpoint (line 1) | defmodule Canary.HooksHelper.Endpoint
FILE: test/support/page_live.ex
class Canary.HooksHelper.PageLive (line 1) | defmodule Canary.HooksHelper.PageLive
method render (line 15) | def render(assigns) do
method mount (line 21) | def mount(_params, _session, socket) do
method handle_call (line 27) | def handle_call({:run, func}, _, socket), do: func.(socket)
method run (line 29) | def run(lv, func) do
method fetch_lifecycle (line 33) | def fetch_lifecycle(lv) do
FILE: test/support/post_live.ex
class Canary.HooksHelper.PostLive (line 1) | defmodule Canary.HooksHelper.PostLive
method render (line 13) | def render(assigns) do
method mount (line 19) | def mount(_params, _session, socket) do
method handle_call (line 25) | def handle_call({:run, func}, _, socket), do: func.(socket)
method run (line 27) | def run(lv, func) do
method fetch_assigns (line 31) | def fetch_assigns(lv) do
method fetch_socket (line 37) | def fetch_socket(lv) do
FILE: test/support/router.ex
class Canary.HooksHelper.Router (line 1) | defmodule Canary.HooksHelper.Router
FILE: test/test_helper.exs
class User (line 4) | defmodule User
class Post (line 8) | defmodule Post
class Repo (line 20) | defmodule Repo
method get (line 21) | def get(User, 1), do: %User{}
method get (line 22) | def get(User, _id), do: nil
method get (line 24) | def get(Post, 1), do: %Post{id: 1}
method get (line 25) | def get(Post, 2), do: %Post{id: 2, user_id: 2}
method get (line 26) | def get(Post, _), do: nil
method all (line 28) | def all(_), do: [%Post{id: 1}, %Post{id: 2, user_id: 2}]
method preload (line 30) | def preload(%Post{id: post_id, user_id: user_id}, :user) do
method preload (line 35) | def preload([%Post{id: 1}, %Post{id: 2, user_id: 2}], :user),
method preload (line 38) | def preload(resources, _), do: resources
method get_by (line 40) | def get_by(User, %{id: "1"}), do: %User{}
method get_by (line 41) | def get_by(User, _), do: nil
method get_by (line 43) | def get_by(Post, %{id: "1"}), do: %Post{id: 1}
method get_by (line 44) | def get_by(Post, %{id: "2"}), do: %Post{id: 2, user_id: 2}
method get_by (line 45) | def get_by(Post, %{id: _}), do: nil
method get_by (line 47) | def get_by(Post, %{slug: "slug1"}), do: %Post{id: 1, slug: "slug1"}
method get_by (line 48) | def get_by(Post, %{slug: "slug2"}), do: %Post{id: 2, slug: "slug2", us...
method get_by (line 49) | def get_by(Post, %{slug: _}), do: nil
class Helpers (line 89) | defmodule Helpers
method unauthorized_handler (line 90) | def unauthorized_handler(conn) do
method not_found_handler (line 96) | def not_found_handler(conn) do
method non_halting_unauthorized_handler (line 103) | def non_halting_unauthorized_handler(conn) do
class ErrorHandler (line 108) | defmodule ErrorHandler
method not_found_handler (line 111) | def not_found_handler(%Plug.Conn{} = conn) do
method not_found_handler (line 115) | def not_found_handler(%Phoenix.LiveView.Socket{} = socket) do
method unauthorized_handler (line 119) | def unauthorized_handler(%Plug.Conn{} = conn) do
method unauthorized_handler (line 123) | def unauthorized_handler(%Phoenix.LiveView.Socket{} = socket) do
Condensed preview — 27 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (181K chars).
[
{
"path": ".credo.exs",
"chars": 5436,
"preview": "# This file contains the configuration for Credo and you are probably reading\n# this after creating it with `mix credo.g"
},
{
"path": ".formatter.exs",
"chars": 148,
"preview": "[\n import_deps: [:plug, :phoenix, :phoenix_live_view],\n inputs: [\n \"lib/**/*.ex\",\n \"config/*.exs\",\n \"test/**/"
},
{
"path": ".github/workflows/elixir.yml",
"chars": 1223,
"preview": "name: CI\n\non:\n push:\n pull_request:\n branches:\n - master\n\njobs:\n mix_test:\n name: mix test (OTP ${{matrix."
},
{
"path": ".gitignore",
"chars": 80,
"preview": "/_build\n/deps\nerl_crash.dump\n*.ez\ntags\n/doc\n/cover\n*.beam\n.history\n.tool-version"
},
{
"path": ".travis.yml",
"chars": 503,
"preview": "language: elixir\nelixir:\n - 1.8\n - 1.7\n - 1.6\n - 1.5\n - 1.4\n \notp_release:\n - 21.1\n - 20.3\n - 19.3\n - 18.3\n\nma"
},
{
"path": "CHANGELOG.md",
"chars": 4265,
"preview": "## Changelog\n\n## v2.0.0-dev\n Canary 2.0.0 introduces authorization hooks for Phoenix LiveView. The Plug based authoriza"
},
{
"path": "LICENSE",
"chars": 1062,
"preview": "Copyright (c) 2016 Chris Kelly\n\n\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this s"
},
{
"path": "README.md",
"chars": 13989,
"preview": "Canary\n======\n[](https://github.com/runhyve/cana"
},
{
"path": "config/config.exs",
"chars": 1029,
"preview": "# This file is responsible for configuring your application\n# and its dependencies with the aid of the Mix.Config module"
},
{
"path": "docs/getting-started.md",
"chars": 21133,
"preview": "# Getting Started\n\nThis guide introduces **Canary**, an authorization library for **Elixir** applications using `Plug` a"
},
{
"path": "docs/upgrade.md",
"chars": 1790,
"preview": "# Upgrade guides\n\n## Upgrading from Canary 1.2.0 to 2.0.0\n\n### Update Your Non-ID Actions\n\n> Since 2.0.0, the `:persiste"
},
{
"path": "lib/canary/default_handler.ex",
"chars": 1715,
"preview": "defmodule Canary.DefaultHandler do\n @moduledoc \"\"\"\n The fallback Canary handler.\n\n This module is used primarily as a"
},
{
"path": "lib/canary/error_handler.ex",
"chars": 599,
"preview": "defmodule Canary.ErrorHandler do\n @moduledoc \"\"\"\n Specifies the behavior for handling errors in Canary.\n\n\n \"\"\"\n @mod"
},
{
"path": "lib/canary/hooks.ex",
"chars": 17181,
"preview": "if Code.ensure_loaded?(Phoenix.LiveView) do\n defmodule Canary.Hooks do\n @moduledoc \"\"\"\n\n Hooks functions for load"
},
{
"path": "lib/canary/plugs.ex",
"chars": 14955,
"preview": "defmodule Canary.Plugs do\n import Canary.Utils\n import Canada.Can, only: [can?: 3]\n import Ecto.Query\n\n @moduledoc \""
},
{
"path": "lib/canary/utils.ex",
"chars": 6220,
"preview": "defmodule Canary.Utils do\n @moduledoc \"\"\"\n Common utils functions for `Canary.Plugs` and `Canary.Hooks`\n \"\"\"\n\n @doc "
},
{
"path": "lib/canary.ex",
"chars": 1555,
"preview": "defmodule Canary do\n @moduledoc \"\"\"\n An authorization library in Elixir for `Plug` and `Phoenix.LiveView` applications"
},
{
"path": "mix.exs",
"chars": 1788,
"preview": "defmodule Canary.Mixfile do\n use Mix.Project\n\n def project do\n [\n app: :canary,\n version: \"2.0.0-dev\",\n "
},
{
"path": "test/canary/default_handler_test.exs",
"chars": 2972,
"preview": "defmodule CustomHandlers do\n def not_found_handler(conn) do\n conn\n |> Plug.Conn.assign(:legacy_error_handler, tru"
},
{
"path": "test/canary/hooks_test.exs",
"chars": 14183,
"preview": "defmodule Canary.HooksTest do\n use ExUnit.Case, async: true\n\n import Phoenix.ConnTest\n import Phoenix.LiveViewTest\n\n "
},
{
"path": "test/canary/plugs_test.exs",
"chars": 51303,
"preview": "defmodule Canary.PlugsTest do\n import Canary.Plugs\n\n import Plug.Adapters.Test.Conn, only: [conn: 4]\n\n use ExUnit.Cas"
},
{
"path": "test/canary/utils_test.exs",
"chars": 3873,
"preview": "defmodule UtilsTest do\n import Canary.Utils\n\n use ExUnit.Case, async: true\n\n describe \"get_resource_id/2\" do\n test"
},
{
"path": "test/support/endpoint.ex",
"chars": 163,
"preview": "defmodule Canary.HooksHelper.Endpoint do\n use Phoenix.Endpoint, otp_app: :canary\n\n socket \"/live\", Phoenix.LiveView.So"
},
{
"path": "test/support/page_live.ex",
"chars": 721,
"preview": "defmodule Canary.HooksHelper.PageLive do\n use Phoenix.LiveView\n use Canary.Hooks\n\n mount_canary :load_resource,\n m"
},
{
"path": "test/support/post_live.ex",
"chars": 725,
"preview": "defmodule Canary.HooksHelper.PostLive do\n use Phoenix.LiveView\n use Canary.Hooks\n\n mount_canary :load_resource,\n m"
},
{
"path": "test/support/router.ex",
"chars": 551,
"preview": "defmodule Canary.HooksHelper.Router do\n use Phoenix.Router\n import Plug.Conn\n import Phoenix.LiveView.Router\n\n pipel"
},
{
"path": "test/test_helper.exs",
"chars": 3435,
"preview": "ExUnit.start()\n\n\ndefmodule User do\n defstruct id: 1\nend\n\ndefmodule Post do\n use Ecto.Schema\n\n schema \"posts\" do\n #"
}
]
About this extraction
This page contains the full source code of the cpjk/canary GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 27 files (168.6 KB), approximately 44.8k tokens, and a symbol index with 112 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.