masks-server 0.11.0 → 0.12.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 (155) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/masks/server/account_controller.rb +3 -0
  3. data/app/controllers/masks/server/app_passwords_controller.rb +41 -0
  4. data/app/controllers/masks/server/authorize_controller.rb +33 -2
  5. data/app/controllers/masks/server/forward_auth_controller.rb +104 -12
  6. data/app/controllers/masks/server/logins_controller.rb +4 -3
  7. data/app/controllers/masks/server/organization_access_controller.rb +39 -0
  8. data/app/graphql/masks/server/manage/mutations/base_mutation.rb +7 -0
  9. data/app/graphql/masks/server/manage/mutations/create_group.rb +6 -4
  10. data/app/graphql/masks/server/manage/mutations/decline_access.rb +27 -0
  11. data/app/graphql/masks/server/manage/mutations/grant_access.rb +29 -0
  12. data/app/graphql/masks/server/manage/mutations/group_source.rb +34 -0
  13. data/app/graphql/masks/server/manage/mutations/issue_ldap_credential.rb +5 -2
  14. data/app/graphql/masks/server/manage/mutations/refresh_group.rb +21 -0
  15. data/app/graphql/masks/server/manage/mutations/remove_group_member.rb +2 -0
  16. data/app/graphql/masks/server/manage/mutations/run_health_checks.rb +19 -0
  17. data/app/graphql/masks/server/manage/mutations/update_client.rb +6 -0
  18. data/app/graphql/masks/server/manage/mutations/update_group.rb +7 -4
  19. data/app/graphql/masks/server/manage/mutations/update_tenant.rb +14 -1
  20. data/app/graphql/masks/server/manage/types/access_request_type.rb +21 -0
  21. data/app/graphql/masks/server/manage/types/actor_type.rb +6 -0
  22. data/app/graphql/masks/server/manage/types/client_type.rb +12 -0
  23. data/app/graphql/masks/server/manage/types/group_condition_input.rb +16 -0
  24. data/app/graphql/masks/server/manage/types/group_condition_type.rb +29 -0
  25. data/app/graphql/masks/server/manage/types/group_member_type.rb +16 -0
  26. data/app/graphql/masks/server/manage/types/group_type.rb +17 -1
  27. data/app/graphql/masks/server/manage/types/health_check_type.rb +18 -0
  28. data/app/graphql/masks/server/manage/types/ldap_credential_type.rb +1 -0
  29. data/app/graphql/masks/server/manage/types/mutation_type.rb +4 -0
  30. data/app/graphql/masks/server/manage/types/query_type.rb +24 -0
  31. data/app/graphql/masks/server/manage/types/release_type.rb +16 -0
  32. data/app/graphql/masks/server/manage/types/tenant_type.rb +6 -1
  33. data/app/helpers/masks/server/account_helper.rb +4 -0
  34. data/app/jobs/masks/server/cleanup_job.rb +1 -0
  35. data/app/jobs/masks/server/health_check_job.rb +21 -0
  36. data/app/jobs/masks/server/refresh_groups_job.rb +18 -0
  37. data/app/jobs/masks/server/release_check_job.rb +21 -0
  38. data/app/ldap/masks/server/ldap/connection.rb +3 -2
  39. data/app/ldap/masks/server/ldap/gate.rb +31 -4
  40. data/app/logins/masks/server/login_states/second_factor.rb +1 -1
  41. data/app/mailers/masks/server/actor_mailer.rb +31 -0
  42. data/app/models/masks/server/access_request.rb +103 -0
  43. data/app/models/masks/server/actor.rb +6 -0
  44. data/app/models/masks/server/app_password.rb +91 -0
  45. data/app/models/masks/server/app_passwords.rb +82 -0
  46. data/app/models/masks/server/client.rb +15 -0
  47. data/app/models/masks/server/connection.rb +7 -0
  48. data/app/models/masks/server/declared.rb +10 -2
  49. data/app/models/masks/server/email_verification.rb +2 -2
  50. data/app/models/masks/server/event.rb +11 -1
  51. data/app/models/masks/server/federation/protocol.rb +1 -1
  52. data/app/models/masks/server/federation/saml.rb +1 -1
  53. data/app/models/masks/server/forward_auth_rules.rb +162 -0
  54. data/app/models/masks/server/group.rb +207 -4
  55. data/app/models/masks/server/group_member.rb +7 -0
  56. data/app/models/masks/server/group_rule.rb +63 -0
  57. data/app/models/masks/server/health.rb +243 -0
  58. data/app/models/masks/server/health_check.rb +27 -0
  59. data/app/models/masks/server/invitation.rb +2 -2
  60. data/app/models/masks/server/ldap_credential.rb +2 -2
  61. data/app/models/masks/server/login.rb +28 -2
  62. data/app/models/masks/server/mail_previews.rb +5 -0
  63. data/app/models/masks/server/mail_template.rb +2 -0
  64. data/app/models/masks/server/membership.rb +10 -3
  65. data/app/models/masks/server/notifications.rb +3 -1
  66. data/app/models/masks/server/password_reset.rb +2 -2
  67. data/app/models/masks/server/provider.rb +1 -0
  68. data/app/models/masks/server/queue.rb +60 -0
  69. data/app/models/masks/server/release.rb +74 -0
  70. data/app/models/masks/server/session.rb +8 -0
  71. data/app/models/masks/server/tenant.rb +39 -2
  72. data/app/models/masks/server/token.rb +2 -1
  73. data/app/views/masks/server/account/_app_passwords.html.erb +83 -0
  74. data/app/views/masks/server/account/_organizations.html.erb +33 -0
  75. data/app/views/masks/server/account/index.html.erb +5 -0
  76. data/app/views/masks/server/actor_mailer/access_declined.html.erb +5 -0
  77. data/app/views/masks/server/actor_mailer/access_declined.text.erb +5 -0
  78. data/app/views/masks/server/actor_mailer/access_granted.html.erb +7 -0
  79. data/app/views/masks/server/actor_mailer/access_granted.text.erb +7 -0
  80. data/app/views/masks/server/authorize/error.html.erb +25 -0
  81. data/config/locales/de/access_requests.yml +43 -0
  82. data/config/locales/de/account/app_passwords.yml +42 -0
  83. data/config/locales/de/events.yml +5 -0
  84. data/config/locales/de/flashes.yml +1 -0
  85. data/config/locales/de/group_sources.yml +5 -0
  86. data/config/locales/de/manage/access_requests.yml +12 -0
  87. data/config/locales/de/manage/app_passwords.yml +16 -0
  88. data/config/locales/de/manage/forward_auth_rules.yml +29 -0
  89. data/config/locales/de/manage/group_sources.yml +44 -0
  90. data/config/locales/de/manage/health.yml +78 -0
  91. data/config/locales/de/manage/links.yml +20 -0
  92. data/config/locales/en/access_requests.yml +43 -0
  93. data/config/locales/en/account/app_passwords.yml +42 -0
  94. data/config/locales/en/events.yml +5 -0
  95. data/config/locales/en/flashes.yml +1 -0
  96. data/config/locales/en/group_sources.yml +5 -0
  97. data/config/locales/en/manage/access_requests.yml +12 -0
  98. data/config/locales/en/manage/app_passwords.yml +16 -0
  99. data/config/locales/en/manage/forward_auth_rules.yml +29 -0
  100. data/config/locales/en/manage/group_sources.yml +44 -0
  101. data/config/locales/en/manage/health.yml +78 -0
  102. data/config/locales/en/manage/links.yml +20 -0
  103. data/config/locales/es/access_requests.yml +43 -0
  104. data/config/locales/es/account/app_passwords.yml +42 -0
  105. data/config/locales/es/events.yml +5 -0
  106. data/config/locales/es/flashes.yml +1 -0
  107. data/config/locales/es/group_sources.yml +5 -0
  108. data/config/locales/es/manage/access_requests.yml +12 -0
  109. data/config/locales/es/manage/app_passwords.yml +16 -0
  110. data/config/locales/es/manage/forward_auth_rules.yml +29 -0
  111. data/config/locales/es/manage/group_sources.yml +44 -0
  112. data/config/locales/es/manage/health.yml +78 -0
  113. data/config/locales/es/manage/links.yml +20 -0
  114. data/config/locales/fr/access_requests.yml +43 -0
  115. data/config/locales/fr/account/app_passwords.yml +42 -0
  116. data/config/locales/fr/events.yml +5 -0
  117. data/config/locales/fr/flashes.yml +1 -0
  118. data/config/locales/fr/group_sources.yml +5 -0
  119. data/config/locales/fr/manage/access_requests.yml +12 -0
  120. data/config/locales/fr/manage/app_passwords.yml +16 -0
  121. data/config/locales/fr/manage/forward_auth_rules.yml +29 -0
  122. data/config/locales/fr/manage/group_sources.yml +44 -0
  123. data/config/locales/fr/manage/health.yml +78 -0
  124. data/config/locales/fr/manage/links.yml +20 -0
  125. data/config/routes.rb +5 -0
  126. data/db/migrate/20261020100000_create_access_requests.rb +28 -0
  127. data/db/migrate/20261020110000_add_app_passwords_to_clients.rb +5 -0
  128. data/db/migrate/20261020120000_add_forward_auth_rules_to_clients.rb +6 -0
  129. data/db/migrate/20261020130000_add_sources_to_groups.rb +15 -0
  130. data/db/migrate/20261020140000_create_health_checks.rb +25 -0
  131. data/db/migrate/20261020150000_add_link_lifetimes_to_tenants.rb +8 -0
  132. data/db/migrate/20261021100000_add_pinned_origin_to_tenants.rb +5 -0
  133. data/lib/generators/masks/server/install/install_generator.rb +3 -2
  134. data/lib/masks/server/configuration.rb +2 -11
  135. data/lib/masks/server/schema_reference.rb +12 -0
  136. data/lib/masks/server/tenancy/middleware.rb +17 -6
  137. data/public/masks-assets/.vite/manifest.json +13 -13
  138. data/public/masks-assets/assets/{account-D46qDQCo.js → account-h7I6VUPR.js} +2 -2
  139. data/public/masks-assets/assets/{account-D46qDQCo.js.map → account-h7I6VUPR.js.map} +1 -1
  140. data/public/masks-assets/assets/{app-D7W0gAyT.css → app-BXOScMjL.css} +1 -1
  141. data/public/masks-assets/assets/{handshake-DIFZPSAK.js → handshake-YQpRNMIs.js} +2 -2
  142. data/public/masks-assets/assets/{handshake-DIFZPSAK.js.map → handshake-YQpRNMIs.js.map} +1 -1
  143. data/public/masks-assets/assets/initial-Cn-UuAsy.js +3 -0
  144. data/public/masks-assets/assets/{initial-C1GpCiEh.js.map → initial-Cn-UuAsy.js.map} +1 -1
  145. data/public/masks-assets/assets/{login-C_6igRZ3.js → login-C21_jb6L.js} +3 -3
  146. data/public/masks-assets/assets/{login-C_6igRZ3.js.map → login-C21_jb6L.js.map} +1 -1
  147. data/public/masks-assets/assets/{manage-D4MzaDvM.js → manage-DpVxe5sv.js} +80 -51
  148. data/public/masks-assets/assets/manage-DpVxe5sv.js.map +1 -0
  149. data/public/masks-assets/assets/passkey-5UYPVD3c.js +2 -0
  150. data/public/masks-assets/assets/{passkey-DIj_5LCY.js.map → passkey-5UYPVD3c.js.map} +1 -1
  151. data/version.txt +1 -1
  152. metadata +87 -14
  153. data/public/masks-assets/assets/initial-C1GpCiEh.js +0 -3
  154. data/public/masks-assets/assets/manage-D4MzaDvM.js.map +0 -1
  155. data/public/masks-assets/assets/passkey-DIj_5LCY.js +0 -2
