Hot Glue Example #4: Human Spa

Example 4: Hawking with a Non-Usual Scope

In Tutorial #3 we made a Pet spa and demonstrated how to hawk the Appointments scaffold to only allow the pet_id to be set to a pet owned by the current human (user).

In this example, we are operating another kind of spa, but this one is the Human Spa and we do not see pets as that is not allowed here in New York State where I live.

In this spa, we have an enumerated type on the Appointments table to specify the treatment: manicure, pedicure, nails, hair. This is a good demonstration of enumerated types for Postgresql and how Hot Glue can pick up your enum definitions and automatically turn them into a drop-down list.

In our human spa, our Entity Relationship Diagram (ERD) looks different because the humans all come to the spa as part of a family.

In the Human Spa, a user has_many appointments (appointments belong to a user). But the user belongs_to a family, and using the through: flag, an appointment has_one :family, through: :user

Let’s assume the mother of the family is booking appointments for her children. She should be allowed to set up appointments for herself or anyone else in her family.

That’s the most basic definition of what this example app will do, and in this app we also going to create an Appointments scaffold. This time, however, we will hawk the user_id to the current user’s family.

Finally, this app demonstrates using a has_one, through: relationship, a more rare ActiveRecord relationship that should get more attention. Particularly with ActiveRecord’s implementation, when you correctly define the corollary associations on the “opposite” models, you can harness a great deal of power which this very simple app demonstrates.

Start by making a new Rails app:

rails new HumanSpa --database=postgresql --javascript=esbuild --css=bootstrap

Next be sure to install Hot Glue along with Devise. Make sure Devise is installed before generating the Devise user.

rails generate devise User name:string family_id:integer

Make the landing controller with rails generate controller Home

And then create a new empty file at views/home/index.erb and add an empty method to your new home controller:

class HomeController < ApplicationController
def index

end
end

In views/layouts/application.html, add this button somewhere within the <body> tag:

<% if current_user %>
    <%= button_to "Log Out", destroy_user_session_path, method: :delete %>
<% else %>
    <%= link_to "Log In", new_user_session_path %>
<% end %>

Notice that the current_user method is provided to us by Devise because we defined the User object as where Devise does its authentication above.

After you do these steps, you should be able to log in as nonone@nowhere.com using the password password.

Then create the models:

app/models/appointment.rb and app/models/family.rb

rails generate model Family name:string
rails generate model Appointment when_at:time user_id:integer treatment:enum

To define the treatments enum, add the following code to the newly generated migration file you just generated.

Remember, in all of these examples, you must add the code in orange.

class CreateAppointments < ActiveRecord::Migration[7.0]
  def change

    create_enum "treatment_types",  %w[manicure pedicure massage haircut]
    create_table :appointments do |t|
      t.time :when_at
      t.integer :user_id
      t.enum :treatment, enum_type:  :treatment_types

      t.timestamps
    end
  end
end

Then you will also need to define the enum on the Appointment model itself and make the following associations on your models.

class Appointment < ApplicationRecord
  enum treatment_types: {
    manicure: "Manicure",
    pedicure: "Pedicure",
    massage: "Massage",
    haircut: "Haircut"
  }

  belongs_to :user
  has_one :family, through: :users

  def name
    "#{treatment} for #{user.email} at #{when_at}"
  end
end
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
  
  has_many :appointments
  belongs_to :family
end
class Family < ApplicationRecord
  has_many :users
  has_many :appointments, through: :users
end

(If these relationships don’t make sense to you, refer to the diagram “Example 4 App Entity Relationship Diagram” above.)

Now to get started with this demo, we’ll need some test data. In this example, a Family has_many users (family members). The requirement is to let the logged-in family member make an appointment for themselves or anyone else in their family.

Notice the non-usual relationships here: Appointment belongs to user_id, so normally if Appointment were guarded by the object owner guard you could only see & manage appointments that belong to yourself.

In this case, we need to two things:

(1) Rescope the appointments themselves to use the has_one :family, through: :users relationship from Appointment to Family

(2) Hawk the foreign key user_id on the appointments scaffolding to only allow users who are within the same family as the current user.

Please note that because of our data model, we need to set up our first Family and User by hand in the rails console:

You can also do this with the scaffolding below if you skip ahead and build it first.

Please note that this app is very simple, so after you log in you should go directly to /appointments to see appointments for your entire family.

