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,21 @@
1
+ module Masks
2
+ module Server
3
+ class HealthCheckJob < ApplicationJob
4
+ queue_as :maintenance
5
+ across_tenants!
6
+
7
+ def perform
8
+ server = Health.server_results
9
+ failing = Hash.new { |held, name| held[name] = [] }
10
+
11
+ begin
12
+ each_tenant do |tenant|
13
+ Health.store!(server + Health.tenant_results).select(&:failing?).each { |row| failing[row.name] << tenant.subdomain }
14
+ end
15
+ ensure
16
+ Health.remember_all!(failing)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ module Masks
2
+ module Server
3
+ class RefreshGroupsJob < ApplicationJob
4
+ queue_as :maintenance
5
+ across_tenants!
6
+
7
+ def perform
8
+ each_tenant { refresh }
9
+ end
10
+
11
+ private
12
+
13
+ def refresh
14
+ Group.sourced.find_each(&:refresh!)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,21 @@
1
+ module Masks
2
+ module Server
3
+ class ReleaseCheckJob < ApplicationJob
4
+ queue_as :maintenance
5
+ across_tenants!
6
+
7
+ def perform
8
+ return unless ::Rails.configuration.masks.release_check
9
+
10
+ found = Release.check!
11
+
12
+ return if found.nil?
13
+
14
+ each_tenant do
15
+ Event.record!(Event::RELEASE_AVAILABLE, by: nil, version: found["version"], url: found["url"],
16
+ operator_step: found["operator_step"])
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -58,7 +58,7 @@ module Masks
58
58
 
59
59
  def bind(message)
60
60
  request = message.operation
61
- @bound = nil
61
+ previous, @bound = @bound, nil
62
62
 
63
63
  if request.version != 3
64
64
  return write(Protocol.bind_response(message.id, Protocol::PROTOCOL_ERROR, "only LDAPv3 is spoken here"))
@@ -68,7 +68,8 @@ module Masks
68
68
  return write(Protocol.bind_response(message.id, Protocol::AUTH_METHOD_NOT_SUPPORTED, "only simple binds are supported"))
69
69
  end
70
70
 
71
- outcome = Gate.new(ip_address: @ip_address).bind(request.name, request.password)
71
+ outcome = Gate.new(ip_address: @ip_address, client_id: previous&.service? ? previous.client_id : nil)
72
+ .bind(request.name, request.password)
72
73
  @bound = outcome.bound
73
74
 
74
75
  write(Protocol.bind_response(message.id, outcome.code, outcome.message))
@@ -2,10 +2,13 @@ module Masks
2
2
  module Server
3
3
  module Ldap
4
4
  class Gate
5
- WINDOW = 3.minutes
6
5
  VIA = "ldap".freeze
7
6
 
8
- Bound = Data.define(:kind, :tenant, :dn, :subject) do
7
+ Bound = Data.define(:kind, :tenant, :dn, :subject, :client_id) do
8
+ def initialize(client_id: nil, **rest)
9
+ super(client_id: client_id, **rest)
10
+ end
11
+
9
12
  def service?
10
13
  kind == :service
11
14
  end
@@ -17,15 +20,16 @@ module Masks
17
20
 
18
21
  Outcome = Data.define(:code, :message, :bound)
19
22
 
20
- def initialize(ip_address:)
23
+ def initialize(ip_address:, client_id: nil)
21
24
  @ip_address = ip_address
25
+ @client_id = client_id
22
26
  end
23
27
 
24
28
  def bind(name, password)
25
29
  password = password.to_s
26
30
  return anonymous if name.blank? && password.empty?
27
31
  return refused(Protocol::UNWILLING_TO_PERFORM, "unauthenticated binds are refused") if password.empty?
28
- return throttled if failures(ip_key) >= ::Rails.configuration.masks.attempt_limit
32
+ return throttled(:attempt) if Throttle.exceeded?(:attempt, ip_key)
29
33
 
