masks-server 0.11.0 → 0.13.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 (181) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/masks/server/scim_endpoint.rb +2 -2
  3. data/app/controllers/masks/server/account_controller.rb +3 -0
  4. data/app/controllers/masks/server/account_exports_controller.rb +2 -2
  5. data/app/controllers/masks/server/addresses_controller.rb +2 -3
  6. data/app/controllers/masks/server/app_passwords_controller.rb +41 -0
  7. data/app/controllers/masks/server/authenticator_apps_controller.rb +2 -3
  8. data/app/controllers/masks/server/authorize_controller.rb +33 -2
  9. data/app/controllers/masks/server/device_authorizations_controller.rb +2 -2
  10. data/app/controllers/masks/server/device_verifications_controller.rb +2 -2
  11. data/app/controllers/masks/server/enrollments_controller.rb +4 -4
  12. data/app/controllers/masks/server/forward_auth_controller.rb +106 -14
  13. data/app/controllers/masks/server/introspections_controller.rb +2 -2
  14. data/app/controllers/masks/server/logins_controller.rb +14 -15
  15. data/app/controllers/masks/server/organization_access_controller.rb +39 -0
  16. data/app/controllers/masks/server/organization_members_controller.rb +2 -2
  17. data/app/controllers/masks/server/passwords_controller.rb +2 -3
  18. data/app/controllers/masks/server/pushed_authorizations_controller.rb +2 -2
  19. data/app/controllers/masks/server/received_signals_controller.rb +2 -2
  20. data/app/controllers/masks/server/registrations_controller.rb +2 -2
  21. data/app/controllers/masks/server/revocations_controller.rb +2 -2
  22. data/app/controllers/masks/server/saml_controller.rb +2 -2
  23. data/app/controllers/masks/server/sign_in_approvals_controller.rb +2 -3
  24. data/app/controllers/masks/server/tokens_controller.rb +2 -2
  25. data/app/controllers/masks/server/verifications_controller.rb +2 -3
  26. data/app/graphql/masks/server/manage/mutations/base_mutation.rb +7 -0
  27. data/app/graphql/masks/server/manage/mutations/create_group.rb +6 -4
  28. data/app/graphql/masks/server/manage/mutations/decline_access.rb +27 -0
  29. data/app/graphql/masks/server/manage/mutations/grant_access.rb +29 -0
  30. data/app/graphql/masks/server/manage/mutations/group_source.rb +34 -0
  31. data/app/graphql/masks/server/manage/mutations/issue_ldap_credential.rb +5 -2
  32. data/app/graphql/masks/server/manage/mutations/refresh_group.rb +21 -0
  33. data/app/graphql/masks/server/manage/mutations/remove_group_member.rb +2 -0
  34. data/app/graphql/masks/server/manage/mutations/run_health_checks.rb +19 -0
  35. data/app/graphql/masks/server/manage/mutations/update_client.rb +6 -0
  36. data/app/graphql/masks/server/manage/mutations/update_group.rb +7 -4
  37. data/app/graphql/masks/server/manage/mutations/update_tenant.rb +14 -1
  38. data/app/graphql/masks/server/manage/types/access_request_type.rb +21 -0
  39. data/app/graphql/masks/server/manage/types/actor_type.rb +6 -0
  40. data/app/graphql/masks/server/manage/types/client_type.rb +12 -0
  41. data/app/graphql/masks/server/manage/types/group_condition_input.rb +16 -0
  42. data/app/graphql/masks/server/manage/types/group_condition_type.rb +29 -0
  43. data/app/graphql/masks/server/manage/types/group_member_type.rb +16 -0
  44. data/app/graphql/masks/server/manage/types/group_type.rb +17 -1
  45. data/app/graphql/masks/server/manage/types/health_check_type.rb +18 -0
  46. data/app/graphql/masks/server/manage/types/ldap_credential_type.rb +1 -0
  47. data/app/graphql/masks/server/manage/types/mutation_type.rb +4 -0
  48. data/app/graphql/masks/server/manage/types/query_type.rb +24 -0
  49. data/app/graphql/masks/server/manage/types/release_type.rb +16 -0
  50. data/app/graphql/masks/server/manage/types/tenant_type.rb +6 -1
  51. data/app/helpers/masks/server/account_helper.rb +4 -0
  52. data/app/jobs/masks/server/cleanup_job.rb +1 -0
  53. data/app/jobs/masks/server/health_check_job.rb +21 -0
  54. data/app/jobs/masks/server/refresh_groups_job.rb +18 -0
  55. data/app/jobs/masks/server/release_check_job.rb +21 -0
  56. data/app/ldap/masks/server/ldap/connection.rb +3 -2
  57. data/app/ldap/masks/server/ldap/gate.rb +44 -19
  58. data/app/logins/masks/server/login_states/second_factor.rb +1 -1
  59. data/app/mailers/masks/server/actor_mailer.rb +31 -0
  60. data/app/models/masks/server/access_request.rb +103 -0
  61. data/app/models/masks/server/actor.rb +6 -0
  62. data/app/models/masks/server/app_password.rb +91 -0
  63. data/app/models/masks/server/app_passwords.rb +76 -0
  64. data/app/models/masks/server/client.rb +15 -0
  65. data/app/models/masks/server/client_metadata_document.rb +4 -8
  66. data/app/models/masks/server/confirmation_code.rb +2 -2
  67. data/app/models/masks/server/connection.rb +7 -0
  68. data/app/models/masks/server/declared.rb +13 -3
  69. data/app/models/masks/server/email_verification.rb +2 -2
  70. data/app/models/masks/server/event.rb +13 -3
  71. data/app/models/masks/server/federation/protocol.rb +1 -1
  72. data/app/models/masks/server/federation/saml.rb +1 -1
  73. data/app/models/masks/server/forward_auth_rules.rb +162 -0
  74. data/app/models/masks/server/group.rb +207 -4
  75. data/app/models/masks/server/group_member.rb +7 -0
  76. data/app/models/masks/server/group_rule.rb +63 -0
  77. data/app/models/masks/server/health.rb +310 -0
  78. data/app/models/masks/server/health_check.rb +27 -0
  79. data/app/models/masks/server/invitation.rb +2 -2
  80. data/app/models/masks/server/ldap_credential.rb +2 -2
  81. data/app/models/masks/server/login.rb +28 -2
  82. data/app/models/masks/server/mail_previews.rb +5 -0
  83. data/app/models/masks/server/mail_template.rb +2 -0
  84. data/app/models/masks/server/membership.rb +10 -3
  85. data/app/models/masks/server/notifications.rb +3 -1
  86. data/app/models/masks/server/password_reset.rb +2 -2
  87. data/app/models/masks/server/provider.rb +1 -0
  88. data/app/models/masks/server/queue.rb +60 -0
  89. data/app/models/masks/server/release.rb +74 -0
  90. data/app/models/masks/server/saml_identity/farewells.rb +1 -0
  91. data/app/models/masks/server/session.rb +8 -0
  92. data/app/models/masks/server/tenant.rb +87 -6
  93. data/app/models/masks/server/throttle.rb +165 -0
  94. data/app/models/masks/server/token.rb +2 -1
  95. data/app/views/masks/server/account/_app_passwords.html.erb +83 -0
  96. data/app/views/masks/server/account/_organizations.html.erb +33 -0
  97. data/app/views/masks/server/account/index.html.erb +5 -0
  98. data/app/views/masks/server/actor_mailer/access_declined.html.erb +5 -0
  99. data/app/views/masks/server/actor_mailer/access_declined.text.erb +5 -0
  100. data/app/views/masks/server/actor_mailer/access_granted.html.erb +7 -0
  101. data/app/views/masks/server/actor_mailer/access_granted.text.erb +7 -0
  102. data/app/views/masks/server/authorize/error.html.erb +25 -0
  103. data/config/locales/de/access_requests.yml +43 -0
  104. data/config/locales/de/account/app_passwords.yml +42 -0
  105. data/config/locales/de/events.yml +5 -0
  106. data/config/locales/de/flashes.yml +1 -0
  107. data/config/locales/de/group_sources.yml +5 -0
  108. data/config/locales/de/manage/access_requests.yml +12 -0
  109. data/config/locales/de/manage/app_passwords.yml +16 -0
  110. data/config/locales/de/manage/forward_auth_rules.yml +29 -0
  111. data/config/locales/de/manage/group_sources.yml +44 -0
  112. data/config/locales/de/manage/health.yml +85 -0
  113. data/config/locales/de/manage/links.yml +20 -0
  114. data/config/locales/en/access_requests.yml +43 -0
  115. data/config/locales/en/account/app_passwords.yml +42 -0
  116. data/config/locales/en/events.yml +5 -0
  117. data/config/locales/en/flashes.yml +1 -0
  118. data/config/locales/en/group_sources.yml +5 -0
  119. data/config/locales/en/manage/access_requests.yml +12 -0
  120. data/config/locales/en/manage/app_passwords.yml +16 -0
  121. data/config/locales/en/manage/forward_auth_rules.yml +29 -0
  122. data/config/locales/en/manage/group_sources.yml +44 -0
  123. data/config/locales/en/manage/health.yml +85 -0
  124. data/config/locales/en/manage/links.yml +20 -0
  125. data/config/locales/es/access_requests.yml +43 -0
  126. data/config/locales/es/account/app_passwords.yml +42 -0
  127. data/config/locales/es/events.yml +5 -0
  128. data/config/locales/es/flashes.yml +1 -0
  129. data/config/locales/es/group_sources.yml +5 -0
  130. data/config/locales/es/manage/access_requests.yml +12 -0
  131. data/config/locales/es/manage/app_passwords.yml +16 -0
  132. data/config/locales/es/manage/forward_auth_rules.yml +29 -0
  133. data/config/locales/es/manage/group_sources.yml +44 -0
  134. data/config/locales/es/manage/health.yml +85 -0
  135. data/config/locales/es/manage/links.yml +20 -0
  136. data/config/locales/fr/access_requests.yml +43 -0
  137. data/config/locales/fr/account/app_passwords.yml +42 -0
  138. data/config/locales/fr/events.yml +5 -0
  139. data/config/locales/fr/flashes.yml +1 -0
  140. data/config/locales/fr/group_sources.yml +5 -0
  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/forward_auth_rules.yml +29 -0
  144. data/config/locales/fr/manage/group_sources.yml +44 -0
  145. data/config/locales/fr/manage/health.yml +85 -0
  146. data/config/locales/fr/manage/links.yml +20 -0
  147. data/config/routes.rb +5 -0
  148. data/db/migrate/20261020100000_create_access_requests.rb +28 -0
  149. data/db/migrate/20261020110000_add_app_passwords_to_clients.rb +5 -0
  150. data/db/migrate/20261020120000_add_forward_auth_rules_to_clients.rb +6 -0
  151. data/db/migrate/20261020130000_add_sources_to_groups.rb +15 -0
  152. data/db/migrate/20261020140000_create_health_checks.rb +25 -0
  153. data/db/migrate/20261020150000_add_link_lifetimes_to_tenants.rb +8 -0
  154. data/db/migrate/20261021100000_add_pinned_origin_to_tenants.rb +5 -0
  155. data/db/migrate/20261031100000_default_key_rotation_to_ninety_days.rb +5 -0
  156. data/lib/generators/masks/server/install/install_generator.rb +22 -2
  157. data/lib/masks/server/configuration.rb +16 -19
  158. data/lib/masks/server/engine.rb +2 -1
  159. data/lib/masks/server/forwarding.rb +26 -3
  160. data/lib/masks/server/schema_reference.rb +12 -0
  161. data/lib/masks/server/tenancy/middleware.rb +21 -13
  162. data/lib/masks/server/tenant_isolation.rb +12 -0
  163. data/public/masks-assets/.vite/manifest.json +13 -13
  164. data/public/masks-assets/assets/{account-D46qDQCo.js → account-h7I6VUPR.js} +2 -2
  165. data/public/masks-assets/assets/{account-D46qDQCo.js.map → account-h7I6VUPR.js.map} +1 -1
  166. data/public/masks-assets/assets/{app-D7W0gAyT.css → app-BXOScMjL.css} +1 -1
  167. data/public/masks-assets/assets/{handshake-DIFZPSAK.js → handshake-YQpRNMIs.js} +2 -2
  168. data/public/masks-assets/assets/{handshake-DIFZPSAK.js.map → handshake-YQpRNMIs.js.map} +1 -1
  169. data/public/masks-assets/assets/initial-Cn-UuAsy.js +3 -0
  170. data/public/masks-assets/assets/{initial-C1GpCiEh.js.map → initial-Cn-UuAsy.js.map} +1 -1
  171. data/public/masks-assets/assets/{login-C_6igRZ3.js → login-C21_jb6L.js} +3 -3
  172. data/public/masks-assets/assets/{login-C_6igRZ3.js.map → login-C21_jb6L.js.map} +1 -1
  173. data/public/masks-assets/assets/{manage-D4MzaDvM.js → manage-DpVxe5sv.js} +80 -51
  174. data/public/masks-assets/assets/manage-DpVxe5sv.js.map +1 -0
  175. data/public/masks-assets/assets/passkey-5UYPVD3c.js +2 -0
  176. data/public/masks-assets/assets/{passkey-DIj_5LCY.js.map → passkey-5UYPVD3c.js.map} +1 -1
  177. data/version.txt +1 -1
  178. metadata +89 -14
  179. data/public/masks-assets/assets/initial-C1GpCiEh.js +0 -3
  180. data/public/masks-assets/assets/manage-D4MzaDvM.js.map +0 -1
  181. data/public/masks-assets/assets/passkey-DIj_5LCY.js +0 -2
