account_authz 0.1.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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +45 -0
  4. data/Rakefile +11 -0
  5. data/app/controllers/account_authz/application_controller.rb +19 -0
  6. data/app/controllers/account_authz/invitations_controller.rb +30 -0
  7. data/app/controllers/account_authz/member_roles_controller.rb +27 -0
  8. data/app/controllers/account_authz/members_controller.rb +38 -0
  9. data/app/controllers/account_authz/roles_controller.rb +56 -0
  10. data/app/controllers/concerns/account_authz/authorization.rb +18 -0
  11. data/app/helpers/account_authz/app_routes_helper.rb +12 -0
  12. data/app/helpers/account_authz/application_helper.rb +4 -0
  13. data/app/models/account_authz/account_id.rb +9 -0
  14. data/app/models/account_authz/acting_member.rb +11 -0
  15. data/app/models/account_authz/application_record.rb +5 -0
  16. data/app/models/account_authz/assignment.rb +8 -0
  17. data/app/models/account_authz/cancel_invitation.rb +23 -0
  18. data/app/models/account_authz/current.rb +7 -0
  19. data/app/models/account_authz/give_role.rb +34 -0
  20. data/app/models/account_authz/invite.rb +17 -0
  21. data/app/models/account_authz/last_manager.rb +43 -0
  22. data/app/models/account_authz/reach.rb +46 -0
  23. data/app/models/account_authz/remove_member.rb +34 -0
  24. data/app/models/account_authz/resend_invitation.rb +23 -0
  25. data/app/models/account_authz/result.rb +24 -0
  26. data/app/models/account_authz/role.rb +24 -0
  27. data/app/models/account_authz/take_role.rb +35 -0
  28. data/app/models/concerns/account_authz/member.rb +33 -0
  29. data/app/policies/account_authz/application_policy.rb +16 -0
  30. data/app/views/account_authz/invitations/new.html.erb +10 -0
  31. data/app/views/account_authz/members/_team.html.erb +84 -0
  32. data/app/views/account_authz/members/index.html.erb +59 -0
  33. data/app/views/account_authz/members/show.html.erb +38 -0
  34. data/app/views/account_authz/roles/_form.html.erb +16 -0
  35. data/app/views/account_authz/roles/edit.html.erb +4 -0
  36. data/app/views/account_authz/roles/index.html.erb +21 -0
  37. data/app/views/account_authz/roles/new.html.erb +4 -0
  38. data/config/locales/en.yml +10 -0
  39. data/config/routes.rb +9 -0
  40. data/db/migrate/20260607000001_create_citizen_roles.rb +15 -0
  41. data/db/migrate/20260607000002_create_citizen_assignments.rb +15 -0
  42. data/db/migrate/20260916000001_add_rank_to_citizen_roles.rb +7 -0
  43. data/db/migrate/20260921220000_rename_citizen_tables_to_account_authz.rb +9 -0
  44. data/lib/account_authz/catalog.rb +24 -0
  45. data/lib/account_authz/engine.rb +7 -0
  46. data/lib/account_authz/reference/guide.md +227 -0
  47. data/lib/account_authz/reference.rb +17 -0
  48. data/lib/account_authz/templates.rb +27 -0
  49. data/lib/account_authz/version.rb +5 -0
  50. data/lib/account_authz.rb +59 -0
  51. data/lib/tasks/account_authz_tasks.rake +4 -0
  52. data/the_local/agents/account_authz-develop.md +252 -0
  53. data/the_local/agents/account_authz-info.md +222 -0
  54. data/the_local/agents/account_authz-install.md +80 -0
  55. data/the_local/interface.yml +71 -0
  56. metadata +160 -0
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccountAuthz
4
+ class Templates
5
+ Template = Struct.new(:name, :capabilities, :default, keyword_init: true) do
6
+ def role_name
7
+ name.to_s.titleize
8
+ end
9
+ end
10
+
11
+ def initialize
12
+ @templates = []
13
+ end
14
+
15
+ def template(name, capabilities:, default: false)
16
+ @templates << Template.new(name: name, capabilities: capabilities, default: default)
17
+ end
18
+
19
+ def find(name)
20
+ @templates.find { |template| template.name == name }
21
+ end
22
+
23
+ def defaults
24
+ @templates.select(&:default)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccountAuthz
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "account_authz/version"
4
+ require_relative "account_authz/engine"
5
+ require_relative "account_authz/catalog"
6
+ require_relative "account_authz/templates"
7
+
8
+ module AccountAuthz
9
+ class Error < StandardError; end
10
+
11
+ class << self
12
+ attr_accessor :members_source
13
+ attr_writer :members_capability, :roles_capability
14
+
15
+ def members_capability
16
+ @members_capability || :manage_members
17
+ end
18
+
19
+ def roles_capability
20
+ @roles_capability || :manage_roles
21
+ end
22
+ end
23
+
24
+ def self.catalog(&block)
25
+ @catalog ||= Catalog.new
26
+ @catalog.instance_eval(&block) if block
27
+ @catalog
28
+ end
29
+
30
+ def self.templates(&block)
31
+ @templates ||= Templates.new
32
+ @templates.instance_eval(&block) if block
33
+ @templates
34
+ end
35
+
36
+ def self.seed_default_roles(account_id)
37
+ templates.defaults.reject { |template| Role.in_account(account_id).exists?(name: template.role_name) }
38
+ .map { |template| Role.from_template(account_id: account_id, template: template.name) }
39
+ end
40
+
41
+ def self.reset!
42
+ @catalog = nil
43
+ @templates = nil
44
+ @members_capability = nil
45
+ @roles_capability = nil
46
+ end
47
+
48
+ def self.capabilities
49
+ catalog.capabilities
50
+ end
51
+
52
+ def self.can?(grants, capability)
53
+ grants.include?(capability)
54
+ end
55
+
56
+ def self.approved_metrics(grants)
57
+ catalog.metrics & grants
58
+ end
59
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :account_authz do
3
+ # # Task goes here
4
+ # end
@@ -0,0 +1,252 @@
1
+ ---
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.
4
+ tools: Read, Write, Edit, Grep
5
+ scope: authorization — capability catalog, roles, and Pundit enforcement in multi-tenant Rails apps
6
+ ---
7
+
8
+ You implement authorization in a host app that already has account_authz installed, using only the entry points below. You always keep capability keys in the catalog, keep roles as database records, gate actions through policies that inherit `AccountAuthz::ApplicationPolicy`, decide who may change whose roles and what rank and capabilities an editor may give a role through `AccountAuthz::Reach`, keep an account's last member manager through `AccountAuthz::LastManager`, and word refusals through the `account_authz.refusals` locale keys.
9
+
10
+ ## What account_authz is
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.
13
+
14
+ ## Interface
15
+
16
+ - `AccountAuthz.catalog` — with a block, declares capability keys using `permission :key` and `metric :key`, and without a block returns the catalog.
17
+ - `AccountAuthz.catalog.permissions` — the declared permission keys, in declaration order.
18
+ - `AccountAuthz.catalog.metrics` — the declared metric keys, in declaration order.
19
+ - `AccountAuthz.capabilities` — every declared key, permissions first and then metrics.
20
+ - `AccountAuthz.reset!` — clears the catalog, the templates, and any `members_capability` or `roles_capability` set, and leaves `members_source` as it was.
21
+ - `AccountAuthz.can?` — `AccountAuthz.can?(grants, capability)` returns true when the `grants` array includes `capability`.
22
+ - `AccountAuthz.approved_metrics` — `AccountAuthz.approved_metrics(grants)` returns the catalog's metrics that appear in `grants`, in catalog order.
23
+ - `AccountAuthz::Role.in_account` — `AccountAuthz::Role.in_account(account_id)` is a scope of the roles belonging to one account.
24
+ - `AccountAuthz::Role.create!` — `AccountAuthz::Role.create!(account_id:, name:, capabilities: [...], rank: 0)` creates a role with the given capability keys and rank, and `rank` defaults to 0 when left out.
25
+ - `AccountAuthz::Role.from_template` — `AccountAuthz::Role.from_template(account_id:, template: :name)` creates a role from a declared template, at rank 0.
26
+ - `AccountAuthz.templates` — with a block, declares role templates using `template :name, capabilities: [...], default: true`, and without a block returns the templates.
27
+ - `AccountAuthz.templates.find` — `AccountAuthz.templates.find(:name)` returns the template with that name, or `nil`.
28
+ - `AccountAuthz.templates.defaults` — the templates declared with `default: true`.
29
+ - `AccountAuthz.seed_default_roles` — `AccountAuthz.seed_default_roles(account_id)` creates one role per default template that the account does not already have, and returns the roles it created.
30
+ - `member.assign_role` — `member.assign_role(role)` gives the member a role, and assigning the same role twice leaves one assignment.
31
+ - `member.revoke_role` — `member.revoke_role(role)` removes the role from the member.
32
+ - `member.account_authz_roles` — every role assigned to the member, whatever account each role belongs to.
33
+ - `member.capabilities` — `member.capabilities(account_id: nil)` returns the union of the member's role capabilities as symbols, limited to one account when `account_id` is given.
34
+ - `member.can?` — `member.can?(capability, account_id: nil)` returns true when that union includes `capability`.
35
+ - `member.approved_metrics` — `member.approved_metrics(account_id: nil)` returns the catalog metrics the member holds.
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.
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
+ - `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
+ - `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.
41
+ - `reach.includes_member?` — `reach.includes_member?(member)` returns true when the manager may change that member's roles in the account.
42
+ - `reach.includes_role?` — `reach.includes_role?(role)` returns true when the manager may give or take that role, or edit it.
43
+ - `reach.includes_capabilities?` — `reach.includes_capabilities?(capabilities)` returns true when the manager holds every one of those capability keys in the account.
44
+ - `reach.includes_rank?` — `reach.includes_rank?(rank)` returns true when `rank` is at or below the manager's highest rank in the account.
45
+ - `AccountAuthz::LastManager` — `AccountAuthz::LastManager.new(account_id:)` answers whether a change would leave one account with nobody holding a role that grants the members capability.
46
+ - `last_manager.lost_by_taking?` — `last_manager.lost_by_taking?(member, role)` returns true when nobody would hold a role granting the members capability in the account once that member no longer holds that role.
47
+ - `last_manager.lost_by_removing?` — `last_manager.lost_by_removing?(member)` returns true when nobody other than that member holds a role granting the members capability in the account.
48
+ - `last_manager.lost_by_changing?` — `last_manager.lost_by_changing?(role, capabilities)` returns true when the role grants the members capability, `capabilities` does not include it, and nobody holds any other role in the account that grants it.
49
+ - `account_authz.refusals` — the locale keys `member_out_of_reach`, `role_out_of_reach`, `last_manager`, `not_removable`, `role_edit_out_of_reach`, `rank_out_of_reach` and `capabilities_out_of_reach`, whose text the members page and role pages show as a flash alert when they refuse a change.
50
+
51
+ ## How to use it
52
+
53
+ 1. **Declare the catalog once, at boot.** Put a single `AccountAuthz.catalog do ... end` block in an initializer such as `config/initializers/account_authz.rb`. Declare every key as a symbol. Each block call appends to the catalog and never removes or deduplicates keys, so do not declare the same key in two places. Ask the developer which keys are permissions (actions a member may take) and which are metrics (data a member may see), because that split decides what `approved_metrics` returns.
54
+
55
+ 2. **Declare templates, if the app gives new accounts starter roles.** In the same initializer, add a `AccountAuthz.templates do ... end` block. Name each template with a symbol. The role created from a template is named from the template name in title case, so `:account_manager` becomes `"Account Manager"`. Template capabilities are not checked when declared, only when a role is created from them, so every key in a template must also be in the catalog. A template carries no rank, so every role created from one starts at rank 0. Ask the developer which templates exist, what each one grants, and which are `default: true`.
56
+
57
+ 3. **Seed default roles when an account is created.** Call `AccountAuthz.seed_default_roles(account.id)` in the code that provisions a new account. It is safe to call again, because it skips any default template whose role name the account already has.
58
+
59
+ 4. **Create roles from the app's own code.** Use `AccountAuthz::Role.create!(account_id:, name:, capabilities:, rank:)` for a role built from scratch, and `AccountAuthz::Role.from_template(account_id:, template:)` for one copied from a template. `account_id` and `name` are required. Capability keys may be strings or symbols, and they are stored and read back as strings. A key that is not in the catalog raises `ActiveRecord::RecordInvalid`. `from_template` raises `NoMethodError` when no template matches, and a template declared as a symbol is not found by its string name, so check `AccountAuthz.templates.find(name)` first when the name comes from user input. Nothing prevents two roles with the same name in one account, so check `AccountAuthz::Role.in_account(account_id).exists?(name:)` before creating one when duplicates are not wanted. List an account's roles with `AccountAuthz::Role.in_account(account_id)`. Neither `create!` nor `from_template` checks the rank or capabilities against the member making the change, so a flow where a member creates a role follows step 7.
60
+
61
+ 5. **Decide the ranks.** Rank is an integer compared only within one account, and a higher number outranks a lower one. A manager reaches a member when the member's highest role in the account ranks below the manager's highest role, and reaches a role when the role ranks below it. A member with no roles in the account is reached by any manager who holds a role there. A manager whose highest role is at the account's highest rank reaches every member and every role in the account, including the manager and other members at that rank. Every role starts at rank 0, so until the developer ranks roles, every manager holding a role is at the top rank and reaches everyone. Ask the developer which roles outrank which, then set `rank:` when creating a role, or on the role form for roles that already exist or came from a template.
62
+
63
+ 6. **Assign and revoke roles.** Call `member.assign_role(role)` and `member.revoke_role(role)` from any flow the host builds for managing members outside the members page. Neither checks that the role belongs to the member's account, so load the role through `AccountAuthz::Role.in_account(account_id)` before assigning it, using the account the membership belongs to. Assigning a role from another account to a membership gives that membership access in an account the person never joined. Neither checks rank either, so when one member changes another member's roles, check both first:
64
+
65
+ ```ruby
66
+ reach = AccountAuthz::Reach.new(current_member, account_id: account_id)
67
+ head :forbidden unless reach.includes_member?(member) && reach.includes_role?(role)
68
+ ```
69
+
70
+ `includes_role?` compares only the rank, so pass it a role loaded through `in_account` for the same account. A `Reach` reads the manager's highest rank once, so build a new one after the manager's own roles change.
71
+
72
+ To tell the manager why, the way account_authz's pages do, redirect instead of returning 403, with `alert: t("account_authz.refusals.member_out_of_reach")` when the member is out of reach and `alert: t("account_authz.refusals.role_out_of_reach")` when the role is. Ask the developer whether each host flow should answer with 403 or with a redirect and message.
73
+
74
+ 7. **Limit what an editor gives a role in host flows.** `AccountAuthz::Role.create!`, `AccountAuthz::Role.from_template`, and updating a role do not check the member making the change, so a host flow where a member creates or edits a role checks first:
75
+
76
+ ```ruby
77
+ reach = AccountAuthz::Reach.new(current_member, account_id: account_id)
78
+ head :forbidden unless reach.includes_role?(role)
79
+ head :forbidden unless reach.includes_rank?(rank)
80
+ head :forbidden unless reach.includes_capabilities?(capabilities)
81
+ ```
82
+
83
+ Check `includes_role?` only when editing a role that already exists, and check `includes_rank?` only when the flow sets a rank. For a role created from a template, check `includes_capabilities?` with `AccountAuthz.templates.find(name).capabilities`.
84
+
85
+ `includes_rank?` returns true for a rank equal to the manager's highest rank in the account, so an editor may create a role at their own rank. Holding the account's top rank does not lift this limit the way it does for `includes_member?` and `includes_role?`. A manager with no role in the account is refused every rank. The rank may be an integer or a string, and a blank or non-numeric string counts as 0. Because `includes_role?` needs the role ranked below the editor's unless the editor is at the top rank, an editor not at the top rank who sets a role to their own rank cannot edit that role again.
86
+
87
+ `includes_capabilities?` takes capability keys as strings or symbols, returns true for an empty list, and reads the manager's capabilities in the account on each call. It does not check the catalog. A blank string is never held, so remove blank strings from form params before passing them. AccountAuthz's role pages pass only the keys an edit adds, the new list minus the role's current capabilities, so an editor may keep a key on a role that the editor does not hold. Ask the developer whether each host flow checks only the added keys the same way or the role's complete new list.
88
+
89
+ To tell the editor why, the way account_authz's pages do, redirect instead of returning 403, with `alert: t("account_authz.refusals.role_edit_out_of_reach")` when the role is out of reach, `alert: t("account_authz.refusals.rank_out_of_reach")` when the rank is, and `alert: t("account_authz.refusals.capabilities_out_of_reach")` when a capability is. Ask the developer whether each host flow should answer with 403 or with a redirect and message.
90
+
91
+ 8. **Keep a member manager in host flows.** `member.revoke_role`, destroying a role-holding record, and updating a role's capabilities do not check whether the account keeps a member manager, so a host flow that does any of them checks first:
92
+
93
+ ```ruby
94
+ last_manager = AccountAuthz::LastManager.new(account_id: account_id)
95
+ head :forbidden if last_manager.lost_by_taking?(member, role)
96
+ head :forbidden if last_manager.lost_by_removing?(member)
97
+ head :forbidden if last_manager.lost_by_changing?(role, new_capabilities)
98
+ ```
99
+
100
+ Ask the developer which host flows take roles, remove members, or change a role's capabilities outside account_authz's pages, and add the matching check to each one. A flow that redirects instead of returning 403 uses `alert: t("account_authz.refusals.last_manager")` for all three checks.
101
+
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
+
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`.
105
+
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
+
108
+ ```ruby
109
+ class OrderPolicy < AccountAuthz::ApplicationPolicy
110
+ def show?
111
+ can?(:view_fulfillment)
112
+ end
113
+ end
114
+ ```
115
+
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
+
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.
119
+
120
+ Write the source as an object with these seven methods, and a class with class methods works:
121
+
122
+ ```ruby
123
+ class AccountMembers
124
+ def self.members(account_id)
125
+ Membership.where(account_id: account_id)
126
+ end
127
+
128
+ def self.invite(account_id:, name:, email:, invited_by:)
129
+ MembershipInvitation.deliver(account_id: account_id, name: name, email: email, invited_by: invited_by)
130
+ end
131
+
132
+ def self.invitations(account_id)
133
+ MembershipInvitation.waiting.where(account_id: account_id)
134
+ end
135
+
136
+ def self.resend_invitation(invitation)
137
+ invitation.deliver_again
138
+ end
139
+
140
+ def self.cancel_invitation(invitation)
141
+ invitation.destroy!
142
+ end
143
+
144
+ def self.removable?(member)
145
+ !member.owner?
146
+ end
147
+
148
+ def self.remove(member)
149
+ member.destroy!
150
+ end
151
+ end
152
+ ```
153
+
154
+ In the initializer, set the source and the capability. Set the source inside `to_prepare` when it is a class the app autoloads, so it is set again after code reloads in development. A lambda does not work as a source.
155
+
156
+ ```ruby
157
+ Rails.application.config.to_prepare do
158
+ AccountAuthz.members_source = AccountMembers
159
+ end
160
+ AccountAuthz.members_capability = :manage_team
161
+ ```
162
+
163
+ 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
+
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.
171
+ - `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
+
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.
174
+
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.
176
+
177
+ 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
+
179
+ 12. **Configure the role pages, if the host serves them.** The roles page opens with a `Members` button that goes to the members page, shown to every viewer, and a viewer without the members capability who presses it gets 403. Below it, the roles page lists the current account's roles by name, with the number of capabilities each one grants, and each name opens that role's edit form. Its new role button opens a form with a name field, a rank field, and one checkbox per catalog key, labelled with the key itself, and saving creates the role in the current account. The edit form is the same form filled in with the role's name, rank and capabilities, and saving renames the role, sets its rank, and replaces its capabilities. Below the list, the roles page shows one button per default template, which creates a role from that template at rank 0, and it leaves that part out when no template is declared `default: true`. Each save and each template button returns to the roles page. The pages delete no roles. In the initializer, set the capability:
180
+
181
+ ```ruby
182
+ AccountAuthz.roles_capability = :manage_team
183
+ ```
184
+
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`.
186
+
187
+ 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
+
189
+ - Saving the new role form with a rank above the editor's highest rank in the account, or with a ticked capability the editor does not hold in the account, creates nothing and returns to a blank new role form with a refusal message.
190
+ - Saving the edit form for a role the editor does not reach saves nothing and returns to the roles page with a refusal message.
191
+ - Saving the edit form with a rank above the editor's highest rank, or with a ticked capability the role did not already have and the editor does not hold, saves nothing and returns to that role's edit form with a refusal message.
192
+ - Saving the edit form when `lost_by_changing?` is true for that role and the ticked capabilities saves nothing and returns to that role's edit form with the `last_manager` refusal message.
193
+ - Pressing a template button when the editor does not hold every capability of that template creates nothing and returns to the roles page with a refusal message.
194
+
195
+ A refused edit form save saves nothing, including a changed name or rank, and the form shows the role as it was last saved. The edit form opens for every role in the account, and a role the editor does not reach is refused only on save. The form shows every catalog checkbox and the roles page shows every default template button, including those the editor may not use. Saving a role with a blank name raises `ActiveRecord::RecordInvalid` instead of showing the form again, and saving one with a blank rank fails with an error instead of showing the form again.
196
+
197
+ Declare every default template's name as a symbol, because a template button for a template declared with a string name raises `NoMethodError`. Every key in a default template must be in the catalog, because otherwise its button never creates the role. Pressing a template button again creates a second role with the same name.
198
+
199
+ Tell the developer what the role pages still allow. An editor at the account's top rank can edit every role in the account, including a role that editor holds. Any editor can lower the rank of a role they reach and untick any capability on it, including capabilities the editor does not hold. An editor can raise a role they reach to their own rank, which gives its holders at least the same reach over members and roles as the editor. The role pages do not stop an editor from removing the roles capability from every role, so an account can be left with no member who can open them.
200
+
201
+ 13. **Show and word the refusal messages, if the host serves either page.** A refused change sets `flash[:alert]` and redirects. AccountAuthz's pages do not display the flash themselves, so the manager sees the message only when the layout the host's `ApplicationController` uses displays `flash[:alert]`. Ask the developer whether that layout already displays it, and add it there if not.
202
+
203
+ Each refusal shows the message under one key:
204
+
205
+ - `account_authz.refusals.member_out_of_reach` — a give, take or removal for a member the viewer does not reach.
206
+ - `account_authz.refusals.role_out_of_reach` — a give or take of a role the viewer does not reach.
207
+ - `account_authz.refusals.last_manager` — a take when `lost_by_taking?` is true, a removal when `lost_by_removing?` is true, or a role edit save when `lost_by_changing?` is true.
208
+ - `account_authz.refusals.not_removable` — a removal when `removable?` returns false.
209
+ - `account_authz.refusals.role_edit_out_of_reach` — a role edit save for a role the editor does not reach.
210
+ - `account_authz.refusals.rank_out_of_reach` — a new role save or role edit save with a rank above the editor's highest rank.
211
+ - `account_authz.refusals.capabilities_out_of_reach` — a new role save with a ticked capability the editor does not hold, a role edit save that ticks a capability the role did not have and the editor does not hold, or a template button for a template with a capability the editor does not hold.
212
+
213
+ When more than one refusal applies, account_authz shows only the first in this order. For a give or take the order is `member_out_of_reach`, `role_out_of_reach`, `last_manager`. For a removal the order is `member_out_of_reach`, `not_removable`, `last_manager`. For a new role save the order is `rank_out_of_reach`, `capabilities_out_of_reach`. For a role edit save the order is `role_edit_out_of_reach`, `rank_out_of_reach`, `capabilities_out_of_reach`, `last_manager`.
214
+
215
+ AccountAuthz ships these English messages:
216
+
217
+ ```yaml
218
+ en:
219
+ account_authz:
220
+ refusals:
221
+ role_out_of_reach: "You can only give or take roles ranked below your own."
222
+ member_out_of_reach: "You can only change members ranked below you."
223
+ last_manager: "Someone else needs to be able to manage members first."
224
+ not_removable: "This member can't be removed from the account."
225
+ capabilities_out_of_reach: "You can only give a role capabilities you have yourself."
226
+ rank_out_of_reach: "You can only set a rank up to your own."
227
+ role_edit_out_of_reach: "You can only change roles ranked below your own."
228
+ ```
229
+
230
+ Ask the developer whether to keep that wording. To reword a message, set the same key in a locale file under the app's `config/locales`, which takes precedence over account_authz's. AccountAuthz ships no other language, so for each other locale the app serves, add all seven keys to that locale's file. The message is looked up in the request's current locale when the change is refused.
231
+
232
+ A request from a member without the page's capability, with no signed-in member, or with no current account still gets 403 with no message.
233
+
234
+ 14. **Reset in tests.** Call `AccountAuthz.reset!` in test setup when a test declares its own catalog or templates, then declare what the test needs. After `reset!` the catalog is empty, so creating any role with capabilities fails validation until the catalog is declared again. `reset!` returns `members_capability` to `:manage_members` and `roles_capability` to `:manage_roles`, but does not clear `members_source`, so a test that sets a source must set it back itself.
235
+
236
+ 15. **Run the test suite** after each change.
237
+
238
+ ## Conventions
239
+
240
+ - A member is a person's membership in one account, and a role assigned to it always belongs to that account.
241
+ - Capability keys exist only in the catalog, and are never stored as records or built from user input.
242
+ - Roles are records, and are never hardcoded in application code; starter roles come from templates.
243
+ - Every capability check goes through `member.can?`, `AccountAuthz.can?`, or a policy that inherits `AccountAuthz::ApplicationPolicy`, never through role names or flags compared in controllers or views.
244
+ - Every capability check in a multi-tenant request passes the current account's id, unless the developer has decided otherwise.
245
+ - The members capability and the roles capability are catalog keys like any other, and are never checked by comparing role names.
246
+ - Every host flow where one member changes another member's roles checks `AccountAuthz::Reach` for both the member and the role, and never compares ranks by hand.
247
+ - Every host flow where a member creates or edits a role checks `AccountAuthz::Reach` for the role, the rank and the capabilities, and never compares ranks or capability lists by hand.
248
+ - Every host flow that takes a role, removes a member, or changes a role's capabilities checks `AccountAuthz::LastManager`, and never counts an account's managers by hand.
249
+ - A refusal message is reworded under `account_authz.refusals` in the app's own locale files, and a host flow that explains a refusal reuses those keys rather than writing its own text for the same reason.
250
+ - Adding the gem, running its migrations, choosing which model holds roles, preparing the host's models and controllers, making the members page and role pages reachable, and loading keystone_ui's styles into the host's layout are out of scope for this local.
251
+ - AccountAuthz's pages give and take an account's roles and create, rename, rank, and change the capabilities of roles, so the host builds any screen that deletes a role.
252
+ - AccountAuthz's members page hands inviting, listing waiting invitations, sending one again, cancelling one and removing a member to the members source, so the host stores each invitation, sends it, decides when it stops waiting for an answer, builds the flow for accepting one, and decides what removing a member does to its records.
@@ -0,0 +1,222 @@
1
+ ---
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.
4
+ tools: Read
5
+ scope: authorization — capability catalog, roles, and Pundit enforcement in multi-tenant Rails apps
6
+ ---
7
+
8
+ You explain what account_authz is and when to reach for it, and route the reader to the local that does the work. You make no changes and give no steps.
9
+
10
+ ## What account_authz is
11
+
12
+ AccountAuthz is authorization for a Rails app where many accounts share one
13
+ installation and each account decides who may do what. The app's code declares
14
+ the fixed list of things the software can gate. Each account then builds its own
15
+ roles out of that list, ranks them, and assigns them to its people. AccountAuthz
16
+ answers five questions about a person in an account: may they do this, which
17
+ metrics may they see, which members and roles may they manage, which
18
+ capabilities and ranks may they put on a role, and would a change leave the
19
+ account with nobody able to manage its members.
20
+
21
+ Reach for it when the set of gated actions is decided by the developers but the
22
+ bundling of those actions into roles is decided by each account. It is a Rails
23
+ engine that stores roles and role assignments in its own tables, and it enforces
24
+ through Pundit.
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.
40
+
41
+ ## Interface
42
+
43
+ This local declares no commands. AccountAuthz's surface is split between the other
44
+ two locals:
45
+
46
+ - **account_authz-install** owns adding the gem to a Rails app, installing its
47
+ migrations, connecting the host's member model and controllers, setting the
48
+ per-request account, and mounting the engine.
49
+ - **account_authz-develop** owns everything written against account_authz after that:
50
+ - declaring the catalog, defining roles and templates, seeding them, and
51
+ assigning roles to members;
52
+ - checking capabilities and approved metrics, and writing policies;
53
+ - asking whether a manager's rank reaches a member or a role, whether an editor
54
+ may put a set of capabilities or a rank on a role, and whether a change would
55
+ 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;
58
+ - rewording the refusal messages.
59
+
60
+ ## How to use it
61
+
62
+ Decide which of the two you need, then go there.
63
+
64
+ - 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**.
67
+ - You are choosing which of the host's models holds roles: use
68
+ **account_authz-install**.
69
+ - AccountAuthz is connected and you are adding a capability, a role, a template, or a
70
+ 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**.
73
+ - AccountAuthz is connected and you are adding a check in the host's own screens on
74
+ who may manage whom, on which capabilities and ranks an editor may set, or on
75
+ keeping a member manager, or changing the wording of a refusal message: use
76
+ **account_authz-develop**.
77
+
78
+ ## Conventions
79
+
80
+ - **Capability** — one key the software can gate. Capabilities come in two
81
+ kinds: a **permission** gates an action, and a **metric** gates a figure the
82
+ person may see.
83
+ - **Catalog** — the complete list of capabilities, declared in the app's code.
84
+ Capabilities are never created as data.
85
+ - **Role** — a named set of capabilities belonging to one account, stored as
86
+ data. A role with no name, or naming a capability the catalog does not
87
+ contain, fails validation.
88
+ - **Rank** — a whole number on each role, 0 unless set, where a higher number
89
+ ranks higher. Ranks compare only within one account.
90
+ - **Template** — a role definition written in code, used to create a role in an
91
+ account. The role's name is the template's key in title case, so a `sales`
92
+ template makes a role named `Sales`.
93
+ - **Default template** — a template created in every account that is seeded.
94
+ Seeding skips a default template whose role name already exists in the
95
+ account.
96
+ - **Member** — the record roles are assigned to. It is a person's membership in
97
+ one account, not the person, so a person who belongs to three accounts has
98
+ three members. Deleting a member deletes its role assignments with it.
99
+ Assigning the same role twice leaves one assignment.
100
+ - **Person as member** — the fallback for an app with no membership record, where
101
+ the person holds roles directly. A check made from a controller, a view or a
102
+ policy still counts only the current account's roles. A check made anywhere
103
+ else must name the account, or it counts the person's roles in every account.
104
+ - **Grants** — the capabilities a member holds: every capability of every role
105
+ assigned to them, with duplicates removed.
106
+ - **Approved metrics** — the metrics in the catalog that appear in a member's
107
+ grants.
108
+ - **Current account** — the account a request is acting in, set by the host.
109
+ Checks made from a controller, a view or a policy count only the member's roles
110
+ in the current account, and every check is denied when no current account is
111
+ set.
112
+ - **Highest role** — the member's role in the account with the greatest rank. A
113
+ member with no roles in the account ranks below every role.
114
+ - **Reach** — the members, roles, capabilities and ranks a manager may act on. A
115
+ manager reaches a role ranked below their highest role, and a member whose
116
+ highest role ranks below it. A manager reaches the capabilities in their own
117
+ grants, and every rank up to and including their highest role's rank.
118
+ - **Top rank** — the greatest rank among the account's roles. A manager whose
119
+ highest role is at the top rank reaches every member and every role in the
120
+ account, including members at that rank and themselves. The top rank does not
121
+ widen which capabilities or ranks they may put on a role. While every role
122
+ keeps the default rank of 0, every manager holding a role is at the top rank,
123
+ so ranks limit no member or role until an account sets them.
124
+ - **Member manager** — a member who holds a role in the account that includes
125
+ the members capability.
126
+ - **Last member manager** — the rule that an account always keeps at least one
127
+ member manager. AccountAuthz refuses three changes that would break it. Taking a
128
+ role from a member is refused when that member holding that role is the only
129
+ way anyone in the account holds the members capability. Removing a member is
130
+ refused when no other member holds a role with the members capability.
131
+ Unticking the members capability on a role is refused when no member holds it
132
+ through any other role. The rule holds whatever the members capability is
133
+ named, and it applies to every manager, including one at the top rank.
134
+ - **Role pages** — the engine's pages for the current account's roles. The list
135
+ shows every role's name and how many capabilities it holds, and each name
136
+ opens that role's edit form. Above the list is a Members link back to the
137
+ members page, shown to every viewer, and a viewer without the members
138
+ capability who follows it gets a forbidden response. The new and edit forms
139
+ take a name, a rank of 0 or more, and a checkbox for every capability in the
140
+ catalog. Saving is limited by the editor limits and by the last member manager
141
+ rule.
142
+ - **Editor limits** — the changes the role pages refuse from a person who may
143
+ open them. Creating a role is refused when it sets a rank above the editor's
144
+ highest role, or includes a capability the editor does not hold. Saving an
145
+ edit is refused when the role does not rank below the editor's highest role,
146
+ unless the editor is at the top rank. Saving an edit is also refused when it
147
+ sets a rank above the editor's highest role, or ticks a capability the editor
148
+ does not hold. Capabilities already on the role may stay ticked or be unticked
149
+ whether or not the editor holds them. An editor below the top rank may create
150
+ a role at their own rank, and cannot change it after that. Every role still
151
+ appears in the list and its edit form still opens, and the limits apply only
152
+ when a form is saved.
153
+ - **Adding from a template** — the role list shows an Add button for each default
154
+ template, and hides that part of the page when the app has no default
155
+ templates. The button is refused when the template includes a capability the
156
+ editor does not hold. The added role has the default rank of 0. Unlike seeding,
157
+ the button does not check whether the account already has a role of that name.
158
+ - **Members page** — the engine's page for the current account's members. It
159
+ 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.
169
+ - **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
174
+ email, both required. AccountAuthz passes the current account, the name, the email
175
+ and the inviting member to the host. The host sends its own invitation and
176
+ decides when the person becomes a member. Rank does not limit inviting, and the
177
+ action returns to the members page.
178
+ - **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.
192
+ - **Host layout** — every engine page is built from keystone_ui components and
193
+ shown inside the layout the host's own controllers use, so the host loads
194
+ keystone_ui's styles. Links in that layout to the host's own pages work on the
195
+ 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.
200
+ - **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.
203
+ - **Roles capability** — the capability a person needs to open the role pages and
204
+ to create or change roles there, `manage_roles` unless the app names another.
205
+ - **Forbidden** — a person without the page's capability, or a request with no
206
+ current account, gets a forbidden response from any engine page. Another
207
+ account's roles, members and waiting invitations cannot be read or changed from
208
+ these pages.
209
+ - **Refusal message** — what a manager sees when they may use the page but not
210
+ make a particular change. AccountAuthz changes nothing and sends them back with a
211
+ flash alert saying why. Seven reasons have a message: a role outside the
212
+ manager's reach to give or take, a member outside the manager's reach, a
213
+ member the host says may not be removed, a change that breaks the last member
214
+ 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.
221
+ - The rule the gem follows: capabilities are code, roles are data, and Pundit
222
+ enforces.