30
34
  dn = parse(name)
31
35
  tenant = dn && Directory.tenant_for(dn)
@@ -80,22 +84,24 @@ module Masks
80
84
 
81
85
  Outcome.new(
82
86
  code: Protocol::SUCCESS, message: "",
83
- bound: Bound.new(kind: :service, tenant: tenant, dn: directory.service_dn(credential.label), subject: credential.id)
87
+ bound: Bound.new(kind: :service, tenant: tenant, dn: directory.service_dn(credential.label), subject: credential.id,
88
+ client_id: credential.client_id)
84
89
  )
85
90
  end
86
91
 
87
92
  def person(tenant, directory, uid, password)
88
93
  account = account_key(tenant, uid)
89
94
 
90
- if failures(account) >= ::Rails.configuration.masks.account_attempt_limit
91
- Event.record!(Event::LOGIN_THROTTLED, actor: Actor.locate(uid), by: nil, identifier: uid, via: VIA)
95
+ if Throttle.exceeded?(:account_attempt, account)
96
+ Throttle.hit!(:account_attempt, actor: Actor.locate(uid), address: @ip_address, identifier: uid, via: VIA)
92
97
  return throttled
93
98
  end
94
99
 
95
100
  actor = Actor.authenticate(uid, password)
101
+ return app_password(tenant, directory, uid, password, account) if actor.nil? && app_password?(password)
96
102
 
97
103
  if actor.nil?
98
- failed!(ip_key, account)
104
+ failed!(account)
99
105
  return refuse(uid, "password")
100
106
  end
101
107
 
@@ -103,8 +109,28 @@ module Masks
103
109
  return refuse(uid, reason, actor) if reason
104
110
 
105
111
  actor.active!
112
+ bound(tenant, directory, actor)
113
+ end
114
+
115
+ def app_password?(password)
116
+ @client_id && AppPassword.shaped?(password)
117
+ end
118
+
119
+ def app_password(tenant, directory, uid, password, account)
120
+ client = Client.active.find_by(id: @client_id)
121
+ outcome = AppPasswords.authenticate(client: client, identifier: uid, secret: password, via: VIA,
122
+ ip_address: @ip_address, tenant: tenant)
123
+
124
+ return throttled if outcome.throttled?
125
+ return bound(tenant, directory, outcome.actor, factor: AppPasswords::FACTOR) if outcome.actor
126
+
127
+ failed!(account) if outcome.wrong?
128
+ invalid
129
+ end
130
+
131
+ def bound(tenant, directory, actor, factor: "password")
106
132
  dn = directory.person_dn(actor)
107
- Event.record!(Event::LDAP_BOUND, actor: actor, dn: dn.to_s)
133
+ Event.record!(Event::LDAP_BOUND, actor: actor, dn: dn.to_s, factor: factor)
108
134
 
109
135
  Outcome.new(code: Protocol::SUCCESS, message: "", bound: Bound.new(kind: :person, tenant: tenant, dn: dn, subject: actor.id))
110
136
  end
@@ -132,7 +158,7 @@ module Masks
132
158
 
133
159
  def decoy(password)
134
160
  BCrypt::Password.new(Actor.decoy_digest) == password
135
- failed!(ip_key)
161
+ Throttle.count!(:attempt, ip_key)
136
162
  invalid
137
163
  end
138
164
 
@@ -140,7 +166,9 @@ module Masks
140
166
  refused(Protocol::INVALID_CREDENTIALS, "invalid credentials")
141
167
  end
142
168
 
143
- def throttled
169
+ def throttled(limit = nil)
170
+ Throttle.hit!(limit, address: @ip_address) if limit
171
+
144
172
  refused(Protocol::UNWILLING_TO_PERFORM, "too many attempts; wait a few minutes")
145
173
  end
146
174
 
@@ -149,19 +177,16 @@ module Masks
149
177
  end
150
178
 