@@ -31,6 +31,21 @@ module Masks
31
31
  end
32
32
  end
33
33
 
34
+ class Pooled < StandardError
35
+ def initialize(message = nil)
36
+ super(
37
+ message ||
38
+ "this server reaches Postgres through a pooler that hands out a different server " \
39
+ "connection for each transaction. The tenant masks sets on one connection is gone from " \
40
+ "the next, so a query would see no tenant's rows, or another tenant's. Connect masks to " \
41
+ "Postgres directly, or run the pooler in session mode."
42
+ )
43
+ end
44
+ end
45
+
46
+ POOL_PROBE = "masks.pool_probe".freeze
47
+ POOL_PROBE_ROUNDS = 3
48
+
34
49
  encrypts :pairwise_salt, :setup_token, key_provider: Server.tenant_key_provider
35
50
 
36
51
  attribute :uuid, default: -> { SecureRandom.uuid }
@@ -64,6 +79,12 @@ module Masks
64
79
 
65
80
  IDLE_DAYS = (60..3650)
66
81
  RETENTION_DAYS = (30..2555)
82
+ LINK_LIFETIMES = {
83
+ invitation_lifetime: (1.hour.to_i..30.days.to_i),
84
+ organization_invitation_lifetime: (1.hour.to_i..30.days.to_i),
85
+ password_reset_lifetime: (5.minutes.to_i..1.day.to_i),
86
+ email_verification_lifetime: (1.hour.to_i..14.days.to_i)
87
+ }.freeze
67
88
 
