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
@@ -0,0 +1,25 @@
1
+ class CreateHealthChecks < ActiveRecord::Migration[8.1]
2
+ include Masks::Server::TenantIsolation
3
+
4
+ def up
5
+ create_table :health_checks do |t|
6
+ t.references :tenant, null: false, foreign_key: { on_delete: :cascade }, index: false
7
+ t.string :name, null: false
8
+ t.string :status, null: false
9
+ t.string :summary, null: false
10
+ t.jsonb :details, null: false, default: {}
11
+ t.datetime :checked_at, null: false
12
+ t.datetime :changed_at, null: false
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :health_checks, %i[tenant_id name], unique: true
17
+
18
+ enable_row_level_security(:health_checks)
19
+ end
20
+
21
+ def down
22
+ disable_row_level_security(:health_checks)
23
+ drop_table :health_checks
24
+ end
25
+ end
@@ -0,0 +1,8 @@
1
+ class AddLinkLifetimesToTenants < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :tenants, :invitation_lifetime, :integer, null: false, default: 7 * 24 * 60 * 60
4
+ add_column :tenants, :organization_invitation_lifetime, :integer, null: false, default: 14 * 24 * 60 * 60
5
+ add_column :tenants, :password_reset_lifetime, :integer, null: false, default: 30 * 60
6
+ add_column :tenants, :email_verification_lifetime, :integer, null: false, default: 2 * 24 * 60 * 60
7
+ end
8
+ end
@@ -0,0 +1,5 @@
1
+ class AddPinnedOriginToTenants < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :tenants, :pinned_origin, :string
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ class DefaultKeyRotationToNinetyDays < ActiveRecord::Migration[8.1]
2
+ def change
3
+ change_column_default :tenants, :key_rotation_days, from: nil, to: 90
4
+ end
5
+ end
@@ -39,9 +39,11 @@ module Masks
39
39
  say ""
40
40
 
41
41
  engine? ? say_engine_database : say_server_database
42
+ say_cache_store if engine?
42
43
 
43
- say "In production, set MASKS_PUBLIC_ORIGIN_TEMPLATE to #{origin_example}, so the issuer and every"
44
- say "emailed link are built from it and never from a request's Host header."
44
+ say "In production, masks keeps the address its setup screen was first opened at, and builds the"
45
+ say "issuer and every emailed link from it. To name the address yourself, set"
46
+ say "MASKS_PUBLIC_ORIGIN_TEMPLATE to #{origin_example}."
45
47
  say ""
46
48
  say "Then run bin/rails db:prepare and bin/rails masks:tenants, and open #{login_path} to set up"
47
49
  say "the first account with the setup token masks prints."
@@ -104,6 +106,24 @@ module Masks
104
106
  say ""
105
107
  end
106
108
 
109
+ def say_cache_store
110
+ say "masks holds one-time sign-in state, rate limits, and health findings in Rails.cache, so"
111
+ say "every process that serves it must share one cache store, such as :solid_cache_store,"
112
+ say ":redis_cache_store or :mem_cache_store. In production, masks refuses to boot on"
113
+ say ":memory_store or :null_store."
114
+
115
+ if Configuration.unshared_cache_store?(cache_store)
116
+ say ""
117
+ say "This app's cache_store is #{Array(cache_store).first.inspect}. Change it before you deploy.", :yellow
118
+ end
119
+
120
+ say ""
121
+ end
122
+
123
+ def cache_store
124
+ ::Rails.application&.config&.cache_store if defined?(::Rails.application)
125
+ end
126
+
107
127
  def say_server_database
108
128
  say "masks keeps its tables in the primary database. Connect as a role holding neither"
109
129
  say "SUPERUSER nor BYPASSRLS, and set config.active_record.schema_format = :sql, since"
@@ -4,6 +4,7 @@ module Masks
4
4
  module Server
5
5
  module Configuration
6
6
  MODES = %i[server engine].freeze
7
+ UNSHARED_CACHE_STORES = %i[memory_store null_store].freeze
7
8
 
8
9
  module_function
9
10
 
@@ -12,19 +13,13 @@ module Masks
12
13
  config.mode = ENV.fetch("MASKS_MODE", "server").to_sym
13
14
  config.database = nil
14
15
  config.secret = nil