@@ -3,29 +3,232 @@ module Masks
3
3
  class Group < ApplicationRecord
4
4
  include TenantScoped
5
5
 
6
+ class Sourced < StandardError; end
7
+
6
8
  KEY = /\A[a-z0-9][a-z0-9._-]*\z/
9
+ MANUAL = "manual".freeze
10
+ RULE = "rule".freeze
11
+ PROVIDER = "provider".freeze
12
+ NESTED = "group".freeze
13
+ SOURCES = [ MANUAL, RULE, PROVIDER, NESTED ].freeze
14
+ SOURCED = [ RULE, PROVIDER, NESTED ].freeze
15
+ SETTINGS = %w[source rule provider_id upstream_group parent_labels].freeze
16
+ WATCHED = (%w[email email_verified_at] + GroupRule::FIELDS).freeze
17
+ PARENTS = 10
18
+ LONGEST = 200
7
19
 
20
+ belongs_to :provider, optional: true
8
21
  has_many :members, class_name: "GroupMember", dependent: :delete_all
9
22
  has_many :actors, through: :members
10
23
 
24
+ scope :sourced, -> { where(source: SOURCED) }
25
+ scope :ruled, -> { where(source: RULE) }
26
+ scope :filled_by, ->(provider) { where(source: PROVIDER, provider_id: provider.id) }
27
+ scope :nested_under, ->(label) { where(source: NESTED).where("groups.parent_labels @> ?", [ label ].to_json) }
28
+ scope :nested_under_organization, lambda { |key|
29
+ where(source: NESTED).where(
30
+ "EXISTS (SELECT 1 FROM jsonb_array_elements_text(groups.parent_labels) AS label WHERE label LIKE ?)",
31
+ "#{sanitize_sql_like(key)}:%"
32
+ )
33
+ }
34
+
11
35
  normalizes :key, with: ->(value) { value.to_s.strip.downcase }