68
89
  validates :named_by, inclusion: { in: NAMES }, allow_nil: true
69
90
  validates :accent_color, format: { with: ACCENT }, allow_nil: true
@@ -71,6 +92,18 @@ module Masks
71
92
  validates :dynamic_registration, inclusion: { in: REGISTRATIONS }, allow_nil: true
72
93
  validates :suspend_after, :delete_after, numericality: { only_integer: true, in: IDLE_DAYS }, allow_nil: true
73
94
  validates :event_retention_days, numericality: { only_integer: true, in: RETENTION_DAYS }, allow_nil: true
95
+ def self.spoken(seconds)
96
+ unit, length = { "day" => 1.day, "hour" => 1.hour, "minute" => 1.minute }.find { |_, held| (seconds % held).zero? }
97
+ count = seconds / length
98
+
99
+ "#{count} #{unit.pluralize(count)}"
100
+ end
101
+
102
+ LINK_LIFETIMES.each do |name, range|
103
+ shortest, longest = [ range.begin, range.end ].map { |seconds| spoken(seconds) }
104
+
105
+ validates name, numericality: { only_integer: true, in: range, message: "must be from #{shortest} to #{longest}" }
106
+ end
74
107
  validates :delete_after, comparison: { greater_than: :suspend_after, message: "must be longer than suspend after" },
75
108
  if: -> { suspend_after && delete_after }
76
109
 
@@ -104,8 +137,20 @@ module Masks
104
137
  event_retention_days&.days || Event::RETENTION
105
138
  end
106
139
 
140
+ def self.link_lifetime(name, tenant = Current.tenant)
141
+ (tenant ? tenant[name] : column_defaults.fetch(name.to_s)).seconds
142
+ end
143
+
144
+ def self.default_link_lifetime(name)
145
+ column_defaults.fetch(name.to_s)
146
+ end
147
+
107
148
  def public_origin
108
- custom_origin || templated_origin
149
+ custom_origin || templated_origin || held_origin
150
+ end
151
+
152
+ def held_origin
153
+ pinned_origin if self.class.pins_origin?
109
154
  end
110
155
 
111
156
  def templated_origin
