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 +4 -4
- data/app/controllers/account_authz/members_controller.rb +1 -1
- data/app/controllers/account_authz/roles_controller.rb +2 -2
- data/app/models/account_authz/cancel_invitation.rb +1 -1
- data/app/models/account_authz/give_role.rb +2 -2
- data/app/models/account_authz/remove_member.rb +1 -1
- data/app/models/account_authz/resend_invitation.rb +1 -1
- data/app/models/account_authz/role.rb +1 -0
- data/app/models/account_authz/take_role.rb +2 -2
- data/app/views/account_authz/members/_team.html.erb +14 -8
- data/app/views/account_authz/members/show.html.erb +1 -1
- data/db/migrate/20261012230000_add_public_code_to_account_authz_roles.rb +17 -0
- data/lib/account_authz/reference/guide.md +11 -0
- data/lib/account_authz/version.rb +1 -1
- data/lib/account_authz.rb +13 -0
- data/the_local/agents/account_authz-develop.md +38 -14
- data/the_local/agents/account_authz-info.md +88 -60
- data/the_local/agents/account_authz-install.md +29 -23
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2a6d242a905644ec6879015808f39da3e4b74d95768602bd45dccd36568cb818
|
|
4
|
+
data.tar.gz: 535257175b0c79d2a1606d07ac9d1926849e22886d35aec0a4dddf8c894cde70
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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).
|
|
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).
|
|
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)
|
|
@@ -24,11 +24,11 @@ module AccountAuthz
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def member
|
|
27
|
-
@member ||= AccountAuthz.
|
|
27
|
+
@member ||= AccountAuthz.find_member(@account, @values[:member_id])
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def role
|
|
31
|
-
@role ||= Role.in_account(@account).
|
|
31
|
+
@role ||= Role.in_account(@account).find_by_public_code!(@values[:role_id])
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
end
|
|
@@ -25,11 +25,11 @@ module AccountAuthz
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def member
|
|
28
|
-
@member ||= AccountAuthz.
|
|
28
|
+
@member ||= AccountAuthz.find_member(@account, @values[:member_id])
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def role
|
|
32
|
-
@role ||= Role.in_account(@account).
|
|
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.
|
|
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
|
-
<%
|
|
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.
|
|
34
|
-
<%= hidden_field_tag :role_id, role.
|
|
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.
|
|
41
|
-
<%= hidden_field_tag :role_id, role.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
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
|
|
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.
|
|
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.
|
|
168
|
-
- `resend_invitation(invitation)` receives one invitation found
|
|
169
|
-
- `cancel_invitation(invitation)` withdraws one invitation found
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
57
|
-
invitations, sends one again, cancels one
|
|
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
|
|
66
|
-
|
|
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
|
|
72
|
-
and removals,
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
member
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
viewer's reach, can be given or taken, and
|
|
172
|
-
members page.
|
|
173
|
-
- **Inviting** — sending an invitation from the
|
|
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,
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
- **Removing** — taking a member off the current account from the
|
|
185
|
-
AccountAuthz first takes away every role the member holds in the
|
|
186
|
-
then asks the host to remove the member, both in one database
|
|
187
|
-
Roles the member holds in other accounts are left as they are.
|
|
188
|
-
be within the viewer's reach, and the host decides whether a
|
|
189
|
-
removed at all, such as refusing the account owner. A top-rank
|
|
190
|
-
themselves, so they can remove themselves unless the host
|
|
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
|
|
197
|
-
which members belong to an account, sends an invitation,
|
|
198
|
-
still waiting, sends one again, cancels one, says
|
|
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
|
|
202
|
-
roles there, `manage_members` unless
|
|
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
|
|
216
|
-
the members page. Adding from a template, and saving a
|
|
217
|
-
change, return to the role list. A refused new role
|
|
218
|
-
role form, and any other refused edit returns to the
|
|
219
|
-
the role as it was saved. The message shows only when
|
|
220
|
-
flash alerts, and the app can reword each one in its
|
|
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,
|
|
15
|
-
- `bin/rails account_authz:install:migrations` — copies the engine's
|
|
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
|
|
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
|
|
29
|
-
- `account_authz_roles` has `account_id` (bigint, required, indexed), `name` (string, required), `capabilities` (json, default empty array),
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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`,
|
|
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`,
|
|
76
|
-
- After step 12, once the develop local has configured the members
|
|
77
|
-
- After step 13, load the roles page as a member who holds the roles capability
|
|
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
|
-
-
|
|
80
|
-
-
|
|
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.
|
|
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
|