A SIMPLE ADMIN DASHBOARD (unprotected)

Be sure to rails db:migrate before building scaffolding.

rails generate hot_glue:scaffold Family --plural=families --namespace=admin_dashboard --gd --downnest=users --smart-layout

Here, let’s build a simple admin dashboard scaffold with a Gd controller (access to all records).

rails generate hot_glue:scaffold User --namespace=admin_dashboard --gd --nested=family --smart-layout

In this example app, I have provided no security to guard against the Admin controllers whatsoever.

Like the last example, we need to as this small glue onto the User object to be able to smoothly give new users passwords:

class User < ApplicationRecord
  ...

  before_validation :check_if_missing_password!

  def check_if_missing_password!
    if encrypted_password.nil? || encrypted_password.empty?
      new_password = (0...8).map { (65 + rand(26)).chr }.join
      self.password = new_password
      self.password_confirmation = new_password
    end
  end
end

Here, we can create two families: Jones and Smiths. The Smith family has two people: Me (Jason) and Sue.

The Jones family has two people: Ben and Bruce.

Go to config/routes.rb and add

 namespace :admin_dashboard do # this is an unprotected dashboard!
    resources :families do
      resources :users
    end
  end

Now boot your app and go to http://127.0.0.1:3000/admin_dashboard/families

THE ACCESS-CONTROLLED CONTROLLER

Now let’s build the main access-controlled scaffolding — this is like what you would build for a real user and the purpose of this demonstration.

rails generate hot_glue:scaffold Appointment --hawk=user_id{current_user.family.users} --auth=current_user.family

Here we’re telling Hot Glue to hawk the user_id to the current_user‘s family.users. You can put anything you want inside of the { } as long as it will evaluate correctly

We also need to scope the load of the Appointments themselves to the current_user.family using the --auth flag. Both are achieved via the ActiveRecord relations you’ve defined on your models.

When this code is generated, it looks like this:

def load_appointment
  @appointment = (current_user.family.appointments.find(params[:id]))
end


def load_all_appointments 
  @appointments = ( current_user.family.appointments.page(params[:page]).includes(:user))  
end

As well, this controller’s create and update actions will have the special hawk guard to ensure the user_id is within the scope of current_user.family.users

Finally, let’s put in the routes:

Rails.application.routes.draw do
  devise_for :users
  resources :appointments

  namespace :admin_dashboard do # this is an unprotected dashboard!
    resources :families do
      resources :users
    end
  end

  root to: "home#index"
end

Wrapping it all together, it looks like this when I’m logged in as myself, a member of the Jones family (Jason and Sue). I can create or update Appointments only for members of my family:

First, you’ll need to log in as one of the users in your clan. To do that, drop into the rails console and hijack the last user to reset their password to “password”

% rails console
Loading development environment (Rails 7.0.4)
irb: warn: can't alias context from irb_context.
3.1.3 :001 > last_user = User.last
  User Load (0.5ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT $1  [["LIMIT", 1]]
 => #<User id: 3, email: "sue@nowhere.com", name: "Sue Smith", family_id: 2, created_at: "2023-01-02 19:05:56.779346000 +0000", updated_at: "2023... 
3.1.3 :002 > last_user.password = "password"; last_user.password_confirmation="password"; last_user.save;
  TRANSACTION (0.2ms)  BEGIN
  Family Load (0.5ms)  SELECT "families".* FROM "families" WHERE "families"."id" = $1 LIMIT $2  [["id", 2], ["LIMIT", 1]]                         
  User Update (0.4ms)  UPDATE "users" SET "encrypted_password" = $1, "updated_at" = $2 WHERE "users"."id" = $3  [["encrypted_password", "[FILTERED]"], ["updated_at", "2023-01-02 19:08:02.869665"], ["id", 3]]                                                                                          
  TRANSACTION (4.0ms)  COMMIT                                                                                                                     
 => true                                                                                                                                          

Now boot your app and go to http://127.0.0.1:3000/appointments

Notice you are redirected to log-in.

Once you login, you should see the appointments screen:

On this screen, try to create new appointments:

Notice that logged in as Sue, Sue can only make appointments for Sue or Jason.

When Bruce logs in, he can see only members of his own family (Bruce & Ben):

Like the last example, the Hawk works in both directions: On the output, it shows only user records in scope, and on the input, it wipes away foreign keys that aren’t in scope, protecting your records for foreign key hijacking.