@@ -246,6 +291,10 @@ module Masks
246
291
  serving(host) || named(host)
247
292
  end
248
293
 
294
+ def pins_origin?
295
+ ::Rails.configuration.masks.public_origin_template.nil? && ::Rails.configuration.masks.pin_origin
296
+ end
297
+
249
298
  def served_domain
250
299
  template = ::Rails.configuration.masks.public_origin_template
251
300
 
@@ -314,10 +363,28 @@ module Masks
314
363
 
315
364
  raise Exposed, held&.fetch("rolname", nil) unless held && held["bypasses"] == false
316
365
  raise Owner, held["rolname"] if held["owns"] && guards_ownership?
366
+ raise Pooled if pooled?
317
367
 
318
368
  @isolated = true
319
369
  end
320
370
 
371
+ def pooled?
372
+ return false if connection_pool.size < 2
373
+
374
+ POOL_PROBE_ROUNDS.times do
375
+ mine = SecureRandom.hex(8)
376
+ connection.exec_query("SELECT set_config($1, $2, false)", "probe", [ POOL_PROBE, mine ])
377
+
378
+ return false unless touched_another_connection?
379
+
380
+ return true unless connection.select_value("SELECT current_setting('#{POOL_PROBE}', true)") == mine
381
+ end
382
+
383
+ false
384
+ rescue ActiveRecord::ConnectionTimeoutError
385
+ false
386
+ end
387
+
321
388
  def role_privileges
322
389
  connection.select_one(<<~SQL)
323
390
  SELECT rolname, rolsuper OR rolbypassrls AS bypasses,
@@ -349,23 +416,34 @@ module Masks
349
416
  end
350
417
 
351
418
  def clear!
352
- enter(nil)
419
+ enter(nil, for_transaction: false)
353
420
  end
354
421
 
355
422
  private
356
423
 
357
- def enter(tenant)
424
+ def enter(tenant, for_transaction: connection.transaction_open?)
358
425
  Current.tenant = tenant
359
426
 
360
427
  connection.exec_query(
361
- "SELECT set_config($1, $2, false)", "tenant",
362
- [ TenantIsolation::SETTING, tenant&.id.to_s ]
428
+ "SELECT set_config($1, $2, $3)", "tenant",
429
+ [ TenantIsolation::SETTING, tenant&.id.to_s, for_transaction ]
363
430
  )
364
431
 
365
432
  connection.clear_query_cache
366
433
  rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::ConnectionFailed
367
434
  nil
368
435
  end
436
+
437
+ def touched_another_connection?
438
+ other = connection_pool.checkout
439
+
440
+ return false if other.equal?(connection)
441
+
442
+ other.exec_query("SELECT set_config($1, $2, false)", "probe", [ POOL_PROBE, "another connection" ])
443
+ true
444
+ ensure
445
+ connection_pool.checkin(other) if other
446
+ end
369
447
  end
370
448
 
371
449
  def signing_key
@@ -403,7 +481,10 @@ module Masks
403
481
  end
404
482
 
405
483
  def set_up!
406
- update!(setup_token: nil) if setup_token.present?
484
+ self.setup_token = nil
485
+ self.pinned_origin ||= Current.origin.presence if self.class.pins_origin?
486
+
487
+ save! if changed?
407
488
  end
408
489
 
409
490
  def setup_announcement