12
36
  normalizes :name, with: ->(value) { value.to_s.strip }
37
+ normalizes :source, with: ->(value) { value.to_s.strip.downcase.presence || MANUAL }
38
+ normalizes :rule, with: ->(value) { GroupRule.normalize(value) }
39
+ normalizes :upstream_group, with: ->(value) { value.to_s.strip.presence }
40
+ normalizes :parent_labels, with: ->(value) { Array(value).map { |label| label.to_s.strip.downcase }.compact_blank.uniq.sort }
13
41
 
14
42
  validates :key, presence: true, format: { with: KEY }, length: { maximum: 64 },
15
43
  uniqueness: { scope: :tenant_id }
16
44
  validates :name, presence: true, length: { maximum: 100 }
45
+ validates :source, inclusion: { in: SOURCES }
46
+ validate :source_is_complete
47
+
48
+ after_commit :refresh_after_change, on: %i[create update], if: -> { saved_changes.keys.intersect?(SETTINGS) }
49
+ after_commit :refresh_children, on: :destroy
50
+
51
+ def sourced?
52
+ SOURCED.include?(source)
53
+ end
54
+
55
+ def children
56
+ Group.nested_under(key)
57
+ end
17
58
 
18
59
  def add!(actor, by: nil)
19
60
  member = members.find_or_create_by!(actor: actor)
