Authorization testing in Rails

Chapter 12 added users, sessions, sign-in tests, and read-only access for guests. Guests may view the list and a recipe detail page only but cannot create, edit, or destroy, and they do not see change buttons.

Right now one user can sign in, open another user’s recipe, and change the title, or even destroy the recipe. That’s because Chapter 12 did not add ownership to the recipes (no user_id in each recipe row) so there was no way to check if the signed-in user is authorized to perform those actions or not.

This chapter is about authorization (can this user perform that action?) and ownership (who owns this resource?). After this chapter, users can sign in, create their own recipe and browse another user’s recipes but will not be able to edit or delete them.

What you will do in this chapter #

  1. Add user_id to recipes, associate User and Recipe, give fixture recipes an owner, and assign the signed-in user on create with current_user.recipes.build.
  2. Add one deny test to the existing access file and make it green with a short ensure_recipe_owner guard (sample authorization for hobby projects).
  3. Put flash notices and alerts in the application layout so authorization (and the rest of the app) can show messages on every page, then remove the one-off flash markup from individual views.
  4. Install Action Policy, put the same rule in RecipePolicy, and replace the inline ensure_recipe_owner guard.
  5. Grow recipe_access_integration_test.rb with non-owner denials for modify, destroy, and ingredient/step Remove. Access tests for owners stay in recipes_integration_test.rb (unchanged from the previous chapter).
  6. Hide buttons for Edit, Destroy, and Remove with authenticated? && allowed_to? so guests skip Action Policy and non-owners fail ownership.
  7. Assert in the same access integration file that a non-owner does not see Destroy button on the list nor see Edit, Destroy, or Remove buttons on show.
  8. Run bin/rails test:all to ensure everything is green, then commit.

Scenarios to automate #

Scenario Expected
Owner can modify their own recipe Already covered by updates a recipe in recipes_integration_test.rb (Alice signed in). Do not re-test the happy path here.
Non-owner cannot modify another user’s recipe Bob GETs edit and PATCHes on Alice’s recipe; land on the recipes list at the root path; title unchanged
Owner can destroy their own recipe Already covered by destroys a recipe in recipes_integration_test.rb (Alice deletes lentil soup). Do not re-test the happy path here.
Non-owner cannot destroy another user’s recipe Bob DELETE on Alice’s recipe; redirects to the recipe list page; no row removed
Owner can remove an ingredient and a step from the detail page Already covered by removes ingredients and steps from the detail page in recipes_integration_test.rb (Alice, Turbo Stream DELETEs). Do not re-test the happy path here.
Owner can remove an ingredient and a step when updating a recipe Already covered by removes existing ingredients and steps when updating a recipe in recipes_integration_test.rb (Alice, PATCH with _destroy). Do not re-test the happy path here.
Non-owner cannot remove an ingredient or a step from the detail page Bob removes ingredient or step from Alice’s pancakes; redirects to the recipe list page; no rows removed
Non-owner cannot remove an ingredient or a step when updating a recipe Bob PATCHes with _destroy on Alice’s Salt and preheat; redirects to the recipe list page; no rows removed
Guest cannot edit, destroy, or remove Unsigned requests redirect to sign-in (Chapter 12 covers the recipe actions: create, edit and destroy; this chapter adds the nested ones: remove ingredients and steps)
Owner can see Edit, Destroy, and Remove buttons on their own recipe Already covered by signed-in user sees edit and destroy on show and Destroy on the list by signed-in user sees new recipe on the list (Alice).
Non-owner cannot see Edit, Destroy, or Remove on another user’s recipe Bob does not see Destroy button on the recipes list nor any Edit, Destroy, or Remove buttons on Alice’s show (assert_select with count: 0)

Authentication vs authorization (quick look) #

You added Authentication in Chapter 12 and this chapter adds Authorization to the Cookbook app. These two concepts sound similar but are different. Authentication answers who is signed in. Authorization answers what that signed-in user is allowed to do.

Question Layer Chapter Example actor
Is anyone signed in? Authentication 12 Guest vs Alice session
Can this signed-in user change this recipe? Authorization 13 Bob vs Alice on pancakes

Authentication failures send guests to sign-in. Authorization failures happen after sign-in: Bob is signed-in, but not the owner of the recipe so they should be redirected to the recipe list page and shown a denial flash message.

Why ownership waited #

Chapter 12 taught sessions and guest access on purpose without user_id on recipes. That helped keep the chapter focused only on authentication without drifting to ownership of the recipe. This chapter will now introduce ownership that is required for authorization. Once every recipe has a user, you can compare the recipe’s owner to current_user and write allow and deny tests for Bob (non-owner) and Alice (owner).

You will see four labels show up in the rest of this chapter frequently: guest, signed-in user, owner and non-owner.

  • Guest is a user that is not signed in to the app.
  • Signed-in users have an active session in the app.
  • For any given recipe, a signed-in user is either the owner or a non-owner and permissions differ for each four actors.

Following table summarizes the permissions for each actor:

Actor Who this is Browse list and show Create a new recipe Edit, update, or destroy this recipe
Guest No session Yes No (redirect to sign-in) No (redirect to sign-in)
Signed-in user Has a session Yes Yes Only when they own this recipe
Owner Signed-in and owns this recipe (Alice on pancakes) Yes Yes Yes
Non-owner Signed-in but someone else owns this recipe (Bob on pancakes) Yes Yes No

Add user_id and model associations #

To start testing authorization, you first need to introduce the concept of ownership for each recipe. This is done by adding a user_id column to the recipes table and a foreign key to the users table.

Run the following command to add the user_id column to the recipes table:

bin/rails generate migration AddUserToRecipes user:references

Now persist the new column to the database by migrating the database:

bin/rails db:migrate

If you didn’t have any record in the database for recipes then you will be fine and that command should have worked without any errors.

But if you had some recipes in the database then you will see an error like this:

bin/rails aborted!
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)

SQLite3::ConstraintException: NOT NULL constraint failed: recipes.user_id

Caused by:
ActiveRecord::NotNullViolation: SQLite3::ConstraintException: NOT NULL constraint failed: recipes.user_id (ActiveRecord::NotNullViolation)

Caused by:
SQLite3::ConstraintException: NOT NULL constraint failed: recipes.user_id (SQLite3::ConstraintException)

If you check the migration file, you will see something like this:

add_reference :recipes, :user, null: false, foreign_key: true

Notice the null: false part? That’s the “not null” constraint causing the error. With that constraint in place, the new migration requires every recipe to have an owner, but the old rows have no owner. So, what’s next?

The easiest way to fix this is to wipe the development database and rebuild the schema from migrations. Wipe out the database? Are you out of your mind?

Nope I am not out of my mind, it’s fine to wipe out the database for this Cookbook app because it’s just a sample app and not used in production. But you should never run these commands against a real production database.

bin/rails db:drop db:create db:migrate

This is what’s happening in the commands above:

  • db:drop and db:create clear the development database, wiping out all the data in the database and creates a new one back again.
  • db:migrate applies AddUserToRecipes on an empty table, so the NOT NULL constraint succeeds.

You might be wondering: What if this was a production app?

In that case, you would add a nullable user_id, backfill every existing recipe with an owner, then add the NOT NULL constraint in a follow-up migration so new recipes always have an owner going forward.

Now that you have the user_id column, you need to add the associations on both User and Recipe models.

User model in app/models/user.rb already has has_many :sessions added by the generator, append has_many :recipes association next to it:

# app/models/user.rb
class User < ApplicationRecord
  # ... existing code
  has_many :sessions, dependent: :destroy
  has_many :recipes, dependent: :destroy

  # ... existing code
end

Also update the Recipe model at app/models/recipe.rb to include the user association just above the has_many :ingredients association:

# app/models/recipe.rb
class Recipe < ApplicationRecord
  belongs_to :user

  has_many :ingredients, dependent: :destroy
  # ... existing code
end

This is what’s happening in the code above:

  • has_many :recipes in the User model lets you get all recipes of that user by calling current_user.recipes. It is also very useful when creating a new recipe with an owner by calling current_user.recipes.build, you will see this in action when working with recipes controller later in this chapter.
  • belongs_to :user ensures every recipe to have an owner. This also allows you to access the owner of a recipe by calling recipe.user.

Wire Bob and recipe owners in fixtures #

Chapter 12 only needed one user so Alice was enough for the Authentication chapter. But authorization is different, it needs two users because authorization is about checking permission of one user against another user’s resource. That’s where Bob comes in, a signed-in user who does not own the recipe you are testing.

Open the test/fixtures/users.yml and replace the leftover two: row from the generator with bob. Replace the whole file with the following:

# test/fixtures/users.yml
<% password_digest = BCrypt::Password.create("password") %>

alice:
  email_address: alice@example.com
  password_digest: <%= password_digest %>

bob:
  email_address: bob@example.com
  password_digest: <%= password_digest %>

Also update test/fixtures/recipes.yml so each recipe has Alice as the owner. Replace the whole file with the following:

# test/fixtures/recipes.yml
pancakes:
  title: Fluffy pancakes
  description: Weekend breakfast
  prep_time: 15
  servings: 4
  user: alice

lentil_soup:
  title: Lentil soup
  description: Simple dinner
  prep_time: 30
  servings: 6
  user: alice

This is what’s happening in the fixture files above:

  • user: alice is the fixture label from users.yml, this is how you let Rails fixtures know that the pancakes and lentil soup recipes belong to Alice.
  • Both fixture recipes belong to Alice so your existing integration and system tests keep a stable owner. Bob exists and does not own any recipes, a perfect candidate to test deny paths for authorization.

Fix failing tests before moving to authorization #

Associations and fixture owners are now in place. But before you move to authorization, you need to fix the tests that break due to the introduction of ownership on recipes. Run the test suite to see the failures:

bin/rails test

These are the failures you will see:

F

Failure:
RecipeTest#test_strips_whitespace_from_title_before_validation [test/models/recipe_test.rb:43]:
Expected false to be truthy.

F

Failure:
RecipesIntegrationTest#test_creates_a_recipe_with_an_ingredient_and_a_step [test/integration/recipes_integration_test.rb:60]:
`Recipe.count` didn't change by 1, but by 0.
Expected: 3
  Actual: 2

F

Failure:
RecipesIntegrationTest#test_creates_a_recipe [test/integration/recipes_integration_test.rb:42]:
`Recipe.count` didn't change by 1, but by 0.
Expected: 3
  Actual: 2

You will see failures in two main test files:

Where Why it fails
Model tests that call Recipe.new (test/models/recipe_test.rb) belongs_to :user is required. A new recipe with no owner is invalid, so assertions that used to isolate title or prep time now fail on user too.
Integration tests for creating recipes (test/integration/recipes_integration_test.rb) The controller still builds with Recipe.new(recipe_params), so save fails when there is no user_id in the recipe enforced by the not null constraint.

Fixture-backed examples that only read recipes(:pancakes) should stay green since you have already wired Alice as the owner for those recipes.

Model tests: pass an owner on Recipe.new #

Open test/models/recipe_test.rb you added in Chapter 8, search for Recipe.new and pass , user: users(:alice) (owner of the recipe) as an argument. Following is an example for the rejects a blank title test:

# test/models/recipe_test.rb
test "rejects a blank title" do
  recipe = Recipe.new(title: "", user: users(:alice))
  assert_not recipe.valid?
  assert_includes recipe.errors[:title], "can't be blank"
end

Do the same for all other Recipe.new examples in that file (negative prep time, negative servings, whitespace-only description, printable? false case and strip title). Keep recipes(:pancakes) as-is for fixture-based tests since you have already updated the fixture to make pancakes belong to Alice.

user: users(:alice) satisfies the not null constraint on user_id (or belongs_to :user) so the failure you assert is still prep time (or title, servings, description), not a missing owner.

Run the model tests to ensure they pass, you should see 0 failures and 0 errors:

bin/rails test test/models

Controller create: assign the signed-in owner #

If a signed-in user creates a new recipe, it should belong to that user (or owner of the recipe). Open app/controllers/recipes_controller.rb and update create action so that instead of using Recipe.new on create, it uses current_user.recipes.build:

# app/controllers/recipes_controller.rb
class RecipesController < ApplicationController
  # ... existing code

  def create
    @recipe = current_user.recipes.build(recipe_params)
    # ... existing save and respond logic
  end

  # ... existing actions
end

current_user.recipes.build sets user_id from the session before saving the recipe to the database. Create tests (both integration and system) that already sign in as Alice should start passing again.

current_user comes from the Authentication concern we had added in Chapter 12 so controllers and views can call current_user instead of Current.user.

Run the full test suite to ensure everything is green, you should see 0 failures and 0 errors:

bin/rails test:all

With the tests now passing again, commit the changes as a checkpoint before you start authorization:

git add .
git commit -m "Assign recipe owners in fixtures, model tests, and while creating a new recipe"

A hobby-sized ownership check #

For a tiny app like Cookbook, you can get by with a simple DIY check in the controller for authorization. Compare @recipe.user == current_user, redirect when it fails, and stop there. No gem and no policy file required: just a simple custom authorization check. That is a fair trade when the app is small and you know it will not grow into a multi-resource product.

In this chapter, you will start with that DIY check first so the deny test turns green. Then you will move the same rule into a separate file provided by an authorization gem, the way a production app usually encodes authorization, and delete ensure_recipe_owner.

What counts as working? #

Flow Expected?
Non-owner cannot update another user’s recipe Bob GETs edit for Alice’s recipe and updates the title to “Hacked pancakes”; redirects to the recipe show page; title unchanged

Add scenarios to the test file #

Open the existing integration test file test/integration/recipe_access_integration_test.rb you added in Chapter 12 and add the following scenario to the file:

# test/integration/recipe_access_integration_test.rb
  # Actor: Bob, signed in
  # Starting point: Alice owns recipes(:pancakes)
  # Action: GET edit, PATCH new title
  # Expected outcome: redirect to recipe show; title unchanged
  # test "non-owner cannot update a recipe" do
  # end

Red: add the deny test #

Replace the body of test "non-owner cannot update a recipe" with the following:

# test/integration/recipe_access_integration_test.rb
  test "non-owner cannot update a recipe" do
    sign_in_as users(:bob)
    recipe = recipes(:pancakes)

    patch recipe_url(recipe), params: { recipe: { title: "Hacked pancakes" } }
    assert_redirected_to recipe_url(recipe)
    assert_not_equal "Hacked pancakes", recipe.reload.title
  end

This is what’s happening in the code above:

  • The test signs in as Bob first.
  • It then tries to patch the title of a recipe owned by another user to “Hacked pancakes”.
  • Finally it asserts that the title has not changed and Bob is redirected to the recipe show page again.

Run the test:

bin/rails test test/integration/recipe_access_integration_test.rb -i test_non-owner_cannot_update_a_recipe

You want a failure because the controller hasn’t enforced ownership which means Bob can still PATCH Alice’s recipe.