151
179
  def ip_key
152
- "ldap-attempts:ip:#{@ip_address}"
180
+ "ldap:#{@ip_address}"
153
181
  end
154
182
 
155
183
  def account_key(tenant, uid)
156
- "ldap-attempts:#{tenant.id}:#{Digest::SHA256.hexdigest(uid.to_s.strip.downcase)}"
157
- end
158
-
159
- def failures(key)
160
- ::Rails.cache.read(key).to_i
184
+ "ldap:#{tenant.id}:#{Digest::SHA256.hexdigest(uid.to_s.strip.downcase)}"
161
185
  end
162
186
 
163
- def failed!(*keys)
164
- keys.each { |key| ::Rails.cache.increment(key, 1, expires_in: WINDOW) }
187
+ def failed!(account)
188
+ Throttle.count!(:attempt, ip_key)
189
+ Throttle.count!(:account_attempt, account)
165
190
  end
166
191
  end
167
192
  end
@@ -11,7 +11,7 @@ module Masks
11
11
  end
12
12
 
13
13
  prompts "second-factor" do
14
- login.first_factored? && !login.second_factored?
14
+ login.first_factored? && !login.second_factored? && !login.relaxed?
15
15
  end
16
16
 
17
17
  def factor!
@@ -160,6 +160,37 @@ module Masks
160
160
  )
161
161
  end
162
162
 
163
+ def access_granted(asked, journey:)
164
+ return message unless deliverable?
165
+
166
+ journey!(journey)
167
+ @actor = asked.actor
168
+ @app = asked.client
169
+ @url = (@app.approved? && @app.client_uri.presence) || home
170
+
171
+ mail(
172
+ from: self.class.from,
173
+ to: @actor.email,
174
+ subject: customize!(:access_granted, actor: @actor, app: @app.name,
175
+ default: t("actor_mailer.access_granted.subject", app: @app.name))
176
+ )
177
+ end
178
+
179
+ def access_declined(asked, journey:)
180
+ return message unless deliverable?
181
+
182
+ journey!(journey)
183
+ @actor = asked.actor
184
+ @app = asked.client
185
+
186
+ mail(
187
+ from: self.class.from,
188
+ to: @actor.email,
189
+ subject: customize!(:access_declined, actor: @actor, app: @app.name,
190
+ default: t("actor_mailer.access_declined.subject", app: @app.name))
191
+ )
192
+ end
193
+
163
194
  private
164
195
 
165
196
  def tenant_lede(manager)
