Ranks and Permissions

A guild hall where your rank decides what you may do. Each open page renders its own buttons from the user's own rank, so an action your rank may not take is refused on the server. The permission rules are Pundit policies.

The guild hall

You are a guest. Sign in to join the guild.

The coffer

640 gp

The ledger

Only members of the guild read the ledger.

The roster

  • gnome Leader
  • smith Officer
  • farmer Member
  • miner Member
  • wanderer Member
  • baker Recruit
# frozen_string_literal: true

# The guild hall. Who you are comes from the connection, your rank from the
# database, and everything you may do from the Pundit policies, asked once
# per tab and per change, with that tab's own viewer. Each tab renders its
# own hall, so an officer and a recruit looking at the same coffer see
# different buttons, and a promotion changes the buttons of the one tab
# that was promoted.
#
# The buttons are only the half you see. Every action asks the policy
# again, with the rank as the database has it now, and a refused one says
# why.
class Recipes::Permissions::GuildChannel < ApplicationCable::Channel
  include Hibiki::Rails::Channel

  Entry = Recipes::Permissions::Entry
  Hall = Recipes::Permissions::Hall
  Member = Recipes::Permissions::Member
  Viewer = Recipes::Permissions::Viewer
  VIEWS = "app/recipes/permissions"

  # A line under the hall's header. `reward` is for a promotion.
  Notice = Data.define(:text, :reward)

  # What a refused action says, by the policy question that refused it.
  REFUSALS = {
    deposit?: "Only members of the guild may deposit. Sign in first.",
    withdraw?: "Only officers and the leader may withdraw.",
    strike?: "You may strike out only your own lines, and an officer any line.",
    promote?: "Only the leader changes other members' ranks.",
    petition?: "The shared account's rank is fixed."
  }.freeze

  rescue_from Pundit::NotAuthorizedError do |error|
    @notice.value = Notice.new(text: REFUSALS.fetch(error.query.to_sym, "That is not yours to do."), reward: false)
  end

  def build_graph
    @version = Hibiki::State.new(0)
    @notice = Hibiki::State.new(nil)
    @withdrawing = Hibiki::State.new(false)
    @everything = Hibiki::State.new(false)

    # The rank is read again on every ping, so a promotion reaches this tab.
    # A Viewer is a plain value: an unchanged rank changes nothing below.
    @viewer = Hibiki::Derived.new do
      @version.value
      Viewer.for(demo_session&.account)
    end
    hall = Hibiki::Derived.new { Hall.new(viewer: @viewer.value) }

    coffer = section(hall, &:coffer)
    ledger = section(hall, &:ledger)
    roster = section(hall, &:roster)

    rank_watch
    paint(:you) { { viewer: @viewer.value, notice: @notice.value } }
    paint(:coffer) { { coffer: coffer.value, withdrawing: @withdrawing.value, everything: @everything.value } }
    paint(:ledger) { { viewer: @viewer.value, lines: ledger.value, everything: @everything.value } }
    paint(:roster) { { viewer: @viewer.value, members: roster.value, everything: @everything.value } }
  end

  def deposit(data)
    authorize Entry, :deposit?
    amount = data["amount"].to_i
    Entry.record(viewer, kind: "deposit", amount:) if Entry::DEPOSITS.include?(amount)
  end

  def open_withdrawal
    authorize Entry, :withdraw?
    @withdrawing.value = true
  end

  def close_withdrawal = @withdrawing.value = false

  def withdraw(data)
    authorize Entry, :withdraw?
    Entry.record(viewer, kind: "withdrawal", amount: data["amount"].to_i, reason: data["reason"].to_s)
    @withdrawing.value = false
    @notice.value = nil
  rescue ActiveRecord::RecordInvalid => error
    @notice.value = Notice.new(text: error.record.errors.map(&:message).first, reward: false)
  end

  def strike(data)
    line = Entry.find_by(id: data["id"])&.row or return
    authorize line, :strike?
    Entry.where(id: line.id, struck_by: nil).update_all(struck_by: viewer.name)
    Member.announce
  end

  def set_rank(data)
    member = Member.find_by(id: data["id"]) or return
    authorize member.row, :promote?
    member.update!(rank: data["rank"]) if Member::RANKS.include?(data["rank"])
  end

  def petition(data)
    authorize own_row, :petition?
    Member.find(own_row.id).update!(rank: data["rank"]) if Member::RANKS.include?(data["rank"])
  end

  # Demo only: also show the buttons this rank may not use, to watch the
  # server refuse them.
  def show_everything(data) = @everything.value = data["everything"] == true

  private

  def subscribed
    super
    return if subscription_rejected?

    Member.join(demo_session.account) if demo_session

    # A rank or a ledger line changed, by anyone. The block runs on a cable
    # thread, so it hops onto the graph thread before touching a signal.
    stream_from Member::CHANGED do
      graph_actor&.post { hear }
    end
  end

  def hear = @version.value += 1

  # The viewer as the database has it now, not as it was when the last
  # render ran: an action asks the policy with this one.
  def viewer = Viewer.for(demo_session&.account)

  def authorize(record, query) = Pundit.authorize(viewer, record, query)

  def own_row = Hall.new(viewer:).own || Member.join(demo_session.account).row

  # One derived per section, each reading the version first, so a ping asks
  # every section again and only a changed answer reaches its effect.
  def section(hall, &question)
    Hibiki::Derived.new do
      @version.value
      question.call(hall.value)
    end
  end

  def paint(name, &locals)
    Hibiki::Effect.new do
      broadcast_morph target: "guild_#{name}", partial: "#{VIEWS}/#{name}", locals: locals.call
    end
  end

  # When the rank changes, say so, and put away a withdrawal this rank may
  # no longer make. `peek` reads without subscribing, since this writes it.
  def rank_watch
    seen = nil
    Hibiki::Effect.new do
      viewer = @viewer.value
      if seen && seen != viewer.rank
        up = Viewer::LEVELS.index(viewer.rank) > Viewer::LEVELS.index(seen)
        @notice.value = Notice.new(text: "You are now #{viewer.rank == "officer" ? "an" : "a"} #{viewer.title.downcase}.", reward: up)
        if @withdrawing.peek && !Pundit.policy!(viewer, Entry).withdraw?
          @withdrawing.value = false
          @notice.value = Notice.new(text: "You are now a #{viewer.title.downcase}, so the withdrawal was put away.", reward: false)
        end
      end
      seen = viewer.rank
    end
  end
