masks-server 0.10.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 (183) 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/discovery_controller.rb +36 -0
  6. data/app/controllers/masks/server/enrollments_controller.rb +17 -0
  7. data/app/controllers/masks/server/forward_auth_controller.rb +104 -12
  8. data/app/controllers/masks/server/logins_controller.rb +4 -3
  9. data/app/controllers/masks/server/organization_access_controller.rb +39 -0
  10. data/app/controllers/masks/server/organization_members_controller.rb +3 -1
  11. data/app/graphql/masks/server/manage/mutations/base_mutation.rb +7 -0
  12. data/app/graphql/masks/server/manage/mutations/create_group.rb +6 -4
  13. data/app/graphql/masks/server/manage/mutations/decline_access.rb +27 -0
  14. data/app/graphql/masks/server/manage/mutations/grant_access.rb +29 -0
  15. data/app/graphql/masks/server/manage/mutations/group_source.rb +34 -0
  16. data/app/graphql/masks/server/manage/mutations/issue_ldap_credential.rb +5 -2
  17. data/app/graphql/masks/server/manage/mutations/refresh_group.rb +21 -0
  18. data/app/graphql/masks/server/manage/mutations/remove_group_member.rb +2 -0
  19. data/app/graphql/masks/server/manage/mutations/run_health_checks.rb +19 -0
  20. data/app/graphql/masks/server/manage/mutations/unenroll_client.rb +28 -0
  21. data/app/graphql/masks/server/manage/mutations/update_client.rb +6 -0
  22. data/app/graphql/masks/server/manage/mutations/update_group.rb +7 -4
  23. data/app/graphql/masks/server/manage/mutations/update_tenant.rb +14 -1
  24. data/app/graphql/masks/server/manage/types/access_request_type.rb +21 -0
  25. data/app/graphql/masks/server/manage/types/actor_type.rb +6 -0
  26. data/app/graphql/masks/server/manage/types/client_type.rb +18 -0
  27. data/app/graphql/masks/server/manage/types/group_condition_input.rb +16 -0
  28. data/app/graphql/masks/server/manage/types/group_condition_type.rb +29 -0
  29. data/app/graphql/masks/server/manage/types/group_member_type.rb +16 -0
  30. data/app/graphql/masks/server/manage/types/group_type.rb +17 -1
  31. data/app/graphql/masks/server/manage/types/health_check_type.rb +18 -0
  32. data/app/graphql/masks/server/manage/types/ldap_credential_type.rb +1 -0
  33. data/app/graphql/masks/server/manage/types/mutation_type.rb +5 -0
  34. data/app/graphql/masks/server/manage/types/query_type.rb +24 -0
  35. data/app/graphql/masks/server/manage/types/release_type.rb +16 -0
  36. data/app/graphql/masks/server/manage/types/tenant_type.rb +6 -1
  37. data/app/helpers/masks/server/account_helper.rb +4 -0
  38. data/app/jobs/masks/server/application_job.rb +28 -0
  39. data/app/jobs/masks/server/check_domain_claims_job.rb +1 -3
  40. data/app/jobs/masks/server/cleanup_job.rb +5 -6
  41. data/app/jobs/masks/server/health_check_job.rb +21 -0
  42. data/app/jobs/masks/server/idle_accounts_job.rb +1 -3
  43. data/app/jobs/masks/server/prune_queue_job.rb +19 -0
  44. data/app/jobs/masks/server/refresh_groups_job.rb +18 -0
  45. data/app/jobs/masks/server/refresh_saml_metadata_job.rb +1 -3
  46. data/app/jobs/masks/server/release_check_job.rb +21 -0
  47. data/app/jobs/masks/server/rotate_signing_keys_job.rb +1 -3
  48. data/app/jobs/masks/server/text_job.rb +12 -1
  49. data/app/ldap/masks/server/ldap/connection.rb +3 -2
  50. data/app/ldap/masks/server/ldap/gate.rb +31 -4
  51. data/app/logins/masks/server/login_states/second_factor.rb +1 -1
  52. data/app/mailers/masks/server/actor_mailer.rb +31 -0
  53. data/app/models/masks/server/access_request.rb +103 -0
  54. data/app/models/masks/server/actor.rb +6 -0
  55. data/app/models/masks/server/app_password.rb +91 -0
  56. data/app/models/masks/server/app_passwords.rb +82 -0
  57. data/app/models/masks/server/app_preset.rb +102 -0
  58. data/app/models/masks/server/boot_task.rb +41 -0
  59. data/app/models/masks/server/client.rb +15 -0
  60. data/app/models/masks/server/connection.rb +7 -0
  61. data/app/models/masks/server/declared.rb +10 -2
  62. data/app/models/masks/server/email_verification.rb +2 -2
  63. data/app/models/masks/server/enrollment_token.rb +1 -1
  64. data/app/models/masks/server/event.rb +15 -1
  65. data/app/models/masks/server/federation/protocol.rb +1 -1
  66. data/app/models/masks/server/federation/saml.rb +1 -1
  67. data/app/models/masks/server/forward_auth_rules.rb +162 -0
  68. data/app/models/masks/server/group.rb +207 -4
  69. data/app/models/masks/server/group_member.rb +7 -0
  70. data/app/models/masks/server/group_rule.rb +63 -0
  71. data/app/models/masks/server/health.rb +243 -0
  72. data/app/models/masks/server/health_check.rb +27 -0
  73. data/app/models/masks/server/invitation.rb +2 -2
  74. data/app/models/masks/server/job_failures.rb +25 -0
  75. data/app/models/masks/server/ldap_credential.rb +2 -2
  76. data/app/models/masks/server/login.rb +28 -2
  77. data/app/models/masks/server/mail_previews.rb +5 -0
  78. data/app/models/masks/server/mail_template.rb +2 -0
  79. data/app/models/masks/server/membership.rb +10 -3
  80. data/app/models/masks/server/notifications.rb +3 -1
  81. data/app/models/masks/server/password_reset.rb +2 -2
  82. data/app/models/masks/server/provider.rb +1 -0
  83. data/app/models/masks/server/queue.rb +60 -0
  84. data/app/models/masks/server/release.rb +74 -0
  85. data/app/models/masks/server/session.rb +8 -0
  86. data/app/models/masks/server/tenant.rb +39 -2
  87. data/app/models/masks/server/token.rb +2 -1
  88. data/app/views/masks/server/account/_app_passwords.html.erb +83 -0
  89. data/app/views/masks/server/account/_organizations.html.erb +33 -0
  90. data/app/views/masks/server/account/index.html.erb +5 -0
  91. data/app/views/masks/server/actor_mailer/access_declined.html.erb +5 -0
  92. data/app/views/masks/server/actor_mailer/access_declined.text.erb +5 -0
  93. data/app/views/masks/server/actor_mailer/access_granted.html.erb +7 -0
  94. data/app/views/masks/server/actor_mailer/access_granted.text.erb +7 -0
  95. data/app/views/masks/server/authorize/error.html.erb +25 -0
  96. data/config/locales/de/access_requests.yml +43 -0
  97. data/config/locales/de/account/app_passwords.yml +42 -0
  98. data/config/locales/de/events.yml +9 -0
  99. data/config/locales/de/flashes.yml +1 -0
  100. data/config/locales/de/group_sources.yml +5 -0
  101. data/config/locales/de/manage/access_requests.yml +12 -0
  102. data/config/locales/de/manage/app_passwords.yml +16 -0
  103. data/config/locales/de/manage/client.yml +1 -1
  104. data/config/locales/de/manage/forward_auth_rules.yml +29 -0
  105. data/config/locales/de/manage/group_sources.yml +44 -0
  106. data/config/locales/de/manage/health.yml +78 -0
  107. data/config/locales/de/manage/links.yml +20 -0
  108. data/config/locales/de/manage/machines.yml +13 -0
  109. data/config/locales/de/manage/namespaces.yml +1 -1
  110. data/config/locales/en/access_requests.yml +43 -0
  111. data/config/locales/en/account/app_passwords.yml +42 -0
  112. data/config/locales/en/events.yml +9 -0
  113. data/config/locales/en/flashes.yml +1 -0
  114. data/config/locales/en/group_sources.yml +5 -0
  115. data/config/locales/en/manage/access_requests.yml +12 -0
  116. data/config/locales/en/manage/app_passwords.yml +16 -0
  117. data/config/locales/en/manage/forward_auth_rules.yml +29 -0
  118. data/config/locales/en/manage/group_sources.yml +44 -0
  119. data/config/locales/en/manage/health.yml +78 -0
  120. data/config/locales/en/manage/links.yml +20 -0
  121. data/config/locales/en/manage/machines.yml +13 -0
  122. data/config/locales/es/access_requests.yml +43 -0
  123. data/config/locales/es/account/app_passwords.yml +42 -0
  124. data/config/locales/es/events.yml +9 -0
  125. data/config/locales/es/flashes.yml +1 -0
  126. data/config/locales/es/group_sources.yml +5 -0
  127. data/config/locales/es/handshakes.yml +1 -1
  128. data/config/locales/es/manage/access_requests.yml +12 -0
  129. data/config/locales/es/manage/app_passwords.yml +16 -0
  130. data/config/locales/es/manage/forward_auth_rules.yml +29 -0
  131. data/config/locales/es/manage/group_sources.yml +44 -0
  132. data/config/locales/es/manage/health.yml +78 -0
  133. data/config/locales/es/manage/links.yml +20 -0
  134. data/config/locales/es/manage/machines.yml +13 -0
  135. data/config/locales/fr/access_requests.yml +43 -0
  136. data/config/locales/fr/account/app_passwords.yml +42 -0
  137. data/config/locales/fr/events.yml +9 -0
  138. data/config/locales/fr/flashes.yml +1 -0
  139. data/config/locales/fr/group_sources.yml +5 -0
  140. data/config/locales/fr/handshakes.yml +1 -1
  141. data/config/locales/fr/manage/access_requests.yml +12 -0
  142. data/config/locales/fr/manage/app_passwords.yml +16 -0
  143. data/config/locales/fr/manage/client.yml +1 -1
  144. data/config/locales/fr/manage/clients.yml +1 -1
  145. data/config/locales/fr/manage/forward_auth_rules.yml +29 -0
  146. data/config/locales/fr/manage/group_sources.yml +44 -0
  147. data/config/locales/fr/manage/health.yml +78 -0
  148. data/config/locales/fr/manage/links.yml +20 -0
  149. data/config/locales/fr/manage/machines.yml +13 -0
  150. data/config/locales/fr/manage/providers.yml +1 -1
  151. data/config/routes.rb +6 -0
  152. data/db/migrate/20261019140000_add_indexes_for_cleanup_and_metrics.rb +10 -0
  153. data/db/migrate/20261020100000_create_access_requests.rb +28 -0
  154. data/db/migrate/20261020110000_add_app_passwords_to_clients.rb +5 -0
  155. data/db/migrate/20261020120000_add_forward_auth_rules_to_clients.rb +6 -0
  156. data/db/migrate/20261020130000_add_sources_to_groups.rb +15 -0
  157. data/db/migrate/20261020140000_create_health_checks.rb +25 -0
  158. data/db/migrate/20261020150000_add_link_lifetimes_to_tenants.rb +8 -0
  159. data/db/migrate/20261021100000_add_pinned_origin_to_tenants.rb +5 -0
  160. data/lib/generators/masks/server/install/install_generator.rb +3 -2
  161. data/lib/masks/server/configuration.rb +6 -11
  162. data/lib/masks/server/schema_reference.rb +12 -0
  163. data/lib/masks/server/tenancy/middleware.rb +17 -6
  164. data/lib/tasks/masks.rake +31 -7
  165. data/public/masks-assets/.vite/manifest.json +13 -13
  166. data/public/masks-assets/assets/{account-D46qDQCo.js → account-h7I6VUPR.js} +2 -2
  167. data/public/masks-assets/assets/{account-D46qDQCo.js.map → account-h7I6VUPR.js.map} +1 -1
  168. data/public/masks-assets/assets/{app-D7W0gAyT.css → app-BXOScMjL.css} +1 -1
  169. data/public/masks-assets/assets/{handshake-DIFZPSAK.js → handshake-YQpRNMIs.js} +2 -2
  170. data/public/masks-assets/assets/{handshake-DIFZPSAK.js.map → handshake-YQpRNMIs.js.map} +1 -1
  171. data/public/masks-assets/assets/initial-Cn-UuAsy.js +3 -0
  172. data/public/masks-assets/assets/{initial-C1GpCiEh.js.map → initial-Cn-UuAsy.js.map} +1 -1
  173. data/public/masks-assets/assets/{login-C_6igRZ3.js → login-C21_jb6L.js} +3 -3
  174. data/public/masks-assets/assets/{login-C_6igRZ3.js.map → login-C21_jb6L.js.map} +1 -1
  175. data/public/masks-assets/assets/{manage-DR9qQiBU.js → manage-DpVxe5sv.js} +86 -50
  176. data/public/masks-assets/assets/manage-DpVxe5sv.js.map +1 -0
  177. data/public/masks-assets/assets/passkey-5UYPVD3c.js +2 -0
  178. data/public/masks-assets/assets/{passkey-DIj_5LCY.js.map → passkey-5UYPVD3c.js.map} +1 -1
  179. data/version.txt +1 -1
  180. metadata +96 -14
  181. data/public/masks-assets/assets/initial-C1GpCiEh.js +0 -3
  182. data/public/masks-assets/assets/manage-DR9qQiBU.js.map +0 -1
  183. data/public/masks-assets/assets/passkey-DIj_5LCY.js +0 -2