@@ -0,0 +1,103 @@
1
+ module Masks
2
+ module Server
3
+ class AccessRequest < ApplicationRecord
4
+ include TenantScoped
5
+
6
+ class Refused < StandardError; end
7
+
8
+ LIFETIME = 30.days
9
+
10
+ belongs_to :actor
11
+ belongs_to :client
12
+ belongs_to :decided_by, class_name: "Actor", optional: true
13
+
14
+ scope :open, -> { where(granted_at: nil, declined_at: nil) }
15
+ scope :lapsed, -> { where(created_at: ...LIFETIME.ago) }
16
+ scope :newest_first, -> { order(created_at: :desc) }
17
+
18
+ class << self
19
+ def ask!(actor:, client:)
20
+ return nil if client.admits?(actor)
21
+
22
+ open.find_by(actor: actor, client: client) || begin
23
+ created = create!(actor: actor, client: client)
24
+ Event.record!(Event::ACCESS_REQUESTED, actor: actor, client: client)
25
+ created
26
+ end
27
+ rescue ActiveRecord::RecordNotUnique
28
+ open.find_by!(actor: actor, client: client)
29
+ end
30
+
31
+ def for_organization(organization)
32
+ open.joins(:client).where("EXISTS (SELECT 1 FROM unnest(clients.allowed_groups) AS held WHERE held LIKE ?)",
33
+ "#{sanitize_sql_like(organization.key)}:%")
34
+ end
35
+ end
36
+
37
+ def open?
38
+ granted_at.nil? && declined_at.nil?
39
+ end
40
+
41
+ def grant!(label: nil, by:, journey:)
42
+ raise Refused, "#{actor.identifier} is already admitted to #{client.name}" if client.admits?(actor)
43
+ raise Refused, "this request is already decided" unless open?
44
+
45
+ chosen = label.presence || client.allowed_groups.first
46
+ raise Refused, "#{chosen} is not one of the groups #{client.name} allows" unless client.allowed_groups.include?(chosen)
47
+
48
+ transaction do
49
+ admit!(chosen, by: by)
50
+ update!(granted_at: Time.current, decided_by: by, label: chosen)
51
+ Event.record!(Event::ACCESS_GRANTED, actor: actor, by: by, client: client, label: chosen)
52
+ end
53
+
54
+ ActorMailer.access_granted(self, journey: journey).deliver_later if actor.email.present?
55
+
56
+ self
57
+ end
58
+
59
+ def decline!(by:, journey:)
60
+ raise Refused, "this request is already decided" unless open?
61
+
62
+ transaction do
63
+ update!(declined_at: Time.current, decided_by: by)
64
+ Event.record!(Event::ACCESS_DECLINED, actor: actor, by: by, client: client)
65
+ end
66
+
67
+ ActorMailer.access_declined(self, journey: journey).deliver_later if actor.email.present?
68
+
69
+ self
70
+ end
71
+
72
+ def organization_roles
73
+ client.allowed_groups.filter_map do |held|
74
+ key, role = held.split(":", 2)
75
+ [ key, role ] if role
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def admit!(label, by:)
82
+ key, role = label.split(":", 2)
83
+
84
+ if role.nil?
85
+ group = Group.find_by(key: key) || raise(Refused, "there is no group keyed #{key}")
86
+ group.add!(actor, by: by)
87
+ else
88
+ organization = Organization.active.find_by(key: key) || raise(Refused, "there is no organization keyed #{key}")
89
+ held = organization.memberships.find_by(actor: actor)
90
+ held = held.destroy! && nil if held&.pending?
91
+
92
+ if held.nil?
93
+ Members.enroll!(organization, actor, role: role, by: by)
94
+ elsif held.role != role
95
+ Members.assign!(held, role: role, by: by)
96
+ end
97
+ end
98
+ rescue Members::Refused => e
99
+ raise Refused, e.message
100
+ end
101
+ end
102
+ end
103
+ end
@@ -30,6 +30,7 @@ module Masks
30
30
  has_many :approvals, class_name: "Client", foreign_key: :approved_by_id, dependent: :nullify
31
31
  has_one :avatar, dependent: :destroy
32
32
  has_many :group_members, dependent: :delete_all
33
+ has_many :access_requests, dependent: :delete_all
33
34
  has_many :groups, through: :group_members
34
35
 
35
36
  before_destroy :sign_out_everywhere!, prepend: true
@@ -61,6 +62,7 @@ module Masks
61
62
  after_save :release_previous_nickname, if: :saved_change_to_nickname?
62
63
  before_save :forget_code_factors_for_new_addresses
63
64
  after_update :forget_mailed_links, if: :saved_change_to_email?
65
+ after_commit :settle_sourced_groups, on: %i[create update], if: -> { saved_changes.keys.intersect?(Group::WATCHED) }
64
66
 
65
67
  normalizes :nickname, with: ->(value) { value.to_s.strip.presence }
66
68
  normalizes :email, with: ->(value) { value.to_s.strip.downcase.presence }
@@ -469,6 +471,10 @@ module Masks
469
471
 
470
472
  private
471
473
 
474
+ def settle_sourced_groups
475
+ Group.ruled.find_each { |group| group.settle!(self) }
476
+ end
477
+
472
478
  def forget_mailed_links
473
479
  [ PasswordReset, Invitation, EmailVerification ].each do |kind|
