Skip to content

Commit

Permalink
Merge pull request #88 from cpjk/liveview
Browse files Browse the repository at this point in the history
LiveView authentication - Canary 2.0.0
  • Loading branch information
quexpl authored Feb 11, 2025
2 parents 49b47bf + bd526f1 commit 61cee16
Show file tree
Hide file tree
Showing 22 changed files with 2,554 additions and 375 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ erl_crash.dump
*.ez
tags
/doc
/cover
*.beam
.history
.tool-version
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
## 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
Expand Down
197 changes: 147 additions & 50 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,15 @@ Canary
[![Actions Status](https://github.com/cpjk/canary/workflows/CI/badge.svg)](https://github.com/runhyve/canary/actions?query=workflow%3ACI)
[![Hex pm](https://img.shields.io/hexpm/v/canary.svg?style=flat)](https://hex.pm/packages/canary)

An authorization library in Elixir for Plug applications that restricts what resources
the current user is allowed to access, and automatically loads resources for the current request.
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](http://hexdocs.pm/canary)

## Installation

For the latest master:
For the latest master (2.0.0-dev):

```elixir
defp deps do
Expand All @@ -24,59 +23,75 @@ For the latest release:

```elixir
defp deps do
{:canary, "~> 1.2.1"}
{:canary, "~> 2.0.0-dev"}
end
```

Then run `mix deps.get` to fetch the dependencies.

## Usage
## Quick start

Canary provides three functions to be used as plugs to load and authorize resources:
Canary provides functions to be used as plugs or LiveView hooks to load and authorize resources:

`load_resource/2`, `authorize_resource/2`, and `load_and_authorize_resource/2`.
`load_resource`, `authorize_resource`, `authorize_controller`*, and `load_and_authorize_resource`.

`load_resource/2` and `authorize_resource/2` can be used by themselves, while `load_and_authorize_resource/2` combines them both.
`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 `conn.assigns.current_user` (the key can be customized - see https://github.com/cpjk/canary#overriding-the-default-user).
- 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`

Then, just `import Canary.Plugs` in order to use the 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 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.

### load_resource/2
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.

Loads the resource having the id given in `conn.params["id"]` from the database using the given Ecto repo and model, and assigns the resource to `conn.assigns.<resource_name>`, where `resource_name` is inferred from the model name.

For example,
### 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`, into
`conn.assigns.post`

### authorize_resource/2
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 -->

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 `conn.assigns.authorized`. It is up to you to decide what to do with the result.
### 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.


For non-Phoenix applications, or to override the action provided by Phoenix, simply ensure that `conn.assigns.canary_action` contains an atom specifying the 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/2
### load_and_authorize_resource

Authorizes the resource and then loads it if authorization succeeds. Again, the resource is loaded into `conn.assigns.<resource_name>`.
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
## 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.

Expand All @@ -90,7 +105,10 @@ defimpl Canada.Can, for: User do
def can?(%User{ id: user_id }, _, _), do: false
end
```
and in your `web/router.ex:` you have:

### Example for Conn Plugs

In your `web/router.ex:` you have:

```elixir
get "/posts/:id", PostController, :show
Expand All @@ -107,6 +125,46 @@ In this case, on `GET /posts/12` authorization succeeds, and the `Post` specifie

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.
Expand All @@ -117,12 +175,16 @@ 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
Expand All @@ -135,15 +197,19 @@ 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: Passing both `:only` and `:except` to a plug is invalid. Canary will simply pass the `Conn` along unchanged.
> 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

Expand All @@ -153,12 +219,14 @@ Globally, the default key for finding the user to authorize can be set in your c
config :canary, current_user: :some_current_user
```

In this case, canary will look for the current user record in `conn.assigns.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
Expand All @@ -169,26 +237,35 @@ 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 `conn.assigns.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

For the `:index`, `:new`, and `:create` actions, the resource passed to the `Canada.Can` implementation
should be the *module* name of the model rather than a struct.
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.

For example, when authorizing access to the `Post` resource,
To authorize such actions use `authorize_resource` plug with `required: false` option

you should use
```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
Expand All @@ -200,13 +277,51 @@ instead of
def can?(%User{}, :index, %Post{}), do: true
```

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.
> ### 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.

For example,
```elixir
plug :authorize_resource, model: Post, non_id_actions: [:find_by_name]
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`).
Expand All @@ -220,24 +335,6 @@ defimpl Canada.Can, for: Atom do
end
```

### 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. By specifying the `:persisted` option with `true`
you can load and/or authorize a nested resource. Specifying this option overrides the default loading behavior of the
`:index`, `:new`, and `:create` actions by loading an individual resource. It also overrides the default
authorization behavior of the `:index`, `:new`, and `create` actions by loading a struct instead of a module
name for the call to `Canada.can?`.

For example, when loading and authorizing a `Post` resource which can have one or more `Comment` resources, use

```elixir
plug :load_and_authorize_resource, model: Post, id_name: "post_id", persisted: true, only: [:create]
```

to load and authorize the parent `Post` resource using the `post_id` in /posts/:post_id/comments before you
create the `Comment` resource using its parent.

### 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.
Expand Down
Loading

0 comments on commit 61cee16

Please sign in to comment.