@@ -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)
@@ -0,0 +1,25 @@
1
+ module Masks
2
+ module Server
3
+ module JobFailures
4
+ KEEP = 2.days
5
+ PREFIX = "masks:jobs:failed_tenants:".freeze
6
+
7
+ module_function
8
+
9
+ def record(job, subdomains)
10
+ ::Rails.cache.write("#{PREFIX}#{job}", subdomains.sort, expires_in: KEEP)
11
+ end
12
+
13
+ def last(job)
14
+ ::Rails.cache.read("#{PREFIX}#{job}") || []
15
+ end
16
+
17
+ def named(jobs)
18
+ jobs.map do |job|
19
+ tenants = last(job)
20
+ tenants.empty? ? job : "#{job} (#{tenants.join(', ')})"
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -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
@@ -18,8 +18,8 @@ module Masks
18
18
 
19
19
  before_create { self.invited_at ||= Time.current if pending? }
20
20
 
21
- def self.lifetime
22
- ::Rails.configuration.masks.organization_invitation_lifetime
21
+ def self.lifetime(tenant = Current.tenant)
22
+ Tenant.link_lifetime(:organization_invitation_lifetime, tenant)
23
23
  end
24
24
 
25
25
  normalizes :external_id, with: Actor::EXTERNAL_ID
