account_authz 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 349b6e1097c3f9cd3f4211488f800e5132418138e0785573f2810dde8e87287d
4
- data.tar.gz: 9cf6c1e7593dc3bd6e814df1df72d5f0caa488bd868407cf3098e6f189a3950d
3
+ metadata.gz: 2a6d242a905644ec6879015808f39da3e4b74d95768602bd45dccd36568cb818
4
+ data.tar.gz: 535257175b0c79d2a1606d07ac9d1926849e22886d35aec0a4dddf8c894cde70
5
5
  SHA512:
6
- metadata.gz: 02b2a2acf869db601b2cf5470565ef64920543c01c027f21575b22d5e16d98f1ac8e679d9759d9f9f0698f17ee65acc719bd01a147a428ca087aec4d0620e6fa
7
- data.tar.gz: 403e200dd7e81b20319415fbce054f10dd4cbfa9327faa7ddccf399c042fd91c70c03f16f7ecb18269cc86315a56087d942b161127776f6224c68a5361d75ea6
6
+ metadata.gz: db728919480a0a9624bcc39fbab70fef43005076e964dc0f9d7367a37b63d61fbcb0272fbe241c26f592309b4d26a3bfa6fffccb0d9d90c25b051d9cb7a39f53
7
+ data.tar.gz: 54f0e3b29af7748fd4b93ece0ff5bea6f90e16718a6bd3e0e8a5bc125bd133a483ccb4f8ba672a404d8b057a434e357c669bdf73cfeffe9f7a11f4955209a441
@@ -9,7 +9,7 @@ module AccountAuthz
9
9
  end
10
10
 
11
11
  def show
12
- @member = AccountAuthz.members_source.members(Current.account_id).find(params[:id])
12
+ @member = AccountAuthz.find_member(Current.account_id, params[:id])
13
13
  @held = Assignment.where(member: @member, role: Role.in_account(Current.account_id)).includes(:role).map(&:role)
14
14
  @reach = reach
15
15
  @last_manager = LastManager.new(account_id: Current.account_id)
@@ -26,11 +26,11 @@ module AccountAuthz
26
26
  end
27
27
 
28
28
  def edit
29
- @role = Role.in_account(Current.account_id).find(params[:id])
29
+ @role = Role.in_account(Current.account_id).find_by_public_code!(params[:id])
30
30
  end
31
31
 
32
32
  def update
33
- role = Role.in_account(Current.account_id).find(params[:id])
33
+ role = Role.in_account(Current.account_id).find_by_public_code!(params[:id])
34
34
  return refuse(:role_edit_out_of_reach, back_to: roles_path) unless reach.includes_role?(role)
35
35
  return refuse(:rank_out_of_reach, back_to: edit_role_path(role)) if role_params.key?(:rank) && !reach.includes_rank?(role_params[:rank])
36
36
  return refuse(:capabilities_out_of_reach, back_to: edit_role_path(role)) unless reach.includes_capabilities?(Array(role_params[:capabilities]) - role.capabilities)
@@ -17,7 +17,7 @@ module AccountAuthz
17
17
  private
18
18
 
19
19
  def invitation
20
- AccountAuthz.members_source.invitations(@account).find(@values[:invitation_id])
20
+ AccountAuthz.find_invitation(@account, @values[:invitation_id])
21
21
  end
22
22
  end
23
23
  end
@@ -24,11 +24,11 @@ module AccountAuthz
24
24
  end
25
25
 
26
26
  def member
27
- @member ||= AccountAuthz.members_source.members(@account).find(@values[:member_id])
27
+ @member ||= AccountAuthz.find_member(@account, @values[:member_id])
28
28
  end
29
29
 
30
30
  def role
31
- @role ||= Role.in_account(@account).find(@values[:role_id])
31
+ @role ||= Role.in_account(@account).find_by_public_code!(@values[:role_id])
32
32
  end
33
33
  end
34
34
  end
@@ -28,7 +28,7 @@ module AccountAuthz
28
28
  end
29
29
 
30
30
  def member
31
- @member ||= AccountAuthz.members_source.members(@account).find(@values[:member_id])
31
+ @member ||= AccountAuthz.find_member(@account, @values[:member_id])
32
32
  end
33
33
  end
34
34
  end
@@ -17,7 +17,7 @@ module AccountAuthz
17
17
  private
18
18
 
19
19
  def invitation
20
- AccountAuthz.members_source.invitations(@account).find(@values[:invitation_id])
20
+ AccountAuthz.find_invitation(@account, @values[:invitation_id])
21
21
  end
22
22
  end
23
23
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  module AccountAuthz
4
4
  class Role < ApplicationRecord
5
+ has_public_code
5
6
  validates :name, presence: true
6
7
  validate :capabilities_within_catalog
7
8
  after_save { Current.capabilities = {} }
@@ -25,11 +25,11 @@ module AccountAuthz
25
25
  end
26
26
 
27
27
  def member
28
- @member ||= AccountAuthz.members_source.members(@account).find(@values[:member_id])
28
+ @member ||= AccountAuthz.find_member(@account, @values[:member_id])
29
29
  end
30
30
 
31
31
  def role
32
- @role ||= Role.in_account(@account).find(@values[:role_id])
32
+ @role ||= Role.in_account(@account).find_by_public_code!(@values[:role_id])
33
33
  end
34
34
  end
35
35
  end
@@ -9,7 +9,7 @@
9
9
 
10
10
  <%= ui_section(title: "Team") do %>
11
11
  <%= ui_data_table(
12
- items: members.map { |member| {member_id: member.id, name: member.name, email: member.email, roles: held_roles.fetch(member.id, []).map(&:name).join(", ").presence || "None"} },
12
+ items: members.map { |member| {member_id: member.to_param, name: member.name, email: member.email, roles: held_roles.fetch(member.id, []).map(&:name).join(", ").presence || "None"} },
13
13
  columns: [Keystone::Ui::Column.new(:name, "Name"), Keystone::Ui::Column.new(:email, "Email", mobile_hidden: true), Keystone::Ui::Column.new(:roles, "Roles")],
14
14
  empty_message: "No members yet"
15
15
  ) do |table| %>
@@ -17,7 +17,13 @@
17
17
  <% end %>
18
18
  <% end %>
19
19
 
20
- <% person_chosen = chosen && members.find { |member| member.id.to_s == chosen.to_s } %>
20
+ <%
21
+ person_chosen = begin
22
+ chosen && AccountAuthz.find_member(account, chosen)
23
+ rescue ActiveRecord::RecordNotFound
24
+ nil
25
+ end
26
+ %>
21
27
  <% if person_chosen %>
22
28
  <%
23
29
  reach = AccountAuthz::Reach.new(AccountAuthz::ActingMember.for(person: person, account: account), account_id: account)
@@ -30,15 +36,15 @@
30
36
  <div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
31
37
  <% (held & within_reach).reject { |role| last_manager.lost_by_taking?(person_chosen, role) }.each do |role| %>
32
38
  <%= ui_form(action: submit_urls[:take_role], method: :patch) do %>
33
- <%= hidden_field_tag :member_id, person_chosen.id, id: nil %>
34
- <%= hidden_field_tag :role_id, role.id, id: nil %>
39
+ <%= hidden_field_tag :member_id, person_chosen.to_param, id: nil %>
40
+ <%= hidden_field_tag :role_id, role.to_param, id: nil %>
35
41
  <%= ui_button(label: "Take #{role.name}", variant: :danger, size: :sm) %>
36
42
  <% end %>
37
43
  <% end %>
38
44
  <% (within_reach - held).each do |role| %>
39
45
  <%= ui_form(action: submit_urls[:give_role], method: :patch) do %>
40
- <%= hidden_field_tag :member_id, person_chosen.id, id: nil %>
41
- <%= hidden_field_tag :role_id, role.id, id: nil %>
46
+ <%= hidden_field_tag :member_id, person_chosen.to_param, id: nil %>
47
+ <%= hidden_field_tag :role_id, role.to_param, id: nil %>
42
48
  <%= ui_button(label: "Give #{role.name}", variant: :secondary, size: :sm) %>
43
49
  <% end %>
44
50
  <% end %>
@@ -46,7 +52,7 @@
46
52
 
47
53
  <% if AccountAuthz.members_source.removable?(person_chosen) && !last_manager.lost_by_removing?(person_chosen) %>
48
54
  <%= ui_form(action: submit_urls[:remove], method: :patch) do %>
49
- <%= hidden_field_tag :member_id, person_chosen.id, id: nil %>
55
+ <%= hidden_field_tag :member_id, person_chosen.to_param, id: nil %>
50
56
  <%= ui_button(label: "Remove from team", variant: :danger, size: :sm) %>
51
57
  <% end %>
52
58
  <% end %>
@@ -66,7 +72,7 @@
66
72
 
67
73
  <%= ui_section(title: "Invited") do %>