20
- Event.record!(Event::GROUP_JOINED, actor: actor, by: by, group: key) if member.previously_new_record?
61
+
62
+ if member.previously_new_record?
63
+ Event.record!(Event::GROUP_JOINED, actor: actor, by: by, group: key)
64
+ settle_children!(actor, by: by)
65
+ elsif member.sourced?
66
+ member.update!(sourced_by: nil)
67
+ Event.record!(Event::GROUP_JOINED, actor: actor, by: by, group: key, kept: true)
68
+ end
69
+
21
70
  member
22
71
  end
23
72
 
24
73
  def remove!(actor, by: nil)
25
- removed = members.where(actor: actor).delete_all.positive?
26
- Event.record!(Event::GROUP_LEFT, actor: actor, by: by, group: key) if removed
27
- removed
74
+ member = members.find_by(actor: actor)
75
+
76
+ return false if member.nil?
77
+ raise Sourced, "#{actor.identifier} is in #{name} from its #{source}, so the #{source} decides" if member.sourced?
78
+
79
+ member.delete
80
+ Event.record!(Event::GROUP_LEFT, actor: actor, by: by, group: key)
81
+ settle_children!(actor, by: by)
82
+
83
+ true
28
84
  end
85
+
86
+ def refresh!(by: nil, seen: Set.new)
87
+ return self unless seen.add?(id)
88
+
89
+ wanted = sourced? ? wanted_actor_ids : []
90
+ held = members.where(sourced_by: source).pluck(:actor_id)
91
+ pinned = members.manual.pluck(:actor_id)
92
+ joining = wanted - held - pinned
93
+ leaving = (held - wanted) | members.sourced.where.not(sourced_by: source).pluck(:actor_id)
94
+
95
+ transaction do
96
+ members.sourced.where(actor_id: leaving).delete_all if leaving.any?
97
+ members.insert_all(joining.map { |actor_id| { actor_id: actor_id, tenant_id: tenant_id, sourced_by: source } }) if joining.any?
98
+ update_column(:refreshed_at, Time.current)
99
+ end
100
+
101
+ return self if joining.empty? && leaving.empty?
102
+
103
+ Event.record!(Event::GROUP_REFRESHED, by: by, group: key, source: source, added: joining.size, removed: leaving.size)
104
+ children.find_each { |child| child.refresh!(by: by, seen: seen) }
105
+
106
+ self
107
+ end
108
+
109
+ def settle!(actor, by: nil, seen: Set.new)
110
+ return unless seen.add?(id)
111
+
112
+ member = members.find_by(actor: actor)
113
+
114
+ return if member && !member.sourced?
115
+
116
+ if sourced? && wants?(actor)
117
+ return if member&.sourced_by == source
118
+
119
+ member ? member.update!(sourced_by: source) : members.create!(actor: actor, sourced_by: source)
120
+ Event.record!(Event::GROUP_JOINED, actor: actor, by: by, group: key, source: source) if member.nil?
121
+ elsif member
122
+ member.delete
123
+ Event.record!(Event::GROUP_LEFT, actor: actor, by: by, group: key, source: member.sourced_by)
124
+ else
125
+ return
126
+ end
127
+
128
+ children.find_each { |child| child.settle!(actor, by: by, seen: seen) }
129
+ end
130
+
131
+ def wants?(actor)
132
+ case source
133
+ when RULE then GroupRule.scope(rule).exists?(id: actor.id)
134
+ when PROVIDER then provider_connections.exists?(actor_id: actor.id)
135
+ when NESTED then parent_members.exists?(actor_id: actor.id) || parent_memberships.exists?(actor_id: actor.id)
136
+ else false
137
+ end
138
+ end
139
+
140
+ def parent_keys
141
+ parent_labels.grep_v(/:/)
142
+ end
143
+
144
+ private
145
+
146
+ def wanted_actor_ids
147
+ case source
148
+ when RULE then GroupRule.scope(rule).pluck(:id)
149
+ when PROVIDER then provider_connections.distinct.pluck(:actor_id)
150
+ when NESTED then parent_members.distinct.pluck(:actor_id) | parent_memberships.distinct.pluck(:actor_id)
151
+ else []
152
+ end
153
+ end
154
+
155
+ def provider_connections
156
+ return Connection.none if provider_id.nil?
157
+
158
+ Connection.live.where(provider_id: provider_id).where("connections.upstream_groups @> ?", [ upstream_group ].to_json)
159
+ end
160
+
161
+ def parent_members
162
+ GroupMember.joins(:group).where(groups: { key: parent_keys })
163
+ end
164
+
165
+ def parent_memberships
166
+ roles = parent_labels.grep(/:/).map { |label| label.split(":", 2) }
167
+
168
+ return Membership.none if roles.empty?
169
+
170
+ held = roles.map do |key, role|
171
+ Membership.sanitize_sql_array([ "(organizations.key = ? AND memberships.role = ?)", key, role ])
172
+ end
173
+
174
+ Membership.accepted.joins(:organization).merge(Organization.active).where(held.join(" OR "))
175
+ end
176
+
177
+ def settle_children!(actor, by:)
178
+ children.find_each { |child| child.settle!(actor, by: by) }
179
+ end
180
+
181
+ def refresh_after_change
182
+ refresh!
183
+ end
184
+
185
+ def refresh_children
186
+ children.find_each(&:refresh!)
187
+ end
188
+
189
+ def source_is_complete
190
+ case source
191
+ when RULE then GroupRule.problems(rule).each { |problem| errors.add(:rule, problem) }
192
+ when PROVIDER then provider_is_complete
193
+ when NESTED then parents_are_complete
194
+ end
195
+ end
196
+
197
+ def provider_is_complete
198
+ errors.add(:provider, "is needed to fill a group from one") if provider.nil?
199
+ errors.add(:upstream_group, "names the group at the provider") if upstream_group.blank? || upstream_group.length > LONGEST
200
+ end
201
+
202
+ def parents_are_complete
203
+ return errors.add(:parent_labels, "name at least one group or organization role") if parent_labels.empty?
204
+ return errors.add(:parent_labels, "name at most #{PARENTS}") if parent_labels.size > PARENTS
205
+
206
+ parent_labels.grep_v(Client::ALLOWED_GROUP).each do |label|
207
+ errors.add(:parent_labels, "has #{label}, which is not a group key or key:role")
208
+ end
209
+
210
+ (parent_keys - Group.where(key: parent_keys).pluck(:key)).each do |missing|
211
+ errors.add(:parent_labels, "name #{missing}, which is not a group")
212
+ end
213
+
214
+ errors.add(:parent_labels, "would put #{name} inside itself") if cycles?
215
+ end
216
+
217
+ def cycles?
218
+ pending = parent_keys.dup
219
+ seen = Set.new
220
+
221
+ until pending.empty?
222
+ held = pending.shift
223
+
224
+ return true if held == key
225
+ next unless seen.add?(held)
226
+
227
+ pending.concat(Array(Group.where(key: held, source: NESTED).pick(:parent_labels)).grep_v(/:/))
228
+ end
229
+
230
+ false
231
+ end
29
232
  end
