account_authz 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +45 -0
  4. data/Rakefile +11 -0
  5. data/app/controllers/account_authz/application_controller.rb +19 -0
  6. data/app/controllers/account_authz/invitations_controller.rb +30 -0
  7. data/app/controllers/account_authz/member_roles_controller.rb +27 -0
  8. data/app/controllers/account_authz/members_controller.rb +38 -0
  9. data/app/controllers/account_authz/roles_controller.rb +56 -0
  10. data/app/controllers/concerns/account_authz/authorization.rb +18 -0
  11. data/app/helpers/account_authz/app_routes_helper.rb +12 -0
  12. data/app/helpers/account_authz/application_helper.rb +4 -0
  13. data/app/models/account_authz/account_id.rb +9 -0
  14. data/app/models/account_authz/acting_member.rb +11 -0
  15. data/app/models/account_authz/application_record.rb +5 -0
  16. data/app/models/account_authz/assignment.rb +8 -0
  17. data/app/models/account_authz/cancel_invitation.rb +23 -0
  18. data/app/models/account_authz/current.rb +7 -0
  19. data/app/models/account_authz/give_role.rb +34 -0
  20. data/app/models/account_authz/invite.rb +17 -0
  21. data/app/models/account_authz/last_manager.rb +43 -0
  22. data/app/models/account_authz/reach.rb +46 -0
  23. data/app/models/account_authz/remove_member.rb +34 -0
  24. data/app/models/account_authz/resend_invitation.rb +23 -0
  25. data/app/models/account_authz/result.rb +24 -0
  26. data/app/models/account_authz/role.rb +24 -0
  27. data/app/models/account_authz/take_role.rb +35 -0
  28. data/app/models/concerns/account_authz/member.rb +33 -0
  29. data/app/policies/account_authz/application_policy.rb +16 -0
  30. data/app/views/account_authz/invitations/new.html.erb +10 -0
  31. data/app/views/account_authz/members/_team.html.erb +84 -0
  32. data/app/views/account_authz/members/index.html.erb +59 -0
  33. data/app/views/account_authz/members/show.html.erb +38 -0
  34. data/app/views/account_authz/roles/_form.html.erb +16 -0
  35. data/app/views/account_authz/roles/edit.html.erb +4 -0
  36. data/app/views/account_authz/roles/index.html.erb +21 -0
  37. data/app/views/account_authz/roles/new.html.erb +4 -0
  38. data/config/locales/en.yml +10 -0
  39. data/config/routes.rb +9 -0
  40. data/db/migrate/20260607000001_create_citizen_roles.rb +15 -0
  41. data/db/migrate/20260607000002_create_citizen_assignments.rb +15 -0
  42. data/db/migrate/20260916000001_add_rank_to_citizen_roles.rb +7 -0
  43. data/db/migrate/20260921220000_rename_citizen_tables_to_account_authz.rb +9 -0
  44. data/lib/account_authz/catalog.rb +24 -0
  45. data/lib/account_authz/engine.rb +7 -0
  46. data/lib/account_authz/reference/guide.md +227 -0
  47. data/lib/account_authz/reference.rb +17 -0
  48. data/lib/account_authz/templates.rb +27 -0
  49. data/lib/account_authz/version.rb +5 -0
  50. data/lib/account_authz.rb +59 -0
  51. data/lib/tasks/account_authz_tasks.rake +4 -0
  52. data/the_local/agents/account_authz-develop.md +252 -0
  53. data/the_local/agents/account_authz-info.md +222 -0
  54. data/the_local/agents/account_authz-install.md +80 -0
  55. data/the_local/interface.yml +71 -0
  56. metadata +160 -0
@@ -0,0 +1,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,4 @@
1
+ <%= ui_page(max_width: :md) do %>
2
+ <%= ui_form_page(title: "Edit role", back_url: roles_path) %>
3
+ <%= render "form", role: @role, url: role_path(@role), form_method: :patch %>
4
+ <% 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,4 @@
1
+ <%= ui_page(max_width: :md) do %>
2
+ <%= ui_form_page(title: "New role", back_url: roles_path) %>
3
+ <%= render "form", role: @role, url: roles_path, form_method: :post %>
4
+ <% 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddRankToCitizenRoles < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_column :citizen_roles, :rank, :integer, null: false, default: 0
6
+ end
7
+ 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,7 @@
1
+ require "keystone_ui"
2
+
3
+ module AccountAuthz
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace AccountAuthz
6
+ end
7
+ 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