15
- config.attempt_limit = ENV.fetch("MASKS_ATTEMPT_LIMIT", 10).to_i
16
- config.account_attempt_limit = ENV.fetch("MASKS_ACCOUNT_ATTEMPT_LIMIT", 5).to_i
17
- config.registration_limit = ENV.fetch("MASKS_REGISTRATION_LIMIT", 10).to_i
18
- config.recovery_limit = ENV.fetch("MASKS_RECOVERY_LIMIT", 5).to_i
19
- config.device_code_limit = ENV.fetch("MASKS_DEVICE_CODE_LIMIT", 30).to_i
20
- config.claim_limit = ENV.fetch("MASKS_CLAIM_LIMIT", 3).to_i
21
- config.claim_ceiling = ENV.fetch("MASKS_CLAIM_CEILING", 30).to_i
22
16
  config.setup_token = ENV["MASKS_SETUP_TOKEN"].presence
23
17
  config.recover = ENV["MASKS_RECOVER"].presence
24
18
  config.config_path = ENV["MASKS_CONFIG"].presence
25
19
  config.themes_path = ENV["MASKS_THEMES_PATH"].presence
26
20
  config.docs_url = ENV.fetch("MASKS_DOCS_URL", "https://masks.pages.dev")
27
21
  config.public_origin_template = ENV["MASKS_PUBLIC_ORIGIN_TEMPLATE"].presence
22
+ config.pin_origin = !::Rails.env.local?
28
23
  config.tenant = ENV["MASKS_TENANT"].presence
29
24
  config.tenants = ENV["MASKS_TENANTS"].to_s.split(/[\s,]+/).reject(&:empty?)
30
25
  config.dynamic_client_scopes = ENV["MASKS_DYNAMIC_CLIENT_SCOPES"].presence
@@ -35,11 +30,6 @@ module Masks
35
30
  config.mail_from = ENV["MASKS_MAIL_FROM"].presence
36
31
  config.smtp_address = ENV["MASKS_SMTP_ADDRESS"].presence
37
32
  config.smtp_settings = smtp_settings(config.smtp_address)
38
- config.invitation_lifetime = ENV.fetch("MASKS_INVITATION_LIFETIME", 7 * 24 * 60 * 60).to_i.seconds
39
- config.organization_invitation_lifetime =
40
- ENV.fetch("MASKS_ORGANIZATION_INVITATION_LIFETIME", 14 * 24 * 60 * 60).to_i.seconds
41
- config.password_reset_lifetime = ENV.fetch("MASKS_PASSWORD_RESET_LIFETIME", 30 * 60).to_i.seconds
42
- config.email_verification_lifetime = ENV.fetch("MASKS_EMAIL_VERIFICATION_LIFETIME", 2 * 24 * 60 * 60).to_i.seconds
43
33
  config.outbound_allowed = outbound_allowed(ENV["MASKS_OUTBOUND_ALLOWED"])
44
34
  config.ldap_port = ENV.fetch("MASKS_LDAP_PORT", 3389).to_i
45
35
  config.on_demand_tls = ENV.fetch("MASKS_ON_DEMAND_TLS", "false")
@@ -50,6 +40,7 @@ module Masks
50
40
  config.queue_wait = ENV.fetch("MASKS_QUEUE_WAIT", 600).to_i.seconds
51
41
  config.finished_job_retention = ENV.fetch("MASKS_FINISHED_JOB_RETENTION", 7 * 24 * 60 * 60).to_i.seconds
52
42
  config.failed_job_retention = ENV.fetch("MASKS_FAILED_JOB_RETENTION", 30 * 24 * 60 * 60).to_i.seconds
43
+ config.release_check = %w[1 true].include?(ENV.fetch("MASKS_RELEASE_CHECK", "false"))
53
44
  config
54
45
  end
55
46
 
@@ -91,7 +82,11 @@ module Masks
91
82
  }.compact
92
83
  end
93
84
 
94
- def validate!(config, local:, building:)
85
+ def unshared_cache_store?(cache_store)
86
+ UNSHARED_CACHE_STORES.include?(Array(cache_store).first&.to_sym)
87
+ end
88
+
89
+ def validate!(config, local:, building:, cache_store: nil)
95
90
  unless MODES.include?(config.mode)
96
91
  raise "masks runs in server or engine mode, and config.masks.mode is #{config.mode.inspect}."
97
92
  end
@@ -124,12 +119,6 @@ module Masks
124
119
  "be dropped without an error anyone sees."
125
120
  end
126
121
 