@@ -0,0 +1,165 @@
1
+ module Masks
2
+ module Server
3
+ module Throttle
4
+ class Invalid < StandardError; end
5
+
6
+ Budget = Struct.new(:name, :to, :within, :about, :variable, keyword_init: true) do
7
+ def key
8
+ name.to_s
9
+ end
10
+
11
+ def limits
12
+ { to: to, within: within }
13
+ end
14
+
15
+ def reference
16
+ "| `#{variable}` | `#{key}` | #{to} in #{within.inspect} | #{about} |"
17
+ end
18
+ end
19
+
20
+ DEFAULTS = [
21
+ Budget.new(name: :attempt, to: 10, within: 3.minutes, variable: "MASKS_ATTEMPT_LIMIT",
22
+ about: "Sign-in attempts from one IP address. Includes sign-in through a provider, failed LDAP binds, and app passwords."),
23
+ Budget.new(name: :account_attempt, to: 5, within: 3.minutes, variable: "MASKS_ACCOUNT_ATTEMPT_LIMIT",
24
+ about: "Sign-in attempts against one account, from any IP address. Includes failed LDAP binds and app passwords. " \
25
+ "Also how often a signed-in account changes its password, email address, or phone number, adds an " \
26
+ "authenticator app, or approves a sign-in on another device."),
27
+ Budget.new(name: :recovery, to: 5, within: 15.minutes, variable: "MASKS_RECOVERY_LIMIT",
28
+ about: "Codes and links sent by email or text message. Counted by IP address during sign-in, and by account for " \
29
+ "every address or phone number a code goes to, including a phone added or changed during sign-in."),
30
+ Budget.new(name: :registration, to: 10, within: 10.minutes, variable: "MASKS_REGISTRATION_LIMIT",
31
+ about: "Dynamic client registrations from one IP address, and new client metadata documents."),
32
+ Budget.new(name: :device_code, to: 30, within: 3.minutes, variable: "MASKS_DEVICE_CODE_LIMIT",
33
+ about: "Device user codes entered from one IP address."),
34
+ Budget.new(name: :claim, to: 3, within: 1.hour, variable: "MASKS_CLAIM_LIMIT",
35
+ about: "Tenants claimed from one IP address, when a subdomain template claims a tenant on its first visit. " \
36
+ "Counted across the whole server."),
37
+ Budget.new(name: :claim_ceiling, to: 30, within: 1.hour, variable: "MASKS_CLAIM_CEILING",
38
+ about: "Tenants claimed from all IP addresses together, under the same template. Counted across the whole server."),
39
+ Budget.new(name: :token, to: 60, within: 1.minute, variable: "MASKS_TOKEN_LIMIT", about: "Token requests from one IP address."),
40
+ Budget.new(name: :pushed_authorization, to: 60, within: 1.minute, variable: "MASKS_PUSHED_AUTHORIZATION_LIMIT", about: "Pushed authorization requests from one IP address."),
41
+ Budget.new(name: :device_authorization, to: 60, within: 1.minute, variable: "MASKS_DEVICE_AUTHORIZATION_LIMIT", about: "Device authorization requests from one IP address."),
42
+ Budget.new(name: :introspection, to: 120, within: 1.minute, variable: "MASKS_INTROSPECTION_LIMIT", about: "Token introspections from one IP address."),
43
+ Budget.new(name: :revocation, to: 120, within: 1.minute, variable: "MASKS_REVOCATION_LIMIT", about: "Token revocations from one IP address."),
44
+ Budget.new(name: :saml, to: 60, within: 1.minute, variable: "MASKS_SAML_LIMIT", about: "SAML sign-in and logout requests from one IP address."),
45
+ Budget.new(name: :forward_auth, to: 30, within: 1.minute, variable: "MASKS_FORWARD_AUTH_LIMIT", about: "Forward auth sign-ins started or finished from one IP address."),
46
+ Budget.new(name: :enrollment, to: 30, within: 1.minute, variable: "MASKS_ENROLLMENT_LIMIT", about: "Enrollment tokens redeemed from one IP address."),
47
+ Budget.new(name: :app_enrollment, to: 60, within: 1.hour, variable: "MASKS_APP_ENROLLMENT_LIMIT", about: "Enrollment tokens one app mints."),
48
+ Budget.new(name: :invitation, to: 30, within: 1.minute, variable: "MASKS_INVITATION_LIMIT", about: "Organization invitations one account sends or sends again."),
49
+ Budget.new(name: :export, to: 5, within: 1.hour, variable: "MASKS_EXPORT_LIMIT", about: "Account exports one account asks for."),
50
+ Budget.new(name: :access_request, to: 1, within: 1.hour, variable: "MASKS_ACCESS_REQUEST_LIMIT", about: "Times one account asks for access to one app."),
51
+ Budget.new(name: :signal, to: 600, within: 1.minute, variable: "MASKS_SIGNAL_LIMIT", about: "Security events received from one IP address."),
52
+ Budget.new(name: :scim, to: 600, within: 1.minute, variable: "MASKS_SCIM_LIMIT", about: "SCIM provisioning requests from one IP address.")
53
+ ].freeze
54
+
55
+ NAMES = DEFAULTS.map(&:key).freeze
56
+
57
+ module_function
58
+
59
+ def budgets
60
+ @budgets ||= DEFAULTS.to_h { |budget| [ budget.name, budget.dup.tap { |held| held.to = limit_for(budget) } ] }
61
+ end
62
+
63
+ def budget(name)
64
+ budgets.fetch(name.to_sym) { raise Invalid, "no limit is called #{name}. The limits are #{NAMES.join(', ')}." }
65
+ end
66
+
67
+ def limit(name)
68
+ budget(name).to
69
+ end
70
+
71
+ def limits(name)
72
+ budget(name).limits
73
+ end
74
+
75
+ def refused(name, &block)
76
+ held = budget(name)
77
+
78
+ proc { Throttle.hit!(held.name); instance_exec(&block) }
79
+ end
80
+
81
+ def key(name, by, scope: nil, named: nil)
82
+ held = budget(name)
83
+
84
+ [ "rate-limit", scope || "masks/server/#{held.key}", named, by ].compact.join(":")
85
+ end
86
+
87
+ def count!(name, by, scope: nil, named: nil)
88
+ ::Rails.cache.increment(key(name, by, scope: scope, named: named), 1, expires_in: budget(name).within)
89
+ end
90
+
91
+ def exceeded?(name, by, scope: nil, named: nil, times: 1)
92
+ ::Rails.cache.read(key(name, by, scope: scope, named: named)).to_i >= budget(name).to * times
93
+ end
94
+
95
+ def crowded?(name, by, scope: nil, named: nil, times: 1)
96
+ held = budget(name)
97
+
98
+ ::Rails.cache.increment(key(name, by, scope: scope, named: named), 1, expires_in: held.within).to_i > held.to * times
99
+ end
100
+
101
+ def hit!(name, actor: nil, client: nil, address: Current.ip_address, **details)
102
+ held = budget(name)
103
+
104
+ ::Rails.logger.info("masks: the #{held.key} limit of #{held.to} in #{held.within.inspect} was reached" \
105
+ "#{address.present? ? " from #{address}" : ''}")
106
+
107
+ return if Current.tenant.nil?
108
+ return unless ::Rails.cache.write(key(name, "reached:#{address}"), true, unless_exist: true, expires_in: held.within)
109
+
110
+ Event.record!(Event::LIMIT_REACHED, actor: actor, by: nil, client: client, limit: held.key, **details)
111
+ end
112
+
113
+ def reference
114
+ DEFAULTS.map(&:reference)
115
+ end
116
+
117
+ def reset!
118
+ @budgets = nil
119
+ @declared = nil
120
+ end
121
+
122
+ def limit_for(budget)
123
+ from_env(budget) || declared.fetch(budget.key, nil) || budget.to
124
+ end
125
+
126
+ def from_env(budget)
127
+ value = ENV[budget.variable]
128
+
129
+ return nil if value.blank?
130
+
131
+ whole!(value, budget.variable)
132
+ end
133
+
134
+ def declared
135
+ @declared ||= read_declared(::Rails.configuration.masks.config_path)
136
+ end
137
+
138
+ def read_declared(path)
139
+ return {} if path.blank?
140
+
141
+ document = YAML.safe_load_file(path, aliases: false) || {}
142
+ limits = document.is_a?(Hash) ? document.fetch("limits", nil) || {} : {}
143
+
144
+ raise Invalid, "limits in #{path} must map each limit's name to a whole number" unless limits.is_a?(Hash)
145
+
146
+ unknown = limits.keys.map(&:to_s) - NAMES
147
+ raise Invalid, "#{path} sets limits called #{unknown.join(', ')}, which do not exist. The limits are #{NAMES.join(', ')}." if unknown.any?
148
+
149
+ limits.to_h { |name, value| [ name.to_s, whole!(value, "limits.#{name} in #{path}") ] }
150
+ rescue Errno::ENOENT, Psych::Exception
151
+ {}
152
+ end
153
+
154
+ def whole!(value, where)
155
+ count = Integer(value.to_s, 10)
156
+
157
+ raise Invalid, "#{where} is #{value.inspect}. A limit is a whole number, 1 or more." if count < 1
158
+
159
+ count
160
+ rescue ArgumentError, TypeError
161
+ raise Invalid, "#{where} is #{value.inspect}. A limit is a whole number, 1 or more."
162
+ end
163
+ end
164
+ end
165
+ end
@@ -22,7 +22,8 @@ module Masks
22
22
  "pushed" => "PushedRequest",
23
23
  "approval" => "SignInApproval",
24
24
  "forward_code" => "ForwardCode",
25
- "forward_session" => "ForwardSession"
25
+ "forward_session" => "ForwardSession",
26
+ "app_password" => "AppPassword"
26
27
  }.freeze