68
74
  <%= ui_data_table(
69
- items: invitations.map { |invitation| {invitation_id: invitation.id, name: invitation.name, email: invitation.email} },
75
+ items: invitations.map { |invitation| {invitation_id: invitation.to_param, name: invitation.name, email: invitation.email} },
70
76
  columns: [Keystone::Ui::Column.new(:name, "Name"), Keystone::Ui::Column.new(:email, "Email", mobile_hidden: true)],
71
77
  empty_message: "No invitations"
72
78
  ) do |table| %>
@@ -21,7 +21,7 @@
21
21
  <% end %>
22
22
  <% (@roles - @held).each do |role| %>
23
23
  <%= ui_form(action: member_roles_path(@member)) do %>
24
- <%= hidden_field_tag :role_id, role.id, id: nil %>
24
+ <%= hidden_field_tag :role_id, role.to_param, id: nil %>
25
25
  <%= ui_button(label: "Give #{role.name}", variant: :secondary, size: :sm) %>
26
26
  <% end %>
27
27
  <% end %>
@@ -0,0 +1,17 @@
1
+ class AddPublicCodeToAccountAuthzRoles < ActiveRecord::Migration[8.1]
2
+ class Record < ActiveRecord::Base
3
+ self.table_name = "account_authz_roles"
4
+ end
5
+
6
+ def up
7
+ add_column :account_authz_roles, :public_code, :string
8
+ Record.reset_column_information
9
+ Record.find_each { |record| record.update_columns(public_code: PublicCode.generate) }
10
+ change_column_null :account_authz_roles, :public_code, false
11
+ add_index :account_authz_roles, :public_code, unique: true
12
+ end
13
+
14
+ def down
15
+ remove_column :account_authz_roles, :public_code
16
+ end
17
+ end
@@ -116,6 +116,17 @@ layout the host's `ApplicationController` uses, so the host loads keystone_ui's
116
116
  styles. Route helpers in that layout, such as `root_path`, reach the host's own
117
117
  routes without a `main_app.` prefix.
118
118
 
119
+ **Addresses.** Every role carries a random code from the public_code gem, and
120
+ the role pages and the Give and Take buttons address a role by it, so a
121
+ numbered role address is not found. Members and invitations are addressed by
122
+ whatever `to_param` the host's records give. A source that also answers
123
+ `member(account_id, address)` and `invitation(account_id, address)` is asked for
124
+ the member or invitation at an address, which is how an app whose members carry
125
+ codes, or whose invitations are addressed by a token, has them found. A source
126
+ that answers neither has them found with `find` on `members(account_id)` and
127
+ `invitations(account_id)`, by id. Role assignments still point at members and
128
+ roles by id.
129
+
119
130
  **Role pages (engine).** The members page links to them with a Roles link
120
131
  shown only to people who hold the roles capability, and the roles page links
121
132
  back to the members page, so an app needs one navigation entry for both. At `/account_authz/roles`, a member who holds the roles
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AccountAuthz
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/account_authz.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "public_code"
3
4
  require_relative "account_authz/version"
4
5
  require_relative "account_authz/engine"
5
6
  require_relative "account_authz/catalog"
@@ -38,6 +39,18 @@ module AccountAuthz
38
39
  .map { |template| Role.from_template(account_id: account_id, template: template.name) }
39
40
  end
40
41
 
42
+ def self.find_member(account_id, address)
43
+ return members_source.member(account_id, address) if members_source.respond_to?(:member)
44
+
45
+ members_source.members(account_id).find(address)
46
+ end
47
+
48
+ def self.find_invitation(account_id, address)
49
+ return members_source.invitation(account_id, address) if members_source.respond_to?(:invitation)
50
+
51
+ members_source.invitations(account_id).find(address)
52
+ end
53
+
41
54
  def self.reset!
42
55
  @catalog = nil
43
56
  @templates = nil
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: account_authz-develop
3
- description: Use PROACTIVELY for declaring capabilities (permissions and metrics), defining role templates, seeding an account's default roles, creating roles, ranking roles so a manager can change only members and roles below them, assigning or revoking a member's roles, checking whether one member may change another member's roles, checking whether an editor may create or edit a role with a given rank and capabilities, checking whether taking a role, removing a member, or changing a role's capabilities would leave an account with nobody who can manage its members, checking whether a member can do something, filtering which metrics a member may see, writing Pundit policies that gate actions on a capability, choosing which members the members page lists, how it invites a person to an account, which invitations it shows as still waiting for an answer, how it sends one again and cancels one, and how it removes a member from an account, and which capability lets a member open it to invite, send an invitation again, cancel one, remove a member, and give and take roles, choosing which capability lets a member open the role pages to create and edit roles, which viewers see the links between the members page and the role pages, and showing or rewording the message a manager sees when the members page or role pages refuse a change — MUST BE USED instead of hand-rolling role checks, permission flags, role hierarchies, limits on the rank or capabilities an editor may give a role, last-admin checks, role management screens, member invite, waiting invitation or removal screens, refusal messages, or ad hoc authorization in controllers and views.
3
+ description: Use PROACTIVELY for declaring capabilities (permissions and metrics), defining role templates, seeding an account's default roles, creating roles, ranking roles so a manager can change only members and roles below them, assigning or revoking a member's roles, checking whether one member may change another member's roles, checking whether an editor may create or edit a role with a given rank and capabilities, checking whether taking a role, removing a member, or changing a role's capabilities would leave an account with nobody who can manage its members, checking whether a member can do something, filtering which metrics a member may see, writing Pundit policies that gate actions on a capability, choosing which members the members page lists, how it finds a member or an invitation addressed by a code or token, how it invites a person to an account, which invitations it shows as still waiting for an answer, how it sends one again and cancels one, and how it removes a member from an account, and which capability lets a member open it to invite, send an invitation again, cancel one, remove a member, and give and take roles, choosing which capability lets a member open the role pages to create and edit roles, which viewers see the links between the members page and the role pages, and showing or rewording the message a manager sees when the members page or role pages refuse a change — MUST BE USED instead of hand-rolling role checks, permission flags, role hierarchies, limits on the rank or capabilities an editor may give a role, last-admin checks, role management screens, member invite, waiting invitation or removal screens, refusal messages, or ad hoc authorization in controllers and views.
4
4
  tools: Read, Write, Edit, Grep
5
5
  scope: authorization — capability catalog, roles, and Pundit enforcement in multi-tenant Rails apps
6
6
  ---
@@ -9,7 +9,7 @@ You implement authorization in a host app that already has account_authz install
9
9
 
10
10
  ## What account_authz is
11
11
 
12
- AccountAuthz is capability-based authorization for multi-tenant Rails apps. The app declares a fixed list of capability keys in code, each account holds roles as records that bundle a subset of those keys, and a member's access is the union of the capabilities of the roles assigned to them. A member is a person's membership in one account, not the person, so a person who belongs to three accounts is three members, and an app with no membership record lets the person hold roles directly instead. Each role also has an integer rank, and a manager may change only members and roles ranked below the manager's highest role in the account. On account_authz's role pages, an editor may give a role only capabilities the editor holds and a rank no higher than the editor's own. AccountAuthz's members page and role pages refuse a change that would leave an account with nobody holding a role that grants the members capability. When they refuse a change for rank, for capabilities the editor does not hold, for the last member manager, or because a member may not be removed, they change nothing and send the manager back with a flash alert saying why. Fire this local when code needs to declare a capability, create, seed or rank roles, assign roles to a member, answer "may this member do X", answer "may this manager change this member or hand out this role", answer "may this editor create or edit this role with this rank and these capabilities", answer "would this change leave the account with no member manager", pick which metrics a member may see, write a policy that authorizes an action, decide who the members page lists, how it invites a person, which invitations it shows as still waiting and how it sends one again or cancels one, how it removes members, and who may open it, decide who may open the role pages to create and edit roles, or show or reword the message a refused change shows.
12
+ AccountAuthz is capability-based authorization for multi-tenant Rails apps. The app declares a fixed list of capability keys in code, each account holds roles as records that bundle a subset of those keys, and a member's access is the union of the capabilities of the roles assigned to them. A member is a person's membership in one account, not the person, so a person who belongs to three accounts is three members, and an app with no membership record lets the person hold roles directly instead. Each role also has an integer rank, and a manager may change only members and roles ranked below the manager's highest role in the account. On account_authz's role pages, an editor may give a role only capabilities the editor holds and a rank no higher than the editor's own. AccountAuthz's members page and role pages refuse a change that would leave an account with nobody holding a role that grants the members capability. When they refuse a change for rank, for capabilities the editor does not hold, for the last member manager, or because a member may not be removed, they change nothing and send the manager back with a flash alert saying why. Fire this local when code needs to declare a capability, create, seed or rank roles, assign roles to a member, answer "may this member do X", answer "may this manager change this member or hand out this role", answer "may this editor create or edit this role with this rank and these capabilities", answer "would this change leave the account with no member manager", pick which metrics a member may see, write a policy that authorizes an action, decide who the members page lists, how it finds a member or invitation by address, how it invites a person, which invitations it shows as still waiting and how it sends one again or cancels one, how it removes members, and who may open it, decide who may open the role pages to create and edit roles, or show or reword the message a refused change shows.
13
13
 
