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 #
- Add
user_idto recipes, associateUserandRecipe, give fixture recipes an owner, and assign the signed-in user on create withcurrent_user.recipes.build. - Add one deny test to the existing access file and make it green with a short
ensure_recipe_ownerguard (sample authorization for hobby projects). - 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.
- Install Action Policy, put the same rule in
RecipePolicy, and replace the inlineensure_recipe_ownerguard. - Grow
recipe_access_integration_test.rbwith non-owner denials for modify, destroy, and ingredient/step Remove. Access tests for owners stay inrecipes_integration_test.rb(unchanged from the previous chapter). - Hide buttons for Edit, Destroy, and Remove with
authenticated? && allowed_to?so guests skip Action Policy and non-owners fail ownership. - 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.
- Run
bin/rails test:allto 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:dropanddb:createclear the development database, wiping out all the data in the database and creates a new one back again.db:migrateappliesAddUserToRecipeson 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 :recipesin the User model lets you get all recipes of that user by callingcurrent_user.recipes. It is also very useful when creating a new recipe with an owner by callingcurrent_user.recipes.build, you will see this in action when working with recipes controller later in this chapter.belongs_to :userensures every recipe to have an owner. This also allows you to access the owner of a recipe by callingrecipe.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: aliceis the fixture label fromusers.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_ownerruns afterset_recipeso@recipeis loaded and you can compare@recipe.usertocurrent_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.
createandnewdo not useensure_recipe_ownersince 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.
- Start the server with
bin/dev(if not already running). - Sign in as Bob with
bob@example.comandpassword. - Open Alice’s Fluffy pancakes show page, then append
/editto the URL. - 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.erbandapp/views/passwords/edit.html.erb(alerts for reset)app/views/recipes/index.html.erbandapp/views/recipes/show.html.erb(scaffoldnoticeonly, 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:
- Start the server with
bin/dev(if not already running). - Sign in as Bob with
bob@example.comandpassword. - Open Alice’s Fluffy pancakes show page, then append
/editto the URL. - 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:
- Sign out of the app.
- Try to sign in with an email address
bob@example.comand a bad passwordwrong-pass. - 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:
- From the login page, click the “Forgot your password?” link.
- You should be redirected to the password reset request form.
- Enter the email address
bob@example.comand click the “Email reset instructions” button. - 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:
- Sign in as Alice with
alice@example.comandpassword. - Open Alice’s Fluffy pancakes show page, then append
/editto the URL. - You should be redirected to the recipe edit page.
- Update the title to “Fluffy pancakes (updated)” and click the “Update Recipe” button.
- 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_policyputs the gem in the Gemfile and installs it.action_policy:installcreatesapp/policies/application_policy.rbthat 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_useras the policy user; the helper you added in Chapter 12. rescue_from ActionPolicy::Unauthorizedturns a failedauthorize!into a flash message to notify users that they are not authorized to perform the action.redirect_back fallback_location: root_pathredirects 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:
RecipePolicyinherits fromApplicationPolicy,owner?method comes from there.index?andshow?are true for everyone. The Cookbook app has no restrictions on viewing the list of recipes or the details of a recipe.create?andnew?are true for any signed-in user (user.present?). Guests never reach those actions because Authentication still runs.useris provided by the Action Policy by default so you can call it here without any additional setup.edit?,update?, anddestroy?are true only when the current user is an owner (recipe’s user is the current user). That is the same comparison asensure_recipe_owneryou previously implemented in the recipes controller.update?anddestroy?both callowner?directly. Normally in blogs and production apps, you will seedestroy?calling theupdate?permission instead ofowner?. 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 loosenupdate?(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 ofupdate?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! @recipeinfersnew?,create?,edit?,update?, ordestroy?from the controller action name (updatemaps toupdate?). You can also pass the rule explicitly withto:(for exampleauthorize! @recipe, to: :destroy?) which helps when the action name and the rule name for that same resource do not match (for example: you hadarchiveaction for recipes that useddestroy?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_pathbecauseuser_not_authorizedcallsredirect_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
deleteinassert_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.
- 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. - Sign in as Bob with the credentials
bob@example.com/password. - Open the recipe show page for Alice’s Fluffy pancakes
- Click the Destroy button
- You should be redirected with an authorization error message: “You are not authorized to perform this action.”
- 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:
- The show page Remove button hits
IngredientsController#destroy/StepsController#destroy - The recipe edit form has Remove button that marks
_destroyand goes throughRecipesController#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:
-
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 callsallowed_to?(:update?, record.recipe)internally. -
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")andassert_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
Authenticationfrom Chapter 12 stops the request before any policy runs. That is why it expectsnew_session_urland not the root path. as: :turbo_streammatches 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
_destroyparams asremoves existing ingredients and steps when updating a recipeinrecipes_integration_test.rb, but Bob is the actor.RecipesController#updatehitsauthorize! @recipebefore nested attributes run, so this path is denied byRecipePolicy#update?, not byIngredientPolicyorStepPolicy. - 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, sodestroy?is the only rule worth writing today. Everything else is denied by default becauseApplicationPolicyreturns false for every rule. recordis the ingredient or the step, so ownership is one hop away throughrecord.recipe.- The private
recipe_owner?method callsowner?with the recipe record as an argument, this is because the ingredient and step rows have nouser_idcolumn 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_recordisnil, it checks the record’suser_iddirectly. - If the
another_recordis passed as an argument, it derives theuser_idfrom 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! @ingredientpicksIngredientPolicyfrom the record’s class anddestroy?from the action name; same as what you did inRecipesController. And it’s the same forauthorize! @step.- The failed check raises
ActionPolicy::Unauthorized, which therescue_frominApplicationControllerturns 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.
- Start the server with
bin/dev(if not already running) - Sign in as Bob with the credentials
bob@example.com/password. - Open the recipe show page for Alice’s Fluffy pancakes
- Click the Remove button for Salt
- You should be redirected with an authorization error message: “You are not authorized to perform this action.”
- Reload the page and you should see the Salt ingredient is still there.
- Click the Remove button for the preheat step
- You should be redirected with an authorization error message: “You are not authorized to perform this action.”
- 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:
- Start the server with
bin/dev(if not already running) - Sign in as Bob with the credentials
bob@example.com/password. Visithttp://localhost:3000/recipes, open Fluffy pancakes. Confirm the title appears but Edit this recipe, Destroy this recipe, and Remove buttons are not present. - On the list while signed in as Bob, confirm Destroy this recipe is not present on any recipe (every fixture recipe belongs to Alice).
- 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. - On the list while signed in as Alice, confirm Destroy this recipe still appears on a row.
- 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: 0ensures 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 checkallowed_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.