F

Failure:
RecipeAccessIntegrationTest#test_non-owner_cannot_update_a_recipe [test/integration/recipe_access_integration_test.rb:58]:
Expected response to be a <3XX: redirect>, but was a <200: OK>

Green: add ensure_recipe_owner on the controller #

Open app/controllers/recipes_controller.rb and add an owner check on edit, update, and destroy after the before_action :set_recipe. Also add ensure_recipe_owner as a private method just below the set_recipe method:

# app/controllers/recipes_controller.rb
class RecipesController < ApplicationController
  # ... existing code
  before_action :set_recipe, only: %i[show edit update destroy]
  before_action :ensure_recipe_owner, only: %i[edit update destroy]

  # ... existing actions

  private

  def set_recipe
    @recipe = Recipe.find(params[:id])
  end

  def ensure_recipe_owner
    return if @recipe.user == current_user

    redirect_to @recipe, alert: "You are not authorized to modify this recipe."
  end

  # ... existing private methods
end

This is what’s happening in the code above:

  • ensure_recipe_owner runs after set_recipe so @recipe is loaded and you can compare @recipe.user to current_user. It is only called on edit, update, and destroy actions.
  • Non-owners are redirected to the recipe show page with an authorization error flash message.
  • create and new do not use ensure_recipe_owner since any signed-in user can still create a new recipe.

Run the deny test again, you should see 0 failures and 0 errors:

bin/rails test test/integration/recipe_access_integration_test.rb -i test_non-owner_cannot_update_a_recipe

This inline guard is enough for a tiny app with one owned resource. On a client project, or anything you expect to grow, I would not leave the comparison in the controller. After one more UI fix for flash messages, you will move the authorization check to a separate file provided by an authorization gem and delete ensure_recipe_owner.

But before moving on, quickly surf the feature in the browser to ensure it works as expected.

  1. Start the server with bin/dev (if not already running).
  2. Sign in as Bob with bob@example.com and password.
  3. Open Alice’s Fluffy pancakes show page, then append /edit to the URL.
  4. You should be redirected back to the recipe show page which means the deny is working.

The denial and authorization is working but if you look carefully, you probably did not see the authorization message set by the controller alert: "You are not authorized to modify this recipe.". Sign-in failures showed alerts on the login form in Chapter 12, so it is easy to assume flash already works everywhere. It does not, it worked in the login page because it has a custom code to render the alert. You will fix that next so authorization messages or any other flash messages are displayed on every page.

Show flash messages app-wide #

Today the Cookbook app only prints flash messages in a few views:

  • app/views/sessions/new.html.erb (alert and notice for sign-in)
  • app/views/passwords/new.html.erb and app/views/passwords/edit.html.erb (alerts for reset)
  • app/views/recipes/index.html.erb and app/views/recipes/show.html.erb (scaffold notice only, in green; no alerts)

That is why Bob’s authorization alert never appeared on the recipe show page. The message was in the session, but the show template never printed flash[:alert].

Add flash to the application layout #

Open app/views/layouts/application.html.erb and render both keys just above <%= yield %>, next to the Sign in / Sign out block you added in Chapter 12:

<%# app/views/layouts/application.html.erb %>
<body>
  <% if authenticated? %>
    <p>Signed in as <%= current_user.email_address %></p>
    <%= button_to "Sign out", session_path, method: :delete %>
  <% else %>
    <%= link_to "Sign in", new_session_path if request.path != new_session_path %>
  <% end %>

  <%= tag.div(flash[:alert], style: "color: red") if flash[:alert] %>
  <%= tag.div(flash[:notice], style: "color: green") if flash[:notice] %>

  <%= yield %>
</body>

This is what’s happening in the code above:

  • flash[:alert] is the red path (failed sign-in, authorization deny, mismatched password confirmation).
  • flash[:notice] is the green path (signed-in success messages, “Recipe was successfully created.”, password reset sent).
  • Putting both in the layout means every page can show either key after a redirect, including recipe show after ensure_recipe_owner.

Remove one-off flash from individual views #

With the application layout owning flash, you can leave the per-page copies behind so messages do not print twice.

Delete these two lines from the top of app/views/sessions/new.html.erb:

<%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
<%= tag.div(flash[:notice], style: "color:green") if flash[:notice] %>

Delete the alert line from app/views/passwords/new.html.erb and from app/views/passwords/edit.html.erb:

<%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>

Delete the scaffold notice line from the top of app/views/recipes/index.html.erb and app/views/recipes/show.html.erb:

<p style="color: green"><%= notice %></p>

Surf deny checks again #

Surf the deny path for Bob to confirm the authorization message is visible on a recipe page:

  1. Start the server with bin/dev (if not already running).
  2. Sign in as Bob with bob@example.com and password.
  3. Open Alice’s Fluffy pancakes show page, then append /edit to the URL.
  4. You should be redirected back to the recipe show page and also see the red alert with the message: “You are not authorized to modify this recipe.”

While you are at it, also confirm the sign-in failures and password resets still show alerts on the login form.

For the sign-in failures:

  1. Sign out of the app.
  2. Try to sign in with an email address bob@example.com and a bad password wrong-pass.
  3. You should be redirected back to the sign-in page and also see the red alert with the message: “Try another email address or password.”

For the password resets:

  1. From the login page, click the “Forgot your password?” link.
  2. You should be redirected to the password reset request form.
  3. Enter the email address bob@example.com and click the “Email reset instructions” button.
  4. You should be redirected to the sign-in page and also see the green notice with the message: “Password reset instructions sent (if user with that email address exists).”

For the recipe updates:

  1. Sign in as Alice with alice@example.com and password.
  2. Open Alice’s Fluffy pancakes show page, then append /edit to the URL.
  3. You should be redirected to the recipe edit page.
  4. Update the title to “Fluffy pancakes (updated)” and click the “Update Recipe” button.
  5. You should be redirected to the recipe show page and also see the green notice with the message: “Recipe was successfully updated.”

Before you move on to adding an authorization gem, ensure everything is green before committing:

bin/rails test:all

You want 0 failures and 0 errors. Then:

git add .
git commit -m "Render flash alerts and notices in the application layout"

When to use an authorization gem #

If you are building a small app or a hobby project, you can just use the inline guard like ensure_recipe_owner in the controller. It works when there is only one or two controllers, but if you have more resources then this customized check will start being repetitive and hard to maintain. Thus you use an authorization gem to encapsulate the authorization logic and reuse it across multiple controllers.

The two authorization gems you will hear about most in the Rails ecosystem are Pundit and CanCanCan. Pundit is a simple gem that keeps one plain Ruby policy class per model. CanCanCan is a more powerful gem that puts ability rules in one (often large) class and is a bit more complex than Pundit.