@@ -29,13 +29,14 @@ module Masks
29
29
  validate :an_owner_remains, on: :update
30
30
 
31
31
  before_destroy :keep_an_owner
32
+ after_commit :settle_nested_groups
32
33
 
33
34
  def owner?
34
35
  role == Organization::OWNER && !pending?
35
36
  end
36
37
 
37
38
  def expires_at
38
- invited_at + self.class.lifetime if pending?
39
+ invited_at + self.class.lifetime(tenant) if pending?
39
40
  end
40
41
 
41
42
  def expired?
@@ -72,6 +73,12 @@ module Masks
72
73
 
73
74
  private
74
75
 
76
+ def settle_nested_groups
77
+ return if organization.nil? || actor.nil?
78
+
79
+ Group.nested_under_organization(organization.key).find_each { |group| group.settle!(actor) }
80
+ end
81
+
75
82
  def role_is_offered
76
83
  errors.add(:role, "is not one #{organization&.name} offers") unless organization&.role?(role)
77
84
  end
@@ -18,7 +18,9 @@ module Masks
18
18
  Event::TEXT_CODES_ENABLED,
19
19
  Event::TEXT_CODES_DISABLED,
20
20
  Event::RECOVERY_REQUESTED,
21
- Event::SECOND_FACTORS_RESET
21
+ Event::SECOND_FACTORS_RESET,
22
+ Event::APP_PASSWORD_CREATED,
23
+ Event::APP_PASSWORD_REVOKED
22
24
  ],