14
14
  ## Interface
15
15
 
@@ -34,7 +34,7 @@ AccountAuthz is capability-based authorization for multi-tenant Rails apps. The
34
34
  - `member.can?` — `member.can?(capability, account_id: nil)` returns true when that union includes `capability`.
35
35
  - `member.approved_metrics` — `member.approved_metrics(account_id: nil)` returns the catalog metrics the member holds.
36
36
  - `AccountAuthz::ApplicationPolicy` — the base class for Pundit policies, taking `(member, record)`, exposing `member` and `record`, and providing `can?(capability)`.
37
- - `AccountAuthz.members_source` — `AccountAuthz.members_source = AccountMembers` sets the object the members page uses to list an account's members, invite a person, list the invitations still waiting for an answer, send one of those again, cancel one, ask whether a member may be removed, and remove a member, and it has no default.
37
+ - `AccountAuthz.members_source` — `AccountAuthz.members_source = AccountMembers` sets the object the members page uses to list an account's members, find one member or invitation by its address, invite a person, list the invitations still waiting for an answer, send one of those again, cancel one, ask whether a member may be removed, and remove a member, and it has no default.
38
38
  - `AccountAuthz.members_capability` — `AccountAuthz.members_capability = :key` sets the capability a member needs in the current account to open the members page and invite, send an invitation again, cancel one, remove a member, or give or take roles on it, and it defaults to `:manage_members`.
39
39
  - `AccountAuthz.roles_capability` — `AccountAuthz.roles_capability = :key` sets the capability a member needs in the current account to open the role pages and create or edit roles on them, and it defaults to `:manage_roles`.
40
40
  - `AccountAuthz::Reach` — `AccountAuthz::Reach.new(manager, account_id:)` answers which members and roles `manager` may change in one account, and which ranks and capabilities `manager` may give a role there.
@@ -101,7 +101,7 @@ AccountAuthz is capability-based authorization for multi-tenant Rails apps. The
101
101
 
102
102
  A role grants the members capability when its capabilities include the key `AccountAuthz.members_capability` returns at the moment of the call. Every holder of such a role in the account counts, whether or not the members source returns them. A member who holds two such roles is not lost by taking one of them. Pass `lost_by_changing?` the role's complete new list of capability keys, as strings or symbols, not only the keys being removed. `lost_by_changing?` returns true even when nobody holds that role, as long as nobody holds another role in the account that grants the members capability. When nobody in the account holds a role granting the members capability, `lost_by_taking?` and `lost_by_removing?` return true for every member and role. A `LastManager` reads which of the account's roles grant the members capability once, so build a new one after a role's capabilities change. It checks only the account it was built for, and it checks neither rank, capabilities the editor holds, nor `removable?`, so combine it with `AccountAuthz::Reach` when one member changes another member or a role. Giving a role and creating a role are never refused by it.
103
103
 
104
- 9. **Resolve access with the account id.** Pass the current account's id: `member.can?(:view_fulfillment, account_id: account_id)` and `member.approved_metrics(account_id: account_id)`. Omitting `account_id` resolves against every role the member holds, whatever account it belongs to. When the member is a membership that holds only its own account's roles, the answer is the same either way, but pass it anyway. When the person holds roles directly, omitting it counts their roles in every account. Pass capabilities as symbols, because `member.capabilities` returns symbols and a string never matches. When the grants are already in hand, use `AccountAuthz.can?(grants, :key)` and `AccountAuthz.approved_metrics(grants)` instead of querying again. Rank never affects `can?` or `approved_metrics`.
104
+ 9. **Resolve access with the account id.** Pass the current account's id: `member.can?(:view_fulfillment, account_id: account_id)` and `member.approved_metrics(account_id: account_id)`. Omitting `account_id` resolves against every role the member holds, whatever account it belongs to. When the member is a membership that holds only its own account's roles, the answer is the same either way, but pass it anyway. When the person holds roles directly, omitting it counts their roles in every account. Pass capabilities as symbols, because `member.capabilities` returns symbols and a string never matches. When the grants are already in hand, use `AccountAuthz.can?(grants, :key)` and `AccountAuthz.approved_metrics(grants)` instead of querying again. Rank never affects `can?` or `approved_metrics`. A member's capabilities in an account are read once per request and reused, and `assign_role`, `revoke_role` and saving a role clear what was read, so a role changed in the database any other way during the request is not seen until the next request.
105
105
 
106
106
  10. **Gate actions with policies.** Write each policy in `app/policies/` as a subclass of `AccountAuthz::ApplicationPolicy`, and define one query method per action it authorizes, because the base class defines none:
107
107
 
@@ -115,7 +115,9 @@ AccountAuthz is capability-based authorization for multi-tenant Rails apps. The
115
115
 
116
116
  The base class defines no `Scope`, so a policy used with `policy_scope` needs its own. The `member` a policy receives is the object Pundit passes as its user, and it must be an instance of the host's role-holding model. The base `can?(capability)` checks only the roles the member holds in the current request's account, and it returns false when no current account is set, so the request must set the current account before any policy runs. The controller `can?` helper follows the same rule.
117
117
 
118
- 11. **Configure the members page, if the host serves it.** The page opens with a `Members` heading, which carries a `Roles` link to the roles page only when the viewer holds the roles capability in the current account. Under the heading is an invite form holding a required `Name` field, a required `Email` field and an `Invite` button, shown to every member who can open the page. Below it, the page lists each invitation the source says is still waiting for an answer, showing its name, its email, an `Invited` badge, a `Send again` button and a `Cancel` button, all shown to every member who can open the page. Below those, the page lists each member's name, email, and the names of the roles they hold in the current account. For each member the viewer reaches, it shows a button to take away each held role the viewer reaches, a button to give each of the current account's roles the viewer reaches that the member does not hold, and a `Remove` button when the source says that member may be removed. It leaves out a take button when `lost_by_taking?` is true for that member and role, and the `Remove` button when `lost_by_removing?` is true for that member. A member the viewer does not reach is listed with no buttons. The invite form and every button return to the page. The page creates no roles, so an account with no roles shows no give buttons. The members page and the role pages are drawn inside the layout the host's `ApplicationController` uses, so nothing in this step or the next changes how they look.
118
+ 11. **Configure the members pages, if the host serves them.** The members page opens with a `Members` heading, which carries a `Roles` link to the roles page only when the viewer holds the roles capability in the current account. Below it, a `Team` section with an `Invite` link lists each member's name, email, and the names of the roles they hold in the current account, or `None`, and each name opens that member's page. Below that, an `Invited` section lists each invitation the source says is still waiting for an answer, with its name, its email, a `Send again` button and a `Cancel` button, shown to every member who can open the page. The `Invite` link opens an invite page holding a required `Name` field, a required `Email` field and an `Invite` button. A member's page shows the member's name and email, a link back to the members page, and the roles they hold in the current account. When the viewer reaches the member, it also shows a `Take <role>` button for each held role the viewer reaches, a `Give <role>` button for each of the current account's roles the viewer reaches that the member does not hold, and a `Remove from team` button when the source says that member may be removed. It leaves out a take button when `lost_by_taking?` is true for that member and role, and the `Remove from team` button when `lost_by_removing?` is true for that member. A member the viewer does not reach is shown with no buttons. The invite form and every button return to the members page. The pages create no roles, so an account with no roles shows no give buttons. The members pages and the role pages are drawn inside the layout the host's `ApplicationController` uses, so nothing in this step or the next changes how they look.
119
+
120
+ Each member's page and each button for a member is addressed by the member's `to_param`, and each invitation button by the invitation's `to_param`, so whatever those return is the address account_authz later looks the record up by. Each `Give` and `Take` button addresses its role by the role's code, not its id.
119
121
 
120
122
  Write the source as an object with these seven methods, and a class with class methods works:
121
123
 
@@ -162,17 +164,39 @@ AccountAuthz is capability-based authorization for multi-tenant Rails apps. The
162
164
 
163
165
  Ask the developer what each of the seven methods does, because each depends on how the host stores which members belong to an account and how it invites people to one:
164
166
 