27
28
 
28
29
  self.inheritance_column = "kind"
@@ -0,0 +1,83 @@
1
+ <section class="group" id="app-passwords">
2
+ <h2><%= account_text("app_passwords.heading") %></h2>
3
+
4
+ <div class="entries">
5
+ <details class="entry" <%= "open" if app_password %>>
6
+ <summary>
7
+ <span class="entry-icon"><%= account_icon(:password) %></span>
8
+ <span class="entry-label"><%= account_text("app_passwords.entry") %></span>
9
+ <span class="entry-state">
10
+ <%= app_passwords.any? ? app_passwords.size : account_text("app_passwords.state.none") %>
11
+ </span>
12
+ <span class="entry-mark" aria-hidden="true"></span>
13
+ </summary>
14
+
15
+ <div class="entry-body">
16
+ <p class="aside"><%= account_text("app_passwords.lede") %></p>
17
+
18
+ <% if app_password %>
19
+ <p class="aside aside-bad" role="alert">
20
+ <%= account_text("app_passwords.shown_once", name: app_password["name"], app: app_password["app"]) %>
21
+ </p>
22
+
23
+ <ol class="codes">
24
+ <li><code><%= app_password["secret"] %></code></li>
25
+ </ol>
26
+ <% end %>
27
+
28
+ <% if app_passwords.any? %>
29
+ <div class="items">
30
+ <% app_passwords.each do |held| %>
31
+ <div class="item">
32
+ <span class="item-name">
33
+ <span><%= held.name %></span>
34
+ <span class="tag"><%= held.client.name %></span>
35
+ </span>
36
+
37
+ <span class="item-meta">
38
+ <%= account_text("app_passwords.made_ago", age: time_ago_in_words(held.created_at)) %>
39
+ ·
40
+ <% if held.used_at %>
41
+ <%= account_text("app_passwords.used_ago", age: time_ago_in_words(held.used_at)) %>
42
+ <% else %>
43
+ <%= account_text("app_passwords.never_used") %>
44
+ <% end %>
45
+ </span>
46
+
47
+ <%= button_to account_text("app_passwords.revoke"),
48
+ account_app_password_path(held.id), method: :delete,
49
+ class: "action action-quiet action-sm", form_class: "item-act",
50
+ form: { data: { turbo_confirm: account_text("app_passwords.confirm_revoke", name: held.name) } } %>
51
+ </div>
52
+ <% end %>
53
+ </div>
54
+ <% end %>
55
+
56
+ <% if offered.any? %>
57
+ <%= form_with url: account_app_passwords_path, method: :post, class: "member-add" do |form| %>
58
+ <label class="field member-add-email">
59
+ <span class="field-label"><%= account_text("app_passwords.name") %></span>
60
+ <%= form.text_field :name, required: true, autocomplete: "off", class: "control", id: "app-password-name",
61
+ maxlength: Masks::Server::AppPassword::NAME_LIMIT,
62
+ placeholder: account_text("app_passwords.name_placeholder") %>
63
+ </label>
64
+
65
+ <label class="field member-add-role">
66
+ <span class="field-label"><%= account_text("app_passwords.app") %></span>
67
+ <%= form.select :client_id, offered.map { |client| [ client.name, client.client_id ] }, {}, class: "control",
68
+ id: "app-password-client" %>
69
+ </label>
70
+
71
+ <button type="submit" class="action action-sm member-add-go">
72
+ <%= account_text(fresh ? "app_passwords.make" : "app_passwords.make_again") %>
73
+ </button>
74
+
75
+ <span class="field-hint member-add-hint"><%= account_text("app_passwords.hint") %></span>
76
+ <% end %>
77
+ <% else %>
78
+ <p class="aside"><%= account_text("app_passwords.none_offered") %></p>
79
+ <% end %>
80
+ </div>
81
+ </details>
82
+ </div>
83
+ </section>
@@ -135,6 +135,39 @@
135
135
  <% end %>
136
136
  </div>
137
137
 