30
233
  end
31
234
  end
@@ -5,6 +5,13 @@ module Masks
5
5
 
6
6
  belongs_to :group
7
7
  belongs_to :actor
8
+
9
+ scope :manual, -> { where(sourced_by: nil) }
10
+ scope :sourced, -> { where.not(sourced_by: nil) }
11
+
12
+ def sourced?
13
+ sourced_by.present?
14
+ end
8
15
  end
9
16
  end
10
17
  end
@@ -0,0 +1,63 @@
1
+ module Masks
2
+ module Server
3
+ module GroupRule
4
+ EMAIL_DOMAIN = "email_domain".freeze
5
+ PROFILE = "profile".freeze
6
+ KINDS = [ EMAIL_DOMAIN, PROFILE ].freeze
7
+ FIELDS = %w[name given_name family_name middle_name nickname gender zoneinfo locale].freeze
8
+ LIMIT = 5
9
+ DOMAIN = /\A[a-z0-9][a-z0-9.-]*\.[a-z]{2,}\z/
10
+ LONGEST = 200
11
+
12
+ class << self
13
+ def normalize(value)
14
+ Array(value).filter_map do |condition|
15
+ next unless condition.respond_to?(:to_h)
16
+
17
+ held = condition.to_h.to_h { |key, item| [ key.to_s, item.to_s.strip ] }
18
+ kind = held["kind"]
19
+ value = kind == EMAIL_DOMAIN ? held["value"].to_s.downcase.delete_prefix("@") : held["value"].to_s
20
+
21
+ { "kind" => kind, "field" => (held["field"] if kind == PROFILE), "value" => value }.compact
22
+ end
23
+ end
24
+
25
+ def problems(conditions)
26
+ return [ "a rule has at most #{LIMIT} conditions" ] if conditions.size > LIMIT
27
+ return [ "a rule needs at least one condition" ] if conditions.empty?
28
+
29
+ conditions.filter_map { |condition| problem(condition) }
30
+ end
31
+
32
+ def scope(conditions)
33
+ conditions.reduce(Actor.all) do |scope, condition|
34
+ case condition["kind"]
35
+ when EMAIL_DOMAIN
36
+ scope.where.not(email_verified_at: nil)
37
+ .where("LOWER(actors.email) LIKE ?", "%@#{Actor.sanitize_sql_like(condition['value'])}")
38
+ when PROFILE
39
+ scope.merge(Actor.directed).where(condition["field"] => condition["value"])
40
+ else
41
+ scope.none
42
+ end
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def problem(condition)
49
+ case condition["kind"]
50
+ when EMAIL_DOMAIN
51
+ "#{condition['value'].inspect} is not a domain" unless condition["value"].match?(DOMAIN)
52
+ when PROFILE
53
+ return "#{condition['field'].inspect} is not a profile field a rule can read" unless FIELDS.include?(condition["field"])
54
+
55
+ "a profile condition needs a value" if condition["value"].blank? || condition["value"].length > LONGEST
56
+ else
57
+ "#{condition['kind'].inspect} is not a kind of condition"
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,243 @@
1
+ require "net/smtp"
2
+
3
+ module Masks
4
+ module Server
5
+ module Health
6
+ Result = Struct.new(:name, :status, :summary, :details) do
7
+ def failing?
8
+ status == HealthCheck::FAILING
9
+ end
10
+ end
11
+
12
+ SERVER = %w[jobs worker queue ldap metrics].freeze
13
+ TENANT = %w[mail origin domains keys].freeze
14
+ NAMES = (SERVER + TENANT).freeze
15
+
16
+ FAILING_KEY = "masks:health:failing".freeze
17
+ ORIGIN_PREFIX = "masks:health:origin:".freeze
18
+ KEEP = 1.day
19
+ NOTE_EVERY = 10.minutes
20
+ SMTP_TIMEOUT = 5
21
+ TOKEN_LENGTH = 24
22
+
23
+ SMTP_ERRORS = [
24
+ Net::SMTPError, IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError
25
+ ].freeze
26
+
27
+ module_function
28
+
29
+ def check!(tenant = Current.tenant)
30
+ rows = store!(server_results + tenant_results)
31
+ remember!(tenant, rows)
32
+ rows
33
+ end
34
+
35
+ def server_results
36
+ [ jobs_result, worker_result, queue_result, ldap_result, metrics_result ]
37
+ end
38
+
39
+ def tenant_results
40
+ [ mail_result, origin_result, domains_result, keys_result ]
41
+ end
42
+
43
+ def store!(results)
44
+ now = Time.current
45
+
46
+ results.map do |result|
47
+ row = HealthCheck.find_or_initialize_by(name: result.name)
48
+ was = row.status
49
+
50
+ row.assign_attributes(status: result.status, summary: result.summary, details: result.details, checked_at: now)
51
+ row.changed_at = now if was != result.status
52
+ row.save!
53
+
54
+ announce(row, was)
55
+
56
+ row
57
+ end
58
+ end
59
+
60
+ def announce(row, was)
61
+ if row.failing? && was != HealthCheck::FAILING
62
+ Event.record!(Event::HEALTH_FAILING, by: nil, check: row.name, summary: row.summary)
63
+ elsif row.ok? && was == HealthCheck::FAILING
64
+ Event.record!(Event::HEALTH_RECOVERED, by: nil, check: row.name, summary: row.summary)
65
+ end
66
+ end
67
+
68
+ def remember!(tenant, rows)
69
+ held = (::Rails.cache.read(FAILING_KEY) || {}).transform_values { |subdomains| subdomains - [ tenant.subdomain ] }
70
+ rows.select(&:failing?).each { |row| (held[row.name] ||= []) << tenant.subdomain }
71
+
72
+ remember_all!(held)
73
+ end
74
+
75
+ def remember_all!(failing)
76
+ ::Rails.cache.write(FAILING_KEY, failing.reject { |_, subdomains| subdomains.empty? }, expires_in: KEEP)
77
+ end
78
+
79
+ def failing_named
80
+ (::Rails.cache.read(FAILING_KEY) || {}).sort.map do |name, subdomains|
81
+ SERVER.include?(name) ? name : "#{name} (#{subdomains.sort.join(', ')})"
82
+ end
83
+ end
84
+
85
+ def jobs_result
86
+ return ok("jobs", "elsewhere") unless Queue.run?
87
+
88
+ failed = Queue.failing_jobs
89
+
90
+ return ok("jobs", "none") if failed.empty?
91
+
92
+ failing("jobs", "failed", count: failed.size, jobs: failed.map(&:demodulize))
93
+ end
94
+
95
+ def worker_result
96
+ return ok("worker", "elsewhere") unless Queue.run?
97
+ return ok("worker", "unchecked") unless Queue.silence.positive?
98
+ return failing("worker", "silent", minutes: Queue.silence.to_i / 60) if Queue.worker_silent?
99
+
100
+ ok("worker", "reporting")
101
+ end
102
+
103
+ def queue_result
104
+ return ok("queue", "elsewhere") unless Queue.run?
105
+ return ok("queue", "unchecked") unless Queue.wait.positive?
106
+
107
+ stuck = Queue.stuck_since
108
+
109
+ return failing("queue", "stuck", since: stuck.utc.iso8601, minutes: Queue.wait.to_i / 60) if stuck
110
+
111
+ ok("queue", "moving")
112
+ end
113
+
114
+ def ldap_result
115
+ settings = ::Rails.configuration.masks
116
+
117
+ return ok("ldap", "tls") if settings.ldap_cert.present?
118
+ return ok("ldap", "local") unless settings.ldap_plaintext
119
+
120
+ warning("ldap", "plain", port: settings.ldap_port)
121
+ end
122
+
123
+ def metrics_result
124
+ token = ENV["MASKS_METRICS_TOKEN"]
125
+
126
+ return ok("metrics", "off") if token.blank?
127
+ return warning("metrics", "short", length: token.length, needed: TOKEN_LENGTH) if token.length < TOKEN_LENGTH
128
+
129
+ ok("metrics", "on")
130
+ end
131
+
132
+ def mail_result
133
+ settings = smtp_settings
134
+
135
+ return ok("mail", "none") if settings.nil?
136
+
137
+ return failing("mail", "silent", error: Senders::Smtp::UNREACHABLE) if settings.delete(:vetted) && !public_smtp?(settings)
138
+
139
+ error = smtp_problem(settings)
140
+
141
+ error ? failing("mail", "silent", error: error) : ok("mail", "answers")
142
+ end
143
+
144
+ def smtp_settings
145
+ sender = Sender.primary(Sender::MAIL)
146
+
147
+ if sender.is_a?(Senders::Smtp)
148
+ { address: sender[:address], port: sender[:port].to_i, tls: sender[:tls] == true, domain: sender[:domain], vetted: true }
149
+ elsif (held = ::Rails.configuration.masks.smtp_settings)
150
+ { address: held[:address], port: held[:port].to_i, tls: held[:tls] == true, domain: held[:domain] }
151
+ end
152
+ end
153
+
154
+ def public_smtp?(settings)
155
+ ::Rails.env.local? || Outbound.public_address(settings[:address], settings[:port]).present?
156
+ end
157
+
158
+ def smtp_problem(settings)
159
+ smtp = Net::SMTP.new(settings[:address], settings[:port])
160
+ smtp.open_timeout = SMTP_TIMEOUT
161
+ smtp.read_timeout = SMTP_TIMEOUT
162
+ settings[:tls] ? smtp.enable_tls : smtp.enable_starttls_auto
163
+
164
+ smtp.start(settings[:domain].presence || "localhost") { nil }
165
+
166
+ nil
167
+ rescue *SMTP_ERRORS => error
168
+ error.class.name
169
+ end
170
+
171
+ def origin_result
172
+ return ok("origin", "unset") if Current.tenant&.public_origin.nil?
173
+
174
+ noted = ::Rails.cache.read(origin_key(Current.tenant))
175
+
176
+ return ok("origin", "matches") if noted.nil?
177
+
178
+ warning("origin", "mismatch", seen: noted["seen"], origin: noted["origin"])
179
+ end
180
+
181
+ def note_origin!(tenant, seen, origin)
182
+ return if seen.nil? || origin.nil? || seen.casecmp?(origin)
183
+
184
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
185
+ last = noted[tenant.id]
186
+
187
+ return if last && now - last < NOTE_EVERY
188
+
189
+ noted[tenant.id] = now
190
+ ::Rails.cache.write(origin_key(tenant), { "seen" => seen, "origin" => origin }, expires_in: KEEP)
191
+ end
192
+
193
+ def forget_origin!(tenant)
194
+ noted.delete(tenant.id)
195
+ ::Rails.cache.delete(origin_key(tenant))
196
+ end
197
+
198
+ def noted
199
+ @noted ||= Concurrent::Map.new
200
+ end
201
+
202
+ def origin_key(tenant)
203
+ "#{ORIGIN_PREFIX}#{tenant.id}"
204
+ end
205
+
206
+ def domains_result
207
+ return ok("domains", "none") unless DomainClaim.exists?
208
+
209
+ missing = DomainClaim.where.not(missing_since: nil).order(:domain).pluck(:domain)
210
+
211
+ return ok("domains", "proven") if missing.empty?
212
+
213
+ warning("domains", "missing", domains: missing)
214
+ end
215
+
216
+ def keys_result
217
+ active = SigningKey.active.first
218
+
219
+ return failing("keys", "missing") if active.nil?
220
+
221
+ days = ((Time.current - active.activated_at) / 1.day).floor
222
+ period = Current.tenant.key_rotation_days
223
+
224
+ return ok("keys", "manual", days: days) if period.nil?
225
+ return warning("keys", "stale", days: days, period: period) if days > period
226
+
227
+ ok("keys", "fresh", days: days, period: period)
228
+ end
229
+
230
+ def ok(name, summary, **details)
231
+ Result.new(name, HealthCheck::OK, summary, details.stringify_keys)
232
+ end
233
+
234
+ def warning(name, summary, **details)
235
+ Result.new(name, HealthCheck::WARNING, summary, details.stringify_keys)
236
+ end
237
+
238
+ def failing(name, summary, **details)
239
+ Result.new(name, HealthCheck::FAILING, summary, details.stringify_keys)
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,27 @@
1
+ module Masks
2
+ module Server
3
+ class HealthCheck < ApplicationRecord
4
+ include TenantScoped
5
+
6
+ OK = "ok".freeze
7
+ WARNING = "warning".freeze
8
+ FAILING = "failing".freeze
9
+ STATUSES = [ OK, WARNING, FAILING ].freeze
10
+
11
+ validates :name, presence: true, uniqueness: { scope: :tenant_id }
12
+ validates :status, inclusion: { in: STATUSES }
13
+ validates :summary, presence: true
14
+
15
+ scope :failing, -> { where(status: FAILING) }
16
+ scope :by_name, -> { order(:name) }
17
+
18
+ def ok?
19
+ status == OK
20
+ end
21
+
22
+ def failing?
23
+ status == FAILING
24
+ end
25
+ end
26
+ end
27
+ end
@@ -7,8 +7,8 @@ module Masks
7
7
 