165
- - `members(account_id)` returns the account's members. It must return a query that looks a record up by id with `find`, such as an Active Record relation, because giving or taking a role and removing a member find the member through it. Each record it returns must respond to `name` and `email` and be an instance of the host's role-holding model. A membership model often has neither, so ask the developer how it answers them, such as from the person record it belongs to. It is called with the current account's id on each page load, each give or take, and each removal.
166
- - `invite(account_id:, name:, email:, invited_by:)` receives the current account's id, the name and email typed into the form, and the signed-in member who sent it. AccountAuthz sends no email, stores no invitation, and gives the invited person no role, so `invite` does what the host's invitation needs and the host builds the flow for accepting one. What `invite` stores is what `invitations` then lists. AccountAuthz does not check the name or email, so `invite` decides what to do with a blank, malformed, or already used email. Rank does not limit inviting. AccountAuthz ignores what `invite` returns and does not catch an error it raises.
167
- - `invitations(account_id)` returns the account's invitations that are still waiting for an answer. It must return a query that looks a record up by id with `find`, such as an Active Record relation, because sending an invitation again and cancelling one find it through it. Each record it returns must respond to `name` and `email` and be routable by id. AccountAuthz filters nothing out of it, so an invitation the host has already accepted or expired stays on the page with both buttons until `invitations` stops returning it. It is called with the current account's id on each page load, each send again, and each cancel.
168
- - `resend_invitation(invitation)` receives one invitation found through `invitations` and sends it again. AccountAuthz sends nothing itself and records nothing about the send. AccountAuthz ignores what it returns and does not catch an error it raises.
169
- - `cancel_invitation(invitation)` withdraws one invitation found through `invitations`, so that `invitations` no longer returns it. Ask the developer whether cancelling destroys the record or marks it withdrawn, because account_authz only stops listing what `invitations` stops returning. AccountAuthz ignores what it returns and does not catch an error it raises.
170
- - `removable?(member)` returns false for a member nobody may remove, such as the account owner. It receives only the member, not the manager removing them. It is called for each member the viewer reaches on every page load, and again on each removal.
167
+ - `members(account_id)` returns the account's members. Each record it returns must respond to `name` and `email` and be an instance of the host's role-holding model. A membership model often has neither, so ask the developer how it answers them, such as from the person record it belongs to. It is called with the current account's id on each load of the members page. When the source does not answer `member`, it must return a query that looks a record up with `find`, such as an Active Record relation, because the member's page, giving or taking a role, and removing a member find the member with `find` on it.
168
+ - `invite(account_id:, name:, email:, invited_by:)` receives the current account's id, the name and email typed into the form, and the signed-in member who sent it. AccountAuthz sends no email, stores no invitation, and gives the invited person no role, so `invite` does what the host's invitation needs and the host builds the flow for accepting one. What `invite` stores is what `invitations` then lists. AccountAuthz does not check the name or email, and passes `nil` for one a request leaves out, so `invite` decides what to do with a missing, blank, malformed, or already used email. Rank does not limit inviting. AccountAuthz ignores what `invite` returns and does not catch an error it raises.
169
+ - `invitations(account_id)` returns the account's invitations that are still waiting for an answer. Each record it returns must respond to `name`, `email` and `to_param`. AccountAuthz filters nothing out of it, so an invitation the host has already accepted or expired stays on the page with both buttons until `invitations` stops returning it. It is called with the current account's id on each load of the members page. When the source does not answer `invitation`, it must return a query that looks a record up with `find`, such as an Active Record relation, because sending an invitation again and cancelling one find it with `find` on it.
170
+ - `resend_invitation(invitation)` receives one invitation found by its address and sends it again. AccountAuthz sends nothing itself and records nothing about the send. AccountAuthz ignores what it returns and does not catch an error it raises.
171
+ - `cancel_invitation(invitation)` withdraws one invitation found by its address, so that `invitations` no longer returns it. Ask the developer whether cancelling destroys the record or marks it withdrawn, because account_authz only stops listing what `invitations` stops returning. AccountAuthz ignores what it returns and does not catch an error it raises.
172
+ - `removable?(member)` returns false for a member nobody may remove, such as the account owner. It receives only the member, not the manager removing them. It is called on each load of the page of a member the viewer reaches, and again on each removal.
171
173
  - `remove(member)` takes the member off the account, so that `members` no longer returns them. Before it runs, account_authz takes away every role the member holds in the current account and leaves their roles in other accounts alone. When the member is a membership, `remove` normally destroys it, and that deletes nothing in any other account. When the person holds roles directly, destroying the record also deletes their roles in every other account, and the page checks for a remaining member manager only in the current account, so ask the developer whether `remove` destroys the person or only detaches them from the account. Taking away the roles and calling `remove` run in one database transaction, so a `remove` that raises leaves the roles in place when the host's records share account_authz's database.
172
174
 
173
- A give, take or removal for a member `members` does not return raises `ActiveRecord::RecordNotFound`. A give or take for a role outside the current account also raises `ActiveRecord::RecordNotFound` when the viewer reaches the member. A give or take for a member or role the viewer does not reach changes nothing and returns to the members page with a refusal message. So does a take when `lost_by_taking?` is true for that member and role. A removal does the same when the viewer does not reach the member, when `removable?` returns false, or when `lost_by_removing?` is true for that member. Step 13 lists which message each refusal shows. Sending again or cancelling an invitation `invitations` does not return raises `ActiveRecord::RecordNotFound`. Neither rank nor the last member manager limits the invitation buttons, so every member who can open the page may invite a person and send again or cancel any invitation the account is waiting on. An invite request that leaves out the name or the email fails with an error rather than a refusal message, because account_authz relies on the form requiring both. With no source set, every request to the page, the invite form and the buttons from a member who holds the capability raises an error.
175
+ The source may also answer three more methods. Ask the developer whether the host needs each one:
176
+
177
+ ```ruby
178
+ class AccountMembers
179
+ def self.member(account_id, address)
180
+ Membership.where(account_id: account_id).find_by!(code: address)
181
+ end
182
+
183
+ def self.invitation(account_id, address)
184
+ MembershipInvitation.waiting.where(account_id: account_id).find_by!(token: address)
185
+ end
186
+
187
+ def self.member_for(account_id:, person:)
188
+ Membership.find_by(account_id: account_id, person: person)
189
+ end
190
+ end
191
+ ```
192
+
193
+ - `member(account_id, address)` returns the member of that account at the address its `to_param` gives. Add it when members are addressed by a code or anything else `find` on `members` does not look up. When the source answers it, the member's page, giving or taking a role, and removing a member find the member only through it. AccountAuthz does not check that the record it returns belongs to the account, so it must return only a member of that account. It must raise `ActiveRecord::RecordNotFound` when no member is at the address, because account_authz does not handle `nil`.
194
+ - `invitation(account_id, address)` returns the waiting invitation of that account at the address its `to_param` gives. Add it when invitations are addressed by a token or anything else `find` on `invitations` does not look up. When the source answers it, sending an invitation again and cancelling one find the invitation only through it. AccountAuthz does not check that the record it returns belongs to the account or is still waiting, so it must return only such an invitation. It must raise `ActiveRecord::RecordNotFound` when no invitation is at the address, because account_authz does not handle `nil`.
195
+ - `member_for(account_id:, person:)` returns the membership the signed-in member holds in that account, or `nil`. Add it only when the host's signed-in member is the person rather than the membership that includes `AccountAuthz::Member`. When the source answers it, a give, take or removal measures the viewer's reach from the membership it returns, and from the signed-in member when it returns `nil`. The capability check that opens the pages, the buttons a member's page shows, and the `invited_by:` passed to `invite` still use the signed-in member as it is.
196
+
197
+ A member's page, give, take or removal for an address the member lookup does not find raises `ActiveRecord::RecordNotFound`. A give or take for a role address that is not the code of a role in the current account also raises `ActiveRecord::RecordNotFound` when the viewer reaches the member, and so does a role's id. A give or take for a member or role the viewer does not reach changes nothing and returns to the members page with a refusal message. So does a take when `lost_by_taking?` is true for that member and role. A removal does the same when the viewer does not reach the member, when `removable?` returns false, or when `lost_by_removing?` is true for that member. Step 13 lists which message each refusal shows. Sending again or cancelling an invitation at an address the invitation lookup does not find raises `ActiveRecord::RecordNotFound`. Neither rank nor the last member manager limits inviting or the invitation buttons, so every member who can open the members page may invite a person and send again or cancel any invitation the account is waiting on. An invite request that sends neither a name nor an email fails with an error rather than a refusal message. With no source set, every request to the members pages, the invite form and the buttons from a member who holds the capability raises an error, apart from opening the invite page.
174
198
 
175
- Ask the developer which capability opens the page: the default `:manage_members`, or a key the app already uses for managing its team. The same capability is required to invite, send an invitation again, cancel one, remove a member, and give or take a role. Set it as a symbol, because a string never matches a member's capabilities. Declare that key in the catalog as a permission, because otherwise no role can grant it and every request to the page gets 403. A request with no signed-in member or no current account also gets 403.
199
+ Ask the developer which capability opens the members page, a member's page and the invite page: the default `:manage_members`, or a key the app already uses for managing its team. The same capability is required to invite, send an invitation again, cancel one, remove a member, and give or take a role. Set it as a symbol, because a string never matches a member's capabilities. Declare that key in the catalog as a permission, because otherwise no role can grant it and every request to those pages gets 403. A request with no signed-in member or no current account also gets 403.
176
200
 