end

The panel above is a guild hall with a coffer, a ledger and a roster. As a guest you see only how much is in the coffer. You can sign in to join as a recruit, who may deposit and read their own lines of the ledger. Pick a higher rank and press Petition the leader to grant you more privileges (this leader always says yes).

Authorization itself is easy in stock Rails: a Pundit policy in the view decides which buttons to draw. The hard part is keeping those buttons live. A broadcast is rendered once and sent to every page, so it cannot ask the policy who is looking. Here each open page renders its own hall and asks the same Pundit policies every time it does.

How it fits together

An island is the part of the page Hibiki looks after, here the whole hall. It belongs to a channel: the server side of an Action Cable connection, an object that stays alive while the page is open, one for each open page. The channel's public methods are its actions, the methods the page may call, and its states are values that remember who read them. Here the states are a version that counts changes to the hall, the line under the header, and whether the withdrawal form is open.

The rules are Pundit policies, plain Ruby classes in app/policies. Pundit hands a policy a user and a record, and asks it a question such as withdraw?, which answers true or false. Here EntryPolicy answers for the coffer and its ledger, and MemberPolicy for ranks. EntryPolicy::Scope says which lines of the ledger a user may read. The user Pundit hands each policy is a Viewer: an account and its rank, read from the database. A guest is a viewer too, with the rank guest.