127
- if config.public_origin_template.nil?
128
- raise "MASKS_PUBLIC_ORIGIN_TEMPLATE is required outside development. Without it the " \
129
- "issuer, the registration endpoint and every emailed link follow the Host header, " \
130
- "so anyone who can reach this server can have a password reset delivered to theirs."
131
- end
132
-
133
122
  if config.setup_token && config.setup_token.length < 24
134
123
  raise "MASKS_SETUP_TOKEN is #{config.setup_token.length} characters, and at least 24 are " \
135
124
  "required outside development. Whoever holds it creates the first account of every " \
@@ -140,6 +129,14 @@ module Masks
140
129
  raise "masks in engine mode needs a database of its own. Set config.masks.database to " \
141
130
  "the name of a database.yml entry whose role cannot bypass row-level security."
142
131
  end
132
+
133
+ if unshared_cache_store?(cache_store)
134
+ raise "config.cache_store is #{Array(cache_store).first.inspect}, which every process keeps " \
135
+ "to itself. masks holds one-time sign-in state, rate limits, and health findings in " \
136
+ "Rails.cache, so a provider's callback redeemed in one process is lost in the next. " \
137
+ "Use a store every process shares, such as :solid_cache_store, :redis_cache_store " \
138
+ "or :mem_cache_store."
139
+ end
143
140
  end
144
141
  end
145
142
  end
@@ -20,7 +20,8 @@ module Masks
20
20
  Configuration.validate!(
21
21
  app.config.masks,
22
22
  local: ::Rails.env.local?,
23
- building: ENV["SECRET_KEY_BASE_DUMMY"].present?
23
+ building: ENV["SECRET_KEY_BASE_DUMMY"].present?,
24
+ cache_store: app.config.cache_store
24
25
  )
25
26
 
26
27
  middleware.insert_before Tenancy::Middleware, Isolation if app.config.masks.mode == :engine
@@ -1,7 +1,11 @@
1
1
  module Masks
2
2
  module Server
3
3
  class Forwarding
4
- HEADERS = %w[HTTP_X_FORWARDED_FOR HTTP_X_FORWARDED_HOST HTTP_CLIENT_IP HTTP_FORWARDED].freeze
4
+ HEADERS = %w[
5
+ HTTP_X_FORWARDED_FOR HTTP_X_FORWARDED_HOST HTTP_CLIENT_IP HTTP_FORWARDED
6
+ HTTP_X_FORWARDED_PROTO HTTP_X_FORWARDED_SCHEME HTTP_X_FORWARDED_SSL HTTP_X_FORWARDED_PORT
7
+ ].freeze
8
+ PROTO = "HTTP_X_FORWARDED_PROTO".freeze
5
9
 
6
10
  def initialize(app, proxies)
7
11
  @app = app
@@ -9,9 +13,20 @@ module Masks
9
13
  end
10
14
 
11
15
  def call(env)
12
- HEADERS.each { |header| env.delete(header) } unless trusted?(env["REMOTE_ADDR"])
16
+ address = env["REMOTE_ADDR"]
17
+ trusted = trusted?(address)
13
18
 
14
- @app.call(env)
19
+ unless trusted
20
+ Health.note_proxy!("untrusted", address) if HEADERS.any? { |header| env.key?(header) }
21
+ HEADERS.each { |header| env.delete(header) }
22
+ end
23
+
24
+ plain = trusted && plain?(env)
25
+ status, headers, body = @app.call(env)
26
+
27
+ Health.note_proxy!("plain", address) if plain && !sent_to_https?(status, headers)
28
+
29
+ [ status, headers, body ]
15
30
  end
16
31
 
17
32
  private
@@ -24,6 +39,14 @@ module Masks
24
39
  rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
25
40
  false
26
41
  end
42
+
43
+ def plain?(env)
44
+ env[PROTO].to_s.split(",").first.to_s.strip.casecmp?("http")
45
+ end
46
+
47
+ def sent_to_https?(status, headers)
48
+ (300..399).cover?(status) && headers["location"].to_s.downcase.start_with?("https://")
49
+ end
27
50
  end
28
51
  end
29
52
  end
@@ -65,6 +65,8 @@ module Masks
65
65
  values(type)
66
66
  elsif type.kind.scalar?
67
67
  "A scalar.\n"
68
+ elsif type.kind.input_object?
69
+ arguments(type)
68
70
  else
69
71
  fields(type)
70
72
  end
@@ -88,6 +90,16 @@ module Masks
88
90
  table([ "Value", "Description" ], rows, described)
89
91
  end