177
201
  Tell the developer that the page refuses only the change that removes the last holder of a role granting the members capability. While another member still holds such a role, a manager at the account's top rank can take such a role from, or remove, any member `removable?` allows, including themselves.
178
202
 
@@ -182,7 +206,7 @@ AccountAuthz is capability-based authorization for multi-tenant Rails apps. The
182
206
  AccountAuthz.roles_capability = :manage_team
183
207
  ```
184
208
 
185
- Ask the developer which capability opens the role pages: the default `:manage_roles`, or a key the app already uses for managing its team. It may be the same key as the members capability. Set it as a symbol, and declare it in the catalog as a permission, because otherwise no role can grant it and every request to the role pages gets 403. A request with no signed-in member or no current account also gets 403. Opening or saving the edit form for a role outside the current account raises `ActiveRecord::RecordNotFound`.
209
+ Ask the developer which capability opens the role pages: the default `:manage_roles`, or a key the app already uses for managing its team. It may be the same key as the members capability. Set it as a symbol, and declare it in the catalog as a permission, because otherwise no role can grant it and every request to the role pages gets 403. A request with no signed-in member or no current account also gets 403. Every role gets a random code when it is created, and the role pages address a role by that code, so a link to a role's edit form built from its id is not found. Opening or saving the edit form at an address that is not the code of a role in the current account raises `ActiveRecord::RecordNotFound`.
186
210
 
187
211
  The role pages limit what the editor, the member saving a form or pressing a template button, may do, using the same rules as `includes_role?`, `includes_rank?` and `includes_capabilities?` in step 7:
188
212
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: account_authz-info
3
- description: Use to learn what account_authz offers — its capability catalog, account roles and ranks, members as account memberships, reach limits on who may manage whom, the rule that every account keeps a member manager, the members and role pages with their invitations and refusal messages, and Pundit enforcement.
3
+ description: Use to learn what account_authz offers — its capability catalog, account roles and ranks, members as account memberships, reach limits on who may manage whom, the rule that every account keeps a member manager, the members, member, invite and role pages and the team section with their invitations and refusal messages, how roles, members and invitations are addressed, and Pundit enforcement.
4
4
  tools: Read
5
5
  scope: authorization — capability catalog, roles, and Pundit enforcement in multi-tenant Rails apps
6
6
  ---
@@ -23,20 +23,21 @@ bundling of those actions into roles is decided by each account. It is a Rails
23
23
  engine that stores roles and role assignments in its own tables, and it enforces
24
24
  through Pundit.
25
25
 
26
- It ships two sets of pages, drawn with keystone_ui inside the host's own layout.
27
- The role pages let a manager list, create and edit the account's roles, including
28
- each role's rank, limited to capabilities the manager holds, ranks up to their
29
- own, and roles ranked below them. The members page lets a manager invite a
30
- person, remove a member, and give a member a role or take one away, with removing
31
- and role changes limited to the members and roles ranked below the manager. It
32
- also lists the invitations still waiting for an answer, each with a button to
33
- send it again and one to cancel it. The members page links to the role pages and
34
- the role pages link back, so the host's navigation needs one link to reach both.
35
- Both sets of pages refuse any change that would leave the account with no member
36
- able to manage members, and tell the manager why a change was refused. The host
37
- app signs people in, sets which account a request belongs to, supplies the
38
- members and the waiting invitations the members page lists, sends the
39
- invitations, sends them again, cancels them, and carries out the removals.
26
+ It ships pages drawn with keystone_ui inside the host's own layout. The role
27
+ pages let a manager list, create and edit the account's roles, including each
28
+ role's rank, limited to capabilities the manager holds, ranks up to their own,
29
+ and roles ranked below them. The members page lists the account's members and
30
+ the invitations still waiting for an answer, with a button to send each
31
+ invitation again and one to cancel it, and an Invite button that opens a page
32
+ for inviting a person. Each member's name opens a member page, where a manager
33
+ gives the member a role, takes one away, or removes the member, limited to the
34
+ members and roles ranked below the manager. The members page links to the role
35
+ pages and the role pages link back, so the host's navigation needs one link to
36
+ reach both. Every page refuses any change that would leave the account with no
37
+ member able to manage members, and tells the manager why a change was refused.
38
+ The host app signs people in, sets which account a request belongs to, supplies
39
+ the members and the waiting invitations, sends the invitations, sends them
40
+ again, cancels them, and carries out the removals.
40
41
 
41
42
  ## Interface
42
43
 
@@ -53,8 +54,10 @@ two locals:
53
54
  - asking whether a manager's rank reaches a member or a role, whether an editor
54
55
  may put a set of capabilities or a rank on a role, and whether a change would
55
56
  remove the account's last member manager;
56
- - configuring the members page, including how it invites, lists the waiting
57
- invitations, sends one again, cancels one and removes, and the role pages;
57
+ - configuring the members source the members pages read from, including how it
58
+ invites, lists the waiting invitations, sends one again, cancels one,
59
+ removes, and finds a member or an invitation at an address;
60
+ - configuring the role pages;
58
61
  - rewording the refusal messages.
59
62
 
60
63
  ## How to use it
@@ -62,14 +65,17 @@ two locals:
62
65
  Decide which of the two you need, then go there.
63
66
 
64
67
  - AccountAuthz is not yet in the app, a model or controller is not yet connected to
65
- it, the engine is not yet mounted, or the migration that adds ranks has not
66
- been installed after an update: use **account_authz-install**.
68
+ it, or the engine is not yet mounted: use **account_authz-install**.
69
+ - An update added a migration, such as the one that adds ranks or the one that
70
+ gives every role a code, and it has not been installed: use
71
+ **account_authz-install**.
67
72
  - You are choosing which of the host's models holds roles: use
68
73
  **account_authz-install**.
69
74
  - AccountAuthz is connected and you are adding a capability, a role, a template, or a
70
75
  check on an action: use **account_authz-develop**.
71
- - AccountAuthz is connected and you are setting up the members page, its invitations
72
- and removals, or the role pages: use **account_authz-develop**.
76
+ - AccountAuthz is connected and you are setting up the members pages, their
77
+ invitations and removals, how members and invitations are found by address,
78
+ or the role pages: use **account_authz-develop**.
73
79
  - AccountAuthz is connected and you are adding a check in the host's own screens on
74
80
  who may manage whom, on which capabilities and ranks an editor may set, or on
75
81
  keeping a member manager, or changing the wording of a refusal message: use
@@ -85,6 +91,14 @@ Decide which of the two you need, then go there.
85
91
  - **Role** — a named set of capabilities belonging to one account, stored as
86
92
  data. A role with no name, or naming a capability the catalog does not
87
93
  contain, fails validation.
94
+ - **Role code** — a random code every role carries, given when the role is
95
+ created. Roles made before the code existed get one when the migration that
96
+ adds it runs.
97
+ - **Address** — what a page link or a submitted form uses to name a record. A
98
+ role is addressed by its code, so an address that names a role by its number
99
+ is not found. A member or an invitation is addressed by whatever address the
100
+ host's record gives, which is its number unless the host's model says
101
+ otherwise. Role assignments still point at members and roles by number.
88
102
  - **Rank** — a whole number on each role, 0 unless set, where a higher number
89
103
  ranks higher. Ranks compare only within one account.
90
104
  - **Template** — a role definition written in code, used to create a role in an
@@ -157,66 +171,80 @@ Decide which of the two you need, then go there.
157
171
  the button does not check whether the account already has a role of that name.
158
172
  - **Members page** — the engine's page for the current account's members. It
159
173
  lists each member's name, email and the roles they hold in that account only,
160
- under a form for inviting a person. Between that form and the members it lists
161
- the account's invitations still waiting. Beside the page title is a Roles link
162
- to the role pages, shown only to a viewer who holds the roles capability in the
163
- current account. Beside each member the viewer reaches is a Give button for
164
- every role within reach the member does not hold, and a Take button for every
165
- role within reach they do. It also shows a Remove button when the host says
166
- that member may be removed. A Take or Remove button that would break the last
167
- member manager rule is not shown. A member outside the viewer's reach shows no
168
- buttons.
174
+ and each name opens that member's member page. Beside the list is an Invite
175
+ button that opens the invite page. Below the members it lists the account's
176
+ waiting invitations. Beside the page title is a Roles link to the role pages,
177
+ shown only to a viewer who holds the roles capability in the current account.
178
+ - **Member page** — the engine's page for one member of the current account,
179
+ showing their name, email and the roles they hold there. When the viewer
180
+ reaches the member, it shows a Give button for every role within reach the
181
+ member does not hold, and a Take button for every role within reach they do.
182
+ It also shows a Remove button when the host says that member may be removed. A
183
+ Take or Remove button that would break the last member manager rule is not
184
+ shown. A member outside the viewer's reach shows no buttons.
169
185
  - **Giving and taking** — assigning a role to a member, or removing one, from the
170
- members page. Only members and roles of the current account, and within the
171
- viewer's reach, can be given or taken, and either action returns to the
172
- members page.
173
- - **Inviting** — sending an invitation from the members page with a name and an
186
+ member page or the team section. Only members and roles of the current
187
+ account, and within the viewer's reach, can be given or taken, and from the
188
+ member page either action returns to the members page.
189
+ - **Inviting** — sending an invitation from the invite page with a name and an
174
190
  email, both required. AccountAuthz passes the current account, the name, the email
175
191
  and the inviting member to the host. The host sends its own invitation and
176
192
  decides when the person becomes a member. Rank does not limit inviting, and the
177
193
  action returns to the members page.
178
194
  - **Waiting invitation** — an invitation the host says is still waiting for an
179
- answer. Each one shows the name and email it was sent to, an Invited badge, a
180
- Send again button and a Cancel button. Sending again asks the host to send that
181
- invitation once more, and cancelling asks the host to withdraw it. Only the
182
- current account's waiting invitations can be sent again or cancelled, rank does
183
- not limit either, and both actions return to the members page.
184
- - **Removing** — taking a member off the current account from the members page.
185
- AccountAuthz first takes away every role the member holds in the current account,
186
- then asks the host to remove the member, both in one database transaction.
187
- Roles the member holds in other accounts are left as they are. The member must
188
- be within the viewer's reach, and the host decides whether a member may be
189
- removed at all, such as refusing the account owner. A top-rank manager reaches
190
- themselves, so they can remove themselves unless the host refuses it or they
191
- are the last member manager.
195
+ answer. Each one shows the name and email it was sent to, a Send again button
196
+ and a Cancel button. Sending again asks the host to send that invitation once
197
+ more, and cancelling asks the host to withdraw it. Only the current account's
198
+ waiting invitations can be sent again or cancelled, rank does not limit
199
+ either, and both actions return to the members page.
200
+ - **Removing** — taking a member off the current account from the member page or
201
+ the team section. AccountAuthz first takes away every role the member holds in the
202
+ current account, then asks the host to remove the member, both in one database
203
+ transaction. Roles the member holds in other accounts are left as they are.
204
+ The member must be within the viewer's reach, and the host decides whether a
205
+ member may be removed at all, such as refusing the account owner. A top-rank
206
+ manager reaches themselves, so they can remove themselves unless the host
207
+ refuses it or they are the last member manager.
208
+ - **Team section** — the members list, the chosen member's Give, Take and Remove
209
+ buttons, an invite form and the waiting invitations, drawn as sections with no
210
+ page heading of its own so the host can place them inside one of its own
211
+ pages. Choosing a member's name adds that member's address to the page's own
212
+ link. The host gives the section the person, the account, the chosen member
213
+ and the address each form submits to, and handles what is submitted there.
192
214
  - **Host layout** — every engine page is built from keystone_ui components and
193
215
  shown inside the layout the host's own controllers use, so the host loads
194
216
  keystone_ui's styles. Links in that layout to the host's own pages work on the
195
217
  engine pages without change.
196
- - **Members source** — what the host supplies for the members page. It lists
197
- which members belong to an account, sends an invitation, lists the invitations
198
- still waiting, sends one again, cancels one, says whether a member may be
199
- removed, and removes a member.
218
+ - **Members source** — what the host supplies for the members pages and the team
219
+ section. It lists which members belong to an account, sends an invitation,
220
+ lists the invitations still waiting, sends one again, cancels one, says
221
+ whether a member may be removed, and removes a member. It may also find the
222
+ member at an address and the invitation at an address, which is how an app
223
+ whose members carry codes, or whose invitations are addressed by a token, has
224
+ them found. A source that does neither has members and invitations found by
225
+ number within the lists it gives.
200
226
  - **Members capability** — the capability a person needs to open the members
201
- page and to invite, send an invitation again, cancel one, remove, give or take
202
- roles there, `manage_members` unless the app names another.
227
+ page, the member page and the invite page, and to invite, send an invitation
228
+ again, cancel one, remove, give or take roles there, `manage_members` unless
229
+ the app names another.
203
230
  - **Roles capability** — the capability a person needs to open the role pages and
204
231
  to create or change roles there, `manage_roles` unless the app names another.
205
232
  - **Forbidden** — a person without the page's capability, or a request with no
206
233
  current account, gets a forbidden response from any engine page. Another
207
234
  account's roles, members and waiting invitations cannot be read or changed from
208
- these pages.
235
+ these pages, and an address naming one is not found.
209
236
  - **Refusal message** — what a manager sees when they may use the page but not
210
237
  make a particular change. AccountAuthz changes nothing and sends them back with a
211
238
  flash alert saying why. Seven reasons have a message: a role outside the
212
239
  manager's reach to give or take, a member outside the manager's reach, a
213
240
  member the host says may not be removed, a change that breaks the last member
214
241
  manager rule, a capability the editor does not hold, a rank above the editor's
215
- own, and a role the editor may not change. A give, take or removal returns to
216
- the members page. Adding from a template, and saving a role the editor may not
217
- change, return to the role list. A refused new role returns to an empty new
218
- role form, and any other refused edit returns to the role's edit form showing
219
- the role as it was saved. The message shows only when the host's layout renders
220
- flash alerts, and the app can reword each one in its locale files.
242
+ own, and a role the editor may not change. A give, take or removal from the
243
+ member page returns to the members page. Adding from a template, and saving a
244
+ role the editor may not change, return to the role list. A refused new role
245
+ returns to an empty new role form, and any other refused edit returns to the
246
+ role's edit form showing the role as it was saved. The message shows only when
247
+ the host's layout renders flash alerts, and the app can reword each one in its
248
+ locale files.
221
249
  - The rule the gem follows: capabilities are code, roles are data, and Pundit
222
250
  enforces.
@@ -11,12 +11,12 @@ You follow the steps below exactly, in order, and invent none. Where a step says
11
11
  A Rails engine for capability-based authorization in multi-tenant apps, hooked in when a host needs account-scoped roles enforced through Pundit.
12
12
 
13
13
  ## Interface
14
- - `gem "account_authz"` — adds the engine to the host's Gemfile, and brings in `rails`, `pundit` and `keystone_ui` as dependencies.
15
- - `bin/rails account_authz:install:migrations` — copies the engine's three migrations into the host's `db/migrate/`.
14
+ - `gem "account_authz"` — adds the engine to the host's Gemfile, brings in `rails`, `pundit`, `public_code` and `keystone_ui` as dependencies, and holds `json` below version 3.
15
+ - `bin/rails account_authz:install:migrations` — copies the engine's six migrations into the host's `db/migrate/`.
16
16
  - `AccountAuthz::Member` — a model concern included in the host model that holds roles, normally the record for a person inside one account, giving it role assignments.
17
17
  - `AccountAuthz::Authorization` — a controller concern that includes `Pundit::Authorization` and adds a `can?(capability)` helper to controllers and views.
18
18
  - `AccountAuthz::Current.account_id` — the per-request account that the `can?` helper scopes its answer to.
19
- - `mount AccountAuthz::Engine` — a line in the host's `config/routes.rb` that serves two sets of pages for the current account. The members page lists each member with their name, email and roles, gives or takes away roles ranked below the viewer's own, invites a person by name and email, lists the invitations still waiting with a button to send each again and one to cancel it, and removes a member from the account. The role pages list the account's roles, create a role, add a role from a default template, and change a role's name, rank or capabilities, limited to roles ranked below the viewer unless the viewer holds the top rank, ranks up to the viewer's own and capabilities the viewer holds. The members page links to the roles page for a viewer who holds the roles capability, and the roles page links back to the members page. All of them are built from keystone_ui components and shown inside the host's own layout.
19
+ - `mount AccountAuthz::Engine` — a line in the host's `config/routes.rb` that serves the members pages and the role pages for the current account. The members page lists each member with their name, email and roles, links each name to that member's page, opens an invite page that invites a person by name and email, and lists the invitations still waiting with a button to send each again and one to cancel it. A member's page shows the roles they hold, gives or takes away roles ranked below the viewer's own, and removes the member from the account. The role pages list the account's roles, create a role, add a role from a default template, and change a role's name, rank or capabilities, limited to roles ranked below the viewer unless the viewer holds the top rank, ranks up to the viewer's own and capabilities the viewer holds. The members page links to the roles page for a viewer who holds the roles capability, and the roles page links back to the members page. All of them are built from keystone_ui components and shown inside the host's own layout.
20
20
 
21
21
  ## How to use it
22
22
  1. Confirm the host runs Ruby 3.2 or later and Rails 7.1 or later.
@@ -24,13 +24,14 @@ A Rails engine for capability-based authorization in multi-tenant apps, hooked i
24
24
  - RubyGems: `gem "account_authz"`
25
25
  - Git: `gem "account_authz", github: "DYB-Development/account_authz", branch: "main"`
26
26
 
27
- Add the chosen line to `Gemfile`, then run `bundle install`. This updates `Gemfile.lock`.
28
- 3. Run `bin/rails account_authz:install:migrations`. It copies three files into `db/migrate/`, named `<timestamp>_create_account_authz_roles.account_authz.rb`, `<timestamp>_create_account_authz_assignments.account_authz.rb` and `<timestamp>_add_rank_to_account_authz_roles.account_authz.rb`.
29
- - `account_authz_roles` has `account_id` (bigint, required, indexed), `name` (string, required), `capabilities` (json, default empty array), and `rank` (integer, required, default 0).
27
+ Add the chosen line to `Gemfile`, then run `bundle install`. This updates `Gemfile.lock`. account_authz requires `json` below version 3, so if the host's `Gemfile.lock` holds `json` 3 or later, `bundle install` fails to resolve. In that case, tell the developer and ask whether to run `bundle update json`.
28
+ 3. Run `bin/rails account_authz:install:migrations`. It copies six files into `db/migrate/`, each named `<timestamp>_<name>.account_authz.rb`, with these names in this order: `create_citizen_roles`, `create_citizen_assignments`, `add_rank_to_citizen_roles`, `rename_citizen_tables_to_account_authz`, `rename_citizen_member_index` and `add_public_code_to_account_authz_roles`. The first five create two tables under older names and rename them, so after all six run the host has these two tables.
29
+ - `account_authz_roles` has `account_id` (bigint, required, indexed), `name` (string, required), `capabilities` (json, default empty array), `rank` (integer, required, default 0), and `public_code` (string, required, unique index).
30
30
  - `account_authz_assignments` has a polymorphic `member` reference, a `role` reference, and a unique index on member and role together.
31
31
  - Neither table has a foreign key to an accounts table, so the host needs no account model for the migrations to run.
32
32
  - AccountAuthz stores no invitations of its own, so no table is copied for them.
33
- 4. Open the three copied files. They declare `ActiveRecord::Migration[8.1]`. If the host runs a Rails version older than 8.1, change `[8.1]` in all three files to the host's Rails version, such as `[7.2]`.
33
+ - The last migration gives every role that already exists a code. It needs no setup in the host, because the `public_code` gem is loaded with account_authz.
34
+ 4. Open the six copied files. They declare `ActiveRecord::Migration[8.1]`. If the host runs a Rails version older than 8.1, change `[8.1]` in all six files to the host's Rails version, such as `[7.2]`.
34
35
  5. Run `bin/rails db:migrate`. This updates `db/schema.rb` or `db/structure.sql`.
35
36
  6. Choose the model that holds roles. A role belongs to one account, so the model to include `AccountAuthz::Member` in is the record for a person inside one account, such as `Membership`, not the person record, such as `User`.
36
37
  - Look in `app/models/` for a model that joins a person to an account. If there is one, confirm with the developer that it is the record to use.
@@ -40,29 +41,33 @@ A Rails engine for capability-based authorization in multi-tenant apps, hooked i
40
41
  8. `AccountAuthz::Authorization` calls `current_member` on the controller, and its `can?` helper returns `false` when that returns `nil`. Check whether `ApplicationController` already defines `current_member`. If it does not, ask the developer how the member record for the signed-in request is found, and define `current_member` in `ApplicationController` to return that record. With a membership model, that is the signed-in person's membership in the current account from step 10, so `current_member` must look it up after the current account is set.
41
42
  9. Pundit passes `pundit_user` to every policy, and `pundit_user` returns `current_user` unless the host overrides it. If the model from step 6 is not the model `current_user` returns, define `pundit_user` in `ApplicationController` to return `current_member`.
42
43
  10. Ask the developer how the current account is found for a request, such as from the subdomain, the signed-in user, or a URL parameter. Add a `before_action` to `ApplicationController` that sets `AccountAuthz::Current.account_id` to that account's integer id. Rails resets it at the end of every request.
43
- 11. Ask the developer whether they want account_authz's pages. The one mount serves both the members page and the role pages, so the host gets both or neither. If they do not want them, skip this step and steps 12 and 13. If they do, ask which path to serve them under, offering `/account_authz`, and add `mount AccountAuthz::Engine => "/account_authz"` with that path to `config/routes.rb`. The members page is then at that path followed by `/members`, and the roles page at that path followed by `/roles`.
44
+ 11. Ask the developer whether they want account_authz's pages. The one mount serves both the members pages and the role pages, so the host gets both or neither. If they do not want them, skip this step and steps 12 and 13. If they do, ask which path to serve them under, offering `/account_authz`, and add `mount AccountAuthz::Engine => "/account_authz"` with that path to `config/routes.rb`. The members page is then at that path followed by `/members`, and the roles page at that path followed by `/roles`.
44
45
  - The engine's controllers inherit the host's `ApplicationController`, so the sign-in, `current_member`, current account and CSRF protection from the host apply to every page and every form on them.
45
- - The pages render inside the layout the host's `ApplicationController` uses, such as `app/views/layouts/application.html.erb`. Route helpers in that layout, such as `root_path`, reach the host's own routes on the pages without a `main_app.` prefix, so the layout needs no change for them.
46
+ - The pages render inside the layout the host's `ApplicationController` uses, such as `app/views/layouts/application.html.erb`. Route helpers in that layout, such as `root_path`, reach the host's own routes on the pages without a `main_app.` prefix. The exception is a helper whose name the engine also defines: `members_path`, `member_path`, `member_roles_path`, `member_role_path`, `roles_path`, `new_role_path`, `edit_role_path`, `role_path`, `invitations_path`, `new_invitation_path`, `invitation_path` and `resend_invitation_path`, and their `_url` forms. On the engine's pages these reach the engine's routes. Check whether the layout calls any of them. If it does, tell the developer and change each call to start with `main_app.`, such as `main_app.members_path`.
46
47
  - The pages are built from keystone_ui components and ship no stylesheet of their own, so that layout must load keystone_ui's styles. Check whether the host already loads them. If it does not and the host has a keystone_ui install local, hand that step to it. Otherwise ask the developer how the host loads keystone_ui's styles before continuing.
47
48
  - A change a manager may not make sends them back to a page with a flash alert saying why, and the engine's pages do not show flash messages themselves. Check whether that layout renders `flash[:alert]`. If it does not, tell the developer that refused changes will return to the page with no message, and ask where in the layout to render the alert.
48
49
  - The members page shows a `Roles` link only to a viewer who holds the roles capability in the current account, and the roles page shows a `Members` link to every viewer. A member who holds the roles capability but not the members capability gets a 403 response on the members page, so that member reaches the roles page only through a link straight to it. Ask the developer whether the host's navigation should link to the members page, the roles page, or both, and where to put each link.
49
50
  - A request with no current account gets a 403 response on every page and every form.
50
- 12. Check the members page against these facts, and tell the developer about any that the host does not meet.
51
- - A member who does not hold the members capability in the current account gets a 403 response, whether they load the page, give or take a role, invite a person, send an invitation again, cancel one, or remove a member. The capability is `manage_members` unless the develop local changes it.
52
- - Every member record the page lists must respond to `name` and `email` and include `AccountAuthz::Member`. If the model from step 6 lacks `name` or `email`, as a membership model often does, ask the developer how it should answer them, such as from the person record it belongs to, before continuing.
53
- - Every waiting invitation the page lists must respond to `name` and `email`, and must be findable by its id among the current account's invitations. It needs no account_authz concern and no account_authz table.
51
+ 12. Check the members pages against these facts, and tell the developer about any that the host does not meet.
52
+ - A member who does not hold the members capability in the current account gets a 403 response, whether they load the members page, a member's page or the invite page, give or take a role, invite a person, send an invitation again, cancel one, or remove a member. The capability is `manage_members` unless the develop local changes it.
53
+ - Every member record the members page lists must respond to `name` and `email` and include `AccountAuthz::Member`. If the model from step 6 lacks `name` or `email`, as a membership model often does, ask the developer how it should answer them, such as from the person record it belongs to, before continuing.
54
+ - Each member's name links to their page at an address made from the record's `to_param`, which is its id unless the model changes it. That address must find the same record among the current account's members, either by `find` or through a member lookup the develop local configures.
55
+ - Every waiting invitation the members page lists must respond to `name` and `email`. Its `Send again` and `Cancel` buttons send the record's `to_param`, which must find the same invitation among the current account's invitations, either by `find` or through an invitation lookup the develop local configures. An invitation needs no account_authz concern and no account_authz table.
54
56
  - The engine sends no email and stores no invitation. Inviting a person, sending an invitation again, and cancelling one each call the host's own code, and accepting an invitation is the host's own flow.
55
- - The invite form has a required `Name` field, a required `Email` field and an `Invite` button, and the engine does not validate either value, so the host's invite decides what to do with one it rejects.
57
+ - The members page's `Invite` link opens the invite page, which has a required `Name` field, a required `Email` field and an `Invite` button. The engine does not validate either value, so the host's invite decides what to do with one it rejects. Inviting, sending again and cancelling each return to the members page.
58
+ - A member's page shows a `Take <role>` button for each role they hold and a `Give <role>` button for each role they do not hold, both only for roles ranked below the viewer's own, and a `Remove from team` button. Each returns to the members page.
59
+ - Roles are addressed by their code, not their id, so a link or form that names a role by its number gets a not-found error.
56
60
  - Giving or taking a role, removing a member, sending an invitation again and cancelling one find the record only among the current account's members, roles or invitations, so a record from another account raises a not-found error, which Rails answers with a 404 response outside development.
57
61
  - A refused give, take or removal changes nothing and returns to the members page with a flash alert naming the reason, drawn from these four: `You can only give or take roles ranked below your own.`, `You can only change members ranked below you.`, `Someone else needs to be able to manage members first.` and `This member can't be removed from the account.`