8
8
  path "invite"
9
9
 
10
- def self.lifetime
11
- ::Rails.configuration.masks.invitation_lifetime
10
+ def self.lifetime(tenant = Current.tenant)
11
+ Tenant.link_lifetime(:invitation_lifetime, tenant)
12
12
  end
13
13
 
14
14
  def self.open!(actor:, by: nil)
@@ -7,12 +7,12 @@ module Masks
7
7
  class Refused < StandardError; end
8
8
 
9
9
  class << self
10
- def issue!(label:, by:)
10
+ def issue!(label:, by:, client: nil)
11
11
  name = label.to_s.strip.downcase
12
12
  raise Refused, "a label is lowercase letters, digits, dots, dashes, and underscores" unless name.match?(LABEL)
13
13
  raise Refused, "a live LDAP credential is already labeled #{name}" if named(name)
14
14
 
15
- mint!(payload: { "label" => name, "issued_by" => by&.uuid }.compact, expires_at: LIFETIME.from_now)
15
+ mint!(client: client, payload: { "label" => name, "issued_by" => by&.uuid }.compact, expires_at: LIFETIME.from_now)
16
16
  end
17
17
 
18
18
  def named(label)
@@ -30,6 +30,9 @@ module Masks
30
30
 
31
31
  SETTLED = "settled".freeze