90
92
 
93
+ def arguments(type)
94
+ described = type.arguments.values.map { |argument| argument.description.to_s.strip }
95
+
96
+ rows = type.arguments.values.zip(described).map do |argument, text|
97
+ cells([ "`#{argument.graphql_name}`", link(argument.type), text ], described)
98
+ end
99
+
100
+ table([ "Field", "Type", "Description" ], rows, described)
101
+ end
102
+
91
103
  def fields(type)
92
104
  described = type.fields.values.map { |field| field.description.to_s.strip }
93
105
 
@@ -3,8 +3,9 @@ module Masks
3
3
  module Tenancy
4
4
  class Middleware
5
5
  UNSERVED = "no tenant is served at this hostname".freeze
6
+ MOVED = "masks answers at %s. To serve it at another address, set MASKS_PUBLIC_ORIGIN_TEMPLATE to that address.".freeze
7
+ UNKEPT = "This tenant was set up without a kept address. Set MASKS_PUBLIC_ORIGIN_TEMPLATE to the address it answers at.".freeze
6
8
  CROWDED = "too many tenants were claimed recently; try again later".freeze
7
- CLAIM_WINDOW = 1.hour
8
9
  TENANTLESS = %w[/up /metrics].freeze
9
10
  TLS_ASK = "/tls/allowed".freeze
10
11
 
@@ -40,11 +41,16 @@ module Masks
40
41
 
41
42
  return unserved if tenant.nil?
42
43
 
43
- origin = origin_for(request, tenant)
44
+ held = tenant.held_origin
44
45
 
45
- return unserved if origin.nil?
46
+ return unserved(UNKEPT) if held.nil? && Tenant.pins_origin? && tenant.set_up?
47
+
48
+ origin = origin_for(request, tenant, held)
49
+
50
+ return unserved(held ? format(MOVED, held) : UNSERVED) if origin.nil?
46
51
 
47
52
  Current.origin = origin
53
+ Health.note_origin!(tenant, Forwarded.origin(request.base_url), origin)
48
54
  Current.ip_address = request.remote_ip
49
55
  Current.user_agent = request.user_agent
50
56
 
@@ -67,9 +73,13 @@ module Masks
67
73
  nil
68
74
  end
69
75
 
70
- def origin_for(request, tenant)
71
- return "#{request.base_url}#{request.script_name}" if ::Rails.configuration.masks.public_origin_template.nil?
76
+ def origin_for(request, tenant, held)
77
+ untemplated = ::Rails.configuration.masks.public_origin_template.nil?
78
+ seen = "#{request.base_url}#{request.script_name}"
79
+
80
+ return seen if untemplated && held.nil?
72
81
  return tenant.custom_origin if tenant.custom_host&.casecmp?(request.host.to_s)
82
+ return (held if held.casecmp?(seen)) if untemplated
73
83
 
74
84
  canonical = tenant.templated_origin
75
85
 
@@ -83,27 +93,25 @@ module Masks
83
93
  end
84
94
 
85
95
  def crowded?(request)
86
- window = Time.current.to_i / CLAIM_WINDOW.to_i
87
- from_here = ::Rails.cache.increment("tenant-claims:#{window}:#{request.remote_ip}", 1, expires_in: CLAIM_WINDOW)
88
- from_anywhere = ::Rails.cache.increment("tenant-claims:#{window}", 1, expires_in: CLAIM_WINDOW)
96
+ here = Throttle.crowded?(:claim, "claims:#{request.remote_ip}")
97
+ anywhere = Throttle.crowded?(:claim_ceiling, "claims")
89
98
 
90
- from_here.to_i > ::Rails.configuration.masks.claim_limit ||
91
- from_anywhere.to_i > ::Rails.configuration.masks.claim_ceiling
99
+ (here || anywhere).tap { |crowded| Throttle.hit!(here ? :claim : :claim_ceiling, address: request.remote_ip) if crowded }
92
100
  end
93
101
 
94
102
  def crowded