58
62
  - Sending an invitation again and cancelling one are not limited by rank, so every viewer who holds the members capability may do both to every waiting invitation.
59
- - The account always keeps at least one member who holds the members capability, so a `Take <role>` or `Remove` button is left out when that member is the only way anyone in the current account holds it.
63
+ - The account always keeps at least one member who holds the members capability, so a `Take <role>` or `Remove from team` button is left out when that member is the only way anyone in the current account holds it.
60
64
  - Every role starts at rank 0, so until ranks are set, every viewer who holds a role reaches every member and every role. Tell the developer this, and that a role's rank is set on the role form.
61
65
  - Removing a member takes away every role they hold in the current account and then calls the host's remove, both in one database transaction.
62
- - The page does not know which records to list, which invitations are waiting, how to invite a person, how to send an invitation again, how to cancel one, which members cannot be removed, or how to remove one until the develop local configures that. Until then, a request from a member who holds the members capability raises an error on the page, on every button and on the invite form.
66
+ - The members pages do not know which records to list, which invitations are waiting, how to invite a person, how to send an invitation again, how to cancel one, which members cannot be removed, or how to remove one until the develop local configures that. Until then, a request from a member who holds the members capability raises an error on the members page, on a member's page, on every button and on the invite form.
63
67
  13. Check the role pages against these facts, and tell the developer about any that the host does not meet.
