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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +45 -0
- data/Rakefile +11 -0
- data/app/controllers/account_authz/application_controller.rb +19 -0
- data/app/controllers/account_authz/invitations_controller.rb +30 -0
- data/app/controllers/account_authz/member_roles_controller.rb +27 -0
- data/app/controllers/account_authz/members_controller.rb +38 -0
- data/app/controllers/account_authz/roles_controller.rb +56 -0
- data/app/controllers/concerns/account_authz/authorization.rb +18 -0
- data/app/helpers/account_authz/app_routes_helper.rb +12 -0
- data/app/helpers/account_authz/application_helper.rb +4 -0
- data/app/models/account_authz/account_id.rb +9 -0
- data/app/models/account_authz/acting_member.rb +11 -0
- data/app/models/account_authz/application_record.rb +5 -0
- data/app/models/account_authz/assignment.rb +8 -0
- data/app/models/account_authz/cancel_invitation.rb +23 -0
- data/app/models/account_authz/current.rb +7 -0
- data/app/models/account_authz/give_role.rb +34 -0
- data/app/models/account_authz/invite.rb +17 -0
- data/app/models/account_authz/last_manager.rb +43 -0
- data/app/models/account_authz/reach.rb +46 -0
- data/app/models/account_authz/remove_member.rb +34 -0
- data/app/models/account_authz/resend_invitation.rb +23 -0
- data/app/models/account_authz/result.rb +24 -0
- data/app/models/account_authz/role.rb +24 -0
- data/app/models/account_authz/take_role.rb +35 -0
- data/app/models/concerns/account_authz/member.rb +33 -0
- data/app/policies/account_authz/application_policy.rb +16 -0
- data/app/views/account_authz/invitations/new.html.erb +10 -0
- data/app/views/account_authz/members/_team.html.erb +84 -0
- data/app/views/account_authz/members/index.html.erb +59 -0
- data/app/views/account_authz/members/show.html.erb +38 -0
- data/app/views/account_authz/roles/_form.html.erb +16 -0
- data/app/views/account_authz/roles/edit.html.erb +4 -0
- data/app/views/account_authz/roles/index.html.erb +21 -0
- data/app/views/account_authz/roles/new.html.erb +4 -0
- data/config/locales/en.yml +10 -0
- data/config/routes.rb +9 -0
- data/db/migrate/20260607000001_create_citizen_roles.rb +15 -0
- data/db/migrate/20260607000002_create_citizen_assignments.rb +15 -0
- data/db/migrate/20260916000001_add_rank_to_citizen_roles.rb +7 -0
- data/db/migrate/20260921220000_rename_citizen_tables_to_account_authz.rb +9 -0
- data/lib/account_authz/catalog.rb +24 -0
- data/lib/account_authz/engine.rb +7 -0
- data/lib/account_authz/reference/guide.md +227 -0
- data/lib/account_authz/reference.rb +17 -0
- data/lib/account_authz/templates.rb +27 -0
- data/lib/account_authz/version.rb +5 -0
- data/lib/account_authz.rb +59 -0
- data/lib/tasks/account_authz_tasks.rake +4 -0
- data/the_local/agents/account_authz-develop.md +252 -0
- data/the_local/agents/account_authz-info.md +222 -0
- data/the_local/agents/account_authz-install.md +80 -0
- data/the_local/interface.yml +71 -0
- metadata +160 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AccountAuthz
|
|
4
|
+
module Member
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
has_many :account_authz_assignments, as: :member, class_name: "AccountAuthz::Assignment", dependent: :destroy
|
|
9
|
+
has_many :account_authz_roles, through: :account_authz_assignments, source: :role
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def assign_role(role)
|
|
13
|
+
account_authz_assignments.find_or_create_by(role: role)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def revoke_role(role)
|
|
17
|
+
account_authz_assignments.where(role: role).destroy_all
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def capabilities(account_id: nil)
|
|
21
|
+
roles = account_id ? account_authz_roles.where(account_id: account_id) : account_authz_roles
|
|
22
|
+
roles.flat_map(&:capabilities).uniq.map(&:to_sym)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def can?(capability, account_id: nil)
|
|
26
|
+
AccountAuthz.can?(capabilities(account_id: account_id), capability)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def approved_metrics(account_id: nil)
|
|
30
|
+
AccountAuthz.approved_metrics(capabilities(account_id: account_id))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AccountAuthz
|
|
4
|
+
class ApplicationPolicy
|
|
5
|
+
attr_reader :member, :record
|
|
6
|
+
|
|
7
|
+
def initialize(member, record)
|
|
8
|
+
@member = member
|
|
9
|
+
@record = record
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def can?(capability)
|
|
13
|
+
Current.account_id.present? && member.can?(capability, account_id: Current.account_id)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<%= ui_page(max_width: :md) do %>
|
|
2
|
+
<%= ui_form_page(title: "Invite", back_url: members_path, subtitle: "Members") %>
|
|
3
|
+
<%= ui_panel do %>
|
|
4
|
+
<%= ui_form(action: invitations_path) do %>
|
|
5
|
+
<%= ui_form_field(attribute: "invitation[name]", label: "Name", required: true) %>
|
|
6
|
+
<%= ui_form_field(attribute: "invitation[email]", label: "Email", type: :email, required: true) %>
|
|
7
|
+
<%= ui_button(label: "Invite") %>
|
|
8
|
+
<% end %>
|
|
9
|
+
<% end %>
|
|
10
|
+
<% end %>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<%
|
|
2
|
+
account = AccountAuthz::AccountId.from(account)
|
|
3
|
+
chosen = selection[:member_id].presence
|
|
4
|
+
members = AccountAuthz.members_source.members(account)
|
|
5
|
+
held_roles = AccountAuthz::Assignment.where(member: members.to_a, role: AccountAuthz::Role.in_account(account)).includes(:role)
|
|
6
|
+
.group_by(&:member_id).transform_values { |assignments| assignments.map(&:role) }
|
|
7
|
+
invitations = AccountAuthz.members_source.invitations(account)
|
|
8
|
+
%>
|
|
9
|
+
|
|
10
|
+
<%= ui_section(title: "Team") do %>
|
|
11
|
+
<%= ui_data_table(
|
|
12
|
+
items: members.map { |member| {member_id: member.id, name: member.name, email: member.email, roles: held_roles.fetch(member.id, []).map(&:name).join(", ").presence || "None"} },
|
|
13
|
+
columns: [Keystone::Ui::Column.new(:name, "Name"), Keystone::Ui::Column.new(:email, "Email", mobile_hidden: true), Keystone::Ui::Column.new(:roles, "Roles")],
|
|
14
|
+
empty_message: "No members yet"
|
|
15
|
+
) do |table| %>
|
|
16
|
+
<% table.link(:name) { |row| "?member_id=#{row[:member_id]}" } %>
|
|
17
|
+
<% end %>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
<% person_chosen = chosen && members.find { |member| member.id.to_s == chosen.to_s } %>
|
|
21
|
+
<% if person_chosen %>
|
|
22
|
+
<%
|
|
23
|
+
reach = AccountAuthz::Reach.new(AccountAuthz::ActingMember.for(person: person, account: account), account_id: account)
|
|
24
|
+
last_manager = AccountAuthz::LastManager.new(account_id: account)
|
|
25
|
+
held = AccountAuthz::Assignment.where(member: person_chosen, role: AccountAuthz::Role.in_account(account)).includes(:role).map(&:role)
|
|
26
|
+
within_reach = AccountAuthz::Role.in_account(account).select { |role| reach.includes_role?(role) }
|
|
27
|
+
%>
|
|
28
|
+
<%= ui_section(title: person_chosen.name, subtitle: person_chosen.email) do %>
|
|
29
|
+
<%= ui_panel do %>
|
|
30
|
+
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
|
|
31
|
+
<% (held & within_reach).reject { |role| last_manager.lost_by_taking?(person_chosen, role) }.each do |role| %>
|
|
32
|
+
<%= ui_form(action: submit_urls[:take_role], method: :patch) do %>
|
|
33
|
+
<%= hidden_field_tag :member_id, person_chosen.id, id: nil %>
|
|
34
|
+
<%= hidden_field_tag :role_id, role.id, id: nil %>
|
|
35
|
+
<%= ui_button(label: "Take #{role.name}", variant: :danger, size: :sm) %>
|
|
36
|
+
<% end %>
|
|
37
|
+
<% end %>
|
|
38
|
+
<% (within_reach - held).each do |role| %>
|
|
39
|
+
<%= ui_form(action: submit_urls[:give_role], method: :patch) do %>
|
|
40
|
+
<%= hidden_field_tag :member_id, person_chosen.id, id: nil %>
|
|
41
|
+
<%= hidden_field_tag :role_id, role.id, id: nil %>
|
|
42
|
+
<%= ui_button(label: "Give #{role.name}", variant: :secondary, size: :sm) %>
|
|
43
|
+
<% end %>
|
|
44
|
+
<% end %>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<% if AccountAuthz.members_source.removable?(person_chosen) && !last_manager.lost_by_removing?(person_chosen) %>
|
|
48
|
+
<%= ui_form(action: submit_urls[:remove], method: :patch) do %>
|
|
49
|
+
<%= hidden_field_tag :member_id, person_chosen.id, id: nil %>
|
|
50
|
+
<%= ui_button(label: "Remove from team", variant: :danger, size: :sm) %>
|
|
51
|
+
<% end %>
|
|
52
|
+
<% end %>
|
|
53
|
+
<% end %>
|
|
54
|
+
<% end %>
|
|
55
|
+
<% end %>
|
|
56
|
+
|
|
57
|
+
<%= ui_section(title: "Invite") do %>
|
|
58
|
+
<%= ui_panel do %>
|
|
59
|
+
<%= ui_form(action: submit_urls[:invite], method: :patch) do %>
|
|
60
|
+
<%= ui_form_field(attribute: "name", label: "Name", required: true) %>
|
|
61
|
+
<%= ui_form_field(attribute: "email", label: "Email", type: :email, required: true) %>
|
|
62
|
+
<%= ui_button(label: "Invite") %>
|
|
63
|
+
<% end %>
|
|
64
|
+
<% end %>
|
|
65
|
+
<% end %>
|
|
66
|
+
|
|
67
|
+
<%= ui_section(title: "Invited") do %>
|
|
68
|
+
<%= ui_data_table(
|
|
69
|
+
items: invitations.map { |invitation| {invitation_id: invitation.id, name: invitation.name, email: invitation.email} },
|
|
70
|
+
columns: [Keystone::Ui::Column.new(:name, "Name"), Keystone::Ui::Column.new(:email, "Email", mobile_hidden: true)],
|
|
71
|
+
empty_message: "No invitations"
|
|
72
|
+
) do |table| %>
|
|
73
|
+
<% table.actions do |row| %>
|
|
74
|
+
<%= ui_form(action: submit_urls[:resend], method: :patch) do %>
|
|
75
|
+
<%= hidden_field_tag :invitation_id, row[:invitation_id], id: nil %>
|
|
76
|
+
<%= ui_button(label: "Send again", variant: :secondary, size: :sm) %>
|
|
77
|
+
<% end %>
|
|
78
|
+
<%= ui_form(action: submit_urls[:cancel], method: :patch) do %>
|
|
79
|
+
<%= hidden_field_tag :invitation_id, row[:invitation_id], id: nil %>
|
|
80
|
+
<%= ui_button(label: "Cancel", variant: :danger, size: :sm) %>
|
|
81
|
+
<% end %>
|
|
82
|
+
<% end %>
|
|
83
|
+
<% end %>
|
|
84
|
+
<% end %>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<%= ui_page(max_width: :lg) do %>
|
|
2
|
+
<%= ui_page_header(title: "Members") do |header| %>
|
|
3
|
+
<% if can?(AccountAuthz.roles_capability) %>
|
|
4
|
+
<% header.action do %>
|
|
5
|
+
<%= ui_button(label: "Roles", href: roles_path, variant: :secondary) %>
|
|
6
|
+
<% end %>
|
|
7
|
+
<% end %>
|
|
8
|
+
<% end %>
|
|
9
|
+
|
|
10
|
+
<% if can?(AccountAuthz.roles_capability) %>
|
|
11
|
+
<%= ui_mobile_actions do %>
|
|
12
|
+
<%= link_to "Roles", roles_path %>
|
|
13
|
+
<% end %>
|
|
14
|
+
<% end %>
|
|
15
|
+
|
|
16
|
+
<%= ui_section(title: "Team", action: {label: "Invite", href: new_invitation_path}) do %>
|
|
17
|
+
<%
|
|
18
|
+
member_columns = [
|
|
19
|
+
Keystone::Ui::Column.new(:name, "Name"),
|
|
20
|
+
Keystone::Ui::Column.new(:email, "Email", mobile_hidden: true),
|
|
21
|
+
Keystone::Ui::Column.new(:roles, "Roles")
|
|
22
|
+
]
|
|
23
|
+
member_rows = @members.map do |member|
|
|
24
|
+
held = @held_roles.fetch(member.id, [])
|
|
25
|
+
{
|
|
26
|
+
member: member,
|
|
27
|
+
name: member.name,
|
|
28
|
+
email: member.email,
|
|
29
|
+
roles: held.any? ? held.map(&:name).join(", ") : "None"
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
%>
|
|
33
|
+
<%= ui_data_table(items: member_rows, columns: member_columns, empty_message: "No members yet") do |table| %>
|
|
34
|
+
<% table.link(:name) { |row| member_path(row[:member]) } %>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% end %>
|
|
37
|
+
|
|
38
|
+
<%= ui_section(title: "Invited") do %>
|
|
39
|
+
<%
|
|
40
|
+
invitation_columns = [
|
|
41
|
+
Keystone::Ui::Column.new(:name, "Name"),
|
|
42
|
+
Keystone::Ui::Column.new(:email, "Email", mobile_hidden: true)
|
|
43
|
+
]
|
|
44
|
+
invitation_rows = @invitations.map do |invitation|
|
|
45
|
+
{ invitation: invitation, name: invitation.name, email: invitation.email }
|
|
46
|
+
end
|
|
47
|
+
%>
|
|
48
|
+
<%= ui_data_table(items: invitation_rows, columns: invitation_columns, empty_message: "No invitations") do |table| %>
|
|
49
|
+
<% table.actions do |row| %>
|
|
50
|
+
<%= ui_form(action: resend_invitation_path(row[:invitation])) do %>
|
|
51
|
+
<%= ui_button(label: "Send again", variant: :secondary, size: :sm) %>
|
|
52
|
+
<% end %>
|
|
53
|
+
<%= ui_form(action: invitation_path(row[:invitation]), method: :delete) do %>
|
|
54
|
+
<%= ui_button(label: "Cancel", variant: :danger, size: :sm) %>
|
|
55
|
+
<% end %>
|
|
56
|
+
<% end %>
|
|
57
|
+
<% end %>
|
|
58
|
+
<% end %>
|
|
59
|
+
<% end %>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<%= ui_page(max_width: :lg) do %>
|
|
2
|
+
<%= ui_show_page(title: @member.name, back_url: members_path, subtitle: @member.email) %>
|
|
3
|
+
<%= ui_section(title: "Roles") do %>
|
|
4
|
+
<%= ui_panel do %>
|
|
5
|
+
<% if @held.any? %>
|
|
6
|
+
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
|
|
7
|
+
<% @held.each do |role| %>
|
|
8
|
+
<%= ui_badge(label: role.name, variant: :info) %>
|
|
9
|
+
<% end %>
|
|
10
|
+
</div>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% end %>
|
|
13
|
+
|
|
14
|
+
<% if @reach.includes_roles?(@held) %>
|
|
15
|
+
<%= ui_panel do %>
|
|
16
|
+
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
|
|
17
|
+
<% (@held & @roles).reject { |role| @last_manager.lost_by_taking?(@member, role) }.each do |role| %>
|
|
18
|
+
<%= ui_form(action: member_role_path(@member, role), method: :delete) do %>
|
|
19
|
+
<%= ui_button(label: "Take #{role.name}", variant: :danger, size: :sm) %>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% end %>
|
|
22
|
+
<% (@roles - @held).each do |role| %>
|
|
23
|
+
<%= ui_form(action: member_roles_path(@member)) do %>
|
|
24
|
+
<%= hidden_field_tag :role_id, role.id, id: nil %>
|
|
25
|
+
<%= ui_button(label: "Give #{role.name}", variant: :secondary, size: :sm) %>
|
|
26
|
+
<% end %>
|
|
27
|
+
<% end %>
|
|
28
|
+
</div>
|
|
29
|
+
<% end %>
|
|
30
|
+
|
|
31
|
+
<% if AccountAuthz.members_source.removable?(@member) && !@last_manager.lost_by_removing?(@member) %>
|
|
32
|
+
<%= ui_form(action: member_path(@member), method: :delete) do %>
|
|
33
|
+
<%= ui_button(label: "Remove from team", variant: :danger, size: :sm) %>
|
|
34
|
+
<% end %>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% end %>
|
|
37
|
+
<% end %>
|
|
38
|
+
<% end %>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<% held = Array(role.capabilities).map(&:to_s) %>
|
|
2
|
+
<%= ui_panel do %>
|
|
3
|
+
<%= ui_form(action: url, method: form_method) do %>
|
|
4
|
+
<%= hidden_field_tag "role[capabilities][]", "", id: nil %>
|
|
5
|
+
<%= ui_form_field(attribute: "role[name]", label: "Name", value: role.name, errors: role.errors.full_messages_for(:name)) %>
|
|
6
|
+
<%= ui_form_field(attribute: "role[rank]", label: "Rank", type: :number, min: 0, value: role.rank, hint: "A member manages members and roles ranked below their highest role.") %>
|
|
7
|
+
<%= ui_section(title: "Capabilities", spacing: :sm) do %>
|
|
8
|
+
<%= ui_grid(cols: { default: 1, sm: 2 }, gap: :sm) do %>
|
|
9
|
+
<% AccountAuthz.capabilities.each do |capability| %>
|
|
10
|
+
<%= ui_checkbox_row(name: "role[capabilities][]", value: capability.to_s, label: capability.to_s, checked: held.include?(capability.to_s)) %>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% end %>
|
|
13
|
+
<% end %>
|
|
14
|
+
<%= ui_button(label: "Save") %>
|
|
15
|
+
<% end %>
|
|
16
|
+
<% end %>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<%= ui_page(max_width: :lg) do %>
|
|
2
|
+
<%= ui_button(label: "Members", href: members_path, variant: :secondary, size: :sm) %>
|
|
3
|
+
<%= ui_section(title: "Roles", action: { label: "New role", href: new_role_path }) do %>
|
|
4
|
+
<% rows = @roles.map { |role| { role: role, name: role.name, capabilities: Array(role.capabilities).size } } %>
|
|
5
|
+
<%= ui_data_table(items: rows, columns: [{ name: "Name" }, { capabilities: "Capabilities" }], empty_message: "No roles yet") do |table| %>
|
|
6
|
+
<% table.link(:name) { |row| edit_role_path(row[:role]) } %>
|
|
7
|
+
<% end %>
|
|
8
|
+
<% end %>
|
|
9
|
+
<% if AccountAuthz.templates.defaults.any? %>
|
|
10
|
+
<%= ui_section(title: "Templates") do %>
|
|
11
|
+
<%= ui_grid(cols: { default: 1, sm: 2 }, gap: :sm) do %>
|
|
12
|
+
<% AccountAuthz.templates.defaults.each do |template| %>
|
|
13
|
+
<%= ui_form(action: roles_path) do %>
|
|
14
|
+
<%= hidden_field_tag :template, template.name, id: nil %>
|
|
15
|
+
<%= ui_button(label: "Add #{template.role_name}", variant: :secondary, size: :sm) %>
|
|
16
|
+
<% end %>
|
|
17
|
+
<% end %>
|
|
18
|
+
<% end %>
|
|
19
|
+
<% end %>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% end %>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
en:
|
|
2
|
+
account_authz:
|
|
3
|
+
refusals:
|
|
4
|
+
role_out_of_reach: "You can only give or take roles ranked below your own."
|
|
5
|
+
member_out_of_reach: "You can only change members ranked below you."
|
|
6
|
+
last_manager: "Someone else needs to be able to manage members first."
|
|
7
|
+
not_removable: "This member can't be removed from the account."
|
|
8
|
+
capabilities_out_of_reach: "You can only give a role capabilities you have yourself."
|
|
9
|
+
rank_out_of_reach: "You can only set a rank up to your own."
|
|
10
|
+
role_edit_out_of_reach: "You can only change roles ranked below your own."
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
AccountAuthz::Engine.routes.draw do
|
|
2
|
+
resources :members, only: [ :index, :show, :destroy ] do
|
|
3
|
+
resources :roles, only: [ :create, :destroy ], controller: "member_roles"
|
|
4
|
+
end
|
|
5
|
+
resources :roles, only: [ :index, :new, :create, :edit, :update ]
|
|
6
|
+
resources :invitations, only: [ :new, :create, :destroy ] do
|
|
7
|
+
post :resend, on: :member
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateCitizenRoles < ActiveRecord::Migration[8.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :citizen_roles do |t|
|
|
6
|
+
t.bigint :account_id, null: false
|
|
7
|
+
t.string :name, null: false
|
|
8
|
+
t.json :capabilities, null: false, default: []
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :citizen_roles, :account_id
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateCitizenAssignments < ActiveRecord::Migration[8.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :citizen_assignments do |t|
|
|
6
|
+
t.references :member, polymorphic: true, null: false
|
|
7
|
+
t.references :role, null: false
|
|
8
|
+
|
|
9
|
+
t.timestamps
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :citizen_assignments, %i[member_type member_id role_id], unique: true,
|
|
13
|
+
name: "index_citizen_assignments_unique"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
class RenameCitizenTablesToAccountAuthz < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
rename_table :citizen_roles, :account_authz_roles
|
|
4
|
+
rename_table :citizen_assignments, :account_authz_assignments
|
|
5
|
+
rename_index :account_authz_assignments,
|
|
6
|
+
"index_citizen_assignments_unique",
|
|
7
|
+
"index_account_authz_assignments_unique"
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AccountAuthz
|
|
4
|
+
class Catalog
|
|
5
|
+
attr_reader :permissions, :metrics
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@permissions = []
|
|
9
|
+
@metrics = []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def permission(key)
|
|
13
|
+
@permissions << key
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def metric(key)
|
|
17
|
+
@metrics << key
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def capabilities
|
|
21
|
+
permissions + metrics
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
## AccountAuthz
|
|
2
|
+
|
|
3
|
+
> **DO NOT** explore the account_authz gem source code. This reference is the complete
|
|
4
|
+
> user-facing API, embedded verbatim into every account_authz local so their guidance
|
|
5
|
+
> never drifts. Keep it the single source of truth.
|
|
6
|
+
|
|
7
|
+
AccountAuthz is capability-based authorization for multi-tenant Rails apps:
|
|
8
|
+
**capabilities are code, roles are data, Pundit enforces.** The app declares a
|
|
9
|
+
fixed catalog of capabilities in code; accounts manage roles (data) that bundle
|
|
10
|
+
those capabilities; AccountAuthz resolves what a member may do (`can?`) and which
|
|
11
|
+
metrics they may see (`approved_metrics`), and plugs into Pundit for enforcement.
|
|
12
|
+
It is a mountable Rails engine.
|
|
13
|
+
|
|
14
|
+
### What it offers
|
|
15
|
+
|
|
16
|
+
**Catalog (code).** Declare the fixed set of capabilities — `permission` and
|
|
17
|
+
`metric` keys — the software supports:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
AccountAuthz.catalog do
|
|
21
|
+
permission :view_fulfillment
|
|
22
|
+
metric :revenue
|
|
23
|
+
metric :deals
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
AccountAuthz.catalog.permissions # => [:view_fulfillment]
|
|
27
|
+
AccountAuthz.catalog.metrics # => [:revenue, :deals]
|
|
28
|
+
AccountAuthz.capabilities # => [:view_fulfillment, :revenue, :deals]
|
|
29
|
+
AccountAuthz.reset! # clears the catalog and templates (mainly for tests)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Resolution.** Decide from a member's granted capabilities:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
AccountAuthz.can?(grants, :view_fulfillment) # => true/false (grants.include?)
|
|
36
|
+
AccountAuthz.approved_metrics(grants) # => catalog metrics ∩ grants
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Roles (data).** `AccountAuthz::Role` is account-scoped: `account_id`, `name`
|
|
40
|
+
(required), and `capabilities` (a JSON array). A role's capabilities must be a
|
|
41
|
+
subset of `AccountAuthz.capabilities` — unknown keys fail validation.
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
AccountAuthz::Role.in_account(account_id) # scope
|
|
45
|
+
AccountAuthz::Role.create!(account_id:, name:, capabilities: %w[...]) # blank add
|
|
46
|
+
AccountAuthz::Role.from_template(account_id:, template: :sales) # one-click add
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Templates + seeding.** Templates are seed *data* for roles; capabilities stay
|
|
50
|
+
code:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
AccountAuthz.templates do
|
|
54
|
+
template :sales, capabilities: %w[view_fulfillment revenue], default: true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
AccountAuthz.templates.find(:sales) # => the Template
|
|
58
|
+
AccountAuthz.templates.defaults # => templates flagged default: true
|
|
59
|
+
AccountAuthz.seed_default_roles(account_id) # one role per default template (idempotent)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Members.** Include `AccountAuthz::Member` in the record that represents a person
|
|
63
|
+
*inside one account* — an account membership, not the person. A role belongs to
|
|
64
|
+
one account, so holding it on the membership means it can only apply where the
|
|
65
|
+
person belongs and it goes when the membership goes. Where an app has no such
|
|
66
|
+
record, the person can hold roles and every check passes `account_id:`, which
|
|
67
|
+
scopes resolution to one account (nil = all roles):
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
member.assign_role(role)
|
|
71
|
+
member.revoke_role(role)
|
|
72
|
+
member.account_authz_roles
|
|
73
|
+
member.capabilities(account_id: 1) # union of role capabilities (symbols)
|
|
74
|
+
member.can?(:view_fulfillment, account_id: 1)
|
|
75
|
+
member.approved_metrics(account_id: 1)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Pundit bridge.** Policies inherit `AccountAuthz::ApplicationPolicy` —
|
|
79
|
+
`initialize(member, record)` with `#can?(capability)` delegating to
|
|
80
|
+
`member.can?` within `AccountAuthz::Current.account_id`, and denying when no current
|
|
81
|
+
account is set. Controllers include `AccountAuthz::Authorization` (which mixes in
|
|
82
|
+
`Pundit::Authorization` and exposes a `can?` helper). `AccountAuthz::Current.account_id`
|
|
83
|
+
scopes per-request resolution.
|
|
84
|
+
|
|
85
|
+
**Members page (engine).** Mount the engine and a member who holds the members
|
|
86
|
+
capability in the current account sees each member's name, email and roles in
|
|
87
|
+
that account, with a button to give each of the account's roles the member does
|
|
88
|
+
not hold and to take away each one they do. It lists the account's invitations
|
|
89
|
+
still waiting for an answer, each with a button to send it again and one to
|
|
90
|
+
cancel it. The page also invites a person by name and email, and removes a member from the account, which first takes away
|
|
91
|
+
every role they held in it. Anyone else, or a request with no current account,
|
|
92
|
+
gets 403. Only the current account's members and roles can be changed.
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
mount AccountAuthz::Engine => "/account_authz" # members page at /account_authz/members
|
|
96
|
+
|
|
97
|
+
AccountAuthz.members_source = AccountMembers
|
|
98
|
+
AccountAuthz.members_capability = :manage_team # default :manage_members
|
|
99
|
+
|
|
100
|
+
class AccountMembers
|
|
101
|
+
def self.members(account_id) = Membership.where(account_id: account_id)
|
|
102
|
+
def self.invite(account_id:, name:, email:, invited_by:) = Invitation.send_to(account_id, name, email, invited_by)
|
|
103
|
+
def self.invitations(account_id) = Invitation.where(account_id: account_id)
|
|
104
|
+
def self.resend_invitation(invitation) = invitation.send_again
|
|
105
|
+
def self.cancel_invitation(invitation) = invitation.destroy!
|
|
106
|
+
def self.removable?(member) = !member.owner?
|
|
107
|
+
def self.remove(member) = member.destroy!
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The source's `members(account_id)` returns the account's members as a relation that responds to `find`, `invite` sends the app's own invitation, `invitations(account_id)` returns the ones still waiting as a relation that responds to `find`, `resend_invitation` sends one again, `cancel_invitation` withdraws one, `removable?` says whether a member may be removed at all (such as the account owner), and `remove` takes the member off the account; each responds to `name` and `email`. A source may also answer `member_for(account_id:, person:)`, which gives back the membership a signed-in person holds in that account — needed when a host app hands the person rather than their membership, and left out when the two are the same thing
|
|
112
|
+
and includes `AccountAuthz::Member`. Engine controllers inherit the host's
|
|
113
|
+
`ApplicationController`, so the host's sign-in, `current_member` and
|
|
114
|
+
`AccountAuthz::Current.account_id` apply. The page renders with keystone_ui inside the
|
|
115
|
+
layout the host's `ApplicationController` uses, so the host loads keystone_ui's
|
|
116
|
+
styles. Route helpers in that layout, such as `root_path`, reach the host's own
|
|
117
|
+
routes without a `main_app.` prefix.
|
|
118
|
+
|
|
119
|
+
**Role pages (engine).** The members page links to them with a Roles link
|
|
120
|
+
shown only to people who hold the roles capability, and the roles page links
|
|
121
|
+
back to the members page, so an app needs one navigation entry for both. At `/account_authz/roles`, a member who holds the roles
|
|
122
|
+
capability in the current account lists that account's roles, creates a role
|
|
123
|
+
with a name and chosen capabilities, adds a role from each default template,
|
|
124
|
+
and renames a role or changes its capabilities. Anyone else, or a request with
|
|
125
|
+
no current account, gets 403, and another account's roles cannot be changed.
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
AccountAuthz.roles_capability = :manage_team # default :manage_roles
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Ranks (who may manage whom).** Each role has an integer `rank` (default 0),
|
|
132
|
+
set on the role form. A manager gives or takes only roles ranked below their
|
|
133
|
+
highest role in the account, and changes only members whose highest role ranks
|
|
134
|
+
below it. A manager holding a role at the account's top rank reaches every
|
|
135
|
+
member and role. The members page shows only the changes the viewer may make,
|
|
136
|
+
and a refused change sent directly gets 403. `AccountAuthz::Reach` answers the same
|
|
137
|
+
questions for host code:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
reach = AccountAuthz::Reach.new(current_member, account_id: account.id)
|
|
141
|
+
reach.includes_member?(member) # => true/false
|
|
142
|
+
reach.includes_role?(role) # => true/false
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Keeping a member manager.** An account always keeps at least one member who
|
|
146
|
+
holds the members capability. Taking away that member's last members role,
|
|
147
|
+
removing that member, or unticking the members capability on the only role
|
|
148
|
+
anyone holds it through is refused with 403, and the members page hides those
|
|
149
|
+
buttons. `AccountAuthz::LastManager` answers the same questions for host code:
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
last_manager = AccountAuthz::LastManager.new(account_id: account.id)
|
|
153
|
+
last_manager.lost_by_taking?(member, role) # => true/false
|
|
154
|
+
last_manager.lost_by_removing?(member) # => true/false
|
|
155
|
+
last_manager.lost_by_changing?(role, capabilities) # => true/false
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Editor limits.** On the role pages an editor can only create or add roles
|
|
159
|
+
with capabilities they hold themselves, including roles added from a template,
|
|
160
|
+
can only set a rank up to their own highest role, and can only change roles
|
|
161
|
+
ranked below their own unless they hold the account's top rank.
|
|
162
|
+
`reach.includes_capabilities?(capabilities)` and `reach.includes_rank?(rank)`
|
|
163
|
+
answer the same questions for host code.
|
|
164
|
+
|
|
165
|
+
**Refused changes.** When a manager tries a change their rank or the last
|
|
166
|
+
manager rule does not allow, account_authz sends them back to the page they came from
|
|
167
|
+
with a flash alert saying why, and changes nothing. The host's layout shows the
|
|
168
|
+
flash. Reword a message under `account_authz.refusals` in the app's locale files:
|
|
169
|
+
|
|
170
|
+
```yaml
|
|
171
|
+
en:
|
|
172
|
+
account_authz:
|
|
173
|
+
refusals:
|
|
174
|
+
role_out_of_reach: "You can only give or take roles ranked below your own."
|
|
175
|
+
member_out_of_reach: "You can only change members ranked below you."
|
|
176
|
+
last_manager: "Someone else needs to be able to manage members first."
|
|
177
|
+
not_removable: "This member can't be removed from the account."
|
|
178
|
+
capabilities_out_of_reach: "You can only give a role capabilities you have yourself."
|
|
179
|
+
rank_out_of_reach: "You can only set a rank up to your own."
|
|
180
|
+
role_edit_out_of_reach: "You can only change roles ranked below your own."
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
A request from someone without the page's capability still gets 403.
|
|
184
|
+
|
|
185
|
+
### Install
|
|
186
|
+
|
|
187
|
+
AccountAuthz is a Rails engine; install it correctly with the engine flow — not a
|
|
188
|
+
plain `gem install`:
|
|
189
|
+
|
|
190
|
+
1. Add the gem (git source until it is on RubyGems), then `bundle install`:
|
|
191
|
+
```ruby
|
|
192
|
+
gem "account_authz", github: "DYB-Development/account_authz", branch: "main"
|
|
193
|
+
```
|
|
194
|
+
2. Install and run the engine's migrations — this creates the `account_authz_roles`
|
|
195
|
+
and `account_authz_assignments` tables:
|
|
196
|
+
```bash
|
|
197
|
+
bin/rails account_authz:install:migrations
|
|
198
|
+
bin/rails db:migrate
|
|
199
|
+
```
|
|
200
|
+
3. Declare the capability catalog in code (e.g. `config/initializers/account_authz.rb`)
|
|
201
|
+
with `AccountAuthz.catalog do … end`.
|
|
202
|
+
4. Include `AccountAuthz::Member` in the host's member/role-holding model.
|
|
203
|
+
5. Include `AccountAuthz::Authorization` in `ApplicationController`, and set
|
|
204
|
+
`AccountAuthz::Current.account_id` per request (e.g. a `before_action`).
|
|
205
|
+
6. Optional: declare templates with `AccountAuthz.templates`, and call
|
|
206
|
+
`AccountAuthz.seed_default_roles(account_id)` when provisioning a new account.
|
|
207
|
+
|
|
208
|
+
7. Optional: mount `AccountAuthz::Engine` and set `AccountAuthz.members_source` to serve
|
|
209
|
+
the members page.
|
|
210
|
+
|
|
211
|
+
AccountAuthz owns no role *storage* beyond its own tables. The host signs members in,
|
|
212
|
+
sets the current account, and supplies the members the members page lists.
|
|
213
|
+
|
|
214
|
+
### AccountAuthz conventions
|
|
215
|
+
|
|
216
|
+
- **A member is a person inside one account.** Include `AccountAuthz::Member` in the
|
|
217
|
+
membership record rather than the person, so roles cannot outlive the
|
|
218
|
+
membership or reach an account the person never joined.
|
|
219
|
+
- **Capabilities are code, roles are data.** Define capability keys only in the
|
|
220
|
+
catalog; never persist capability *definitions* as data, and never hardcode
|
|
221
|
+
role *records* in code (seed them from templates instead).
|
|
222
|
+
- A role's `capabilities` must be a subset of the catalog — adding an unknown key
|
|
223
|
+
is a validation error, by design.
|
|
224
|
+
- Resolution is grant-based: `can?` tests membership in the union of the
|
|
225
|
+
member's role capabilities, optionally scoped by `account_id`.
|
|
226
|
+
- Enforce through Pundit policies via `can?(capability)`; don't re-derive
|
|
227
|
+
permissions ad hoc in controllers or views.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AccountAuthz
|
|
4
|
+
# Single source of truth for account_authz's user-facing API, read by the
|
|
5
|
+
# the_local companion subagents so their guidance never drifts from the docs.
|
|
6
|
+
module Reference
|
|
7
|
+
DIR = File.expand_path("reference", __dir__)
|
|
8
|
+
|
|
9
|
+
def self.content
|
|
10
|
+
read("guide.md")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.read(name)
|
|
14
|
+
File.read(File.join(DIR, name)).chomp
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|