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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c1e9672c8fabc32992a01109eb826f621b631115b131911a9004d0468c1ed969
4
+ data.tar.gz: b1d4e2cc0be088d2cc0bcfbd2dcdb6b71feadce5bff36113aa3732142620ed9f
5
+ SHA512:
6
+ metadata.gz: 2706e214202cdc4e4a2fedcfad8a55b18cdb54b4f3e3fa3c583da7b7550337bdfe2a8d8a62b0e5323ed94e84c77c01e12b0bbbbfa3a07f19e6bfb55baf2b1b75
7
+ data.tar.gz: c5769acd763ab5ab17be67746eb184b61f239360ab717c109384b8bd49151c442aa61f2e0b521076b8161188ed732ca87cd439569cb0970d34c78df1f76dabe9
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 tylercschneider
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # AccountAuthz
2
+
3
+ Capability-based authorization for multi-tenant apps. **Capabilities are code;
4
+ roles are data.**
5
+
6
+ - The app declares a fixed **catalog** of capabilities (permission + metric keys)
7
+ in code — what the software *can* do.
8
+ - Accounts manage **roles** (data) that bundle those capabilities.
9
+ - AccountAuthz resolves what a member may do (`can?`) and which metrics they may see
10
+ (`approved_metrics`) from the roles they hold, and plugs into **Pundit** for
11
+ enforcement.
12
+
13
+ AccountAuthz owns **no role storage** — hosts bring their own (rolify, Jumpstart
14
+ `AccountUser` roles, …) via an injectable resolver. It stays a thin, reusable
15
+ layer over Pundit + your existing roles.
16
+
17
+ ## Why not just Pundit?
18
+
19
+ Pundit enforces a decision but has no capability *catalog*, no role *records*,
20
+ and no "which metrics may this member see" *list*. AccountAuthz adds exactly those —
21
+ the parts you'd otherwise hand-roll in every app — and nothing else.
22
+
23
+ ## Usage (sketch)
24
+
25
+ ```ruby
26
+ AccountAuthz.catalog do
27
+ permission :view_fulfillment
28
+ metric :revenue
29
+ metric :deals
30
+ end
31
+
32
+ # roles -> capabilities comes from the host (data); AccountAuthz resolves:
33
+ AccountAuthz.can?(grants, :view_fulfillment) # => true/false
34
+ AccountAuthz.approved_metrics(grants) # => [:revenue, :deals]
35
+ ```
36
+
37
+ ## Installation
38
+
39
+ ```ruby
40
+ gem "account_authz"
41
+ ```
42
+
43
+ ## License
44
+
45
+ [MIT](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
7
+
8
+ task test: "app:test"
9
+ task default: :test
10
+
11
+ require "the_local/rake"
@@ -0,0 +1,19 @@
1
+ module AccountAuthz
2
+ class ApplicationController < ::ApplicationController
3
+ include AccountAuthz::Authorization
4
+
5
+ helper KeystoneUiHelper, AccountAuthz::Engine.routes.url_helpers, AccountAuthz::AppRoutesHelper
6
+
7
+ before_action { AccountAuthz::AppRoutesHelper.define_app_route_helpers }
8
+
9
+ def self.requires_capability(&capability)
10
+ before_action { head :forbidden unless can?(capability.call) }
11
+ end
12
+
13
+ private
14
+
15
+ def refuse(reason, back_to:)
16
+ redirect_to back_to, alert: t("account_authz.refusals.#{reason}")
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,30 @@
1
+ module AccountAuthz
2
+ class InvitationsController < ApplicationController
3
+ requires_capability { AccountAuthz.members_capability }
4
+
5
+ def new
6
+ end
7
+
8
+ def create
9
+ run Invite, name: params.require(:invitation)[:name], email: params.require(:invitation)[:email]
10
+ end
11
+
12
+ def resend
13
+ run ResendInvitation, invitation_id: params[:id]
14
+ end
15
+
16
+ def destroy
17
+ run CancelInvitation, invitation_id: params[:id]
18
+ end
19
+
20
+ private
21
+
22
+ def run(action, **values)
23
+ result = action.new(person: current_member, account: Current.account_id, values: values).call
24
+
25
+ return redirect_to(members_path, alert: result.message) unless result.ok?
26
+
27
+ redirect_to members_path
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,27 @@
1
+ module AccountAuthz
2
+ class MemberRolesController < ApplicationController
3
+ requires_capability { AccountAuthz.members_capability }
4
+
5
+ def create
6
+ run GiveRole
7
+ end
8
+
9
+ def destroy
10
+ run TakeRole
11
+ end
12
+
13
+ private
14
+
15
+ def run(action)
16
+ result = action.new(person: current_member, account: Current.account_id, values: submitted_values).call
17
+
18
+ return redirect_to(members_path, alert: result.message) unless result.ok?
19
+
20
+ redirect_to members_path
21
+ end
22
+
23
+ def submitted_values
24
+ { member_id: params[:member_id], role_id: params[:role_id] || params[:id] }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,38 @@
1
+ module AccountAuthz
2
+ class MembersController < ApplicationController
3
+ requires_capability { AccountAuthz.members_capability }
4
+
5
+ def index
6
+ @members = AccountAuthz.members_source.members(Current.account_id)
7
+ @held_roles = held_roles
8
+ @invitations = AccountAuthz.members_source.invitations(Current.account_id)
9
+ end
10
+
11
+ def show
12
+ @member = AccountAuthz.members_source.members(Current.account_id).find(params[:id])
13
+ @held = Assignment.where(member: @member, role: Role.in_account(Current.account_id)).includes(:role).map(&:role)
14
+ @reach = reach
15
+ @last_manager = LastManager.new(account_id: Current.account_id)
16
+ @roles = Role.in_account(Current.account_id).select { |role| reach.includes_role?(role) }
17
+ end
18
+
19
+ def destroy
20
+ result = RemoveMember.new(person: current_member, account: Current.account_id, values: { member_id: params[:id] }).call
21
+
22
+ return redirect_to(members_path, alert: result.message) unless result.ok?
23
+
24
+ redirect_to members_path
25
+ end
26
+
27
+ private
28
+
29
+ def held_roles
30
+ Assignment.where(member: @members.to_a, role: Role.in_account(Current.account_id)).includes(:role)
31
+ .group_by(&:member_id).transform_values { |assignments| assignments.map(&:role) }
32
+ end
33
+
34
+ def reach
35
+ @reach ||= Reach.new(current_member, account_id: Current.account_id)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,56 @@
1
+ module AccountAuthz
2
+ class RolesController < ApplicationController
3
+ requires_capability { AccountAuthz.roles_capability }
4
+
5
+ def index
6
+ @roles = Role.in_account(Current.account_id)
7
+ end
8
+
9
+ def new
10
+ @role = Role.new(account_id: Current.account_id)
11
+ end
12
+
13
+ def create
14
+ if params[:template].present?
15
+ return refuse(:capabilities_out_of_reach, back_to: roles_path) unless reach.includes_capabilities?(AccountAuthz.templates.find(params[:template].to_sym).capabilities)
16
+
17
+ Role.from_template(account_id: Current.account_id, template: params[:template].to_sym)
18
+ else
19
+ return refuse(:rank_out_of_reach, back_to: new_role_path) if role_params.key?(:rank) && !reach.includes_rank?(role_params[:rank])
20
+ return refuse(:capabilities_out_of_reach, back_to: new_role_path) unless reach.includes_capabilities?(role_params[:capabilities])
21
+
22
+ Role.create!(account_id: Current.account_id, **role_params)
23
+ end
24
+
25
+ redirect_to roles_path
26
+ end
27
+
28
+ def edit
29
+ @role = Role.in_account(Current.account_id).find(params[:id])
30
+ end
31
+
32
+ def update
33
+ role = Role.in_account(Current.account_id).find(params[:id])
34
+ return refuse(:role_edit_out_of_reach, back_to: roles_path) unless reach.includes_role?(role)
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
+ return refuse(:capabilities_out_of_reach, back_to: edit_role_path(role)) unless reach.includes_capabilities?(Array(role_params[:capabilities]) - role.capabilities)
37
+ return refuse(:last_manager, back_to: edit_role_path(role)) if role_params.key?(:capabilities) && LastManager.new(account_id: Current.account_id).lost_by_changing?(role, role_params[:capabilities])
38
+
39
+ role.update!(**role_params)
40
+
41
+ redirect_to roles_path
42
+ end
43
+
44
+ private
45
+
46
+ def reach
47
+ @reach ||= Reach.new(current_member, account_id: Current.account_id)
48
+ end
49
+
50
+ def role_params
51
+ permitted = params.require(:role).permit(:name, :rank, capabilities: []).to_h.symbolize_keys
52
+ permitted[:capabilities] = permitted[:capabilities].compact_blank if permitted.key?(:capabilities)
53
+ permitted
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pundit"
4
+
5
+ module AccountAuthz
6
+ module Authorization
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ include Pundit::Authorization
11
+ helper_method :can?
12
+ end
13
+
14
+ def can?(capability)
15
+ Current.account_id.present? && current_member&.can?(capability, account_id: Current.account_id) || false
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,12 @@
1
+ module AccountAuthz
2
+ module AppRoutesHelper
3
+ def self.define_app_route_helpers
4
+ @app_route_helpers_defined ||= begin
5
+ (Rails.application.routes.named_routes.helper_names - AccountAuthz::Engine.routes.named_routes.helper_names).each do |name|
6
+ define_method(name) { |*args, **options| main_app.public_send(name, *args, **options) }
7
+ end
8
+ true
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,4 @@
1
+ module AccountAuthz
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccountAuthz
4
+ module AccountId
5
+ def self.from(account)
6
+ account.respond_to?(:id) ? account.id : account
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccountAuthz
4
+ module ActingMember
5
+ def self.for(person:, account:, source: AccountAuthz.members_source)
6
+ return person unless source.respond_to?(:member_for)
7
+
8
+ source.member_for(account_id: AccountId.from(account), person: person) || person
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ module AccountAuthz
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccountAuthz
4
+ class Assignment < ApplicationRecord
5
+ belongs_to :member, polymorphic: true
6
+ belongs_to :role, class_name: "AccountAuthz::Role"
7
+ end
8
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccountAuthz
4
+ class CancelInvitation
5
+ def initialize(person:, account:, values:)
6
+ @person = person
7
+ @account = AccountId.from(account)
8
+ @values = values
9
+ end
10
+
11
+ def call
12
+ AccountAuthz.members_source.cancel_invitation(invitation)
13
+
14
+ Result.ok
15
+ end
16
+
17
+ private
18
+
19
+ def invitation
20
+ AccountAuthz.members_source.invitations(@account).find(@values[:invitation_id])
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccountAuthz
4
+ class Current < ActiveSupport::CurrentAttributes
5
+ attribute :account_id
6
+ end
7
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccountAuthz
4
+ class GiveRole
5
+ def initialize(person:, account:, values:)
6
+ @person = person
7
+ @account = AccountId.from(account)
8
+ @values = values
9
+ end
10
+
11
+ def call
12
+ return Result.refused(:member_out_of_reach) unless reach.includes_member?(member)
13
+ return Result.refused(:role_out_of_reach) unless reach.includes_role?(role)
14
+
15
+ member.assign_role(role)
16
+
17
+ Result.ok
18
+ end
19
+
20
+ private
21
+
22
+ def reach
23
+ @reach ||= Reach.new(ActingMember.for(person: @person, account: @account), account_id: @account)
24
+ end
25
+
26
+ def member
27
+ @member ||= AccountAuthz.members_source.members(@account).find(@values[:member_id])
28
+ end
29
+
30
+ def role
31
+ @role ||= Role.in_account(@account).find(@values[:role_id])
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccountAuthz
4
+ class Invite
5
+ def initialize(person:, account:, values:)
6
+ @person = person
7
+ @account = AccountId.from(account)
8
+ @values = values
9
+ end
10
+
11
+ def call
12
+ AccountAuthz.members_source.invite(account_id: @account, invited_by: @person, name: @values[:name], email: @values[:email])
13
+
14
+ Result.ok
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccountAuthz
4
+ class LastManager
5
+ def initialize(account_id:)
6
+ @account_id = account_id
7
+ end
8
+
9
+ def lost_by_taking?(member, role)
10
+ managing_assignments.none? { |assignment| !holds?(assignment, member, role) }
11
+ end
12
+
13
+ def lost_by_removing?(member)
14
+ managing_assignments.none? { |assignment| !held_by?(assignment, member) }
15
+ end
16
+
17
+ def lost_by_changing?(role, capabilities)
18
+ managing_role_ids.include?(role.id) && !grants_members_capability?(capabilities) && managing_assignments.none? { |assignment| assignment.role_id != role.id }
19
+ end
20
+
21
+ private
22
+
23
+ def managing_assignments
24
+ @managing_assignments ||= Assignment.where(role_id: managing_role_ids).to_a
25
+ end
26
+
27
+ def holds?(assignment, member, role)
28
+ held_by?(assignment, member) && assignment.role_id == role.id
29
+ end
30
+
31
+ def held_by?(assignment, member)
32
+ assignment.member_id == member.id && assignment.member_type == member.class.polymorphic_name
33
+ end
34
+
35
+ def managing_role_ids
36
+ @managing_role_ids ||= Role.in_account(@account_id).select { |role| grants_members_capability?(role.capabilities) }.map(&:id)
37
+ end
38
+
39
+ def grants_members_capability?(capabilities)
40
+ AccountAuthz.can?(Array(capabilities).map(&:to_sym), AccountAuthz.members_capability)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccountAuthz
4
+ class Reach
5
+ def initialize(manager, account_id:)
6
+ @manager = manager
7
+ @account_id = account_id
8
+ end
9
+
10
+ def includes_member?(member)
11
+ includes_roles?(member.account_authz_roles.in_account(@account_id))
12
+ end
13
+
14
+ def includes_roles?(roles)
15
+ top_rank? || (roles.map(&:rank).max || -Float::INFINITY) < own_rank
16
+ end
17
+
18
+ def includes_role?(role)
19
+ top_rank? || role.rank < own_rank
20
+ end
21
+
22
+ def includes_capabilities?(capabilities)
23
+ (Array(capabilities).map(&:to_sym) - @manager.capabilities(account_id: @account_id)).empty?
24
+ end
25
+
26
+ def includes_rank?(rank)
27
+ rank.to_i <= own_rank
28
+ end
29
+
30
+ private
31
+
32
+ def top_rank?
33
+ return @top_rank unless @top_rank.nil?
34
+
35
+ @top_rank = own_rank.finite? && own_rank >= Role.in_account(@account_id).maximum(:rank)
36
+ end
37
+
38
+ def own_rank
39
+ @own_rank ||= highest_rank(@manager)
40
+ end
41
+
42
+ def highest_rank(member)
43
+ member.account_authz_roles.in_account(@account_id).maximum(:rank) || -Float::INFINITY
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccountAuthz
4
+ class RemoveMember
5
+ def initialize(person:, account:, values:)
6
+ @person = person
7
+ @account = AccountId.from(account)
8
+ @values = values
9
+ end
10
+
11
+ def call
12
+ return Result.refused(:member_out_of_reach) unless reach.includes_member?(member)
13
+ return Result.refused(:not_removable) unless AccountAuthz.members_source.removable?(member)
14
+ return Result.refused(:last_manager) if LastManager.new(account_id: @account).lost_by_removing?(member)
15
+
16
+ ApplicationRecord.transaction do
17
+ member.account_authz_roles.in_account(@account).each { |role| member.revoke_role(role) }
18
+ AccountAuthz.members_source.remove(member)
19
+ end
20
+
21
+ Result.ok
22
+ end
23
+
24
+ private
25
+
26
+ def reach
27
+ @reach ||= Reach.new(ActingMember.for(person: @person, account: @account), account_id: @account)
28
+ end
29
+
30
+ def member
31
+ @member ||= AccountAuthz.members_source.members(@account).find(@values[:member_id])
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccountAuthz
4
+ class ResendInvitation
5
+ def initialize(person:, account:, values:)
6
+ @person = person
7
+ @account = AccountId.from(account)
8
+ @values = values
9
+ end
10
+
11
+ def call
12
+ AccountAuthz.members_source.resend_invitation(invitation)
13
+
14
+ Result.ok
15
+ end
16
+
17
+ private
18
+
19
+ def invitation
20
+ AccountAuthz.members_source.invitations(@account).find(@values[:invitation_id])
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccountAuthz
4
+ class Result
5
+ attr_reader :message
6
+
7
+ def self.ok
8
+ new(ok: true)
9
+ end
10
+
11
+ def self.refused(reason)
12
+ new(ok: false, message: I18n.t("account_authz.refusals.#{reason}"))
13
+ end
14
+
15
+ def initialize(ok:, message: nil)
16
+ @ok = ok
17
+ @message = message
18
+ end
19
+
20
+ def ok?
21
+ @ok
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccountAuthz
4
+ class Role < ApplicationRecord
5
+ validates :name, presence: true
6
+ validate :capabilities_within_catalog
7
+
8
+ scope :in_account, ->(account_id) { where(account_id: account_id) }
9
+
10
+ def self.from_template(account_id:, template:)
11
+ definition = AccountAuthz.templates.find(template)
12
+ create!(account_id: account_id, name: definition.role_name, capabilities: definition.capabilities)
13
+ end
14
+
15
+ private
16
+
17
+ def capabilities_within_catalog
18
+ known = AccountAuthz.capabilities.map(&:to_s)
19
+ (Array(capabilities).map(&:to_s) - known).each do |unknown|
20
+ errors.add(:capabilities, "includes unknown capability: #{unknown}")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccountAuthz
4
+ class TakeRole
5
+ def initialize(person:, account:, values:)
6
+ @person = person
7
+ @account = AccountId.from(account)
8
+ @values = values
9
+ end
10
+
11
+ def call
12
+ return Result.refused(:member_out_of_reach) unless reach.includes_member?(member)
13
+ return Result.refused(:role_out_of_reach) unless reach.includes_role?(role)
14
+ return Result.refused(:last_manager) if LastManager.new(account_id: @account).lost_by_taking?(member, role)
15
+
16
+ member.revoke_role(role)
17
+
18
+ Result.ok
19
+ end
20
+
21
+ private
22
+
23
+ def reach
24
+ @reach ||= Reach.new(ActingMember.for(person: @person, account: @account), account_id: @account)
25
+ end
26
+
27
+ def member
28
+ @member ||= AccountAuthz.members_source.members(@account).find(@values[:member_id])
29
+ end
30
+
31
+ def role
32
+ @role ||= Role.in_account(@account).find(@values[:role_id])
33
+ end
34
+ end
35
+ end