95
103
  [
96
104
  429,
97
- { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store", "retry-after" => CLAIM_WINDOW.to_i.to_s },
105
+ { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store", "retry-after" => Throttle.budget(:claim).within.to_i.to_s },
98
106
  [ CROWDED ]
99
107
  ]
100
108
  end
101
109
 
102
- def unserved
110
+ def unserved(body = UNSERVED)
103
111
  [
104
112
  404,
105
113
  { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store" },
106
- [ UNSERVED ]
114
+ [ body ]
107
115
  ]
108
116
  end
109
117
  end
@@ -4,11 +4,23 @@ module Masks
4
4
  SETTING = "masks.tenant_id".freeze
5
5
 
6
6
  def self.adopt(connection)
7
+ return unless masks?(connection)
8
+
7
9
  connection.exec_query("SELECT set_config($1, $2, false)", "tenant", [ SETTING, Current.tenant&.id.to_s ])
8
10
  rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished, ActiveRecord::ConnectionFailed
9
11
  nil
10
12
  end
11
13
 
14
+ def self.masks?(connection)
15
+ database = Server.config.database
16
+
17
+ return true if database.nil?
18
+
19
+ pool = connection.pool
20
+
21
+ pool.respond_to?(:db_config) && pool.db_config.name == database.to_s
22
+ end
23
+
12
24
  def enable_row_level_security(table)
13
25
  execute <<~SQL
14
26
  ALTER TABLE #{table} ENABLE ROW LEVEL SECURITY;
@@ -1,23 +1,23 @@
1
1
  {
2
- "_initial-C1GpCiEh.js": {
3
- "file": "assets/initial-C1GpCiEh.js",
2
+ "_initial-Cn-UuAsy.js": {
3
+ "file": "assets/initial-Cn-UuAsy.js",
4
4
  "name": "initial"
5
5
  },
6
- "_passkey-DIj_5LCY.js": {
7
- "file": "assets/passkey-DIj_5LCY.js",
6
+ "_passkey-5UYPVD3c.js": {
7
+ "file": "assets/passkey-5UYPVD3c.js",
8
8
  "name": "passkey"
9
9
  },
10
10
  "entrypoints/account.js": {
11
- "file": "assets/account-D46qDQCo.js",
11
+ "file": "assets/account-h7I6VUPR.js",
12
12
  "name": "entrypoints/account.js",
13
13
  "src": "entrypoints/account.js",
14
14
  "isEntry": true,
15
15
  "imports": [
16
- "_passkey-DIj_5LCY.js"
16
+ "_passkey-5UYPVD3c.js"
17
17
  ]
18
18
  },
19
19
  "entrypoints/app.css": {
20
- "file": "assets/app-D7W0gAyT.css",
20
+ "file": "assets/app-BXOScMjL.css",
21
21
  "name": "entrypoints/app.css",
22
22
  "names": [
23
23
  "entrypoints/app.css"
@@ -35,28 +35,28 @@
35
35
  "isEntry": true
36
36
  },
37
37
  "entrypoints/handshake.js": {
38
- "file": "assets/handshake-DIFZPSAK.js",
38
+ "file": "assets/handshake-YQpRNMIs.js",
39
39
  "name": "entrypoints/handshake.js",
40
40
  "src": "entrypoints/handshake.js",
41
41
  "isEntry": true
42
42
  },
43
43
  "entrypoints/login.js": {
44
- "file": "assets/login-C_6igRZ3.js",
44
+ "file": "assets/login-C21_jb6L.js",
45
45
  "name": "entrypoints/login.js",
46
46
  "src": "entrypoints/login.js",
47
47
  "isEntry": true,
48
48
  "imports": [
49
- "_passkey-DIj_5LCY.js",
50
- "_initial-C1GpCiEh.js"
49
+ "_passkey-5UYPVD3c.js",
50
+ "_initial-Cn-UuAsy.js"
51
51
  ]
52
52
  },
53
53
  "entrypoints/manage.js": {
54
- "file": "assets/manage-D4MzaDvM.js",
54
+ "file": "assets/manage-DpVxe5sv.js",
55
55
  "name": "entrypoints/manage.js",
56
56
  "src": "entrypoints/manage.js",
57
57
  "isEntry": true,
58
58
  "imports": [
59
- "_initial-C1GpCiEh.js"
59
+ "_initial-Cn-UuAsy.js"
60
60
  ]
61
61
  }
62
62
  }
@@ -1,2 +1,2 @@
1
- import{a as e,i as t,o as n}from"./passkey-DIj_5LCY.js";var r=()=>document.querySelector(`meta[name="csrf-token"]`)?.content??``,i=document.getElementById(`add-passkey`),a=document.getElementById(`passkey-credential`),o=document.getElementById(`passkey-name`),s=document.getElementById(`add-passkey-button`),c=document.getElementById(`passkey-unusable`);async function l(){let e=await fetch(i.dataset.challenge,{method:`POST`,credentials:`same-origin`,headers:{Accept:`application/json`,"X-CSRF-Token":r()}});if(e.status===401){let{redirectTo:t}=await e.json();throw window.location.assign(t),Error(`signing in again first`)}if(!e.ok)throw Error(`could not start enrolment: ${e.status}`);return e.json()}async function u(t){t.preventDefault(),s.disabled=!0,c.hidden=!0;try{a.value=await e(await l()),i.submit()}catch(e){n(e)||(c.textContent=`That passkey could not be added on this device.`,c.hidden=!1),s.disabled=!1}}i&&t()?(i.hidden=!1,s.addEventListener(`click`,u)):o&&o.closest(`[data-passkeys]`)?.setAttribute(`data-unsupported`,`true`);var d=document.getElementById(`picture-file`);d?.addEventListener(`change`,()=>{d.files?.length&&d.form?.requestSubmit()});for(let e of document.querySelectorAll(`[data-autosubmit]`)){let t=e.form?.querySelector(`[data-autosubmitted]`);t&&(t.hidden=!0),e.addEventListener(`change`,()=>e.form?.requestSubmit())}
2
- //# sourceMappingURL=account-D46qDQCo.js.map
1
+ import{a as e,i as t,o as n}from"./passkey-5UYPVD3c.js";var r=()=>document.querySelector(`meta[name="csrf-token"]`)?.content??``,i=document.getElementById(`add-passkey`),a=document.getElementById(`passkey-credential`),o=document.getElementById(`passkey-name`),s=document.getElementById(`add-passkey-button`),c=document.getElementById(`passkey-unusable`);async function l(){let e=await fetch(i.dataset.challenge,{method:`POST`,credentials:`same-origin`,headers:{Accept:`application/json`,"X-CSRF-Token":r()}});if(e.status===401){let{redirectTo:t}=await e.json();throw window.location.assign(t),Error(`signing in again first`)}if(!e.ok)throw Error(`could not start enrolment: ${e.status}`);return e.json()}async function u(t){t.preventDefault(),s.disabled=!0,c.hidden=!0;try{a.value=await e(await l()),i.submit()}catch(e){n(e)||(c.textContent=`That passkey could not be added on this device.`,c.hidden=!1),s.disabled=!1}}i&&t()?(i.hidden=!1,s.addEventListener(`click`,u)):o&&o.closest(`[data-passkeys]`)?.setAttribute(`data-unsupported`,`true`);var d=document.getElementById(`picture-file`);d?.addEventListener(`change`,()=>{d.files?.length&&d.form?.requestSubmit()});for(let e of document.querySelectorAll(`[data-autosubmit]`)){let t=e.form?.querySelector(`[data-autosubmitted]`);t&&(t.hidden=!0),e.addEventListener(`change`,()=>e.form?.requestSubmit())}
2
+ //# sourceMappingURL=account-h7I6VUPR.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"account-D46qDQCo.js","names":["enrol","available","refused","csrf","form","field","name","button","unusable","options","response","redirectTo","add","event","error","picture"],"sources":["../../../../server/app/frontend/entrypoints/account.js"],"sourcesContent":["import { available, enrol, refused } from \"../lib/passkey.js\";\n\nconst csrf = () =>\n document.querySelector('meta[name=\"csrf-token\"]')?.content ?? \"\";\n\nconst form = document.getElementById(\"add-passkey\");\nconst field = document.getElementById(\"passkey-credential\");\nconst name = document.getElementById(\"passkey-name\");\nconst button = document.getElementById(\"add-passkey-button\");\nconst unusable = document.getElementById(\"passkey-unusable\");\n\nasync function options() {\n const response = await fetch(form.dataset.challenge, {\n method: \"POST\",\n credentials: \"same-origin\",\n headers: { Accept: \"application/json\", \"X-CSRF-Token\": csrf() },\n });\n\n if (response.status === 401) {\n const { redirectTo } = await response.json();\n window.location.assign(redirectTo);\n throw new Error(\"signing in again first\");\n }\n\n if (!response.ok)\n throw new Error(`could not start enrolment: ${response.status}`);\n\n return response.json();\n}\n\nasync function add(event) {\n event.preventDefault();\n\n button.disabled = true;\n unusable.hidden = true;\n\n try {\n field.value = await enrol(await options());\n form.submit();\n } catch (error) {\n if (!refused(error)) {\n unusable.textContent = \"That passkey could not be added on this device.\";\n unusable.hidden = false;\n }\n\n button.disabled = false;\n }\n}\n\nif (form && available()) {\n form.hidden = false;\n button.addEventListener(\"click\", add);\n} else if (name) {\n name.closest(\"[data-passkeys]\")?.setAttribute(\"data-unsupported\", \"true\");\n}\n\nconst picture = document.getElementById(\"picture-file\");\n\npicture?.addEventListener(\"change\", () => {\n if (picture.files?.length) picture.form?.requestSubmit();\n});\n\nfor (const field of document.querySelectorAll(\"[data-autosubmit]\")) {\n const button = field.form?.querySelector(\"[data-autosubmitted]\");\n\n if (button) button.hidden = true;\n field.addEventListener(\"change\", () => field.form?.requestSubmit());\n}\n"],"mappings":"wDAEA,IAAMG,MACJ,SAAS,cAAc,yBAAyB,CAAC,EAAE,SAAW,GAE1DC,EAAO,SAAS,eAAe,aAAa,EAC5CC,EAAQ,SAAS,eAAe,oBAAoB,EACpDC,EAAO,SAAS,eAAe,cAAc,EAC7CC,EAAS,SAAS,eAAe,oBAAoB,EACrDC,EAAW,SAAS,eAAe,kBAAkB,EAE3D,eAAeC,GAAU,CACvB,IAAMC,EAAW,MAAM,MAAMN,EAAK,QAAQ,UAAW,CACnD,OAAQ,OACR,YAAa,cACb,QAAS,CAAE,OAAQ,mBAAoB,eAAgBD,EAAK,CAAE,CAChE,CAAC,EAED,GAAIO,EAAS,SAAW,IAAK,CAC3B,GAAM,CAAE,cAAe,MAAMA,EAAS,KAAK,EAE3C,MADA,OAAO,SAAS,OAAOC,CAAU,EACvB,MAAM,wBAAwB,CAC1C,CAEA,GAAI,CAACD,EAAS,GACZ,MAAU,MAAM,8BAA8BA,EAAS,QAAQ,EAEjE,OAAOA,EAAS,KAAK,CACvB,CAEA,eAAeE,EAAI,EAAO,CACxBC,EAAM,eAAe,EAErB,EAAO,SAAW,GAClB,EAAS,OAAS,GAElB,GAAI,CACF,EAAM,MAAQ,MAAMb,EAAM,MAAMS,EAAQ,CAAC,EACzCL,EAAK,OAAO,CACd,OAASU,EAAO,CACTZ,EAAQY,CAAK,IAChB,EAAS,YAAc,kDACvB,EAAS,OAAS,IAGpB,EAAO,SAAW,EACpB,CACF,CAEIV,GAAQH,EAAU,GACpB,EAAK,OAAS,GACdM,EAAO,iBAAiB,QAASK,CAAG,GAC3BN,GACTA,EAAK,QAAQ,iBAAiB,CAAC,EAAE,aAAa,mBAAoB,MAAM,EAG1E,IAAMS,EAAU,SAAS,eAAe,cAAc,EAEtDA,GAAS,iBAAiB,aAAgB,CACpCA,EAAQ,OAAO,QAAQA,EAAQ,MAAM,cAAc,CACzD,CAAC,EAED,IAAK,IAAMV,KAAS,SAAS,iBAAiB,mBAAmB,EAAG,CAClE,IAAME,EAASF,EAAM,MAAM,cAAc,sBAAsB,EAE3DE,IAAQ,EAAO,OAAS,IAC5BF,EAAM,iBAAiB,aAAgBA,EAAM,MAAM,cAAc,CAAC,CACpE"}
1
+ {"version":3,"file":"account-h7I6VUPR.js","names":["enrol","available","refused","csrf","form","field","name","button","unusable","options","response","redirectTo","add","event","error","picture"],"sources":["../../../../server/app/frontend/entrypoints/account.js"],"sourcesContent":["import { available, enrol, refused } from \"../lib/passkey.js\";\n\nconst csrf = () =>\n document.querySelector('meta[name=\"csrf-token\"]')?.content ?? \"\";\n\nconst form = document.getElementById(\"add-passkey\");\nconst field = document.getElementById(\"passkey-credential\");\nconst name = document.getElementById(\"passkey-name\");\nconst button = document.getElementById(\"add-passkey-button\");\nconst unusable = document.getElementById(\"passkey-unusable\");\n\nasync function options() {\n const response = await fetch(form.dataset.challenge, {\n method: \"POST\",\n credentials: \"same-origin\",\n headers: { Accept: \"application/json\", \"X-CSRF-Token\": csrf() },\n });\n\n if (response.status === 401) {\n const { redirectTo } = await response.json();\n window.location.assign(redirectTo);\n throw new Error(\"signing in again first\");\n }\n\n if (!response.ok)\n throw new Error(`could not start enrolment: ${response.status}`);\n\n return response.json();\n}\n\nasync function add(event) {\n event.preventDefault();\n\n button.disabled = true;\n unusable.hidden = true;\n\n try {\n field.value = await enrol(await options());\n form.submit();\n } catch (error) {\n if (!refused(error)) {\n unusable.textContent = \"That passkey could not be added on this device.\";\n unusable.hidden = false;\n }\n\n button.disabled = false;\n }\n}\n\nif (form && available()) {\n form.hidden = false;\n button.addEventListener(\"click\", add);\n} else if (name) {\n name.closest(\"[data-passkeys]\")?.setAttribute(\"data-unsupported\", \"true\");\n}\n\nconst picture = document.getElementById(\"picture-file\");\n\npicture?.addEventListener(\"change\", () => {\n if (picture.files?.length) picture.form?.requestSubmit();\n});\n\nfor (const field of document.querySelectorAll(\"[data-autosubmit]\")) {\n const button = field.form?.querySelector(\"[data-autosubmitted]\");\n\n if (button) button.hidden = true;\n field.addEventListener(\"change\", () => field.form?.requestSubmit());\n}\n"],"mappings":"A,wDAEA,IAAMG,MACJ,SAAS,cAAc,yBAAyB,CAAC,EAAE,SAAW,GAE1DC,EAAO,SAAS,eAAe,aAAa,EAC5CC,EAAQ,SAAS,eAAe,oBAAoB,EACpDC,EAAO,SAAS,eAAe,cAAc,EAC7CC,EAAS,SAAS,eAAe,oBAAoB,EACrDC,EAAW,SAAS,eAAe,kBAAkB,EAE3D,eAAeC,GAAU,CACvB,IAAMC,EAAW,MAAM,MAAMN,EAAK,QAAQ,UAAW,CACnD,OAAQ,OACR,YAAa,cACb,QAAS,CAAE,OAAQ,mBAAoB,eAAgBD,EAAK,CAAE,CAChE,CAAC,EAED,GAAIO,EAAS,SAAW,IAAK,CAC3B,GAAM,CAAE,cAAe,MAAMA,EAAS,KAAK,EAE3C,MADA,OAAO,SAAS,OAAOC,CAAU,EACvB,MAAM,wBAAwB,CAC1C,CAEA,GAAI,CAACD,EAAS,GACZ,MAAU,MAAM,8BAA8BA,EAAS,QAAQ,EAEjE,OAAOA,EAAS,KAAK,CACvB,CAEA,eAAeE,EAAI,EAAO,CACxBC,EAAM,eAAe,EAErB,EAAO,SAAW,GAClB,EAAS,OAAS,GAElB,GAAI,CACF,EAAM,MAAQ,MAAMb,EAAM,MAAMS,EAAQ,CAAC,EACzCL,EAAK,OAAO,CACd,OAASU,EAAO,CACTZ,EAAQY,CAAK,IAChB,EAAS,YAAc,kDACvB,EAAS,OAAS,IAGpB,EAAO,SAAW,EACpB,CACF,CAEIV,GAAQH,EAAU,GACpB,EAAK,OAAS,GACdM,EAAO,iBAAiB,QAASK,CAAG,GAC3BN,GACTA,EAAK,QAAQ,iBAAiB,CAAC,EAAE,aAAa,mBAAoB,MAAM,EAG1E,IAAMS,EAAU,SAAS,eAAe,cAAc,EAEtDA,GAAS,iBAAiB,aAAgB,CACpCA,EAAQ,OAAO,QAAQA,EAAQ,MAAM,cAAc,CACzD,CAAC,EAED,IAAK,IAAMV,KAAS,SAAS,iBAAiB,mBAAmB,EAAG,CAClE,IAAME,EAASF,EAAM,MAAM,cAAc,sBAAsB,EAE3DE,IAAQ,EAAO,OAAS,IAC5BF,EAAM,iBAAiB,aAAgBA,EAAM,MAAM,cAAc,CAAC,CACpE"}