64
68
  - A member who does not hold the roles capability in the current account gets a 403 response on every role page and form. The capability is `manage_roles` unless the develop local changes it.
65
69
  - The roles page lists each role in the current account by name with its number of capabilities, and each name links to that role's edit form. A `New role` link opens the form for a new role.
70
+ - A role's edit form is addressed by the role's code, so an address that names the role by its number gets a not-found error.
66
71
  - The new and edit forms take a name, a rank as a number field with a minimum of 0, and a checkbox for each capability in the catalog, and saving returns to the roles page. Until the develop local declares the catalog, the forms show no checkboxes.
67
72
  - The roles page shows an `Add <template>` button for each default template, which creates that role in the current account at rank 0. The section is left out until the develop local declares a default template.
68
73
  - A refused create or save changes nothing and returns to the form or the roles page with a flash alert naming the reason, drawn from these four: `You can only change roles ranked below your own.`, `You can only set a rank up to your own.`, `You can only give a role capabilities you have yourself.` and `Someone else needs to be able to manage members first.`
@@ -70,11 +75,12 @@ A Rails engine for capability-based authorization in multi-tenant apps, hooked i
70
75
  - Saving a role with a blank name raises a validation error, which Rails answers with a 422 response outside development.
71
76
 
72
77
  ## Conventions