32
32
  FIRST_FACTOR_BY = "first_factor_by".freeze
33
+ RELAXED = "relaxed".freeze
34
+ STEP_UP = "step_up".freeze
35
+ ASKED_FOR = 10.minutes
33
36
 
34
37
  def self.permitted_updates
35
38
  STATES.flat_map { |state| state.declared_updates }.uniq
@@ -256,7 +259,30 @@ module Masks
256
259
 
257
260
  wanted = request.respond_to?(:multi_factor?) && request.multi_factor?
258
261
 
259
- wanted || (request.present? && policy.apps_require_second_factor) || state("risk-check").stepping_up?
262
+ wanted || (request.present? && policy.apps_require_second_factor) || state("risk-check").stepping_up? ||
263
+ asked?(STEP_UP)
264
+ end
265
+
266
+ def relaxed?
267
+ asked?(RELAXED) && !stepping_up? && !policy.second_factor_required
268
+ end
269
+
270
+ def skipped_second_factor?
271
+ relaxed? && actor.present? && actor.second_factor? && !touched?(:second_factor) && !amr.include?(Issuer::MULTI_FACTOR)
272
+ end
273
+
274
+ def relax!(now = Time.current)
275
+ store[RELAXED] = { "until" => (now + ASKED_FOR).to_i }
276
+ end
277
+
278
+ def step_up!(now = Time.current)
279
+ store[STEP_UP] = { "until" => (now + ASKED_FOR).to_i }
280
+ end
281
+
282
+ def asked?(key)
283
+ held = store[key]
284
+
285
+ held.is_a?(Hash) && held["until"].to_i > Time.current.to_i
260
286
  end
