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
# frozen_string_literal: true
# Who may do what with the coffer. Asked about the Entry class (deposit,
# withdraw) or about one line (strike).
class Recipes::Permissions::EntryPolicy < Recipes::Permissions::Policy
def deposit? = at_least?(:recruit)
def withdraw? = at_least?(:officer)
# An officer strikes out any line; anyone else, only their own.
def strike?
return false if record.struck?
at_least?(:officer) || (at_least?(:recruit) && record.account_id == viewer.account_id)
end
# Which lines of the ledger a viewer may read.
class Scope
def initialize(viewer, scope)
@viewer = viewer
@scope = scope
end
def resolve
if @viewer.at_least?(:member) then @scope.all
elsif @viewer.at_least?(:recruit) then @scope.where(account_id: @viewer.account_id)
else @scope.none
end
end
end
end
# frozen_string_literal: true
# Who may change a rank. Asked about one line of the roster.
class Recipes::Permissions::MemberPolicy < Recipes::Permissions::Policy
# The leader sets anyone else's rank, except the shared account's.
def promote? = at_least?(:leader) && record.account_id != viewer.account_id && !record.shared
# Your own rank, by petition. It is a demo: the leader always says yes.
# The shared account is everyone's, so its rank is fixed.
def petition? = !viewer.guest? && !viewer.shared && record.account_id == viewer.account_id
end
# frozen_string_literal: true
# What the guild's policies share. Pundit asks a policy a question such as
# `withdraw?` and expects true or false; `viewer` is the user Pundit was
# handed (Recipes::Permissions::Viewer) and `record` what is asked about.
class Recipes::Permissions::Policy
attr_reader :viewer, :record
def initialize(viewer, record)
@viewer = viewer
@record = record
end
private
def at_least?(rank) = viewer.at_least?(rank)
end
# frozen_string_literal: true
# The guild hall as one viewer sees it. Each method asks the database and
# the policies, and answers with plain values: the page renders from it,
# and the channel holds one in a derived and asks each section again when
# anything in the hall changes. Every answer about permission comes from a
# Pundit policy, here and in the channel's actions alike.
class Recipes::Permissions::Hall < Data.define(:viewer)
Entry = Recipes::Permissions::Entry
Member = Recipes::Permissions::Member
# What the coffer section offers this viewer.
Coffer = Data.define(:total, :deposit, :withdraw)
# How many lines of the ledger are shown.
LINES = 10
def coffer
policy = Pundit.policy!(viewer, Entry)
Coffer.new(total: Entry.total, deposit: policy.deposit?, withdraw: policy.withdraw?)
end
# The newest lines this viewer may read, each with whether they may
# strike it out.
def ledger
Pundit.policy_scope!(viewer, Entry).order(id: :desc).limit(LINES).map(&:row)
.map { [ it, Pundit.policy!(viewer, it).strike? ] }
end
# Every member, each with whether this viewer may change their rank.
def roster = Member.roster.map { [ it, Pundit.policy!(viewer, it).promote? ] }
# The viewer's own line of the roster, for the petition, or nil.
def own = Member.roster.find { it.account_id == viewer.account_id }
end
# frozen_string_literal: true
# Who is looking at the guild hall, as the policies see them: an account
# and its rank, or a guest. It is the `user` Pundit passes to every policy.
# A plain value, read fresh from the database, so a derived holding one
# changes only when the rank does.
class Recipes::Permissions::Viewer < Data.define(:account_id, :name, :rank, :shared)
# Lowest first. A guest has no account.
LEVELS = %w[guest recruit member officer leader].freeze
def self.guest = new(account_id: nil, name: nil, rank: "guest", shared: false)
def self.for(account)
return guest unless account
new(account_id: account.id, name: account.username, rank: Recipes::Permissions::Member.rank_of(account), shared: account.shared?)
end
def guest? = account_id.nil?
def at_least?(rank) = LEVELS.index(self.rank) >= LEVELS.index(rank.to_s)
def title = rank.capitalize
end
# frozen_string_literal: true
# A demo account's rank in the guild hall. A row is made the first time an
# account opens the hall, as a recruit; the seeded accounts have ranks from
# db/seeds/guild.yml. Recipes::Reset puts those back each night, and the
# rows of other accounts go with them.
class Recipes::Permissions::Member < ApplicationRecord
self.table_name = "recipes_permissions_members"
# A line of the roster: a plain copy. `policy_class` tells Pundit which
# policy judges it, since it is not the model.
Row = Data.define(:id, :account_id, :name, :rank, :shared) do
def self.policy_class = Recipes::Permissions::MemberPolicy
end
RANKS = %w[recruit member officer leader].freeze
SEED_FILE = Rails.root.join("db/seeds/guild.yml")
# Every open hall listens here, for a rank or a ledger entry, from anyone.
CHANGED = "recipes:permissions:hall"
belongs_to :account, class_name: "Recipes::Auth::Account"
after_commit { self.class.announce }
validates :rank, inclusion: { in: RANKS }
def self.seeds = YAML.load_file(SEED_FILE)
# A ping with nothing in it. Whoever hears it reads the hall again.
def self.announce = ActionCable.server.broadcast(CHANGED, {})
# The rank, without making a row: an account not seen yet is a recruit.
def self.rank_of(account) = account.shared? ? "member" : where(account:).pick(:rank) || "recruit"
# Makes the row the first time an account opens the hall.
def self.join(account) = find_by(account:) || create!(account:, rank: rank_of(account))
def self.roster
includes(:account).sort_by { [ -RANKS.index(it.rank), it.account.username ] }.map(&:row)
end
# Gives the seeded accounts their ranks. Safe to run again.
def self.seed!
accounts = Recipes::Auth::Account.where(username: seeds.fetch("members").keys).index_by(&:username)
rows = seeds.fetch("members").filter_map { |name, rank| { account_id: accounts[name].id, rank: } if accounts[name] }
upsert_all(rows, unique_by: :account_id) if rows.any?
end
# `delete_all` and `upsert_all` run no callback, so the ping is sent by
# hand.
def self.reset!
transaction do
delete_all
seed!
end
announce
end
def row = Row.new(id:, account_id:, name: account.username, rank:, shared: account.shared?)
end
# frozen_string_literal: true
# A line in the guild coffer's ledger: a deposit or a withdrawal, which an
# officer may strike out. Nothing in it is typed: amounts come from buttons
# and a number field, and reasons from a list. Recipes::Reset puts the
# starting ledger back each night, and `record` caps it in between.
class Recipes::Permissions::Entry < ApplicationRecord
self.table_name = "recipes_permissions_entries"
# A line of the ledger: a plain copy. `policy_class` tells Pundit which
# policy judges it, since it is not the model.
Row = Data.define(:id, :account_id, :name, :kind, :amount, :reason, :struck_by) do
def self.policy_class = Recipes::Permissions::EntryPolicy
def struck? = !struck_by.nil?
end
KINDS = %w[deposit withdrawal].freeze
DEPOSITS = [ 10, 50, 100 ].freeze
REASONS = [ "New anvil", "Feast in the hall", "Repairs to the roof", "A bounty paid" ].freeze
# How many entries the ledger keeps.
KEEP = 200
belongs_to :account, class_name: "Recipes::Auth::Account", optional: true
after_commit { Recipes::Permissions::Member.announce }
validates :kind, inclusion: { in: KINDS }
validates :amount, numericality: { only_integer: true, greater_than: 0, message: "A withdrawal is at least 1 gp." }
validates :reason, inclusion: { in: REASONS, message: "Pick a reason from the list." }, if: -> { kind == "withdrawal" }
validate :within_the_coffer, if: -> { kind == "withdrawal" && new_record? }
# What the coffer holds: the deposits less the withdrawals, struck lines
# left out.
def self.total
where(struck_by: nil).group(:kind).sum(:amount).then { it.fetch("deposit", 0) - it.fetch("withdrawal", 0) }
end
# Writes a line, and drops the oldest past KEEP in the same transaction,
# so there is one ping.
def self.record(viewer, **attrs)
transaction do
create!(account_id: viewer.account_id, name: viewer.name, **attrs).tap { prune! }
end
end
def self.prune! = where.not(id: order(id: :desc).limit(KEEP).select(:id)).delete_all
def self.seeds = Recipes::Permissions::Member.seeds.fetch("entries")
# Fills an empty ledger with the starting lines. Safe to run again.
def self.seed!
return if exists?
accounts = Recipes::Auth::Account.pluck(:username, :id).to_h
insert_all(seeds.map { |name, kind, amount, reason| { account_id: accounts[name], name:, kind:, amount:, reason: } })
end
def self.reset!
transaction do
delete_all
seed!
end
Recipes::Permissions::Member.announce
end
def row = Row.new(id:, account_id:, name:, kind:, amount:, reason:, struck_by:)
private
def within_the_coffer
left = self.class.total
errors.add(:amount, "The coffer holds only #{left.to_fs(:delimited)} gp.") if amount.to_i > left
end
end
<%# The page builds the hall the channel does, from the same viewer, so the
first paint and every later one ask the same policies. %>
<% viewer = Recipes::Permissions::Viewer.for(demo_session&.account) %>
<% hall = Recipes::Permissions::Hall.new(viewer:) %>
<%= island Recipes::Permissions::GuildChannel, class: "panel mx-auto w-full max-w-2xl p-4 sm:p-5" do %>
<header class="flex items-baseline justify-between gap-2">
<h2 class="text-xl font-bold">The guild hall</h2>
<span class="loading loading-spinner loading-xs hidden in-data-hibiki-busy:inline-block" aria-hidden="true"></span>
</header>
<%= render "app/recipes/permissions/you", viewer:, notice: nil %>
<label class="mt-2 flex items-center gap-2 text-sm">
<%= tag.input type: "checkbox", name: "everything", class: "checkbox checkbox-sm", **on(:show_everything, event: :change) %>
<span>Also show the buttons my rank may not use <span class="badge badge-warning badge-sm">demo only</span></span>
</label>
<div class="mt-3 grid gap-4 md:grid-cols-[3fr_2fr]">
<div class="flex flex-col gap-4">
<%= render "app/recipes/permissions/coffer", coffer: hall.coffer, withdrawing: false, everything: false %>
<%= render "app/recipes/permissions/ledger", viewer:, lines: hall.ledger, everything: false %>
</div>
<%= render "app/recipes/permissions/roster", viewer:, members: hall.roster, everything: false %>
</div>
<% end %>
<%# locals: (viewer:, notice:) -%>
<%# Morphed by the channel when the rank changes, or there is something to
say. A promotion is said in rainbow letters. %>
<div id="guild_you" class="mt-2 flex flex-col gap-2">
<% if viewer.guest? %>
<p>You are a guest. <%= link_to "Sign in", app_recipe_path("signin"), class: "link" %> to join the guild.</p>
<% else %>
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
<p>You are <b><%= viewer.name %></b>, <span id="guild_rank" class="badge badge-primary"><%= viewer.title %></span></p>
<% if viewer.shared %>
<p class="text-sm opacity-70">Everyone shares this account, so its rank stays as it is.</p>
<% else %>
<%= tag.form id: "guild_petition", class: "flex items-center gap-2", **on(:petition, event: :submit) do %>
<%= tag.select name: "rank", class: "select select-sm w-auto py-0", "aria-label": "Rank to ask for" do %>
<%= options_for_select Recipes::Permissions::Member::RANKS.map { [ it.capitalize, it ] }, viewer.rank %>
<% end %>
<%= tag.button "Petition the leader", type: "submit", class: "btn btn-sm" %>
<% end %>
<% end %>
</div>
<% end %>
<p id="guild_notice" class="min-h-6 text-sm" role="status">
<% if notice&.reward %>
<%= rainbow_text notice.text, class: "font-semibold" %>
<% elsif notice %>
<%= notice.text %>
<% end %>
</p>
</div>
<%# locals: (coffer:, withdrawing:, everything:) -%>
<%# Morphed by the channel. Each button is drawn when the policy allows it,
or, with the demo box ticked, dashed, to see the server refuse it. %>
<% allowed = ->(ok) { ok ? "btn-primary" : "btn-dash btn-error" } %>
<section id="guild_coffer" class="flex flex-col gap-2">
<h3 class="font-semibold">The coffer</h3>
<p class="text-2xl font-bold tabular-nums"><%= coffer.total.to_fs(:delimited) %> gp</p>
<div class="flex flex-wrap gap-2">
<% if coffer.deposit || everything %>
<% Recipes::Permissions::Entry::DEPOSITS.each do |amount| %>
<%= tag.button "Deposit #{amount} gp", type: "button", id: "guild_deposit_#{amount}",
class: [ "btn btn-sm", allowed.(coffer.deposit) ], **on(:deposit, with: { amount: }) %>
<% end %>
<% end %>
<% if (coffer.withdraw || everything) && !withdrawing %>
<%= tag.button "Withdraw…", type: "button", id: "guild_open_withdrawal",
class: [ "btn btn-sm", allowed.(coffer.withdraw) ], **on(:open_withdrawal) %>
<% end %>
</div>
<% if withdrawing %>
<%= tag.form id: "guild_withdrawal", class: "flex flex-wrap items-end gap-2 rounded-box bg-base-200 p-3",
**on(:withdraw, event: :submit, reset: false) do %>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">Amount</span>
<%= tag.input type: "number", name: "amount", min: 1, value: 10, class: "input input-sm w-28 tabular-nums" %>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">For</span>
<%= tag.select name: "reason", class: "select select-sm w-auto py-0" do %>
<%= options_for_select Recipes::Permissions::Entry::REASONS %>
<% end %>
</label>
<%= tag.button "Withdraw", type: "submit", class: "btn btn-sm btn-primary" %>
<%= tag.button "Cancel", type: "button", class: "btn btn-sm", **on(:close_withdrawal) %>
<% end %>
<% end %>
</section>
<%# locals: (viewer:, lines:, everything:) -%>
<%# Morphed by the channel. `lines` is what the policy's scope lets this
viewer read, each with whether they may strike it out. %>
<section id="guild_ledger" class="flex flex-col gap-1">
<h3 class="font-semibold">The ledger</h3>
<% if viewer.guest? %>
<p class="text-sm opacity-70">Only members of the guild read the ledger.</p>
<% elsif viewer.rank == "recruit" %>
<p class="text-sm opacity-70">A recruit reads only their own lines.</p>
<% end %>
<ul class="flex flex-col divide-y divide-base-300 text-sm">
<% lines.each do |line, strike| %>
<li id="guild_line_<%= line.id %>" class="flex items-center justify-between gap-2 py-1.5">
<span class="<%= "line-through opacity-60" if line.struck? %>">
<b><%= line.name %></b>
<%= line.kind == "deposit" ? "put in" : "took out" %>
<span class="tabular-nums"><%= line.amount.to_fs(:delimited) %> gp</span><%= " for #{line.reason.downcase}" if line.reason %>
</span>
<% if line.struck? %>
<span class="text-xs opacity-70">struck out by <%= line.struck_by %></span>
<% elsif strike || everything %>
<%= tag.button "Strike out", type: "button", class: [ "btn btn-xs", ("btn-dash btn-error" unless strike) ],
**on(:strike, with: { id: line.id }) %>
<% end %>
</li>
<% end %>
</ul>
</section>
<%# locals: (viewer:, members:, everything:) -%>
<%# Morphed by the channel. The leader may set anyone else's rank. %>
<section id="guild_roster" class="flex flex-col gap-1">
<h3 class="font-semibold">The roster</h3>
<ul class="flex flex-col divide-y divide-base-300 text-sm">
<% members.each do |member, promote| %>
<li id="guild_member_<%= member.id %>" class="flex flex-wrap items-center justify-between gap-2 py-1.5">
<span>
<b><%= member.name %></b><%= " (you)" if member.account_id == viewer.account_id %>
</span>
<% if promote || (everything && member.account_id != viewer.account_id) %>
<%= tag.select name: "rank", "aria-label": "Rank of #{member.name}",
class: [ "select select-xs w-auto py-0", ("select-error border-dashed" unless promote) ],
**on(:set_rank, event: :change, with: { id: member.id }) do %>
<%= options_for_select Recipes::Permissions::Member::RANKS.map { [ it.capitalize, it ] }, member.rank %>
<% end %>
<% else %>
<span class="badge badge-sm <%= "badge-primary" if member.rank == "leader" %>"><%= member.rank.capitalize %></span>
<% end %>
</li>
<% end %>
</ul>
</section>
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:
-
The button calls the
depositaction. -
The action reads your rank from the database and asks
EntryPolicywhether that viewer may deposit. If the answer is no, Pundit raises, and the channel'srescue_fromwrites a line under the header. -
If the answer is yes, the line is written. The model's
after_commitbroadcasts that the hall changed, on one stream every open hall listens to. - 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
class MembersController < ApplicationController
include Pundit::Authorization
after_action :verify_authorized
def update
member = Member.find(params[:id])
authorize member, :promote?
member.update!(rank: params[:rank])
redirect_to hall_path, status: :see_other
end
def petition
member = Member.find_by!(account: current_user)
authorize member, :petition?
member.update!(rank: params[:rank])
# Said only to this browser. A promotion made by the leader in another
# browser reaches this one as a refresh, which carries no flash.
redirect_to hall_path, notice: "You are now #{member.rank}.", status: :see_other
end
private
def pundit_user = Viewer.for(current_user)
end
class Entry < ApplicationRecord
# A broadcast partial is rendered once, for everyone, with no
# current_user, so it cannot ask `policy(entry).strike?`. Instead every
# write tells every open hall to fetch itself again, and each browser's
# own request renders the page with its own user.
broadcasts_refreshes_to ->(_entry) { "guild_hall" }
end
class Member < ApplicationRecord
# A rank change has to reach the promoted visitor's page the same way.
broadcasts_refreshes_to ->(_member) { "guild_hall" }
end
<%# app/views/layouts/application.html.erb %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<%# app/views/entries/index.html.erb %>
<%= turbo_stream_from "guild_hall" %>
<p>You are <%= current_user&.username || "a guest" %>, <%= pundit_user.rank %>.</p>
<p><%= Entry.total %> gp</p>
<% if policy(Entry).deposit? %>
<% [ 10, 50, 100 ].each do |amount| %>
<%= button_to "Deposit #{amount} gp", entries_path(amount:) %>
<% end %>
<% end %>
<% if policy(Entry).withdraw? %>
<% if @withdrawing %>
<%= form_with url: withdraw_entries_path do |f| %>
<%= f.number_field :amount, value: 10 %>
<%= f.select :reason, Entry::REASONS %>
<%= f.submit "Withdraw" %>
<%= link_to "Cancel", hall_path %>
<% end %>
<% else %>
<%= link_to "Withdraw…", hall_path(withdrawing: 1) %>
<% end %>
<% end %>
<% @entries.each do |entry| %>
<p id="<%= dom_id(entry) %>">
<%= entry.name %> <%= entry.kind %> <%= entry.amount %> gp
<%= button_to "Strike out", strike_entry_path(entry), method: :patch if policy(entry).strike? %>
</p>
<% end %>
<% @members.each do |member| %>
<p>
<%= member.account.username %>
<% if policy(member).promote? %>
<%= form_with model: member do |f| %>
<%= f.select :rank, Member::RANKS, {}, onchange: "this.form.requestSubmit()" %>
<% end %>
<% else %>
<%= member.rank %>
<% end %>
</p>
<% end %>
get "hall", to: "entries#index", as: :hall
resources :entries, only: :create do
post :withdraw, on: :collection
patch :strike, on: :member
end
resources :members, only: :update do
post :petition, on: :collection
end
The table lists each behaviour of the demo above and where it comes from.
| Stock Rails | Hibiki | |
|---|---|---|
| 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.