organizations 0.5.0 → 0.7.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/.simplecov +17 -10
  5. data/Appraisals +2 -0
  6. data/CHANGELOG.md +112 -0
  7. data/README.md +714 -32
  8. data/app/controllers/organizations/invitations_controller.rb +27 -22
  9. data/app/controllers/organizations/memberships_controller.rb +37 -30
  10. data/app/controllers/organizations/organizations_controller.rb +8 -8
  11. data/app/controllers/organizations/public_invitations_controller.rb +1 -1
  12. data/app/mailers/organizations/invitation_mailer.rb +2 -2
  13. data/app/mailers/organizations/verification_mailer.rb +1 -1
  14. data/app/models/organizations/grant.rb +6 -0
  15. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +11 -11
  16. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +7 -7
  17. data/app/views/organizations/verification_mailer/code_email.html.erb +3 -3
  18. data/app/views/organizations/verification_mailer/code_email.text.erb +3 -3
  19. data/config/locales/en.yml +22 -0
  20. data/config/locales/es.yml +22 -0
  21. data/config/routes.rb +1 -2
  22. data/gemfiles/rails_7.2.gemfile +6 -1
  23. data/gemfiles/rails_8.1.gemfile +5 -1
  24. data/lib/generators/organizations/grants/grants_generator.rb +47 -0
  25. data/lib/generators/organizations/grants/templates/create_organizations_grants.rb.erb +59 -0
  26. data/lib/generators/organizations/install/install_generator.rb +1 -0
  27. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +15 -0
  28. data/lib/generators/organizations/join/join_generator.rb +113 -0
  29. data/lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb +146 -0
  30. data/lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb +93 -0
  31. data/lib/generators/organizations/join/templates/app/views/organization_access/show.html.erb +142 -0
  32. data/lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb +8 -0
  33. data/lib/generators/organizations/join/templates/app/views/organization_joins/show.html.erb +113 -0
  34. data/lib/generators/organizations/join/templates/routes.rb +19 -0
  35. data/lib/generators/organizations/upgrade/templates/add_domain_verification.rb.erb +29 -0
  36. data/lib/generators/organizations/upgrade/templates/add_personal_organizations.rb.erb +22 -0
  37. data/lib/generators/organizations/upgrade/upgrade_generator.rb +79 -14
  38. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +1 -1
  39. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +2 -2
  40. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +1 -1
  41. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +21 -13
  42. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +1 -1
  43. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +3 -3
  44. data/lib/generators/organizations/views/views_generator.rb +4 -6
  45. data/lib/organizations/account_deletion_plan.rb +118 -0
  46. data/lib/organizations/configuration.rb +155 -2
  47. data/lib/organizations/controller_helpers.rb +55 -15
  48. data/lib/organizations/grant_access.rb +75 -0
  49. data/lib/organizations/grant_holder.rb +152 -0
  50. data/lib/organizations/grantable.rb +28 -0
  51. data/lib/organizations/join_flow.rb +16 -12
  52. data/lib/organizations/models/concerns/has_organizations.rb +217 -9
  53. data/lib/organizations/models/domain.rb +283 -1
  54. data/lib/organizations/models/grant.rb +152 -0
  55. data/lib/organizations/models/invitation.rb +79 -8
  56. data/lib/organizations/models/join_request.rb +102 -43
  57. data/lib/organizations/models/membership.rb +96 -15
  58. data/lib/organizations/models/organization.rb +219 -48
  59. data/lib/organizations/organization_scoped.rb +16 -0
  60. data/lib/organizations/personal_owner_backfill.rb +122 -0
  61. data/lib/organizations/public_email_domains.rb +49 -0
  62. data/lib/organizations/resource.rb +11 -0
  63. data/lib/organizations/roles.rb +198 -36
  64. data/lib/organizations/version.rb +1 -1
  65. data/lib/organizations/view_helpers.rb +40 -9
  66. data/lib/organizations.rb +99 -4
  67. 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,13 +649,52 @@ 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"
559
693
  end
560
694
 
561
- unless Roles::HIERARCHY.include?(@default_invitation_role.to_sym)
562
- raise ConfigurationError, "default_invitation_role must be one of: #{Roles::HIERARCHY.join(', ')}"
695
+ roles = Roles.hierarchy_for(@custom_roles_definition)
696
+ unless roles.include?(@default_invitation_role.to_sym)
697
+ raise ConfigurationError, "default_invitation_role must be one of: #{roles.join(', ')}"
563
698
  end
564
699
  end
565
700
 
@@ -580,6 +715,24 @@ module Organizations
580
715
  raise ConfigurationError, "join_request_expiry must be a Duration (e.g., 30.days), Numeric, or nil (never expire)"
581
716
  end
582
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
+
583
736
  def duration_like?(value)
584
737
  value.is_a?(ActiveSupport::Duration) || value.is_a?(Numeric)
585
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 @_current_organization = nil unless user
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 @_current_membership = nil unless user && current_organization
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
- @_current_membership = nil # Clear cached membership
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
- @_current_membership = nil
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
- def handle_unauthorized(permission: nil, required_role: nil)
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: current_organization,
633
+ organization: organization,
619
634
  user: user
620
635
  )
621
636
 
622
637
  respond_to_unauthorized(error)
623
638
  end
624
639
 
625
- def build_unauthorized_message(permission, required_role)
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(:"errors.unauthorized_role",
630
- role: Organizations.t(:"roles.#{required_role}", default: required_role.to_s.humanize))
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", permission: permission.to_s.humanize.downcase)
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
- organization_name = result.invitation.organization.name
808
- return Organizations.t(:"notices.already_member", organization: organization_name) if result.already_member?
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: organization_name)
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
- error(:request_closed, e.message)
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)