261
287
 
262
288
  def multi_factored?
@@ -268,7 +294,7 @@ module Masks
268
294
  def second_factored?
269
295
  return false if reauthenticating? || stale? || actor.nil? || stepping_up?
270
296
 
271
- touched?(:second_factor) || signed_in? || remembered?(:second_factor)
297
+ touched?(:second_factor) || (signed_in? && !session.second_factor_skipped?) || remembered?(:second_factor)
272
298
  end
273
299
 
274
300
  def second_factor_pending?
@@ -29,6 +29,7 @@ module Masks
29
29
  acme = Organization.new(key: "acme", name: "Acme", roles: [ "billing" ])
30
30
  joining = Membership.new(organization: acme, actor: person, role: "billing", invited_by: manager,
31
31
  invited_as: person.email, pending: true)
32
+ asked = AccessRequest.new(actor: person, client: client || Client.new(name: "Grafana"), label: "admins")
32
33
  promoted = Event.new(action: Event::MEMBERSHIP_ROLE_CHANGED, actor: person, by: manager, organization: acme,
33
34
  created_at: Time.current, details: { "was" => "member", "now" => "owner" })
34
35
 
@@ -51,6 +52,10 @@ module Masks
51
52
  ActorMailer.organization_invitation(joining, journey: managing) ],
52
53
  [ "approved", "Account approved", managing,
53
54
  ActorMailer.approved(person, journey: managing) ],
55
+ [ "access_granted", "Access granted", managing,
56
+ ActorMailer.access_granted(asked, journey: managing) ],
57
+ [ "access_declined", "Access declined", managing,
58
+ ActorMailer.access_declined(asked, journey: managing) ],
54
59
  [ "notification", "Security notice", system,
55
60
  ActorMailer.notification(person, changed, journey: system) ],
56
61
  [ "notification_organization", "Organization role changed", system,
@@ -13,6 +13,8 @@ module Masks
13
13
  "approval_requested" => %w[tenant nickname],
14
14
  "recovery_requested" => %w[tenant nickname],
15
15
  "approved" => %w[tenant name nickname],
16
+ "access_granted" => %w[tenant name nickname app],
17
+ "access_declined" => %w[tenant name nickname app],
16
18
  SIGNATURE => %w[tenant]
17
19
  }.freeze
18
20
  SUBJECT_LIMIT = 150