138
+ <% asked = account_access_requests(organization) %>
139
+ <% if asked.any? %>
140
+ <p class="aside"><strong><%= account_text("access.heading") %></strong> <%= account_text("access.lede", organization: organization.name) %></p>
141
+
142
+ <div class="items" id="access-requests-<%= organization.key %>">
143
+ <% asked.each do |request| %>
144
+ <% roles = request.organization_roles.filter_map { |key, role| role if key == organization.key } %>
145
+ <div class="item item-member access-request" id="access-request-<%= request.id %>">
146
+ <span class="item-name"><%= request.actor.identifier %></span>
147
+ <span class="item-meta">
148
+ <%= account_text("access.asked", app: request.client.name, age: time_ago_in_words(request.created_at)) %>
149
+ </span>
150
+
151
+ <div class="item-act item-acts">
152
+ <%= form_with url: account_organization_access_path(organization.key, request.id), method: :post,
153
+ class: "item-acts" do |form| %>
154
+ <%= form.select :role, roles, {}, class: "control control-sm",
155
+ id: "access-role-#{organization.key}-#{request.id}",
156
+ aria: { label: account_text("access.role_of", name: request.actor.identifier) } %>
157
+ <button type="submit" class="action action-sm"><%= account_text("access.grant") %></button>
158
+ <% end %>
159
+
160
+ <%= button_to account_text("access.decline"),
161
+ account_organization_access_path(organization.key, request.id), method: :delete,
162
+ class: "action action-quiet action-sm",
163
+ form: { data: { turbo_confirm: account_text("access.confirm_decline", identifier: request.actor.identifier,
164
+ app: request.client.name) } } %>
165
+ </div>
166
+ </div>
167
+ <% end %>
168
+ </div>
169
+ <% end %>
170
+
138
171
  <%= form_with url: account_organization_members_path(organization.key), method: :post, class: "member-add" do |form| %>
139
172
  <label class="field member-add-email">
140
173
  <span class="field-label"><%= account_text("organizations.add_email") %></span>
@@ -31,6 +31,11 @@
31
31
 
32
32
  <%= render "masks/server/account/apps", apps: @apps, connections: @connections, linkable: @linkable %>
33
33
 
34
+ <% if @app_passwords.any? || @app_password_clients.any? %>
35
+ <%= render "masks/server/account/app_passwords", app_passwords: @app_passwords, offered: @app_password_clients,
36
+ app_password: @app_password, fresh: @fresh %>
37
+ <% end %>
38
+
34
39
  <%= render "masks/server/account/info", actor: @actor, directed: @directed, fresh: @fresh %>
35
40
 
36
41
  <%= render "masks/server/account/activity", events: @events %>
@@ -0,0 +1,5 @@
1
+ <%= render layout: "message" do %>
2
+ <p class="lede"><%= t(".lede", app: @app.name, tenant: @tenant_name) %></p>
3
+ <% end %>
4
+
5
+ <p class="meta"><%= t(".again") %></p>
@@ -0,0 +1,5 @@
1
+ <%= render layout: "message" do %>
2
+ <%= t(".lede", app: @app.name, tenant: @tenant_name) %>
3
+ <% end %>
4
+
5
+ <%= t(".again") %>
@@ -0,0 +1,7 @@
1
+ <%= render layout: "message" do %>
2
+ <p class="lede"><%= t(".lede", app: @app.name, tenant: @tenant_name) %></p>
3
+ <% end %>
4
+
5
+ <% if @url %>
6
+ <%= render "action", url: @url, label: t(".action", app: @app.name) %>
7
+ <% end %>
@@ -0,0 +1,7 @@
1
+ <%= render layout: "message" do %>
2
+ <%= t(".lede", app: @app.name, tenant: @tenant_name) %>
3
+ <% end %>
4
+ <% if @url %>
5
+
6
+ <%= @url %>
7
+ <% end %>
@@ -9,4 +9,29 @@
9
9
  <p class="said"><%= @error_code %> — <%= @error_description %></p>
10
10
 
11
11
  <p class="prompt-lede"><%= @refused ? t(".nothing_shared") : t(".contact") %></p>
12
+
13
+ <% if @asking %>
14
+ <div id="ask-access" class="flow" data-waiting="<%= @waiting.present? %>">
15
+ <% if flash[:alert].present? %>
16
+ <p class="note note-bad" role="alert"><%= flash[:alert] %></p>
17
+ <% elsif flash[:notice].present? %>
18
+ <p class="note note-plain" role="status"><%= flash[:notice] %></p>
19
+ <% end %>
20
+
21
+ <% if @waiting %>
22
+ <p class="prompt-lede"><%= t(".waiting", app: @asking.name) %></p>
23
+ <% else %>
24
+ <p class="prompt-lede"><%= t(".asking", app: @asking.name) %></p>
25
+
26
+ <%= form_with url: authorize_access_request_path, method: :post do |form| %>
27
+ <%= form.hidden_field :rid, value: params[:rid] %>
28
+ <button type="submit" class="action"><%= t(".ask") %></button>
29
+ <% end %>
30
+ <% end %>
31
+ </div>
32
+ <% end %>
33
+
34
+ <% if @return_to %>
35
+ <a class="action action-quiet" id="back-to-app" href="<%= @return_to %>"><%= t(".back", app: @asking&.name || t(".the_app")) %></a>
36
+ <% end %>
12
37
  </div>