Gem Shape
Pundit One plain Ruby policy class per model (RecipePolicy#update?)
CanCanCan Ability rules in one (often large) class

I mostly prefer Pundit in my projects due to its simplicity. But recently I have also seen people (and me) adopt another similar gem called Action Policy developed and maintained by Evil Martians. It was inspired by Pundit and brings some additional features also from CanCanCan. This is what their website says about it:

Pundit has been our framework of choice for a long time. Being too dead-simple, it required a lot of hacking to fulfill business logic requirements.

These hacks later became Action Policy (initially, we even called it “Pundit, re-visited”).

We also took a few ideas from CanCanCan—such as default rules and rule aliases.

This guide also uses Action Policy for Authorization. Next, you will migrate the custom authorization check to Action Policy so you can see how you can test authorization in a production app.

Add Action Policy #

Install the gem and generate the base policy class:

bundle add action_policy
bin/rails generate action_policy:install

This is what’s happening in the commands above:

  • bundle add action_policy puts the gem in the Gemfile and installs it.
  • action_policy:install creates app/policies/application_policy.rb that can be used for adding global permissions and rules for all policies. The idea is to keep the global rules in one place and then override them in the specific policies for each resource.

Handle unauthorized actions #

Once you have installed the gem, it exposes authorize! and allowed_to? helper methods in controllers and views. When you use authorize! in a controller, it will raise ActionPolicy::Unauthorized if the user is not authorized to perform the action (similar to ensure_recipe_owner). You can then rescue from this exception and handle it in the application controller by adding the following code to the app/controllers/application_controller.rb.

Handling it in the application controller means other controllers will automatically inherit the same behavior so you don’t need to repeat the same code in each controller.

Update the application controller to rescue from ActionPolicy::Unauthorized and redirect the user back to the previous page with a flash message:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # ... existing code

  # Changes to the importmap will invalidate the etag for HTML responses
  stale_when_importmap_changes

  rescue_from ActionPolicy::Unauthorized, with: :user_not_authorized

  private

  def user_not_authorized(_exception)
    flash[:alert] = "You are not authorized to perform this action."

    redirect_back fallback_location: root_path
  end
end

This is what’s happening in the code above:

  • Action Policy uses current_user as the policy user; the helper you added in Chapter 12.
  • rescue_from ActionPolicy::Unauthorized turns a failed authorize! into a flash message to notify users that they are not authorized to perform the action.
  • redirect_back fallback_location: root_path redirects the user back to the previous page with a flash message. It falls back to the root path if the previous page is not available.

Rules and permissions refresher #

Before you generate the policy for recipes, refresh your memory on the permissions and rules in the Cookbook app so you understand what to put in the policy file.

Resource Action Rules Guest Signed-in Owner Non-owner
Recipes Index, Show Anyone can view the list and details of all recipes. Allowed Allowed Allowed Allowed
Recipes New, Create Only signed-in users can create new recipes. Denied Allowed - -
Recipes Update Only owners can update their own recipes. Denied Denied Allowed Denied
Recipes Destroy Only owners can destroy their own recipes. Denied Denied Allowed Denied
Ingredients Destroy Only owners can destroy their own ingredients. Denied Denied Allowed Denied
Steps Destroy Only owners can destroy their own steps. Denied Denied Allowed Denied

Tighten application policy rules #

Open app/policies/application_policy.rb and update the rules to ensure none of the actions are allowed by default (more on the why below). Replace the existing code with the following:

# app/policies/application_policy.rb
# Base class for application policies
class ApplicationPolicy < ActionPolicy::Base
  # Configure additional authorization contexts here
  # (`user` is added by default).
  #
  #   authorize :account, optional: true
  #
  # Read more about authorization context: https://actionpolicy.evilmartians.io/#/authorization_context

  def index?
    false
  end

  def show?
    false
  end

  def create?
    false
  end

  def new?
    create?
  end

  def update?
    false
  end

  def edit?
    update?
  end

  def destroy?
    false
  end

  private

  def owner?
    record.user_id == user.id
  end
end

Denying all actions is a good default because it’s fine if a user with permission gets blocked but it’s a huge security risk if a user without permission gets through. That’s why global policy should always be very strict and narrow.

owner? is a shared method that you can use in other policies to check if the owner (or user_id) of the record is the logged-in / current user or not.

Generate a policy for recipes #

Run the following command to generate the policy file for recipes:

bin/rails generate action_policy:policy Recipe

The generator also creates a test file for the policy at test/policies/recipe_policy_test.rb. This chapter proves authorization through HTTP redirects, row counts, and rendered HTML in the access integration file, instead of calling policy methods from a unit test. So, I recommend you to delete the file with the following command:

rm test/policies/recipe_policy_test.rb

Replace the generated app/policies/recipe_policy.rb with the following:

# app/policies/recipe_policy.rb
class RecipePolicy < ApplicationPolicy
  # See https://actionpolicy.evilmartians.io/#/writing_policies
  #

  def index?
    true
  end

  def show?
    true
  end

  def create?
    user.present?
  end

  def new?
    create?
  end

  def update?
    owner?
  end

  def edit?
    update?
  end

  def destroy?
    owner?
  end

  # Scoping
  # See https://actionpolicy.evilmartians.io/#/scoping
  #
  # relation_scope do |relation|
  #   next relation if user.admin?
  #   relation.where(user: user)
  # end
end

This is what’s happening in the code above:

  • RecipePolicy inherits from ApplicationPolicy, owner? method comes from there.
  • index? and show? are true for everyone. The Cookbook app has no restrictions on viewing the list of recipes or the details of a recipe.
  • create? and new? are true for any signed-in user (user.present?). Guests never reach those actions because Authentication still runs. user is provided by the Action Policy by default so you can call it here without any additional setup.
  • edit?, update?, and destroy? are true only when the current user is an owner (recipe’s user is the current user). That is the same comparison as ensure_recipe_owner you previously implemented in the recipes controller.
  • update? and destroy? both call owner? directly. Normally in blogs and production apps, you will see destroy? calling the update? permission instead of owner?. But we are not following that here because editing a recipe and deleting a recipe are two features, and it’s best to keep each rule answering its own question. Reason: if you later loosen update? (say a collaborator may rename a recipe), you don’t want that change to quietly hand the same person the ability to delete the recipe! edit? staying an alias of update? is fine, since the edit form and the PATCH it submits are one feature.

relation_scope is not used in this guide but it’s a good way to filter out (or scope) the records the user can see in the list view. For example, in many apps you may want to show only the recipes that the current user owns. You can do this by adding the following code to the RecipePolicy:

relation_scope do |relation|
  next relation if user.admin?

  relation.where(user: user)
end

That will allow user with admin role to see all recipes but only allows the user with regular role to see the recipes they own.

Replace ensure_recipe_owner with authorize! #

Open RecipesController at app/controllers/recipes_controller.rb and remove before_action :ensure_recipe_owner, only: %i[edit update destroy] you had previously added after before_action :set_recipe. Also search for the method def ensure_recipe_owner and remove it. This guard is no longer needed because you will use authorize! to ensure only the owner can modify or destroy the recipe.

Done with it? Onto the next step then.

Now, update new, create, edit, update, and destroy actions to use authorize!:

# app/controllers/recipes_controller.rb
class RecipesController < ApplicationController
  allow_unauthenticated_access only: %i[index show]
  before_action :set_recipe, only: %i[show edit update destroy]

  def index
    # ... existing code
  end

  def show
    # ... existing code
  end

  def new
    @recipe = Recipe.new

    authorize! @recipe

    # ... existing code
  end

  def create
    @recipe = current_user.recipes.build(recipe_params)

    authorize! @recipe

    # ... existing code
  end

  def edit
    authorize! @recipe

    # ... existing code
  end

  def update
    authorize! @recipe

    # ... existing code
  end

  def destroy
    authorize! @recipe

    # ... existing code
  end

  private

  # ... existing code
end

This is what’s happening in the code above:

  • authorize! @recipe infers new?, create?, edit?, update?, or destroy? from the controller action name (update maps to update?). You can also pass the rule explicitly with to: (for example authorize! @recipe, to: :destroy?) which helps when the action name and the rule name for that same resource do not match (for example: you had archive action for recipes that used destroy? rule).
  • Failed checks raise ActionPolicy::Unauthorized, which you have already rescued globally in the application controller and shows a flash message to the user.
  • Show and index stay public. You do not need to call authorize! there.

Re-run the authorization test you wrote previously for “Non-owner cannot update a recipe”. It should fail at the redirect assertion because Action Policy now redirects with redirect_back fallback_location: root_path instead of sending them to the recipe show page:

bin/rails test test/integration/recipe_access_integration_test.rb -i test_non-owner_cannot_update_a_recipe

You will see 1 failure:

F

Failure:
RecipeAccessIntegrationTest#test_non-owner_cannot_update_a_recipe [test/integration/recipe_access_integration_test.rb:58]:
Expected response to be a redirect to <http://www.example.com/recipes/507601372> but was a redirect to <http://www.example.com/>.
Expected "http://www.example.com/recipes/507601372" to be === "http://www.example.com/".

Update the test at test/integration/recipe_access_integration_test.rb to expect the redirect to the root path instead of the recipe show page:

# test/integration/recipe_access_integration_test.rb
test "non-owner cannot update a recipe" do
  # ... existing code
  get recipe_url(recipe)
  assert_redirected_to root_path

  patch recipe_url(recipe), params: { recipe: { title: "Hacked pancakes" } }
  assert_redirected_to root_path
  get recipe_url(recipe)
  assert_response :success
  assert_not_equal "Hacked pancakes", recipe.reload.title
end

You also need to add a get recipe_url(recipe) to ensure the recipe wasn’t updated since we are now redirecting to the root path (recipe list page) instead of the recipe show page.

Run the test again, you should now have 0 failures and 0 errors.

bin/rails test test/integration/recipe_access_integration_test.rb -i test_non-owner_cannot_update_a_recipe

Non-owner cannot modify the recipe #

You already proved one PATCH deny for Bob in the custom authorization guard example in the hobby project. But editing also needs a deny for the edit form itself, the page a curious (or malicious) signed-in user can open by typing /recipes/1/edit directly in the address bar.

You do not need to add a test for "owner can update a recipe" in this file because it is already covered by test "updates a recipe" in test/integration/recipes_integration_test.rb. That test signs in as Alice, opens edit, PATCHes a title, and checks the show page. That test should still pass even after adding the authorize!. This chapter only adds the non-owner and guest edge cases not covered previously by the recipes_integration_test.rb or recipe_access_integration_test.rb.

You also don’t need to add a guest edit test here. Chapter 12 already proves an unsigned GET edit redirects to sign-in in guest cannot edit a recipe, and that path never reaches Action Policy.

What counts as working? #

Flow Expected
Non-owner opens the edit form Bob GETs edit for Alice’s Fluffy pancakes and lands on the recipes list (root path)
Non-owner PATCHes a title Already in this file from the hobby step (non-owner cannot update a recipe), redirecting to the root path
Owner updates a title Already in recipes_integration_test.rb as updates a recipe (Alice signed in). Leave that test alone.
Guest opens the edit form Already in recipe_access_integration_test.rb as guest cannot edit a recipe. Leave that test alone.

Add scenarios to the test file #

Update the existing test “non-owner cannot update a recipe” to also check for edit form restriction:

# test/integration/recipe_access_integration_test.rb
  # Actor: Bob, signed in
  # Starting point: Alice owns recipes(:pancakes)
  # Action: GET the edit form for Alice's Fluffy pancakes; PATCH the title to "Hacked pancakes"
  # Expected outcome: redirect to the root path; title unchanged in the database
  # test "non-owner cannot update a recipe" do
  # end

Add the integration test #

Replace the body of the test with the following:

# test/integration/recipe_access_integration_test.rb
  test "non-owner cannot update a recipe" do
    sign_in_as users(:bob)
    recipe = recipes(:pancakes)

    get edit_recipe_url(recipe)
    assert_redirected_to root_path

    patch recipe_url(recipe), params: { recipe: { title: "Hacked pancakes" } }
    assert_redirected_to root_path
    get recipe_url(recipe)
    assert_response :success
    assert_not_equal "Hacked pancakes", recipe.reload.title
  end

This is what’s happening in the code above:

  • After signing in to the app, Bob GETs the edit URL directly, the path a curious user could bookmark or guess.
  • The redirect target is root_path because user_not_authorized calls redirect_back fallback_location: root_path. In the Cookbook app the root path is the recipes list, so Bob lands on a page they are allowed to see.
  • Other assertions are the same from the previous test “non-owner cannot update a recipe”: test PATCHes the title and asserts the recipe title is not changed.

Run the access file:

bin/rails test test/integration/recipe_access_integration_test.rb

While you are at it, spot-check the existing owner update test:

bin/rails test test/integration/recipes_integration_test.rb -i test_updates_a_recipe

You want 0 failures and 0 errors on both as the restrictions and permissions are already added to the recipe policy.

You already surfed through the browser to confirm the authorization message is visible on a recipe page for this feature after the authorization check for hobby project so you don’t need to do it again here.

Non-owner cannot destroy the recipe #

Deleting is the action people worry about the most in any app and rightfully so because it can lead to a result that is unrecoverable. A wrong PATCH can be edited back but a wrong DELETE takes the recipe, its ingredients, and its steps with it with no hope of recovery. So, it’s given that we add a strict authorization check for this action.

Similar to the update path, you do not need to add a test for "owner can destroy a recipe" in this file because it is already covered by test "destroys a recipe" in test/integration/recipes_integration_test.rb. That test signs in as Alice, DELETEs recipes(:lentil_soup), asserts Recipe.count drops by one, follows to the list, and checks the remaining recipe rows.

The guest delete path is also already covered by guest cannot destroy a recipe in test/integration/recipe_access_integration_test.rb.

What counts as working? #

Flow Expected
Non-owner deletes a recipe Bob DELETEs Alice’s Fluffy pancakes, Recipe.count is unchanged and lands on the root path
Owner deletes a recipe Already covered by destroys a recipe in recipes_integration_test.rb
Guest deletes a recipe Already covered by guest cannot destroy a recipe in recipe_access_integration_test.rb

Add the scenario and test #

Add the following scenario to the recipe_access_integration_test.rb file:

# test/integration/recipe_access_integration_test.rb
  # Actor: Bob, signed in
  # Starting point: Alice owns recipes(:pancakes)
  # Action: DELETE pancakes
  # Expected outcome: recipe count unchanged; redirect to the root path
  # test "non-owner cannot destroy a recipe" do
  # end

Then replace the body of the test with the following:

# test/integration/recipe_access_integration_test.rb
  test "non-owner cannot destroy a recipe" do
    sign_in_as users(:bob)
    recipe = recipes(:pancakes)

    assert_no_difference("Recipe.count") do
      delete recipe_url(recipe)
    end
    assert_redirected_to root_path
  end

This is what’s happening in the code above:

  • The deny wraps delete in assert_no_difference("Recipe.count") to ensure “the row is still there” after the delete action.
  • Since Bob is not authorized to delete Alice’s recipe, they are redirected back to the root path.

Run the test:

bin/rails test test/integration/recipe_access_integration_test.rb

You want 0 failures and 0 errors. The deny passes right away because RecipePolicy#destroy? asks owner? and Bob is not the owner of Alice’s recipe.

While you are at it, ensure the existing test for owner deleting their recipe also passes:

bin/rails test test/integration/recipes_integration_test.rb -i test_destroys_a_recipe

You should see 0 failures and 0 errors.

Before you move on, quickly surf through the browser to ensure the authorization for non-owner not able to delete a recipe works as expected.

  1. If you already have the server running, stop it and restart back again with bin/dev. This is required because you added ActionPolicy gem and without restarting the server, the changes will not be loaded and even may throw errors.
  2. Sign in as Bob with the credentials bob@example.com / password.
  3. Open the recipe show page for Alice’s Fluffy pancakes
  4. Click the Destroy button
  5. You should be redirected with an authorization error message: “You are not authorized to perform this action.”
  6. Reload the page and you should see the recipe is still there.

Non-owner cannot destroy ingredients and steps #

Remove button for Ingredients and Steps you added in Chapter 11 still only look at if the user is signed in or not (similar to how recipe actions worked at the start of this chapter). So, any user that is signed in to the app can delete any ingredient or step on any recipe right now; the behavior you added in Chapter 12.

RecipesController is now locked with the proper authorization checks in the RecipePolicy file, but Bob can still sign in and send a DELETE to /recipes/1/ingredients/2 to take Salt out of Alice’s pancakes. This is what you will fix next.

Cookbook has two ways to delete a nested record for ingredients and steps from the recipe:

  1. The show page Remove button hits IngredientsController#destroy / StepsController#destroy
  2. The recipe edit form has Remove button that marks _destroy and goes through RecipesController#update.

You do not need to add happy path tests for owner being able to remove ingredients and steps, it has already been covered in recipes_integration_test.rb with removes ingredients and steps from the detail page (Turbo Stream DELETEs) and removes existing ingredients and steps when updating a recipe (PATCH with _destroy). Those should stay green even after the authorize checks land in IngredientsController and StepsController.

What counts as working? #

Flow Expected
Guest removes ingredient or step from the detail page No session, so each DELETE redirects to sign-in and the child counts are unchanged
Guest removes ingredient or step when updating a recipe No session, so PATCH redirects to sign-in and the child counts are unchanged
Non-owner removes ingredient or step from the detail page Bob DELETEs Alice’s Salt (ingredient) and preheat (step), lands on the root path; both child counts are unchanged
Non-owner removes ingredient or step when updating a recipe Bob PATCHes Alice’s pancakes with _destroy for Salt (ingredient) and preheat (step), lands on the root path; both child counts are unchanged
Owner removes ingredient or step from the detail page Already covered by removes ingredients and steps from the detail page in recipes_integration_test.rb
Owner removes ingredient or step when updating a recipe Already covered by removes existing ingredients and steps when updating a recipe in recipes_integration_test.rb

Use existing policy or create a new policy for ingredients and steps? #

There are two options to approach authorization for nested resources:

  1. Use the existing recipe policy for ingredients and steps.

    This is a very tempting shortcut, you can do this by calling authorize! @recipe, to: :update? in the ingredients and steps controllers. This will reuse the recipe rule you already have for the update action.

    It perfectly works today but can bite you bitterly in future. Removing an ingredient would be wired to a rule that exists for a different feature, the recipe edit form. The day you loosen update?, say you let a collaborator rename a shared recipe, you also hand that person the Remove button on every ingredient and step, and nothing in the recipe tests goes red to warn you. The same coupling shows up if the child policy calls allowed_to?(:update?, record.recipe) internally.

  2. Create new policies each for ingredients and steps.

    This is the option I like the most and a better approach than just reusing existing policy of a different resource. It’s additional work upfront but something that is worth it in the long run. With this option, you give each resource its own policy file so every rule answers one question for one resource. This will ensure a change to one feature only touches that particular feature and authorization stays tight and narrow to restrict unauthorized access.

In this guide, you will use the second option and create a new policy for ingredients and steps.

Add scenarios to the test file #

Append the following scenarios to the recipe_access_integration_test.rb file:

# test/integration/recipe_access_integration_test.rb
  # Actor: Guest, no session
  # Starting point: Alice owns recipes(:pancakes) with ingredients(:salt) and steps(:preheat)
  # Action: DELETE the salt ingredient; DELETE the preheat step
  # Expected outcome: ingredient count unchanged; step count unchanged; redirect to sign-in
  # test "guest cannot remove an ingredient or a step from the detail page" do
  # end

  # Actor: Guest, no session
  # Starting point: Alice owns recipes(:pancakes) with ingredients(:salt) and steps(:preheat)
  # Action: PATCH pancakes with _destroy for Salt and the preheat step
  # Expected outcome: ingredient count unchanged; step count unchanged; redirect to sign-in
  # test "guest cannot remove an ingredient or a step when updating a recipe" do
  # end

  # Actor: Bob, signed in
  # Starting point: Alice owns recipes(:pancakes) with ingredients(:salt) and steps(:preheat)
  # Action: DELETE the salt ingredient; DELETE the preheat step
  # Expected outcome: ingredient count unchanged; step count unchanged; redirect to the root path
  # test "non-owner cannot remove an ingredient or a step from the detail page" do
  # end

  # Actor: Bob, signed in
  # Starting point: Alice owns recipes(:pancakes) with ingredients(:salt) and steps(:preheat)
  # Action: PATCH pancakes with _destroy for Salt and the preheat step
  # Expected outcome: ingredient count unchanged; step count unchanged; redirect to the root path
  # test "non-owner cannot remove an ingredient or a step when updating a recipe" do
  # end

Red: Add the integration tests #

Replace the body of the new tests with the following:

# test/integration/recipe_access_integration_test.rb
  test "guest cannot remove an ingredient or a step from the detail page" do
    recipe = recipes(:pancakes)
    salt = ingredients(:salt)
    preheat = steps(:preheat)

    assert_no_difference("Ingredient.count") do
      delete recipe_ingredient_url(recipe, salt), as: :turbo_stream
    end
    assert_redirected_to new_session_url

    assert_no_difference("Step.count") do
      delete recipe_step_url(recipe, preheat), as: :turbo_stream
    end
    assert_redirected_to new_session_url

    get recipe_url(recipe)
    assert_response :success
    assert_match salt.name, response.body
    assert_match preheat.instruction, response.body
  end

  test "guest cannot remove an ingredient or a step when updating a recipe" do
    recipe = recipes(:pancakes)
    salt = ingredients(:salt)
    preheat = steps(:preheat)

    assert_no_difference("Ingredient.count", "Step.count") do
      patch recipe_url(recipe),
            params: {
              recipe: {
                ingredients_attributes: {
                  "0" => {
                    id: salt.id,
                    _destroy: "1"
                  }
                },
                steps_attributes: {
                  "0" => {
                    id: preheat.id,
                    _destroy: "1"
                  }
                }
              }
            }
    end
    assert_redirected_to new_session_url

    get recipe_url(recipe)
    assert_response :success
    assert_match salt.name, response.body
    assert_match preheat.instruction, response.body
  end

  test "non-owner cannot remove an ingredient or a step from the detail page" do
    sign_in_as users(:bob)
    recipe = recipes(:pancakes)
    salt = ingredients(:salt)
    preheat = steps(:preheat)

    assert_no_difference("Ingredient.count") do
      delete recipe_ingredient_url(recipe, salt), as: :turbo_stream
    end
    assert_redirected_to root_path

    assert_no_difference("Step.count") do
      delete recipe_step_url(recipe, preheat), as: :turbo_stream
    end
    assert_redirected_to root_path

    get recipe_url(recipe)
    assert_response :success
    assert_match salt.name, response.body
    assert_match preheat.instruction, response.body
  end

  test "non-owner cannot remove an ingredient or a step when updating a recipe" do
    sign_in_as users(:bob)
    recipe = recipes(:pancakes)
    salt = ingredients(:salt)
    preheat = steps(:preheat)

    assert_no_difference %w[Ingredient.count Step.count] do
      patch recipe_url(recipe),
            params: {
              recipe: {
                title: recipe.title,
                ingredients_attributes: {
                  "0" => {
                    id: salt.id,
                    _destroy: "1"
                  }
                },
                steps_attributes: {
                  "0" => {
                    id: preheat.id,
                    _destroy: "1"
                  }
                }
              }
            }
    end
    assert_redirected_to root_path

    get recipe_url(recipe)
    assert_response :success
    assert_match salt.name, response.body
    assert_match preheat.instruction, response.body
  end

This is what’s happening in the code above:

  • assert_no_difference("Ingredient.count") and assert_no_difference("Step.count") wrap the detail-page deletes to ensure the rows are still there after each DELETE.
  • The guest test signs nobody in, so Authentication from Chapter 12 stops the request before any policy runs. That is why it expects new_session_url and not the root path.
  • as: :turbo_stream matches how the Remove button submits the request to the server, the same request format you used for these routes in Chapter 11. The controllers respond with a Turbo Stream that removes the list item.
  • The update deny sends the same nested _destroy params as removes existing ingredients and steps when updating a recipe in recipes_integration_test.rb, but Bob is the actor. RecipesController#update hits authorize! @recipe before nested attributes run, so this path is denied by RecipePolicy#update?, not by IngredientPolicy or StepPolicy.
  • The show-page assertions after the denies check that Salt and the preheat step are still visible.

Run the full access integration test:

bin/rails test test/integration/recipe_access_integration_test.rb

Guest related tests should pass because Authentication stops the request before any policy runs. The update deny should also pass because RecipePolicy#update? is in place from earlier in this chapter. The non-owner detail-page test should fail with the following message:

F

Failure:
RecipeAccessIntegrationTest#test_non-owner_cannot_remove_an_ingredient_or_a_step_from_the_detail_page [test/integration/recipe_access_integration_test.rb:135]:
`Ingredient.count` didn't change by 0, but by -1.
Expected: 2
  Actual: 1

The detail-page deny fails because Bob is not authorized on IngredientsController#destroy and StepsController#destroy yet. You need to fix that by creating policies for ingredients and steps and calling authorize! in those controllers.

Green: generate a policy for ingredients and steps #

Generate policy files each for ingredients and steps with the following command in the terminal:

bin/rails generate action_policy:policy Ingredient --no-test-framework
bin/rails generate action_policy:policy Step --no-test-framework

--no-test-framework skips tests files for ingredients and steps. This option needs to be passed because we don’t want to write policy unit tests as we rely on the integration tests to prove authorization which ensures authorization is working in a full request/response cycle.

Replace the generated app/policies/ingredient_policy.rb with the following:

# app/policies/ingredient_policy.rb
class IngredientPolicy < ApplicationPolicy
  def destroy?
    recipe_owner?
  end

  private

  def recipe_owner?
    owner?(another_record: record.recipe)
  end
end

Then do the same for app/policies/step_policy.rb:

# app/policies/step_policy.rb
class StepPolicy < ApplicationPolicy
  def destroy?
    recipe_owner?
  end

  private

  def recipe_owner?
    owner?(another_record: record.recipe)
  end
end

This is what’s happening in the code above:

  • Both policies are only defining the destroy? rule as the Cookbook app only exposes destroy on these two resources, so destroy? is the only rule worth writing today. Everything else is denied by default because ApplicationPolicy returns false for every rule.
  • record is the ingredient or the step, so ownership is one hop away through record.recipe.
  • The private recipe_owner? method calls owner? with the recipe record as an argument, this is because the ingredient and step rows have no user_id column and need to derive the ownership from the parent recipe record.

IngredientPolicy and StepPolicy call owner? from ApplicationPolicy with the parent recipe but the parent recipe is not yet allowed to be passed as an argument. The owner? helper you wrote earlier only looked at record.user_id, so update it to accept an optional another_record: or you will get an error once you wire up authorize! in IngredientsController and StepsController:

# app/policies/application_policy.rb
class ApplicationPolicy < ActionPolicy::Base
  # ... existing code

  private

  def owner?(another_record: nil)
    return record.user_id == user.id if another_record.nil?

    another_record.user_id == user.id
  end
end

This is what’s happening in the code above:

  • If the another_record is nil, it checks the record’s user_id directly.
  • If the another_record is passed as an argument, it derives the user_id from that another_record.

Finally, update the controllers to call authorize! so they check authorization before the row is destroyed:

# app/controllers/ingredients_controller.rb
def destroy
  authorize! @ingredient

  @ingredient.destroy!
  # ... existing turbo_stream remove
end
# app/controllers/steps_controller.rb
def destroy
  authorize! @step

  @step.destroy!
  # ... existing turbo_stream remove
end

This is what’s happening in the code above:

  • authorize! @ingredient picks IngredientPolicy from the record’s class and destroy? from the action name; same as what you did in RecipesController. And it’s the same for authorize! @step.
  • The failed check raises ActionPolicy::Unauthorized, which the rescue_from in ApplicationController turns into the flash message and the redirect. That is why Bob’s deny tests expect the root path, exactly like the recipe denies.

Run the file again, you should have 0 failures and 0 errors.

bin/rails test test/integration/recipe_access_integration_test.rb

While you are here, spot-check happy path tests for owner being able to remove ingredients and steps, both of them should be green:

bin/rails test test/integration/recipes_integration_test.rb -i test_removes_ingredients_and_steps_from_the_detail_page
bin/rails test test/integration/recipes_integration_test.rb -i test_removes_existing_ingredients_and_steps_when_updating_a_recipe

Before you move on, quickly surf through the browser to ensure the authorization for owner being able to remove ingredients and steps works as expected.

  1. Start the server with bin/dev (if not already running)
  2. Sign in as Bob with the credentials bob@example.com / password.
  3. Open the recipe show page for Alice’s Fluffy pancakes
  4. Click the Remove button for Salt
  5. You should be redirected with an authorization error message: “You are not authorized to perform this action.”
  6. Reload the page and you should see the Salt ingredient is still there.
  7. Click the Remove button for the preheat step
  8. You should be redirected with an authorization error message: “You are not authorized to perform this action.”
  9. Reload the page and you should see the preheat step is still there.

With the authorization checks in place, Bob is signed in but will not be able to rename Alice's pancakes, delete a recipe, or delete one ingredient or step from it. The access tests prove the deny edge cases over HTTP.

Owner-only writes are now a tested rule, not a hope.

You moved ownership out of a controller comparison into RecipePolicy, then gave ingredients and steps their own policies instead of borrowing the recipe update rule. Owner happy paths stay in `recipes_integration_test.rb`. This access file owns the non-owner and nested guest denies. If that habit is sticking, consider supporting this guide by sponsoring it or buying me a drink.

Hide change actions for non-owners and guests #

Chapter 12 hid Edit, Destroy, and Remove behind authenticated?, which means any signed-in user can see those buttons on any recipe. That is why Bob still sees those controls on Alice’s pancakes even though the rule says a non-owner should not see modify buttons.

It’s another story that clicking on any one of them redirects the user to the root path since the controllers are already equipped with authorization checks. Next step is to hide these buttons completely for users that are not authorized to see them. You will do that by adding allowed_to? to the recipe partial along with existing authenticated? check so the UI matches the policy.

Index and show both render app/views/recipes/_recipe.html.erb for guests and signed-in users. Action Policy requires a user in the authorization context. Calling allowed_to? alone for a guest raises ActionView::Template::Error: Missing policy authorization context: user. Combine authorization calls with authenticated? so the policy only runs when someone is signed in.

Update the Destroy button in app/views/recipes/_recipe.html.erb:

<%# app/views/recipes/_recipe.html.erb %>
<% for_show = local_assigns.fetch(:for_show, false) %>
<% destroy_url = for_show ? recipe_path(recipe, format: :html) : recipe %>

<div id="<%= dom_id recipe %>">
  <!-- ... existing code ... -->

  <% if authenticated? && allowed_to?(:destroy?, recipe) %>
    <%= button_to "Destroy this recipe", destroy_url, method: :delete, data: { turbo_confirm: "Are you sure?" } %>
  <% end %>
</div>

Replace the entire app/views/recipes/show.html.erb template so Edit and each Remove use the same two-part check:

<%# app/views/recipes/show.html.erb %>
<%= render @recipe, for_show: true %>

<% if @recipe.ingredients.any? %>
  <h2>Ingredients</h2>
  <ul id="ingredients">
    <% @recipe.ingredients.each do |ingredient| %>
      <li id="<%= dom_id(ingredient) %>">
        <%= ingredient.name %><% if ingredient.quantity.present? %> (<%= number_with_precision(ingredient.quantity, precision: 2, strip_insignificant_zeros: true) %><% if ingredient.unit.present? %> <%= ingredient.unit %><% end %>)<% end %>
        <% if authenticated? && allowed_to?(:destroy?, ingredient) %>
          <%= button_to "Remove",
                recipe_ingredient_path(@recipe, ingredient),
                method: :delete,
                data: { turbo_confirm: "Remove this ingredient?" } %>
        <% end %>
      </li>
    <% end %>
  </ul>
<% end %>

<% if @recipe.steps.any? %>
  <h2>Steps</h2>
  <ol id="steps">
    <% @recipe.steps.order(:position).each do |step| %>
      <li id="<%= dom_id(step) %>">
        <%= step.instruction %>
        <% if authenticated? && allowed_to?(:destroy?, step) %>
          <%= button_to "Remove",
                recipe_step_path(@recipe, step),
                method: :delete,
                data: { turbo_confirm: "Remove this step?" } %>
        <% end %>
      </li>
    <% end %>
  </ol>
<% end %>

<div>
  <% if authenticated? && allowed_to?(:edit?, @recipe) %>
    <%= link_to "Edit this recipe", edit_recipe_path(@recipe) %>
  <% end %>

  <%= link_to "Back to recipes", recipes_path %>
</div>

This is what’s happening in the code above:

  • authenticated? answers “is anyone signed in?” Guests stop here and never build a policy.
  • allowed_to? answers “may this signed-in user change this record?” Bob fails. Alice passes.
  • Destroy asks about the recipe. Each Remove asks about the ingredient or step. Edit asks edit? on the recipe. Same rules the controllers already call.

Next, surf in the browser:

  1. Start the server with bin/dev (if not already running)
  2. Sign in as Bob with the credentials bob@example.com / password. Visit http://localhost:3000/recipes, open Fluffy pancakes. Confirm the title appears but Edit this recipe, Destroy this recipe, and Remove buttons are not present.
  3. On the list while signed in as Bob, confirm Destroy this recipe is not present on any recipe (every fixture recipe belongs to Alice).
  4. Sign out and sign back in as Alice with the credentials alice@example.com / password. Open the Fluffy pancakes recipe. Confirm Edit this recipe, Destroy this recipe, and Remove buttons; all of them are visible.
  5. On the list while signed in as Alice, confirm Destroy this recipe still appears on a row.
  6. Sign out and open the list and a recipe show as a guest. Confirm no Destroy, Edit, or Remove buttons are visible and both pages render without any error.

Non-owner does not see change buttons #

You just tested manually in the browser that the buttons to modify a recipe are not visible to non-owner (Bob) while still being visible to owner (Alice). Next, you need to automate this behavior with tests.

System tests in this guide stay on the happiest path through the app, so you will not add a system file for button visibility. Instead, you will expand the existing access integration test for that.

Destroy this recipe lives in app/views/recipes/_recipe.html.erb, which renders on the list and on show. Edit and Remove live only on the show template and Bob needs deny tests for both pages. Alice’s owner half is already covered: signed-in user sees new recipe on the list finds Destroy on the index, and signed-in user sees edit and destroy on show finds Edit, Destroy, and Remove on pancakes.

What counts as working? #

Flow Expected
Non-owner on the list Bob GETs /recipes and does not see Destroy button on any recipe (every fixture recipe belongs to Alice)
Non-owner on show Bob GETs Alice’s pancakes show and does not see Edit this recipe, Destroy button, or Remove buttons
Owner UI Already covered by the two signed-in Alice tests above

Add the scenario and test #

Append the following scenario to the access integration test file at test/integration/recipe_access_integration_test.rb:

# test/integration/recipe_access_integration_test.rb
  # Actor: Bob, signed in
  # Starting point: Alice owns recipes(:pancakes) and recipes(:lentil_soup)
  # Action: GET recipes list, then GET pancakes show
  # Expected outcome: no Destroy on the list; no Edit, Destroy, or Remove on show
  # test "non-owner does not see change buttons" do
  # end

Now, replace the test body with the following:

# test/integration/recipe_access_integration_test.rb
  test "non-owner does not see change buttons" do
    sign_in_as users(:bob)

    get recipes_url
    assert_response :success
    assert_select "button", text: "Destroy this recipe", count: 0

    get recipe_url(recipes(:pancakes))
    assert_response :success
    assert_match recipes(:pancakes).title, response.body
    assert_select "a", text: "Edit this recipe", count: 0
    assert_select "button", text: "Destroy this recipe", count: 0
    assert_select "button", text: "Remove", count: 0
  end

This is what’s happening in the code above:

  • assert_select ..., count: 0 ensures Bob does not see Destroy button on the list and no Edit, Destroy, or Remove buttons on show. Bob is signed in, so Authentication is not the reason the buttons are gone, the authorization check allowed_to? in the views is.
  • Fixture recipes all belong to Alice today, so Bob’s list has zero Destroy buttons.

Run the access integration test to ensure all of them are passing:

bin/rails test test/integration/recipe_access_integration_test.rb

Commit your work #

Run the full suite to ensure everything is green:

bin/rails test:all

You want 0 failures and 0 errors. Then:

git add .
git commit -m "Add recipe ownership and Action Policy authorization tests"

Small commits make it easier to roll back, bisect a regression, or open a PR with a clear story.

What is next #

You now have authentication (who is signed in) and authorization (who may change which recipe). The Cookbook app still allows everyone to see everyone’s recipes (list and show), that will be unchanged for the rest of the guide. But you have locked all other ownership-based changes (create, update, destroy and remove) to only be visible to the owner backed by a comprehensive set of tests.

In next chapter, you will cover mailers (for example share this recipe) with thin mailer objects and focused assertions.

Continue to Chapter 14: Testing mailers.

Keep Minitest Rails independent

Minitest Rails is an independent educational guide for Rails developers learning automated testing.

Companies can support the guide by sponsoring a chapter (one-time payment) or becoming a Patron with a monthly subscription. This funds new chapters, Rails version updates, and more real-world examples.

If you just want to chip in as a reader, you can also buy me a drink as a thanks.

Disclaimer: This guide is based on hands-on Rails and testing experience and was proofread by AI. I stand by the advice and patterns here.