A derived is a value computed from states and computed again when one of them changes. Here one derived is the viewer, read again whenever the version changes. Hall is the hall as one viewer sees it. Each of its methods asks the policies and answers one section with plain values, such as the coffer's total and which of its buttons this viewer may use. The page renders its first paint from a Hall, and the channel holds one in a derived, with one more derived for each section. An effect is a block that runs again when a state or derived it read changes. One effect for each section renders it. This is what happens when you press Deposit 10 gp:

  1. The button calls the deposit action.
  2. The action reads your rank from the database and asks EntryPolicy whether that viewer may deposit. If the answer is no, Pundit raises, and the channel's rescue_from writes a line under the header.
  3. If the answer is yes, the line is written. The model's after_commit broadcasts that the hall changed, on one stream every open hall listens to.
  4. Each open page hears the broadcast and bumps its version. Its viewer is read again, and each section asks the policies again. The coffer's total is new, so the coffer is rendered again. In a member's page the ledger has a new line, so it is rendered again too.

Every visitor gets their own hall view

An officer and a recruit looking at the same coffer at the same time should see different buttons, and both pages should stay live. That is the part stock Rails finds hard. A Turbo Stream broadcast renders a partial once and sends the same HTML to every subscriber. There is no request behind that render, so there is no current_user, and a partial that calls policy(entry).strike? cannot be broadcast.

A Hibiki channel is one subscription, one open page and one viewer, so its renders can ask the policies for that viewer. Each section is its own derived. A deposit by someone else changes the total and the ledger in every open page. A promotion changes the promoted visitor's buttons and only that visitor's.

# app/channels/recipes/permissions/guild_channel.rb
@viewer = Hibiki::Derived.new do
  @version.value                          # any rank or ledger change, by anyone
  Viewer.for(demo_session&.account)       # a plain value: same rank, same viewer
end
hall = Hibiki::Derived.new { Hall.new(viewer: @viewer.value) }

coffer = section(hall, &:coffer)          # each reads @version, then asks the policies
ledger = section(hall, &:ledger)

# app/models/recipes/permissions/hall.rb
def ledger
  Pundit.policy_scope!(viewer, Entry).order(id: :desc).limit(LINES).map(&:row)
        .map { [ it, Pundit.policy!(viewer, it).strike? ] }
end

The server checks every action

A recruit who cannot see the Withdraw button can still ask for a withdrawal. Their page may be a moment out of date after a demotion and still show an officer's buttons, or they may send the action over the socket by hand (which is a security breach). Therefore, every action needs to check the rank again before it writes anything. It reads the visitor's rank from the database at that moment, asks the policy with it, and goes ahead only if the policy says yes.

When the policy says no, Pundit raises Pundit::NotAuthorizedError. The channel catches it in one rescue_from and writes a warning under the header stating why the action was refused.

# app/channels/recipes/permissions/guild_channel.rb
rescue_from Pundit::NotAuthorizedError do |error|
  @notice.value = Notice.new(text: REFUSALS.fetch(error.query.to_sym), reward: false)
end

def withdraw(data)
  authorize Entry, :withdraw?
  Entry.record(viewer, kind: "withdrawal", amount: data["amount"].to_i, reason: data["reason"].to_s)
  # ...
end

private

# The rank as the database has it now, not as it was at the last render.
def viewer = Viewer.for(demo_session&.account)

def authorize(record, query) = Pundit.authorize(viewer, record, query)

A rank that changes while you look

Try opening the hall as two accounts in two browsers, make one the leader, and change the other's rank from the roster. The other page hears the broadcast and reads the new rank, and every section redraws its buttons. One more effect watches the rank itself. When the rank changes, it shows the new rank, as in "You are now an officer." If the visitor had a withdrawal open and the new rank may not withdraw, the effect puts the form away and tells the user why.

In stock Rails, a page refresh puts the new buttons on the page without any JavaScript, but it cannot say what changed. A refresh is the browser fetching the page again, so the flash set for the leader who made the change never reaches the visitor who was promoted. To tell them, the page needs a Stimulus controller that remembers the last rank it saw, compares it after every morph and writes the line itself. Putting the withdrawal away needs the same care: either that controller notices the form has gone, or the address carries ?withdrawing=1 so the controller can ask the policy again. In this demo, both are one effect in the channel, written in Ruby beside the rest of the hall.