@@ -0,0 +1,43 @@
1
+ de:
2
+ authorize:
3
+ error:
4
+ asking: "Ihr Konto ist in keiner Gruppe, die %{app} nutzen darf. Sie können um Zugang bitten. Die Betreiber von %{app} entscheiden, und Sie erhalten in jedem Fall eine E-Mail."
5
+ ask: "Um Zugang bitten"
6
+ asked: "Ihre Anfrage wurde gesendet. Sie erhalten eine E-Mail, sobald sie entschieden ist."
7
+ waiting: "Ihre Anfrage, %{app} zu nutzen, wartet. Sie erhalten eine E-Mail, sobald sie entschieden ist."
8
+ slow_down: "Sie haben vor weniger als einer Stunde um Zugang zu dieser App gebeten. Warten Sie auf die Antwort."
9
+ back: "Zurück zu %{app}"
10
+ the_app: "der App"
11
+
12
+ account:
13
+ index:
14
+ access:
15
+ heading: "Um Zugang gebeten"
16
+ lede: "Diese Personen wurden von einer App abgewiesen, die Mitglieder von %{organization} zulässt. Mit Genehmigen werden sie Mitglied mit der Rolle, die Sie wählen."
17
+ asked: "Hat vor %{age} gebeten, %{app} zu nutzen"
18
+ role_of: "Rolle für %{name}"
19
+ grant: "Genehmigen"
20
+ decline: "Ablehnen"
21
+ confirm_decline: "Die Anfrage von %{identifier}, %{app} zu nutzen, ablehnen? Die Person erhält eine E-Mail und kann in einer Stunde erneut bitten."
22
+
23
+ organization_access:
24
+ granted: "%{identifier} ist jetzt %{role} von %{organization} und kann %{app} nutzen."
25
+ declined: "Die Anfrage von %{identifier}, %{app} zu nutzen, ist abgelehnt."
26
+ unknown: "Diese Anfrage wartet nicht auf %{organization}."
27
+
28
+ actor_mailer:
29
+ access_granted:
30
+ subject: "Sie können %{app} jetzt nutzen"
31
+ lede: "Ihre Anfrage, %{app} bei %{tenant} zu nutzen, wurde genehmigt. Melden Sie sich erneut bei %{app} an, und Sie werden eingelassen."
32
+ action: "%{app} öffnen"
33
+ access_declined:
34
+ subject: "Ihre Anfrage, %{app} zu nutzen, wurde abgelehnt"
35
+ lede: "Jemand, der %{app} bei %{tenant} betreibt, hat Ihre Anfrage um Zugang abgelehnt."
36
+ again: "Wenn Sie das für einen Fehler halten, fragen Sie die Person direkt. In einer Stunde können Sie erneut bitten."
37
+
38
+ events:
39
+ actions:
40
+ access:
41
+ requested: "Um Zugang zu einer App gebeten"
42
+ granted: "Zugang zu einer App genehmigt"
43
+ declined: "Zugang zu einer App abgelehnt"
@@ -0,0 +1,42 @@
1
+ de:
2
+ account:
3
+ index:
4
+ app_passwords:
5
+ heading: "App-Passwörter"
6
+ entry: "App-Passwörter"
7
+ state:
8
+ none: "Keine"
9
+ lede: "Mit einem App-Passwort meldet sich eine einzelne App, etwa ein Medienserver oder ein NAS, ohne Ihren zweiten Faktor als Sie an. Es gilt nur für die App, für die es erstellt wurde."
10
+ shown_once: "Das ist das App-Passwort für %{name}. Es wird nur einmal angezeigt. Geben Sie es jetzt in %{app} ein."
11
+ made_ago: "erstellt vor %{age}"
12
+ used_ago: "zuletzt verwendet vor %{age}"
13
+ never_used: "nie verwendet"
14
+ revoke: "Widerrufen"
15
+ confirm_revoke: "%{name} widerrufen? Die App kann sich damit sofort nicht mehr anmelden."
16
+ name: "Name"
17
+ name_placeholder: "Jellyfin auf dem Fernseher"
18
+ app: "App"
19
+ make: "App-Passwort erstellen"
20
+ make_again: "Zum Erstellen erneut anmelden"
21
+ hint: "Ihr eigenes Passwort und Ihre Passkeys bleiben unverändert. Das Widerrufen eines App-Passworts ändert sonst nichts."
22
+ none_offered: "Noch keine App akzeptiert App-Passwörter."
23
+ app_passwords:
24
+ again: "Melden Sie sich erneut an, um ein App-Passwort zu erstellen. Ihre letzte Anmeldung liegt eine Weile zurück."
25
+ unknown: "Dieses Konto hat kein solches App-Passwort."
26
+ revoked: "%{name} ist widerrufen."
27
+ refused:
28
+ name: "Geben Sie dem App-Passwort einen Namen mit höchstens 64 Zeichen."
29
+ app: "Diese App akzeptiert keine App-Passwörter."
30
+ too_many: "Sie haben bereits %{most} App-Passwörter. Widerrufen Sie zuerst eines."
31
+ events:
32
+ actions:
33
+ app_password:
34
+ created: "App-Passwort erstellt"
35
+ used: "Mit einem App-Passwort angemeldet"
36
+ revoked: "App-Passwort widerrufen"
37
+ actor_mailer:
38
+ notification:
39
+ said:
40
+ app_password:
41
+ created: "Für Ihr %{tenant}-Konto wurde ein App-Passwort erstellt. Damit meldet sich eine App ohne Ihren zweiten Faktor an."
42
+ revoked: "Ein App-Passwort Ihres %{tenant}-Kontos wurde widerrufen."
@@ -159,6 +159,11 @@ de:
159
159
  updated: "E-Mail-Vorlage geändert"
160
160
  job:
161
161
  failed: "Hintergrundjob für diesen Mandanten fehlgeschlagen"
162
+ health:
163
+ failing: "Zustandsprüfung fehlgeschlagen"
164
+ recovered: "Zustandsprüfung wieder in Ordnung"
165
+ release:
166
+ available: "Neue Version verfügbar"
162
167
  stream:
163
168
  created: "Event-Stream hinzugefügt"
164
169
  updated: "Event-Stream geändert"
@@ -163,3 +163,4 @@ de:
163
163
  forward_auth:
164
164
  unknown: "Unter dieser Adresse ist keine App hinter einem Proxy registriert."
165
165
  not_admitted: "Ihr Konto darf %{app} nicht verwenden."
166
+ denied: "Diese Seite von %{app} ist für alle gesperrt."
@@ -0,0 +1,5 @@
1
+ de:
2
+ events:
3
+ actions:
4
+ group:
5
+ refreshed: "Gruppe aus ihrer Quelle gefüllt"