organizations 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/.rubocop_todo.yml +7 -0
- data/.simplecov +17 -10
- data/CHANGELOG.md +102 -0
- data/README.md +704 -33
- data/app/controllers/organizations/invitations_controller.rb +27 -22
- data/app/controllers/organizations/memberships_controller.rb +37 -30
- data/app/controllers/organizations/organizations_controller.rb +8 -8
- data/app/controllers/organizations/public_invitations_controller.rb +1 -1
- data/app/mailers/organizations/invitation_mailer.rb +2 -2
- data/app/mailers/organizations/verification_mailer.rb +1 -1
- data/app/models/organizations/grant.rb +6 -0
- data/app/views/organizations/invitation_mailer/invitation_email.html.erb +11 -11
- data/app/views/organizations/invitation_mailer/invitation_email.text.erb +7 -7
- data/app/views/organizations/verification_mailer/code_email.html.erb +3 -3
- data/app/views/organizations/verification_mailer/code_email.text.erb +3 -3
- data/config/locales/en.yml +22 -0
- data/config/locales/es.yml +22 -0
- data/config/routes.rb +1 -2
- data/gemfiles/rails_7.2.gemfile +5 -1
- data/gemfiles/rails_8.1.gemfile +5 -1
- data/lib/generators/organizations/grants/grants_generator.rb +47 -0
- data/lib/generators/organizations/grants/templates/create_organizations_grants.rb.erb +59 -0
- data/lib/generators/organizations/install/install_generator.rb +1 -0
- data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +15 -0
- data/lib/generators/organizations/join/join_generator.rb +113 -0
- data/lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb +146 -0
- data/lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb +93 -0
- data/lib/generators/organizations/join/templates/app/views/organization_access/show.html.erb +142 -0
- data/lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb +8 -0
- data/lib/generators/organizations/join/templates/app/views/organization_joins/show.html.erb +113 -0
- data/lib/generators/organizations/join/templates/routes.rb +19 -0
- data/lib/generators/organizations/upgrade/templates/add_domain_verification.rb.erb +29 -0
- data/lib/generators/organizations/upgrade/templates/add_personal_organizations.rb.erb +22 -0
- data/lib/generators/organizations/upgrade/upgrade_generator.rb +79 -14
- data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +1 -1
- data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +2 -2
- data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +1 -1
- data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +21 -13
- data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +1 -1
- data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +3 -3
- data/lib/generators/organizations/views/views_generator.rb +4 -6
- data/lib/organizations/account_deletion_plan.rb +118 -0
- data/lib/organizations/configuration.rb +152 -0
- data/lib/organizations/controller_helpers.rb +55 -15
- data/lib/organizations/grant_access.rb +75 -0
- data/lib/organizations/grant_holder.rb +152 -0
- data/lib/organizations/grantable.rb +28 -0
- data/lib/organizations/join_flow.rb +16 -12
- data/lib/organizations/models/concerns/has_organizations.rb +224 -9
- data/lib/organizations/models/domain.rb +283 -1
- data/lib/organizations/models/grant.rb +152 -0
- data/lib/organizations/models/invitation.rb +76 -7
- data/lib/organizations/models/join_request.rb +102 -43
- data/lib/organizations/models/membership.rb +87 -5
- data/lib/organizations/models/organization.rb +219 -48
- data/lib/organizations/organization_scoped.rb +16 -0
- data/lib/organizations/personal_owner_backfill.rb +122 -0
- data/lib/organizations/public_email_domains.rb +49 -0
- data/lib/organizations/resource.rb +11 -0
- data/lib/organizations/roles.rb +55 -0
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations/view_helpers.rb +40 -9
- data/lib/organizations.rb +99 -4
- metadata +22 -2
|
@@ -87,6 +87,49 @@ module Organizations
|
|
|
87
87
|
# Can be a String or a Proc/Lambda: ->(user) { "#{user.name}'s Workspace" }
|
|
88
88
|
attr_accessor :default_organization_name
|
|
89
89
|
|
|
90
|
+
# Record the auto-created organization as the user's personal
|
|
91
|
+
# organization (user.personal_organization, organization.personal?).
|
|
92
|
+
# Default false: auto-created organizations stay ordinary organizations,
|
|
93
|
+
# as before. Needs the personal_owner_id column (rails g organizations:upgrade).
|
|
94
|
+
attr_accessor :record_personal_organizations
|
|
95
|
+
|
|
96
|
+
# What happens when a user who owns organizations is deleted:
|
|
97
|
+
# :prevent (default) - any owned organization blocks the deletion
|
|
98
|
+
# :destroy_personal_organization - the user's personal organization is
|
|
99
|
+
# deleted with the account when the user is its only member; any other
|
|
100
|
+
# owned organization still blocks the deletion
|
|
101
|
+
# Preview the result with user.account_deletion_plan.
|
|
102
|
+
attr_reader :account_deletion_policy
|
|
103
|
+
|
|
104
|
+
def account_deletion_policy=(value)
|
|
105
|
+
@account_deletion_policy = value.is_a?(String) ? value.to_sym : value
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
ACCOUNT_DELETION_POLICIES = %i[prevent destroy_personal_organization].freeze
|
|
109
|
+
|
|
110
|
+
# === Vocabulary (v0.7.0) ===
|
|
111
|
+
# Organization-specific wording, display text only. nil (default) keeps
|
|
112
|
+
# the ordinary translations. A Symbol/String selects one vocabulary for
|
|
113
|
+
# the whole app; a callable receives the organization:
|
|
114
|
+
# config.vocabulary = :classroom
|
|
115
|
+
# config.vocabulary = ->(organization) { organization.metadata["vocabulary"] }
|
|
116
|
+
# Translations live under organizations.vocabularies.<name>.* in your
|
|
117
|
+
# locale files; a missing key falls back to the ordinary one.
|
|
118
|
+
attr_accessor :vocabulary
|
|
119
|
+
|
|
120
|
+
# === Scoped resource grants (v0.7.0) ===
|
|
121
|
+
# Roles at or above this one see every resource; roles below it see only
|
|
122
|
+
# the resources granted to them. nil = grants limit every role.
|
|
123
|
+
attr_accessor :full_resource_access_role
|
|
124
|
+
|
|
125
|
+
# The capability names a grant may list (nil = any name).
|
|
126
|
+
# config.grant_capabilities = %i[read edit approve]
|
|
127
|
+
attr_accessor :grant_capabilities
|
|
128
|
+
|
|
129
|
+
# The permission a member needs to grant or revoke (default
|
|
130
|
+
# :edit_member_roles). Checked when granted_by:/revoked_by: is a member.
|
|
131
|
+
attr_accessor :grant_management_permission
|
|
132
|
+
|
|
90
133
|
# === Invitations ===
|
|
91
134
|
# How long invitations are valid
|
|
92
135
|
attr_accessor :invitation_expiry
|
|
@@ -124,6 +167,31 @@ module Organizations
|
|
|
124
167
|
# inbox control, skipping the emailed code when the domain matches.
|
|
125
168
|
attr_accessor :trust_confirmed_account_email
|
|
126
169
|
|
|
170
|
+
# === Domain authority (v0.7.0) ===
|
|
171
|
+
# Admit through a domain only when the organization proved control of it
|
|
172
|
+
# (DNS TXT record or operator approval). Default false: unverified domains
|
|
173
|
+
# keep working as before. Turn it on BEFORE you let untrusted organizers
|
|
174
|
+
# enroll domains themselves.
|
|
175
|
+
attr_accessor :require_verified_domains
|
|
176
|
+
|
|
177
|
+
# The DNS label of the TXT record: "_organizations-verification" makes
|
|
178
|
+
# the record "_organizations-verification.acme.com". Use your brand.
|
|
179
|
+
attr_accessor :domain_verification_record_prefix
|
|
180
|
+
|
|
181
|
+
# How long a DNS challenge stays open after start_verification!
|
|
182
|
+
attr_accessor :domain_verification_ttl
|
|
183
|
+
|
|
184
|
+
# Seconds the default resolver waits for a DNS answer
|
|
185
|
+
attr_accessor :domain_verification_timeout
|
|
186
|
+
|
|
187
|
+
# Custom TXT lookup: ->(record_name) { ["value", ...] }. nil = Resolv.
|
|
188
|
+
attr_accessor :domain_txt_resolver
|
|
189
|
+
|
|
190
|
+
# Email domains that can never be enrolled or admit through a domain,
|
|
191
|
+
# such as Organizations::PUBLIC_EMAIL_DOMAINS. Default: none. Invitations
|
|
192
|
+
# and allowlist joins are not affected.
|
|
193
|
+
attr_accessor :blocked_email_domains
|
|
194
|
+
|
|
127
195
|
# How long join requests stay pending before they read as expired
|
|
128
196
|
# (derived status, like invitations). nil = never expire.
|
|
129
197
|
attr_accessor :join_request_expiry
|
|
@@ -209,6 +277,7 @@ module Organizations
|
|
|
209
277
|
@engine_routes = value.nil? ? nil : normalize_engine_routes(value)
|
|
210
278
|
end
|
|
211
279
|
|
|
280
|
+
# @api private
|
|
212
281
|
# The resolved set of enabled groups.
|
|
213
282
|
# @return [Array<Symbol>]
|
|
214
283
|
def engine_route_groups
|
|
@@ -269,6 +338,12 @@ module Organizations
|
|
|
269
338
|
# Auto-creation defaults
|
|
270
339
|
@always_create_personal_organization_for_each_user = false
|
|
271
340
|
@default_organization_name = "Personal"
|
|
341
|
+
@record_personal_organizations = false
|
|
342
|
+
@vocabulary = nil
|
|
343
|
+
@full_resource_access_role = :admin
|
|
344
|
+
@grant_capabilities = nil
|
|
345
|
+
@grant_management_permission = :edit_member_roles
|
|
346
|
+
@account_deletion_policy = :prevent
|
|
272
347
|
|
|
273
348
|
# Invitation defaults
|
|
274
349
|
@invitation_expiry = 7.days
|
|
@@ -286,6 +361,14 @@ module Organizations
|
|
|
286
361
|
@join_request_expiry = 30.days
|
|
287
362
|
@join_code_generator = nil
|
|
288
363
|
|
|
364
|
+
# Domain authority defaults
|
|
365
|
+
@require_verified_domains = false
|
|
366
|
+
@domain_verification_record_prefix = "_organizations-verification"
|
|
367
|
+
@domain_verification_ttl = 7.days
|
|
368
|
+
@domain_verification_timeout = 5
|
|
369
|
+
@domain_txt_resolver = nil
|
|
370
|
+
@blocked_email_domains = []
|
|
371
|
+
|
|
289
372
|
# Limits
|
|
290
373
|
@max_organizations_per_user = nil
|
|
291
374
|
|
|
@@ -500,6 +583,7 @@ module Organizations
|
|
|
500
583
|
end
|
|
501
584
|
end
|
|
502
585
|
|
|
586
|
+
# @api private
|
|
503
587
|
# Normalize an email through the configured (or default) normalizer
|
|
504
588
|
# @param email [String, nil]
|
|
505
589
|
# @return [String] normalized email
|
|
@@ -510,6 +594,14 @@ module Organizations
|
|
|
510
594
|
EmailNormalizer.normalize(email)
|
|
511
595
|
end
|
|
512
596
|
|
|
597
|
+
# @api private
|
|
598
|
+
# The blocked email domains, lowercased.
|
|
599
|
+
# @return [Array<String>]
|
|
600
|
+
def normalized_blocked_email_domains
|
|
601
|
+
Array(@blocked_email_domains).map { |d| d.to_s.strip.downcase.delete_prefix("@") }
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# @api private
|
|
513
605
|
# Resolve the default organization name for a user
|
|
514
606
|
# @param user [Object] The user object
|
|
515
607
|
# @return [String] The organization name
|
|
@@ -528,8 +620,12 @@ module Organizations
|
|
|
528
620
|
# @raise [ConfigurationError] if configuration is invalid
|
|
529
621
|
def validate!
|
|
530
622
|
validate_authentication_methods!
|
|
623
|
+
validate_personal_organization_settings!
|
|
624
|
+
validate_vocabulary!
|
|
625
|
+
validate_grant_settings!
|
|
531
626
|
validate_invitation_settings!
|
|
532
627
|
validate_verification_settings!
|
|
628
|
+
validate_domain_authority_settings!
|
|
533
629
|
validate_limits!
|
|
534
630
|
validate_invitation_redirects!
|
|
535
631
|
validate_no_organization_messages!
|
|
@@ -553,6 +649,44 @@ module Organizations
|
|
|
553
649
|
end
|
|
554
650
|
end
|
|
555
651
|
|
|
652
|
+
def validate_grant_settings!
|
|
653
|
+
validate_full_resource_access_role!
|
|
654
|
+
validate_grant_capabilities!
|
|
655
|
+
return if @grant_management_permission.is_a?(Symbol) || @grant_management_permission.is_a?(String)
|
|
656
|
+
|
|
657
|
+
raise ConfigurationError, "grant_management_permission must be a Symbol"
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
def validate_full_resource_access_role!
|
|
661
|
+
return if @full_resource_access_role.nil?
|
|
662
|
+
return if Roles.hierarchy_for(@custom_roles_definition).include?(@full_resource_access_role.to_sym)
|
|
663
|
+
|
|
664
|
+
raise ConfigurationError, "full_resource_access_role must be nil or a configured role"
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
def validate_grant_capabilities!
|
|
668
|
+
return if @grant_capabilities.nil?
|
|
669
|
+
return if @grant_capabilities.is_a?(Array) && @grant_capabilities.all? { |c| c.is_a?(String) || c.is_a?(Symbol) }
|
|
670
|
+
|
|
671
|
+
raise ConfigurationError, "grant_capabilities must be nil or an Array of Symbols/Strings"
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
def validate_vocabulary!
|
|
675
|
+
return if @vocabulary.nil? || @vocabulary.is_a?(Symbol) || @vocabulary.is_a?(String) || @vocabulary.respond_to?(:call)
|
|
676
|
+
|
|
677
|
+
raise ConfigurationError, "vocabulary must be nil, a Symbol, a String, or callable (receives the organization)"
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def validate_personal_organization_settings!
|
|
681
|
+
unless [true, false].include?(@record_personal_organizations)
|
|
682
|
+
raise ConfigurationError, "record_personal_organizations must be true or false"
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
return if ACCOUNT_DELETION_POLICIES.include?(@account_deletion_policy)
|
|
686
|
+
|
|
687
|
+
raise ConfigurationError, "account_deletion_policy must be one of: #{ACCOUNT_DELETION_POLICIES.join(', ')}"
|
|
688
|
+
end
|
|
689
|
+
|
|
556
690
|
def validate_invitation_settings!
|
|
557
691
|
unless @invitation_expiry.nil? || @invitation_expiry.is_a?(ActiveSupport::Duration) || @invitation_expiry.is_a?(Numeric)
|
|
558
692
|
raise ConfigurationError, "invitation_expiry must be a Duration (e.g., 7.days) or nil"
|
|
@@ -581,6 +715,24 @@ module Organizations
|
|
|
581
715
|
raise ConfigurationError, "join_request_expiry must be a Duration (e.g., 30.days), Numeric, or nil (never expire)"
|
|
582
716
|
end
|
|
583
717
|
|
|
718
|
+
def validate_domain_authority_settings!
|
|
719
|
+
raise ConfigurationError, "require_verified_domains must be true or false" unless [true, false].include?(@require_verified_domains)
|
|
720
|
+
|
|
721
|
+
unless @domain_verification_record_prefix.is_a?(String) && @domain_verification_record_prefix.match?(/\A_[a-z0-9-]+\z/)
|
|
722
|
+
raise ConfigurationError, "domain_verification_record_prefix must be one DNS label that starts with an underscore"
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
validate_duration_option!(@domain_verification_ttl, "domain_verification_ttl", "7.days")
|
|
726
|
+
unless @domain_verification_timeout.is_a?(Numeric) && @domain_verification_timeout.positive?
|
|
727
|
+
raise ConfigurationError, "domain_verification_timeout must be a positive number of seconds"
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
validate_callable_option!(@domain_txt_resolver, "domain_txt_resolver")
|
|
731
|
+
return if @blocked_email_domains.is_a?(Array) && @blocked_email_domains.all?(String)
|
|
732
|
+
|
|
733
|
+
raise ConfigurationError, "blocked_email_domains must be an Array of Strings"
|
|
734
|
+
end
|
|
735
|
+
|
|
584
736
|
def duration_like?(value)
|
|
585
737
|
value.is_a?(ActiveSupport::Duration) || value.is_a?(Numeric)
|
|
586
738
|
end
|
|
@@ -43,8 +43,10 @@ module Organizations
|
|
|
43
43
|
def current_organization
|
|
44
44
|
return @_current_organization if defined?(@_current_organization)
|
|
45
45
|
|
|
46
|
+
# Nothing is memoized until a user is resolved, so a read made before
|
|
47
|
+
# sign-in does not pin nil for the rest of the request.
|
|
46
48
|
user = organizations_current_user
|
|
47
|
-
return
|
|
49
|
+
return nil unless user
|
|
48
50
|
|
|
49
51
|
session_key = Organizations.configuration.session_key
|
|
50
52
|
org_id = session[session_key]
|
|
@@ -79,7 +81,8 @@ module Organizations
|
|
|
79
81
|
return @_current_membership if defined?(@_current_membership)
|
|
80
82
|
|
|
81
83
|
user = organizations_current_user
|
|
82
|
-
return
|
|
84
|
+
return nil unless user # not memoized, as in current_organization
|
|
85
|
+
return @_current_membership = nil unless current_organization
|
|
83
86
|
|
|
84
87
|
@_current_membership = user.memberships.find_by(organization_id: current_organization.id)
|
|
85
88
|
end
|
|
@@ -449,7 +452,7 @@ module Organizations
|
|
|
449
452
|
if org
|
|
450
453
|
session[session_key] = org.id
|
|
451
454
|
@_current_organization = org
|
|
452
|
-
|
|
455
|
+
forget_current_membership!
|
|
453
456
|
|
|
454
457
|
# Update user's context
|
|
455
458
|
user = organizations_current_user
|
|
@@ -568,12 +571,21 @@ module Organizations
|
|
|
568
571
|
session_key = Organizations.configuration.session_key
|
|
569
572
|
session.delete(session_key)
|
|
570
573
|
@_current_organization = nil
|
|
571
|
-
|
|
574
|
+
forget_current_membership!
|
|
572
575
|
|
|
573
576
|
user = organizations_current_user
|
|
574
577
|
user&.clear_organization_cache!
|
|
575
578
|
end
|
|
576
579
|
|
|
580
|
+
# Drop the memoized membership so the next current_membership call reads
|
|
581
|
+
# it again for the organization that is current by then. Assigning nil
|
|
582
|
+
# would not do: current_membership memoizes nil too, so it would keep
|
|
583
|
+
# answering nil for the rest of the request, even after a switch or a
|
|
584
|
+
# fallback to another organization.
|
|
585
|
+
def forget_current_membership!
|
|
586
|
+
remove_instance_variable(:@_current_membership) if defined?(@_current_membership)
|
|
587
|
+
end
|
|
588
|
+
|
|
577
589
|
def fallback_organization_for(user)
|
|
578
590
|
membership = user.memberships.includes(:organization).order(updated_at: :desc, created_at: :desc).first
|
|
579
591
|
membership&.organization
|
|
@@ -594,7 +606,10 @@ module Organizations
|
|
|
594
606
|
end
|
|
595
607
|
|
|
596
608
|
# Handle unauthorized access
|
|
597
|
-
|
|
609
|
+
# @param organization [Organizations::Organization, nil] the organization
|
|
610
|
+
# the check ran against, for the message's vocabulary (default: the
|
|
611
|
+
# current organization)
|
|
612
|
+
def handle_unauthorized(permission: nil, required_role: nil, organization: current_organization)
|
|
598
613
|
config = Organizations.configuration
|
|
599
614
|
user = organizations_current_user
|
|
600
615
|
|
|
@@ -613,25 +628,49 @@ module Organizations
|
|
|
613
628
|
|
|
614
629
|
# Default behavior
|
|
615
630
|
error = Organizations::NotAuthorized.new(
|
|
616
|
-
build_unauthorized_message(permission, required_role),
|
|
631
|
+
build_unauthorized_message(permission, required_role, organization),
|
|
617
632
|
permission: permission,
|
|
618
|
-
organization:
|
|
633
|
+
organization: organization,
|
|
619
634
|
user: user
|
|
620
635
|
)
|
|
621
636
|
|
|
622
637
|
respond_to_unauthorized(error)
|
|
623
638
|
end
|
|
624
639
|
|
|
625
|
-
|
|
640
|
+
# Answer a NotAuthorized raised by the models, such as a role the
|
|
641
|
+
# requester may not act on, the way a failed permission guard is
|
|
642
|
+
# answered: through config.unauthorized_handler when one is set (so hosts
|
|
643
|
+
# that audit refusals there see these too, with the reason in
|
|
644
|
+
# context.metadata[:message]), otherwise with the error's message as an
|
|
645
|
+
# alert (HTML) or a 403 (JSON).
|
|
646
|
+
def handle_not_authorized(error)
|
|
647
|
+
handler = Organizations.configuration.unauthorized_handler
|
|
648
|
+
return respond_to_unauthorized(error) unless handler
|
|
649
|
+
|
|
650
|
+
context = CallbackContext.new(
|
|
651
|
+
event: :unauthorized,
|
|
652
|
+
user: organizations_current_user,
|
|
653
|
+
organization: current_organization,
|
|
654
|
+
permission: error.permission,
|
|
655
|
+
metadata: { message: error.message }
|
|
656
|
+
)
|
|
657
|
+
instance_exec(context, &handler)
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
# The organization is the one the check ran against (it selects the
|
|
661
|
+
# vocabulary, config.vocabulary).
|
|
662
|
+
def build_unauthorized_message(permission, required_role, organization = nil)
|
|
626
663
|
if required_role
|
|
627
664
|
# Same custom-role fallback as ViewHelpers#organization_role_label:
|
|
628
665
|
# humanized, so "superfan" renders as "Superfan" in both places.
|
|
629
|
-
Organizations.t(:"
|
|
630
|
-
|
|
666
|
+
role = Organizations.t(:"roles.#{required_role}", organization: organization,
|
|
667
|
+
default: required_role.to_s.humanize)
|
|
668
|
+
Organizations.t(:"errors.unauthorized_role", organization: organization, role: role)
|
|
631
669
|
elsif permission
|
|
632
|
-
Organizations.t(:"errors.unauthorized_permission",
|
|
670
|
+
Organizations.t(:"errors.unauthorized_permission", organization: organization,
|
|
671
|
+
permission: permission.to_s.humanize.downcase)
|
|
633
672
|
else
|
|
634
|
-
Organizations.t(:"errors.unauthorized")
|
|
673
|
+
Organizations.t(:"errors.unauthorized", organization: organization)
|
|
635
674
|
end
|
|
636
675
|
end
|
|
637
676
|
|
|
@@ -804,10 +843,11 @@ module Organizations
|
|
|
804
843
|
end
|
|
805
844
|
|
|
806
845
|
def default_pending_invitation_acceptance_notice(result)
|
|
807
|
-
|
|
808
|
-
|
|
846
|
+
# The record: its name fills %{organization}, and it selects its vocabulary.
|
|
847
|
+
organization = result.invitation.organization
|
|
848
|
+
return Organizations.t(:"notices.already_member", organization: organization) if result.already_member?
|
|
809
849
|
|
|
810
|
-
Organizations.t(:"notices.welcome", organization:
|
|
850
|
+
Organizations.t(:"notices.welcome", organization: organization)
|
|
811
851
|
end
|
|
812
852
|
end
|
|
813
853
|
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# What a membership may do with the resources of the host app (scoped
|
|
5
|
+
# resource grants). Included in Membership.
|
|
6
|
+
module GrantAccess
|
|
7
|
+
# Whether grants limit this membership: its role ranks below
|
|
8
|
+
# config.full_resource_access_role (:admin by default). A limited member
|
|
9
|
+
# sees only the resources granted to it.
|
|
10
|
+
# @return [Boolean]
|
|
11
|
+
def limited_by_grants?
|
|
12
|
+
full_access_role = Organizations.configuration.full_resource_access_role
|
|
13
|
+
full_access_role.nil? || !Roles.at_least?(role_sym, full_access_role)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The capabilities this member has on a resource: its role's permissions,
|
|
17
|
+
# narrowed by the grant when grants limit it. A grant never widens the
|
|
18
|
+
# role.
|
|
19
|
+
# @param resource [ActiveRecord::Base, Organizations::Resource, #id]
|
|
20
|
+
# @return [Set<Symbol>]
|
|
21
|
+
def capabilities_for(resource)
|
|
22
|
+
owner_id = Organizations::Grant.resource_organization_id(resource)
|
|
23
|
+
return Set.new if owner_id && owner_id != organization_id
|
|
24
|
+
|
|
25
|
+
permitted = Roles.permissions_for(role_sym).to_set
|
|
26
|
+
return permitted unless limited_by_grants?
|
|
27
|
+
|
|
28
|
+
granted = grant_for(resource)&.capabilities&.map(&:to_sym) || []
|
|
29
|
+
permitted & granted
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param capability [Symbol, String]
|
|
33
|
+
# @param resource [ActiveRecord::Base, Organizations::Resource, #id]
|
|
34
|
+
# @return [Boolean]
|
|
35
|
+
#
|
|
36
|
+
# @example
|
|
37
|
+
# membership.can?(:edit, document)
|
|
38
|
+
def can?(capability, resource)
|
|
39
|
+
capabilities_for(resource).include?(capability.to_sym)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The records of a relation this member may see: all of them when
|
|
43
|
+
# grants do not limit it, else the granted ones. With capability:, only
|
|
44
|
+
# where it holds that capability (and its role has it).
|
|
45
|
+
# @param relation [ActiveRecord::Relation]
|
|
46
|
+
# @param capability [Symbol, String, nil]
|
|
47
|
+
# @return [ActiveRecord::Relation]
|
|
48
|
+
def visible(relation, capability: nil)
|
|
49
|
+
return relation.none if capability && !has_permission_to?(capability)
|
|
50
|
+
|
|
51
|
+
# A model that belongs to organizations shows only this one's records.
|
|
52
|
+
if relation.klass.column_names.include?("organization_id")
|
|
53
|
+
relation = relation.where(organization_id: organization_id)
|
|
54
|
+
end
|
|
55
|
+
return relation unless limited_by_grants?
|
|
56
|
+
|
|
57
|
+
relation.where(relation.klass.primary_key => granted_ids(relation.klass, capability: capability))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Ids of the granted resources of one type, where the grant gives at
|
|
61
|
+
# least one capability that the role also has.
|
|
62
|
+
# @param type [Class, String] e.g. Document or "Ledger::Entity"
|
|
63
|
+
# @param capability [Symbol, String, nil] only where the grant and the role both have it
|
|
64
|
+
# @return [Array]
|
|
65
|
+
def granted_ids(type, capability: nil)
|
|
66
|
+
permitted = Roles.permissions_for(role_sym).map(&:to_s)
|
|
67
|
+
return [] if capability && !permitted.include?(capability.to_s)
|
|
68
|
+
|
|
69
|
+
grants.of_type(type).select do |grant|
|
|
70
|
+
effective = grant.capabilities & permitted # the role is the ceiling
|
|
71
|
+
capability ? effective.include?(capability.to_s) : effective.any?
|
|
72
|
+
end.map(&:grantable_id)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# Grant management shared by Membership and Invitation (an invitation holds
|
|
5
|
+
# grants until it is accepted, then they move to the membership).
|
|
6
|
+
module GrantHolder
|
|
7
|
+
# Whether the optional grants table exists (rails g organizations:grants).
|
|
8
|
+
# @return [Boolean]
|
|
9
|
+
def self.grants_table?
|
|
10
|
+
Organizations::Grant.table_exists?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Give access to one resource with these capabilities. Again on the same
|
|
14
|
+
# resource, it replaces the capabilities. No capabilities removes the
|
|
15
|
+
# grant (and returns nil).
|
|
16
|
+
#
|
|
17
|
+
# @param resource [ActiveRecord::Base, Organizations::Resource, #id]
|
|
18
|
+
# @param capabilities [Array<Symbol, String>]
|
|
19
|
+
# @param granted_by [User, nil] checked when it is a member of the
|
|
20
|
+
# organization: it needs config.grant_management_permission, may act
|
|
21
|
+
# only on roles at or below its own, and, when grants limit it, may give
|
|
22
|
+
# only capabilities it has on that resource. An account that is not a
|
|
23
|
+
# member is refused. nil (a console, a job, your own authorization) is
|
|
24
|
+
# not checked.
|
|
25
|
+
# @return [Organizations::Grant, nil]
|
|
26
|
+
# @raise [Organizations::NotAuthorized]
|
|
27
|
+
# @raise [ArgumentError] when the resource belongs to another organization
|
|
28
|
+
def grant!(resource, capabilities:, granted_by: nil)
|
|
29
|
+
ensure_resource_in_organization!(resource)
|
|
30
|
+
capabilities = Array(capabilities).map { |c| c.to_s.strip }.compact_blank.uniq
|
|
31
|
+
authorize_grant!(resource, capabilities, granted_by)
|
|
32
|
+
if capabilities.empty?
|
|
33
|
+
destroy_grants_on(resource)
|
|
34
|
+
return nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
upsert_grant!(*Organizations::Grant.resource_key(resource), capabilities, granted_by)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Remove the grant on a resource, if any.
|
|
41
|
+
# @param resource [ActiveRecord::Base, Organizations::Resource, #id]
|
|
42
|
+
# @param revoked_by [User, nil] checked like granted_by in grant!
|
|
43
|
+
# @return [self]
|
|
44
|
+
def revoke_grant!(resource, revoked_by: nil)
|
|
45
|
+
authorize_grant!(resource, [], revoked_by)
|
|
46
|
+
destroy_grants_on(resource)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @api private
|
|
50
|
+
# Check grants before they are written (an invitation checks them before
|
|
51
|
+
# it is saved). Same rules as grant!.
|
|
52
|
+
# @param grants [Array<Array(resource, capabilities)>]
|
|
53
|
+
# @param actor [User, nil]
|
|
54
|
+
# @raise [Organizations::NotAuthorized]
|
|
55
|
+
def authorize_grants!(grants, actor)
|
|
56
|
+
grants.each do |resource, capabilities|
|
|
57
|
+
ensure_resource_in_organization!(resource)
|
|
58
|
+
authorize_grant!(resource, Array(capabilities).map(&:to_s), actor)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param resource [ActiveRecord::Base, Organizations::Resource, #id]
|
|
63
|
+
# @return [Organizations::Grant, nil]
|
|
64
|
+
# Reads preloaded grants (`includes(:grants)`, `grants.load`) without a
|
|
65
|
+
# query, so a list of records can be checked cheaply.
|
|
66
|
+
def grant_for(resource)
|
|
67
|
+
return grants.for_resource(resource).first unless grants.loaded?
|
|
68
|
+
|
|
69
|
+
type, id = Organizations::Grant.resource_key(resource)
|
|
70
|
+
grants.find { |grant| grant.grantable_type == type && grant.grantable_id == id }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @param resource [ActiveRecord::Base, Organizations::Resource, #id]
|
|
74
|
+
# @return [Boolean] whether a grant on the resource exists
|
|
75
|
+
def granted?(resource)
|
|
76
|
+
grants.for_resource(resource).exists?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def destroy_grants_on(resource)
|
|
82
|
+
grants.for_resource(resource).destroy_all
|
|
83
|
+
grants.reset
|
|
84
|
+
self
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def upsert_grant!(type, id, capabilities, granted_by)
|
|
88
|
+
# SAVEPOINT: the unique-index race below must stay rescuable inside a
|
|
89
|
+
# host transaction on PostgreSQL.
|
|
90
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
91
|
+
lock_for_grant_write!
|
|
92
|
+
grant = grants.find_or_initialize_by(grantable_type: type, grantable_id: id)
|
|
93
|
+
grant.update!(capabilities: capabilities, granted_by_id: granted_by&.id || grant.granted_by_id)
|
|
94
|
+
grant
|
|
95
|
+
end
|
|
96
|
+
rescue ActiveRecord::RecordNotUnique
|
|
97
|
+
# A concurrent grant! on the same resource won; apply ours on top.
|
|
98
|
+
grants.reset
|
|
99
|
+
grant = grants.find_by!(grantable_type: type, grantable_id: id)
|
|
100
|
+
grant.update!(capabilities: capabilities)
|
|
101
|
+
grant
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# A resource that belongs to an organization (organization_id, also
|
|
105
|
+
# when named by an Organizations::Resource of such a model) must belong
|
|
106
|
+
# to this one.
|
|
107
|
+
def ensure_resource_in_organization!(resource)
|
|
108
|
+
owner_id = Organizations::Grant.resource_organization_id(resource)
|
|
109
|
+
return if owner_id.nil? || owner_id == organization_id
|
|
110
|
+
|
|
111
|
+
raise ArgumentError, "The resource belongs to another organization"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def delete_grants
|
|
115
|
+
grants.delete_all(:delete_all) if Organizations::GrantHolder.grants_table? && persisted?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# An invitation takes grants only while it is pending (see Invitation).
|
|
119
|
+
def lock_for_grant_write!; end
|
|
120
|
+
|
|
121
|
+
def authorize_grant!(resource, capabilities, actor)
|
|
122
|
+
return unless actor
|
|
123
|
+
|
|
124
|
+
actor_membership = organization.memberships.find_by(user_id: actor.id)
|
|
125
|
+
raise_grant_refusal(:"errors.grant_not_authorized", actor, :membership) unless actor_membership
|
|
126
|
+
|
|
127
|
+
permission = Organizations.configuration.grant_management_permission
|
|
128
|
+
unless actor_membership.has_permission_to?(permission)
|
|
129
|
+
raise_grant_refusal(:"errors.grant_not_authorized", actor, permission)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
Membership.authorize_rank!(organization, actor, [role], permission: permission,
|
|
133
|
+
message_key: :"errors.grant_outranks_actor")
|
|
134
|
+
authorize_within_own_access!(actor_membership, resource, capabilities, actor, permission)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# A granter that grants limit too may give only access it has itself.
|
|
138
|
+
def authorize_within_own_access!(actor_membership, resource, capabilities, actor, permission)
|
|
139
|
+
return unless actor_membership.limited_by_grants?
|
|
140
|
+
|
|
141
|
+
held = actor_membership.capabilities_for(resource).map(&:to_s)
|
|
142
|
+
return if held.any? && (capabilities - held).empty?
|
|
143
|
+
|
|
144
|
+
raise_grant_refusal(:"errors.grant_exceeds_actor", actor, permission)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def raise_grant_refusal(key, actor, permission)
|
|
148
|
+
raise Organizations::NotAuthorized.new(Organizations.t(key, organization: organization),
|
|
149
|
+
permission: permission, organization: organization, user: actor)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Organizations
|
|
4
|
+
# Include in a host model whose records can be granted to members:
|
|
5
|
+
#
|
|
6
|
+
# class Document < ApplicationRecord
|
|
7
|
+
# include Organizations::Grantable
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# Document.visible_to(membership) # what this member may see
|
|
11
|
+
# Document.visible_to(membership, capability: :edit) # …and edit
|
|
12
|
+
# document.organization_grants # who has access to it
|
|
13
|
+
#
|
|
14
|
+
# Deleting a record deletes its grants.
|
|
15
|
+
module Grantable
|
|
16
|
+
extend ActiveSupport::Concern
|
|
17
|
+
|
|
18
|
+
included do
|
|
19
|
+
has_many :organization_grants, as: :grantable, class_name: "Organizations::Grant", dependent: :destroy
|
|
20
|
+
|
|
21
|
+
# Records of this model the membership may see (see Membership#visible).
|
|
22
|
+
# Chain it after your own organization scope.
|
|
23
|
+
scope :visible_to, lambda { |membership, capability: nil|
|
|
24
|
+
membership ? membership.visible(all, capability: capability) : none
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -84,12 +84,11 @@ module Organizations
|
|
|
84
84
|
# Every parameter is an optional keyword with a safe default — this IS
|
|
85
85
|
# the public API surface, not incidental complexity (same posture as
|
|
86
86
|
# Organization#generate_join_code!).
|
|
87
|
-
# rubocop:disable Metrics/ParameterLists
|
|
87
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
88
88
|
def self.attempt(user:, organization:, code: nil, email: nil, verification_code: nil, message: nil)
|
|
89
89
|
new(user: user, organization: organization)
|
|
90
90
|
.attempt(code: code, email: email, verification_code: verification_code, message: message)
|
|
91
91
|
end
|
|
92
|
-
# rubocop:enable Metrics/ParameterLists
|
|
93
92
|
|
|
94
93
|
def initialize(user:, organization:)
|
|
95
94
|
@user = user
|
|
@@ -119,21 +118,26 @@ module Organizations
|
|
|
119
118
|
|
|
120
119
|
attr_reader :user, :organization
|
|
121
120
|
|
|
121
|
+
# Verification errors and the reason each one maps to. Subclasses come
|
|
122
|
+
# before their parents; anything else is :request_closed.
|
|
123
|
+
VERIFICATION_ERROR_REASONS = {
|
|
124
|
+
Organizations::VerificationAttemptsExceeded => :verification_attempts_exceeded,
|
|
125
|
+
Organizations::VerificationCodeExpired => :verification_code_expired,
|
|
126
|
+
Organizations::VerificationCodeInvalid => :verification_code_invalid,
|
|
127
|
+
Organizations::VerificationEmailAlreadyClaimed => :email_already_claimed,
|
|
128
|
+
# The domain or roster entry stopped admitting during the challenge.
|
|
129
|
+
Organizations::VerificationEmailNotEligible => :email_not_eligible
|
|
130
|
+
}.freeze
|
|
131
|
+
private_constant :VERIFICATION_ERROR_REASONS
|
|
132
|
+
|
|
122
133
|
def verify_emailed_code(typed_code)
|
|
123
134
|
request = user.pending_join_request_for(organization)
|
|
124
135
|
return error(:verification_code_missing, Organizations.t(:"errors.verification_code_missing")) unless request
|
|
125
136
|
|
|
126
137
|
from_outcome(request.verify_email_code!(typed_code))
|
|
127
|
-
rescue Organizations::VerificationAttemptsExceeded => e
|
|
128
|
-
error(:verification_attempts_exceeded, e.message)
|
|
129
|
-
rescue Organizations::VerificationCodeExpired => e
|
|
130
|
-
error(:verification_code_expired, e.message)
|
|
131
|
-
rescue Organizations::VerificationCodeInvalid => e
|
|
132
|
-
error(:verification_code_invalid, e.message)
|
|
133
|
-
rescue Organizations::VerificationEmailAlreadyClaimed => e
|
|
134
|
-
error(:email_already_claimed, e.message)
|
|
135
138
|
rescue Organizations::JoinRequestError => e
|
|
136
|
-
|
|
139
|
+
reason = VERIFICATION_ERROR_REASONS.find { |klass, _| e.is_a?(klass) }&.last || :request_closed
|
|
140
|
+
error(reason, e.message)
|
|
137
141
|
end
|
|
138
142
|
|
|
139
143
|
def redeem_code(raw_code)
|
|
@@ -194,7 +198,7 @@ module Organizations
|
|
|
194
198
|
Organizations.configuration.trust_confirmed_account_email &&
|
|
195
199
|
user.respond_to?(:confirmed_at) && user.confirmed_at.present? &&
|
|
196
200
|
user.respond_to?(:email) &&
|
|
197
|
-
organization.domains.matching_email(user.email).exists?
|
|
201
|
+
organization.domains.joinable.matching_email(user.email).exists?
|
|
198
202
|
end
|
|
199
203
|
|
|
200
204
|
def from_outcome(outcome)
|