73
- - After step 5, confirm the schema file contains both `account_authz_roles` and `account_authz_assignments`, and that `account_authz_roles` has a `rank` column.
78
+ - After step 5, confirm the schema file contains both `account_authz_roles` and `account_authz_assignments`, that `account_authz_roles` has a `rank` column and a `public_code` column, and that no table named `citizen_roles` or `citizen_assignments` remains.
74
79
  - After step 10, confirm the host boots with `bin/rails runner 'puts AccountAuthz::Current.account_id.inspect'`, which prints `nil` outside a request.
75
- - After step 11, confirm `bin/rails routes` lists the mount and, under the routes for `AccountAuthz::Engine`, a `GET` route for `members`, a `DELETE` route for `members/:id`, a `POST` route for `members/:member_id/roles`, a `DELETE` route for `members/:member_id/roles/:id`, a `POST` route for `invitations`, a `POST` route for `invitations/:id/resend`, a `DELETE` route for `invitations/:id`, `GET` routes for `roles`, `roles/new` and `roles/:id/edit`, a `POST` route for `roles`, and `PATCH` and `PUT` routes for `roles/:id`.
76
- - After step 12, once the develop local has configured the members page, load it as a member who holds the members capability and confirm it shows the invite form and any waiting invitations inside the host's layout with keystone_ui's styles applied.
77
- - After step 13, load the roles page as a member who holds the roles capability and confirm it shows inside the host's layout with keystone_ui's styles applied.
78
- - After updating the account_authz gem, run `bin/rails account_authz:install:migrations` again, then `bin/rails db:migrate`. The task copies only migrations the host does not already have. A host that installed account_authz before ranks existed gets the rank migration this way, and every existing role gets rank 0.
79
- - Declaring capabilities, declaring templates, seeding roles for a new account, giving the first member the members or roles capability, writing policies, checking reach in host code, checking in host code whether a change would leave the account with no member who holds the members capability, configuring which members and which waiting invitations the members page lists, how it invites a person, sends an invitation again and cancels one, which members cannot be removed and how it removes one, configuring which capabilities open the pages, and rewording the refusal messages are out of scope for this local. Hand that work to the account_authz-develop local.
80
- - The members page and the role pages are the only screens account_authz adds. Deleting a role is not offered on them, and a screen for it belongs to the host. Sending an invitation and accepting one also belong to the host.
80
+ - After step 11, confirm `bin/rails routes` lists the mount and, under the routes for `AccountAuthz::Engine`, `GET` routes for `members` and `members/:id`, a `DELETE` route for `members/:id`, a `POST` route for `members/:member_id/roles`, a `DELETE` route for `members/:member_id/roles/:id`, a `GET` route for `invitations/new`, a `POST` route for `invitations`, a `POST` route for `invitations/:id/resend`, a `DELETE` route for `invitations/:id`, `GET` routes for `roles`, `roles/new` and `roles/:id/edit`, a `POST` route for `roles`, and `PATCH` and `PUT` routes for `roles/:id`.
81
+ - After step 12, once the develop local has configured the members pages, load the members page as a member who holds the members capability and confirm it shows the members and any waiting invitations inside the host's layout with keystone_ui's styles applied. Then open a member's page from their name and the invite page from `Invite`, and confirm both load.
82
+ - After step 13, load the roles page as a member who holds the roles capability, confirm it shows inside the host's layout with keystone_ui's styles applied, and open a role's edit form from its name.
83
+ - After updating the account_authz gem, run `bin/rails account_authz:install:migrations` again, then `bin/rails db:migrate`. The task copies only migrations the host does not already have. A host that installed account_authz before ranks existed gets the rank migration this way, and every existing role gets rank 0. A host that installed it before role codes existed gets the code migration this way, and every existing role gets a code. Apply step 4 to every newly copied file.
84
+ - After updating the account_authz gem to the version that addresses roles by code, search the host for links or forms that name a role by its id on the engine's routes, such as `edit_role_path(role.id)` or a `role_id` field holding `role.id`, and tell the developer about each one, because those now get a not-found error.
85
+ - Declaring capabilities, declaring templates, seeding roles for a new account, giving the first member the members or roles capability, writing policies, checking reach in host code, checking in host code whether a change would leave the account with no member who holds the members capability, configuring which members and which waiting invitations the members pages list, how a member or an invitation is found at its address, how the pages invite a person, send an invitation again and cancel one, which members cannot be removed and how one is removed, configuring which capabilities open the pages, and rewording the refusal messages are out of scope for this local. Hand that work to the account_authz-develop local.
86
+ - The members pages, the invite page and the role pages are the only screens account_authz adds. Deleting a role is not offered on them, and a screen for it belongs to the host. Sending an invitation and accepting one also belong to the host.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: account_authz
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - tylercschneider
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
53
  version: '2.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: public_code
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.2'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.2'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: keystone_ui
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -119,6 +133,7 @@ files:
119
133
  - db/migrate/20260916000001_add_rank_to_citizen_roles.rb
120
134
  - db/migrate/20260921220000_rename_citizen_tables_to_account_authz.rb
121
135
  - db/migrate/20260922150000_rename_citizen_member_index.rb
136
+ - db/migrate/20261012230000_add_public_code_to_account_authz_roles.rb
122
137
  - lib/account_authz.rb
123
138
  - lib/account_authz/catalog.rb
124
139
  - lib/account_authz/engine.rb