23
25
  "sessions" => [
24
26
  Event::SESSION_STARTED,
@@ -5,8 +5,8 @@ module Masks
5
5
 
6
6
  path "reset"
7
7
 
8
- def self.lifetime
9
- ::Rails.configuration.masks.password_reset_lifetime
8
+ def self.lifetime(tenant = Current.tenant)
9
+ Tenant.link_lifetime(:password_reset_lifetime, tenant)
10
10
  end
11
11
 
12
12
  def self.settle!(secret, password)
@@ -42,6 +42,7 @@ module Masks
42
42
  encrypts :private_key
43
43
 
44
44
  has_many :connections, dependent: :destroy
45
+ has_many :groups, dependent: :nullify
45
46
  has_many :delegations, through: :connections
46
47
 
47
48
  belongs_to :organization, optional: true
@@ -0,0 +1,60 @@
1
+ module Masks
2
+ module Server
3
+ module Queue
4
+ module_function
5
+
6
+ def run?
7
+ ::Rails.application.config.active_job.queue_adapter == :solid_queue
8
+ end
9
+
10
+ def silence
11
+ ::Rails.configuration.masks.worker_silence
12
+ end
13
+
14
+ def wait
15
+ ::Rails.configuration.masks.queue_wait
16
+ end
17
+
18
+ def failing_jobs
19
+ ::SolidQueue::FailedExecution.where(created_at: 1.day.ago..).joins(:job)
20
+ .distinct.pluck("solid_queue_jobs.class_name").sort
21
+ end
22
+
23
+ def worker_heartbeat_at
24
+ ::SolidQueue::Process.where(kind: "Worker").maximum(:last_heartbeat_at)
25
+ end
26
+
27
+ def oldest_waiting_at
28
+ ::SolidQueue::ReadyExecution.minimum(:created_at)
29
+ end
30
+
31
+ def worker_silent?
32
+ return false unless run? && silence.positive?
33
+
34
+ heartbeat = worker_heartbeat_at
35
+
36
+ heartbeat.nil? || heartbeat < silence.ago
37
+ end
38
+
39
+ def stuck_since
40
+ return nil unless run? && wait.positive?
41
+
42
+ oldest = oldest_waiting_at
43
+
44
+ oldest if oldest && oldest < wait.ago
45
+ end
46
+
47
+ def plain(duration)
48
+ ActiveSupport::Duration.build(duration.to_i).inspect
49
+ end
50
+
51
+ def worker_problem
52
+ return "no worker has reported in the last #{plain(silence)}" if worker_silent?
53
+
54
+ stuck = stuck_since
55
+
56
+ "a job has waited since #{stuck.utc.iso8601}, longer than #{plain(wait)}" if stuck
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,74 @@
1
+ module Masks
2
+ module Server
3
+ module Release
4
+ URL = "https://api.github.com/repos/masksrb/masks/releases?per_page=20".freeze
5
+ KEY = "masks:release".freeze
6
+ KEEP = 3.days
7
+ TAG = /\Aserver-v(\d+\.\d+\.\d+)\z/
8
+ OPERATOR = /^\s*Operator:/i
9
+ TIMEOUT = 5
10
+ CEILING = 1.megabyte
11
+ UNREADABLE = [ Outbound::Refused, JSON::ParserError, TypeError, NoMethodError ].freeze
12
+
13
+ module_function
14
+
15
+ def check!
16
+ found = fetch
17
+
18
+ return nil if found.nil?
19
+
20
+ was = latest
21
+ remember!(found)
22
+
23
+ found if newer?(found["version"]) && was&.dig("version") != found["version"]
24
+ end
25
+
26
+ def fetch
27
+ headers = { "Accept" => "application/vnd.github+json", "User-Agent" => "masks-server/#{VERSION}" }
28
+ body = Outbound.get!(URI(URL), open: TIMEOUT, read: TIMEOUT, within: TIMEOUT, ceiling: CEILING, headers: headers).body
29
+
30
+ JSON.parse(body).each do |release|
31
+ version = release["tag_name"].to_s[TAG, 1]
32
+
33
+ next if version.nil? || release["draft"] || release["prerelease"]
34
+
35
+ return {
36
+ "version" => version,
37
+ "url" => release["html_url"].to_s,
38
+ "operator_step" => release["body"].to_s.match?(OPERATOR),
39
+ "checked_at" => Time.current.utc.iso8601
40
+ }
41
+ end
42
+
43
+ nil
44
+ rescue *UNREADABLE => error
45
+ ::Rails.logger.warn("the release check could not read the releases: #{error.class}")
46
+ nil
47
+ end
48
+
49
+ def remember!(found)
50
+ ::Rails.cache.write(KEY, found, expires_in: KEEP)
51
+ end
52
+
53
+ def forget!
54
+ ::Rails.cache.delete(KEY)
55
+ end
56
+
57
+ def latest
58
+ ::Rails.cache.read(KEY)
59
+ end
60
+
61
+ def available
62
+ found = latest
63
+
64
+ found if found && newer?(found["version"])
65
+ end
66
+
67
+ def newer?(version)
68
+ Gem::Version.new(version) > Gem::Version.new(VERSION)
69
+ rescue ArgumentError
70
+ false
71
+ end
72
+ end
73
+ end
74
+ end
@@ -118,6 +118,14 @@ module Masks
118
118
  true
119
119
  end
120
120
 
121
+ def multi_factored?
122
+ Array(amr).include?(Issuer::MULTI_FACTOR) || !actor.second_factor?
123
+ end
124
+
125
+ def complete?
126
+ !second_factor_skipped?
127
+ end
128
+
121
129
  def fresh?
122
130
  authenticated_at.present? && authenticated_at > FRESHNESS.ago
123
131
  end