474
480
  kind.where(actor_id: id).live.find_each(&:consume!)
@@ -0,0 +1,91 @@
1
+ module Masks
2
+ module Server
3
+ class AppPassword < Token
4
+ ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789".freeze
5
+ LENGTH = 24
6
+ GROUP = 4
7
+ LIFETIME = 100.years
8
+ NAME_LIMIT = 64
9
+ MOST = 20
10
+ USED_GRAIN = 1.minute
11
+
12
+ class Refused < StandardError; end
13
+
14
+ scope :newest_first, -> { order(created_at: :desc) }
15
+
16
+ class << self
17
+ def offered(actor)
18
+ Client.active.where(app_passwords: true).order(:name).select { |client| client.admits?(actor) }
19
+ end
20
+
21
+ def issue!(actor:, client:, name:)
22
+ label = name.to_s.strip
23
+
24
+ raise Refused, "name" if label.empty? || label.length > NAME_LIMIT
25
+ raise Refused, "app" if client.nil? || offered(actor).none? { |offered| offered.id == client.id }
26
+ raise Refused, "too_many" if live.where(actor: actor).count >= MOST
27
+
28
+ secret = Array.new(LENGTH) { ALPHABET[SecureRandom.random_number(ALPHABET.length)] }.join
29
+ held = create!(actor: actor, client: client, digest: BCrypt::Password.create(secret, cost: cost),
30
+ payload: { "name" => label }, expires_at: LIFETIME.from_now)
31
+ held.instance_variable_set(:@secret, grouped(secret))
32
+ held
33
+ end
34
+
35
+ def normalize(secret)
36
+ secret.to_s.downcase.delete("- ")
37
+ end
38
+
39
+ def shaped?(secret)
40
+ offered = normalize(secret)
41
+
42
+ offered.length == LENGTH && offered.each_char.all? { |char| ALPHABET.include?(char) }
43
+ end
44
+
45
+ def check(actor:, client:, secret:)
46
+ offered = normalize(secret)
47
+ return burn(offered) unless actor && client && shaped?(offered)
48
+
49
+ found = live.where(actor: actor, client: client).to_a.find { |held| BCrypt::Password.new(held.digest) == offered }
50
+
51
+ found || burn(offered)
52
+ end
53
+
54
+ def grouped(secret)
55
+ secret.scan(/.{1,#{GROUP}}/o).join("-")
56
+ end
57
+
58
+ private
59
+
60
+ def cost
61
+ ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
62
+ end
63
+
64
+ def burn(secret)
65
+ BCrypt::Password.new(Actor.decoy_digest) == secret
66
+ nil
67
+ end
68
+ end
69
+
70
+ def name
71
+ held("name")
72
+ end
73
+
74
+ def used_at
75
+ held("used_at")&.then { |value| Time.zone.parse(value) }
76
+ end
77
+
78
+ def used!
79
+ return if used_at && used_at > USED_GRAIN.ago
80
+
81
+ now = Time.current
82
+ today = now.to_date.iso8601
83
+ first_today = held("used_on") != today
84
+
85
+ update_columns(payload: (payload || {}).merge("used_at" => now.iso8601, "used_on" => today))
86
+
87
+ Event.record!(Event::APP_PASSWORD_USED, actor: actor, client: client, name: name) if first_today
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,76 @@
1
+ module Masks
2
+ module Server
3
+ module AppPasswords
4
+ FACTOR = "app_password".freeze
5
+ THROTTLED = "throttled".freeze
6
+ SCOPE = "masks/server/logins".freeze
7
+
8
+ Outcome = Data.define(:actor, :refusal) do
9
+ def throttled?
10
+ refusal == THROTTLED
11
+ end
12
+
13
+ def wrong?
14
+ refusal == FACTOR
15
+ end
16
+ end
17
+
18
+ class << self
19
+ def authenticate(client:, identifier:, secret:, via:, ip_address: Current.ip_address, tenant: Current.tenant)
20
+ return refused(nil, identifier, "factor-not-offered", via, client) unless client&.app_passwords? && !client.archived?
21
+
22
+ actor = Actor.locate(identifier)
23
+ keys = { account_attempt: [ account_key(tenant, actor, identifier), "identifier" ], attempt: [ address_key(tenant, ip_address), nil ] }
24
+ exceeded = keys.find { |limit, (by, named)| Throttle.exceeded?(limit, by, scope: SCOPE, named: named) }&.first
25
+
26
+ if exceeded
27
+ Throttle.hit!(exceeded, actor: actor, client: client, address: ip_address, identifier: identifier, via: via)
28
+ return Outcome.new(actor: nil, refusal: THROTTLED)
29
+ end
30
+
31
+ held = AppPassword.check(actor: actor, client: client, secret: secret)
32
+
33
+ if held.nil?
34
+ failed!(keys)
35
+ return refused(actor, identifier, FACTOR, via, client)
36
+ end
37
+
38
+ reason = obstacle(client, actor)
39
+ return refused(actor, identifier, reason, via, client) if reason
40
+
41
+ actor.active!
42
+ held.used!
43
+
44
+ Outcome.new(actor: actor, refusal: nil)
45
+ end
46
+
47
+ private
48
+
49
+ def obstacle(client, actor)
50
+ return "suspended" if actor.suspended?
51
+ return "pending_approval" if actor.pending_approval_at.present?
52
+
53
+ "not_admitted" unless client.admits?(actor)
54
+ end
55
+
56
+ def refused(actor, identifier, factor, via, client)
57
+ Event.record!(Event::LOGIN_REFUSED, actor: actor, by: nil, client: client, factor: factor, identifier: identifier, via: via)
58
+
59
+ Outcome.new(actor: nil, refusal: factor)
60
+ end
61
+
62
+ def account_key(tenant, actor, identifier)
63
+ [ tenant.id, actor&.id || identifier.to_s.strip.downcase ].join(":")
64
+ end
65
+
66
+ def address_key(tenant, ip_address)
67
+ [ tenant.id, ip_address ].join(":")
68
+ end
69
+
70
+ def failed!(keys)
71
+ keys.each { |limit, (by, named)| Throttle.count!(limit, by, scope: SCOPE, named: named) }
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -54,6 +54,7 @@ module Masks
54
54
  has_many :consents, dependent: :destroy
55
55
  has_many :delegations, dependent: :destroy
56
56
  has_many :saml_participations, dependent: :delete_all
57
+ has_many :access_requests, dependent: :delete_all
57
58
  has_many :namespaces, -> { order(:name) }, dependent: :nullify
58
59
  has_many :enrolled, class_name: "Client", foreign_key: :enrolled_by_id, inverse_of: :enrolled_by,
59
60
  dependent: :nullify
@@ -73,6 +74,7 @@ module Masks
73
74
  validate :keys_are_usable
74
75
  validate :saml_is_described, if: :saml?
75
76
  validate :forward_is_described, if: :forward?
77
+ validate :forward_auth_rules_are_usable
76
78
  validate :backchannel_logout_uri_is_usable
77
79
  validate :sector_is_derivable
78
80
  validate :consent_is_skipped_only_when_approved
@@ -89,6 +91,7 @@ module Masks
89
91
 
90
92
  normalizes :allowed_groups, with: ->(value) { Array(value).map { |group| group.to_s.strip.downcase }.compact_blank.uniq.sort }
91
93
  normalizes :saml_entity_id, :saml_slo_url, with: ->(value) { value.to_s.strip.presence }
94
+ normalizes :forward_auth_rules, with: ->(value) { ForwardAuthRules.normalize(value) }
92
95
  normalizes(*METADATA_URIS, with: ->(value) { value.to_s.strip.presence })
93
96
 
94
97
  after_commit :refresh_logo!, on: %i[create update], if: :saved_change_to_logo_uri?
@@ -464,6 +467,10 @@ module Masks
464
467
  allowed_groups.empty? || actor.group_labels.intersect?(allowed_groups)
465
468
  end
466
469
 
470
+ def forward_rules
471
+ ForwardAuthRules.new(forward_auth_rules)
472
+ end
473
+
467
474
  def permitted_scopes(requested)
468
475
  return scope_list if Scopes.list(requested).empty?
469
476
 
@@ -604,6 +611,14 @@ module Masks
604
611
  errors.add(:grant_types, "not supported: #{unknown.join(', ')}") if unknown.any?
605
612
  end
606
613
 
614
+ def forward_auth_rules_are_usable
615
+ held = forward_auth_rules
616
+
617
+ return errors.add(:base, "forward auth rules apply only to an app behind a proxy") if held.present? && !forward?
618
+
619
+ ForwardAuthRules.problems(held).each { |problem| errors.add(:base, problem) }
620
+ end
621
+
607
622
  def forward_is_described
608
623
  errors.add(:grant_types, "are not used by an app behind a proxy") if grant_types.any?
609
624
  errors.add(:token_endpoint_auth_method, "is none for an app behind a proxy") unless public?
@@ -12,7 +12,6 @@ module Masks
12
12
  LONGEST = 24.hours
13
13
  REFUSAL_LIFETIME = 5.minutes
14
14
  NAME_LIMIT = 100
15
- WINDOW = 10.minutes
16
15
  TENANT_FACTOR = 10
17
16
  AUTH_METHODS = %w[none private_key_jwt].freeze
18
17
  DEFAULT_AUTH_METHOD = "none".freeze
@@ -62,14 +61,11 @@ module Masks
62
61
  end
63
62
 
64
63
  def crowded?
65
- limit = ::Rails.configuration.masks.registration_limit
66
- key = "masks:client-metadata-documents:#{Current.tenant&.id}:#{Time.current.to_i / WINDOW.to_i}"
67
- here = ::Rails.cache.increment("#{key}:#{Current.ip_address}", 1, expires_in: WINDOW)
68
- anywhere = ::Rails.cache.increment(key, 1, expires_in: WINDOW)
64
+ tenant = Current.tenant&.id
65
+ here = Throttle.crowded?(:registration, "documents:#{tenant}:#{Current.ip_address}")
66
+ anywhere = Throttle.crowded?(:registration, "documents:#{tenant}", times: TENANT_FACTOR)
69
67
 
70
- (here.to_i > limit || anywhere.to_i > limit * TENANT_FACTOR).tap do |crowded|
71
- ::Rails.logger.info("masks: too many new client metadata documents") if crowded
72
- end
68
+ (here || anywhere).tap { |crowded| Throttle.hit!(:registration, document: true) if crowded }
73
69
  end
74
70
 
75
71
  def refusal_key(client_id)
@@ -31,7 +31,7 @@ module Masks
31
31
 
32
32
  def inbox_crowded?(address:, ip: nil)
33
33
  recent = inbox.where(created_at: WINDOW.ago..)
34
- limit = ::Rails.configuration.masks.recovery_limit
34
+ limit = Throttle.limit(:recovery)
35
35
 
36
36
  recent.where("payload->>'address' = ?", address).count >= limit ||
37
37
  (ip.present? && recent.where("payload->>'ip' = ?", ip).count >= limit * INBOX_PER_IP)
@@ -40,7 +40,7 @@ module Masks
40
40
  def crowded?(actor:, channel:)
41
41
  where(actor_id: actor.id, created_at: WINDOW.ago..)
42
42
  .where("payload->>'channel' = ?", channel)
43
- .count >= ::Rails.configuration.masks.recovery_limit
43
+ .count >= Throttle.limit(:recovery)
44
44
  end
45
45
 
46
46
  def inbox