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
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,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,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,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,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
|