Master and tenant users

v0.0.1

Gervaz assumes that two kinds of people use an admin: a master, who works across every tenant, and a tenant user, who works inside one site or organization. One DSL declaration serves both. This page explains how Gervaz tells them apart, and which options change with the role: the Ash action that runs, the relationships that load, what a person sees, and which live updates they receive.

What it is and why

A platform that hosts many sites has operators who see every site and site staff who see their own. The two groups use the same screens but not the same data, and often not the same Ash actions. A master reads through an action that bypasses multitenancy, and a tenant user reads through one that is scoped by the tenant. Gervaz lets one resource declare both, so you do not write two admin surfaces.

How Gervaz tells them apart

Each component computes state.master_user? when it starts. The built-in access module treats a user whose site_id is nil as a master (MishkaGervaz.Helpers.master_user?/1). The user map must have the site_id key, because a map without the key is not a master. Gervaz reads the tenant of a user from the same site_id key.

The master_check option of a domain or a resource is stored in the config, but the built-in access modules do not call it. To use another rule, write an access module that overrides master_user?/1 and name it in the DSL. The table and the form each have one:

elixir
defmodule MyApp.Table.Access do
  use MishkaGervaz.Table.Web.State.Access

  def master_user?(%{role: :admin}), do: true
  def master_user?(user), do: super(user)
end

mishka_gervaz do
  table do
    state do
      access MyApp.Table.Access
    end
  end
end

For a form, use MishkaGervaz.Form.Web.State.Access in the same way.

What changes with the role

The Ash action

Every action option takes an atom, which serves both roles, or a tuple {master_action, tenant_action}. Gervaz picks the element with state.master_user?. If your application has no master and tenant split, use plain atoms. The Mishka Blog domain names both:

elixir
actions do
  read {:master_read, :read}
  get {:master_get, :read}
  destroy {:master_destroy, :destroy}
end

The preloads

A preload has three tiers: always, master and tenant. They let the two roles expose different relationships under one alias. The tags table loads master_language_variants for a master and language_variants for a tenant user, and a column reads language_variants in both cases:

elixir
preload do
  always [:site, :version_count, :post_count]
  tenant [:language_variants]
  master master_language_variants: :language_variants
end

Gervaz keeps an alias map per role, and MishkaGervaz.Resource.Info.Table.preload_aliases/2 returns it.

What a person can see

restricted true means master only. It is an option of filters, filter groups, row and bulk actions, form fields, groups and submit buttons, headers, footers, notices and the archive toggle. The tags table shows its site filter and its site field to masters only, and shows the field only when a tag is created:

elixir
filter :site_id, :relation do
  mode :search_multi
  display_field :name
  search_field :name
  restricted true
end

field :site_id, :relation do
  restricted true
  required true
  show_on :create
  mode :search
end

A whole form mode can be master only with access :create, restricted: true in the form's source, and source restricted true closes the entire form to everyone but masters.

The live updates

A table subscribes to "<prefix>:created", "<prefix>:updated" and "<prefix>:destroyed". A user whose site_id is set also subscribes to "<prefix>:<site_id>:created", :updated and :destroyed. The subscriptions follow the site_id of the user and not state.master_user?. The realtime block's visible function runs only for users who are not masters, and a master always passes. It replaces the default check, which compares the tenant attribute of the record with the user's, so it must keep other tenants out itself.

elixir
realtime do
  prefix "tag"

  visible fn record, user ->
    is_nil(user.site_id) or record.site_id == user.site_id
  end
end

The code you write

Functions in the DSL read state.master_user? and state.current_user to branch. This option list of the tags form reads the site that a master picked in the form, and the site of a tenant user:

elixir
load fn query, state ->
  site_id =
    if state.master_user? do
      Map.get(state.field_values, :site_id)
    else
      Map.get(state.current_user, :site_id)
    end

  Ash.Query.filter_input(query, %{site_id: site_id})
end