The one shared account

The sign-in page prints the password for an account named wanderer, so anyone can try the hall without signing up, and many visitors may be signed in as it at once. Its rank stays at member. If it could rise, one visitor could make it the leader, and everyone else signed in as wanderer would be handed the roster and could change other people's ranks. The rule sits in MemberPolicy with the others: the leader may not change the shared account's rank, and the shared account may not petition.

The same feature in stock Rails

The same policies work in controllers unchanged, through Pundit::Authorization. What stock Rails lacks is a render for each viewer that stays live. Its best answer is Turbo's page refresh: every write broadcasts "refresh", and every open page fetches itself again over HTTP and morphs the result in, so each browser's own request renders the page with its own user. Here is a sketch. It is not run here.

# The same guild hall in stock Rails, with the same Pundit policies.
# Display only: never routed or loaded.
class EntriesController < ApplicationController
  include Pundit::Authorization
  after_action :verify_authorized, except: :index

  rescue_from Pundit::NotAuthorizedError do |error|
    # The reason has to be looked up by the question that failed.
    redirect_to hall_path, alert: REFUSALS.fetch(error.query.to_sym), status: :see_other
  end

  def index
    @entries = policy_scope(Entry).order(id: :desc).limit(10)
    @members = Member.includes(:account)
    # Open the withdrawal form only for a rank that may use it. A page
    # refreshed after a demotion asks again and leaves it out.
    @withdrawing = params[:withdrawing] && policy(Entry).withdraw?
  end

  def create
    authorize Entry, :deposit?
    Entry.create!(account: current_user, kind: "deposit", amount: params[:amount])
    redirect_to hall_path, status: :see_other
  end

  def withdraw
    authorize Entry, :withdraw?
    Entry.create!(account: current_user, kind: "withdrawal", amount: params[:amount], reason: params[:reason])
    redirect_to hall_path, status: :see_other
  rescue ActiveRecord::RecordInvalid => error
    redirect_to hall_path(withdrawing: 1), alert: error.record.errors.map(&:message).first, status: :see_other
  end

  def strike
    entry = Entry.find(params[:id])
    authorize entry, :strike?
    entry.update!(struck_by: current_user.username)
    redirect_to hall_path, status: :see_other
  end

  private

  # Pundit calls this to get the user it hands to every policy.
  def pundit_user = Viewer.for(current_user)
end

The table lists each behaviour of the demo above and where it comes from.

Stock RailsHibiki
Buttons for each viewer policy(...) in the page; a broadcast partial cannot ask it, so pages refresh policy(...) in each page's own renders
A rank changed by someone else broadcasts_refreshes_to; every open page fetches itself again A broadcast; the page asks the policies again and sends only the sections that changed
Saying why the buttons changed Nothing: a refresh carries no flash One effect on the rank
A form the new rank may not use A query parameter the controller checks again The same effect puts it away
A refused action rescue_from, a redirect and a flash rescue_from in the channel; a line in place, nothing reloads
What one deposit costs A full page request and render for each open page A few queries for each open page; unchanged sections send nothing
The policies The same files The same files

The page refresh works, and it is short to write. Its cost grows with the number of people in the hall. Every deposit sends a broadcast to every open page, and each of those pages asks the server for itself again. The server then renders the whole hall once for each of them, roster included, even though only the coffer and the ledger changed.

Stock Rails has two other common ways to do this. You could broadcast a separate partial to each signed-in user, rendered with that user's rank. Then every write renders the partial once for every visitor in the hall. Or you could broadcast one partial with every button in it, and hide the ones a rank may not use with CSS. Then every browser holds buttons its visitor is not allowed to press.

With Hibiki, each open page has its own channel on the server, and the channel knows who is looking. When something changes, the channel asks the policies again for that visitor and sends only the sections that came out different. The policies are the same files a controller would use, and the channel's actions call Pundit's authorize the way a controller does.