masks-server 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/masks/server/account_controller.rb +3 -0
  3. data/app/controllers/masks/server/app_passwords_controller.rb +41 -0
  4. data/app/controllers/masks/server/authorize_controller.rb +33 -2
  5. data/app/controllers/masks/server/discovery_controller.rb +36 -0
  6. data/app/controllers/masks/server/enrollments_controller.rb +17 -0
  7. data/app/controllers/masks/server/forward_auth_controller.rb +104 -12
  8. data/app/controllers/masks/server/logins_controller.rb +4 -3
  9. data/app/controllers/masks/server/organization_access_controller.rb +39 -0
  10. data/app/controllers/masks/server/organization_members_controller.rb +3 -1
  11. data/app/graphql/masks/server/manage/mutations/base_mutation.rb +7 -0
  12. data/app/graphql/masks/server/manage/mutations/create_group.rb +6 -4
  13. data/app/graphql/masks/server/manage/mutations/decline_access.rb +27 -0
  14. data/app/graphql/masks/server/manage/mutations/grant_access.rb +29 -0
  15. data/app/graphql/masks/server/manage/mutations/group_source.rb +34 -0
  16. data/app/graphql/masks/server/manage/mutations/issue_ldap_credential.rb +5 -2
  17. data/app/graphql/masks/server/manage/mutations/refresh_group.rb +21 -0
  18. data/app/graphql/masks/server/manage/mutations/remove_group_member.rb +2 -0
  19. data/app/graphql/masks/server/manage/mutations/run_health_checks.rb +19 -0
  20. data/app/graphql/masks/server/manage/mutations/unenroll_client.rb +28 -0
  21. data/app/graphql/masks/server/manage/mutations/update_client.rb +6 -0
  22. data/app/graphql/masks/server/manage/mutations/update_group.rb +7 -4
  23. data/app/graphql/masks/server/manage/mutations/update_tenant.rb +14 -1
  24. data/app/graphql/masks/server/manage/types/access_request_type.rb +21 -0
  25. data/app/graphql/masks/server/manage/types/actor_type.rb +6 -0
  26. data/app/graphql/masks/server/manage/types/client_type.rb +18 -0
  27. data/app/graphql/masks/server/manage/types/group_condition_input.rb +16 -0
  28. data/app/graphql/masks/server/manage/types/group_condition_type.rb +29 -0
  29. data/app/graphql/masks/server/manage/types/group_member_type.rb +16 -0
  30. data/app/graphql/masks/server/manage/types/group_type.rb +17 -1
  31. data/app/graphql/masks/server/manage/types/health_check_type.rb +18 -0
  32. data/app/graphql/masks/server/manage/types/ldap_credential_type.rb +1 -0
  33. data/app/graphql/masks/server/manage/types/mutation_type.rb +5 -0
  34. data/app/graphql/masks/server/manage/types/query_type.rb +24 -0
  35. data/app/graphql/masks/server/manage/types/release_type.rb +16 -0
  36. data/app/graphql/masks/server/manage/types/tenant_type.rb +6 -1
  37. data/app/helpers/masks/server/account_helper.rb +4 -0
  38. data/app/jobs/masks/server/application_job.rb +28 -0
  39. data/app/jobs/masks/server/check_domain_claims_job.rb +1 -3
  40. data/app/jobs/masks/server/cleanup_job.rb +5 -6
  41. data/app/jobs/masks/server/health_check_job.rb +21 -0
  42. data/app/jobs/masks/server/idle_accounts_job.rb +1 -3
  43. data/app/jobs/masks/server/prune_queue_job.rb +19 -0
  44. data/app/jobs/masks/server/refresh_groups_job.rb +18 -0
  45. data/app/jobs/masks/server/refresh_saml_metadata_job.rb +1 -3
  46. data/app/jobs/masks/server/release_check_job.rb +21 -0
  47. data/app/jobs/masks/server/rotate_signing_keys_job.rb +1 -3
  48. data/app/jobs/masks/server/text_job.rb +12 -1
  49. data/app/ldap/masks/server/ldap/connection.rb +3 -2
  50. data/app/ldap/masks/server/ldap/gate.rb +31 -4
  51. data/app/logins/masks/server/login_states/second_factor.rb +1 -1
  52. data/app/mailers/masks/server/actor_mailer.rb +31 -0
  53. data/app/models/masks/server/access_request.rb +103 -0
  54. data/app/models/masks/server/actor.rb +6 -0
  55. data/app/models/masks/server/app_password.rb +91 -0
  56. data/app/models/masks/server/app_passwords.rb +82 -0
  57. data/app/models/masks/server/app_preset.rb +102 -0
  58. data/app/models/masks/server/boot_task.rb +41 -0
  59. data/app/models/masks/server/client.rb +15 -0
  60. data/app/models/masks/server/connection.rb +7 -0
  61. data/app/models/masks/server/declared.rb +10 -2
  62. data/app/models/masks/server/email_verification.rb +2 -2
  63. data/app/models/masks/server/enrollment_token.rb +1 -1
  64. data/app/models/masks/server/event.rb +15 -1
  65. data/app/models/masks/server/federation/protocol.rb +1 -1
  66. data/app/models/masks/server/federation/saml.rb +1 -1
  67. data/app/models/masks/server/forward_auth_rules.rb +162 -0
  68. data/app/models/masks/server/group.rb +207 -4
  69. data/app/models/masks/server/group_member.rb +7 -0
  70. data/app/models/masks/server/group_rule.rb +63 -0
  71. data/app/models/masks/server/health.rb +243 -0
  72. data/app/models/masks/server/health_check.rb +27 -0
  73. data/app/models/masks/server/invitation.rb +2 -2
  74. data/app/models/masks/server/job_failures.rb +25 -0
  75. data/app/models/masks/server/ldap_credential.rb +2 -2
  76. data/app/models/masks/server/login.rb +28 -2
  77. data/app/models/masks/server/mail_previews.rb +5 -0
  78. data/app/models/masks/server/mail_template.rb +2 -0
  79. data/app/models/masks/server/membership.rb +10 -3
  80. data/app/models/masks/server/notifications.rb +3 -1
  81. data/app/models/masks/server/password_reset.rb +2 -2
  82. data/app/models/masks/server/provider.rb +1 -0
  83. data/app/models/masks/server/queue.rb +60 -0
  84. data/app/models/masks/server/release.rb +74 -0
  85. data/app/models/masks/server/session.rb +8 -0
  86. data/app/models/masks/server/tenant.rb +39 -2
  87. data/app/models/masks/server/token.rb +2 -1
  88. data/app/views/masks/server/account/_app_passwords.html.erb +83 -0
  89. data/app/views/masks/server/account/_organizations.html.erb +33 -0
  90. data/app/views/masks/server/account/index.html.erb +5 -0
  91. data/app/views/masks/server/actor_mailer/access_declined.html.erb +5 -0
  92. data/app/views/masks/server/actor_mailer/access_declined.text.erb +5 -0
  93. data/app/views/masks/server/actor_mailer/access_granted.html.erb +7 -0
  94. data/app/views/masks/server/actor_mailer/access_granted.text.erb +7 -0
  95. data/app/views/masks/server/authorize/error.html.erb +25 -0
  96. data/config/locales/de/access_requests.yml +43 -0
  97. data/config/locales/de/account/app_passwords.yml +42 -0
  98. data/config/locales/de/events.yml +9 -0
  99. data/config/locales/de/flashes.yml +1 -0
  100. data/config/locales/de/group_sources.yml +5 -0
  101. data/config/locales/de/manage/access_requests.yml +12 -0
  102. data/config/locales/de/manage/app_passwords.yml +16 -0
  103. data/config/locales/de/manage/client.yml +1 -1
  104. data/config/locales/de/manage/forward_auth_rules.yml +29 -0
  105. data/config/locales/de/manage/group_sources.yml +44 -0
  106. data/config/locales/de/manage/health.yml +78 -0
  107. data/config/locales/de/manage/links.yml +20 -0
  108. data/config/locales/de/manage/machines.yml +13 -0
  109. data/config/locales/de/manage/namespaces.yml +1 -1
  110. data/config/locales/en/access_requests.yml +43 -0
  111. data/config/locales/en/account/app_passwords.yml +42 -0
  112. data/config/locales/en/events.yml +9 -0
  113. data/config/locales/en/flashes.yml +1 -0
  114. data/config/locales/en/group_sources.yml +5 -0
  115. data/config/locales/en/manage/access_requests.yml +12 -0
  116. data/config/locales/en/manage/app_passwords.yml +16 -0
  117. data/config/locales/en/manage/forward_auth_rules.yml +29 -0
  118. data/config/locales/en/manage/group_sources.yml +44 -0
  119. data/config/locales/en/manage/health.yml +78 -0
  120. data/config/locales/en/manage/links.yml +20 -0
  121. data/config/locales/en/manage/machines.yml +13 -0
  122. data/config/locales/es/access_requests.yml +43 -0
  123. data/config/locales/es/account/app_passwords.yml +42 -0
  124. data/config/locales/es/events.yml +9 -0
  125. data/config/locales/es/flashes.yml +1 -0
  126. data/config/locales/es/group_sources.yml +5 -0
  127. data/config/locales/es/handshakes.yml +1 -1
  128. data/config/locales/es/manage/access_requests.yml +12 -0
  129. data/config/locales/es/manage/app_passwords.yml +16 -0
  130. data/config/locales/es/manage/forward_auth_rules.yml +29 -0
  131. data/config/locales/es/manage/group_sources.yml +44 -0
  132. data/config/locales/es/manage/health.yml +78 -0
  133. data/config/locales/es/manage/links.yml +20 -0
  134. data/config/locales/es/manage/machines.yml +13 -0
  135. data/config/locales/fr/access_requests.yml +43 -0
  136. data/config/locales/fr/account/app_passwords.yml +42 -0
  137. data/config/locales/fr/events.yml +9 -0
  138. data/config/locales/fr/flashes.yml +1 -0
  139. data/config/locales/fr/group_sources.yml +5 -0
  140. data/config/locales/fr/handshakes.yml +1 -1
  141. data/config/locales/fr/manage/access_requests.yml +12 -0
  142. data/config/locales/fr/manage/app_passwords.yml +16 -0
  143. data/config/locales/fr/manage/client.yml +1 -1
  144. data/config/locales/fr/manage/clients.yml +1 -1
  145. data/config/locales/fr/manage/forward_auth_rules.yml +29 -0
  146. data/config/locales/fr/manage/group_sources.yml +44 -0
  147. data/config/locales/fr/manage/health.yml +78 -0
  148. data/config/locales/fr/manage/links.yml +20 -0
  149. data/config/locales/fr/manage/machines.yml +13 -0
  150. data/config/locales/fr/manage/providers.yml +1 -1
  151. data/config/routes.rb +6 -0
  152. data/db/migrate/20261019140000_add_indexes_for_cleanup_and_metrics.rb +10 -0
  153. data/db/migrate/20261020100000_create_access_requests.rb +28 -0
  154. data/db/migrate/20261020110000_add_app_passwords_to_clients.rb +5 -0
  155. data/db/migrate/20261020120000_add_forward_auth_rules_to_clients.rb +6 -0
  156. data/db/migrate/20261020130000_add_sources_to_groups.rb +15 -0
  157. data/db/migrate/20261020140000_create_health_checks.rb +25 -0
  158. data/db/migrate/20261020150000_add_link_lifetimes_to_tenants.rb +8 -0
  159. data/db/migrate/20261021100000_add_pinned_origin_to_tenants.rb +5 -0
  160. data/lib/generators/masks/server/install/install_generator.rb +3 -2
  161. data/lib/masks/server/configuration.rb +6 -11
  162. data/lib/masks/server/schema_reference.rb +12 -0
  163. data/lib/masks/server/tenancy/middleware.rb +17 -6
  164. data/lib/tasks/masks.rake +31 -7
  165. data/public/masks-assets/.vite/manifest.json +13 -13
  166. data/public/masks-assets/assets/{account-D46qDQCo.js → account-h7I6VUPR.js} +2 -2
  167. data/public/masks-assets/assets/{account-D46qDQCo.js.map → account-h7I6VUPR.js.map} +1 -1
  168. data/public/masks-assets/assets/{app-D7W0gAyT.css → app-BXOScMjL.css} +1 -1
  169. data/public/masks-assets/assets/{handshake-DIFZPSAK.js → handshake-YQpRNMIs.js} +2 -2
  170. data/public/masks-assets/assets/{handshake-DIFZPSAK.js.map → handshake-YQpRNMIs.js.map} +1 -1
  171. data/public/masks-assets/assets/initial-Cn-UuAsy.js +3 -0
  172. data/public/masks-assets/assets/{initial-C1GpCiEh.js.map → initial-Cn-UuAsy.js.map} +1 -1
  173. data/public/masks-assets/assets/{login-C_6igRZ3.js → login-C21_jb6L.js} +3 -3
  174. data/public/masks-assets/assets/{login-C_6igRZ3.js.map → login-C21_jb6L.js.map} +1 -1
  175. data/public/masks-assets/assets/{manage-DR9qQiBU.js → manage-DpVxe5sv.js} +86 -50
  176. data/public/masks-assets/assets/manage-DpVxe5sv.js.map +1 -0
  177. data/public/masks-assets/assets/passkey-5UYPVD3c.js +2 -0
  178. data/public/masks-assets/assets/{passkey-DIj_5LCY.js.map → passkey-5UYPVD3c.js.map} +1 -1
  179. data/version.txt +1 -1
  180. metadata +96 -14
  181. data/public/masks-assets/assets/initial-C1GpCiEh.js +0 -3
  182. data/public/masks-assets/assets/manage-DR9qQiBU.js.map +0 -1
  183. data/public/masks-assets/assets/passkey-DIj_5LCY.js +0 -2
@@ -3,7 +3,11 @@ module Masks
3
3
  class TextJob < ApplicationJob
4
4
  queue_as :mailers
5
5
 
6
- retry_on Sender::Failed, wait: :polynomially_longer, attempts: 3
6
+ ATTEMPTS = 3
7
+
8
+ retry_on Sender::Failed, wait: :polynomially_longer, attempts: ATTEMPTS do |job, error|
9
+ job.gave_up!(error)
10
+ end
7
11
 
8
12
  def perform(to, body)
9
13
  sender = Current.tenant&.sms_sender
@@ -12,6 +16,13 @@ module Masks
12
16
 
13
17
  sender.deliver(to: to, body: body)
14
18
  end
19
+
20
+ def gave_up!(error)
21
+ Tenant.switch(held_tenant) do
22
+ Event.record!(Event::TEXT_UNDELIVERED, actor: nil, by: nil, device: nil, ip_address: nil, user_agent: nil,
23
+ said: error.message)
24
+ end
25
+ end
15
26
  end
16
27
  end
17
28
  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))
@@ -5,7 +5,11 @@ module Masks
5
5
  WINDOW = 3.minutes
6
6
  VIA = "ldap".freeze
7
7
 
8
- Bound = Data.define(:kind, :tenant, :dn, :subject) do
8
+ Bound = Data.define(:kind, :tenant, :dn, :subject, :client_id) do
9
+ def initialize(client_id: nil, **rest)
10
+ super(client_id: client_id, **rest)
11
+ end
12
+
9
13
  def service?
10
14
  kind == :service
11
15
  end
@@ -17,8 +21,9 @@ module Masks
17
21
 
18
22
  Outcome = Data.define(:code, :message, :bound)
19
23
 
20
- def initialize(ip_address:)
24
+ def initialize(ip_address:, client_id: nil)
21
25
  @ip_address = ip_address
26
+ @client_id = client_id
22
27
  end
23
28
 
24
29
  def bind(name, password)
@@ -80,7 +85,8 @@ module Masks
80
85
 
81
86
  Outcome.new(
82
87
  code: Protocol::SUCCESS, message: "",
83
- bound: Bound.new(kind: :service, tenant: tenant, dn: directory.service_dn(credential.label), subject: credential.id)
88
+ bound: Bound.new(kind: :service, tenant: tenant, dn: directory.service_dn(credential.label), subject: credential.id,
89
+ client_id: credential.client_id)
84
90
  )
85
91
  end
86
92
 
@@ -93,6 +99,7 @@ module Masks
93
99
  end
94
100
 
95
101
  actor = Actor.authenticate(uid, password)
102
+ return app_password(tenant, directory, uid, password, account) if actor.nil? && app_password?(password)
96
103
 
97
104
  if actor.nil?
98
105
  failed!(ip_key, account)
@@ -103,8 +110,28 @@ module Masks
103
110
  return refuse(uid, reason, actor) if reason
104
111
 
105
112
  actor.active!
113
+ bound(tenant, directory, actor)
114
+ end
115
+
116
+ def app_password?(password)
117
+ @client_id && AppPassword.shaped?(password)
118
+ end
119
+
120
+ def app_password(tenant, directory, uid, password, account)
121
+ client = Client.active.find_by(id: @client_id)
122
+ outcome = AppPasswords.authenticate(client: client, identifier: uid, secret: password, via: VIA,
123
+ ip_address: @ip_address, tenant: tenant)
124
+
125
+ return throttled if outcome.throttled?
126
+ return bound(tenant, directory, outcome.actor, factor: AppPasswords::FACTOR) if outcome.actor
127
+
128
+ failed!(ip_key, account) if outcome.wrong?
129
+ invalid
130
+ end
131
+
132
+ def bound(tenant, directory, actor, factor: "password")
106
133
  dn = directory.person_dn(actor)
107
- Event.record!(Event::LDAP_BOUND, actor: actor, dn: dn.to_s)
134
+ Event.record!(Event::LDAP_BOUND, actor: actor, dn: dn.to_s, factor: factor)
108
135
 
109
136
  Outcome.new(code: Protocol::SUCCESS, message: "", bound: Bound.new(kind: :person, tenant: tenant, dn: dn, subject: actor.id))
110
137
  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,82 @@
1
+ module Masks
2
+ module Server
3
+ module AppPasswords
4
+ FACTOR = "app_password".freeze
5
+ THROTTLED = "throttled".freeze
6
+ WINDOW = 3.minutes
7
+ SCOPE = "masks/server/logins".freeze
8
+
9
+ Outcome = Data.define(:actor, :refusal) do
10
+ def throttled?
11
+ refusal == THROTTLED
12
+ end
13
+
14
+ def wrong?
15
+ refusal == FACTOR
16
+ end
17
+ end
18
+
19
+ class << self
20
+ def authenticate(client:, identifier:, secret:, via:, ip_address: Current.ip_address, tenant: Current.tenant)
21
+ return refused(nil, identifier, "factor-not-offered", via, client) unless client&.app_passwords? && !client.archived?
22
+
23
+ actor = Actor.locate(identifier)
24
+ keys = [ account_key(tenant, actor, identifier), address_key(tenant, ip_address) ]
25
+
26
+ if throttled?(keys)
27
+ Event.record!(Event::LOGIN_THROTTLED, actor: actor, by: nil, client: client, 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
+ [ "rate-limit", SCOPE, "identifier", tenant.id, actor&.id || identifier.to_s.strip.downcase ].join(":")
64
+ end
65
+
66
+ def address_key(tenant, ip_address)
67
+ [ "rate-limit", SCOPE, tenant.id, ip_address ].join(":")
68
+ end
69
+
70
+ def throttled?(keys)
71
+ limits = [ ::Rails.configuration.masks.account_attempt_limit, ::Rails.configuration.masks.attempt_limit ]
72
+
73
+ keys.zip(limits).any? { |key, limit| ::Rails.cache.read(key).to_i >= limit }
74
+ end
75
+
76
+ def failed!(keys)
77
+ keys.each { |key| ::Rails.cache.increment(key, 1, expires_in: WINDOW) }
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -182,6 +182,108 @@ module Masks
182
182
  [ "OAUTH_PROVIDER_NAME", "{tenant}" ], [ "ENABLE_OAUTH_SIGNUP", "true" ],
183
183
  [ "ENABLE_OAUTH_ROLE_MANAGEMENT", "true" ], [ "OAUTH_ROLES_CLAIM", "groups" ], [ "OAUTH_ADMIN_ROLES", "admins" ]
184
184
  ]
185
+ },
186
+ {
187
+ key: "tailscale", name: "Tailscale",
188
+ redirects: [ "https://login.tailscale.com/a/oauth_response" ],
189
+ scopes: %w[openid profile email],
190
+ guide: "https://tailscale.com/kb/1240/sso-custom-oidc",
191
+ settings: [
192
+ [ "WebFinger domain", "{masks_host}" ], [ "Issuer URL", "{issuer}" ],
193
+ [ "Client ID", "{client_id}" ], [ "Client secret", "{client_secret}" ]
194
+ ]
195
+ },
196
+ {
197
+ key: "headscale", name: "Headscale",
198
+ redirects: [ "{base}/oidc/callback" ],
199
+ scopes: %w[openid profile email groups],
200
+ guide: "https://headscale.net/stable/ref/oidc/",
201
+ settings: [
202
+ [ "oidc.issuer", "{issuer}" ], [ "oidc.client_id", "{client_id}" ], [ "oidc.client_secret", "{client_secret}" ],
203
+ [ "oidc.scope", "[openid, profile, email, groups]" ], [ "oidc.allowed_groups", "[admins]" ]
204
+ ]
205
+ },
206
+ {
207
+ key: "home-assistant", name: "Home Assistant (OIDC integration)",
208
+ redirects: [ "{base}/auth/oidc/callback" ],
209
+ scopes: %w[openid profile email groups],
210
+ guide: "https://github.com/christiaangoossens/hass-oidc-auth",
211
+ settings: [
212
+ [ "auth_oidc.discovery_url", "{discovery}" ], [ "auth_oidc.client_id", "{client_id}" ],
213
+ [ "auth_oidc.client_secret", "{client_secret}" ], [ "auth_oidc.display_name", "{tenant}" ],
214
+ [ "auth_oidc.roles.admin", "admins" ]
215
+ ]
216
+ },
217
+ {
218
+ key: "synapse", name: "Matrix (Synapse)", asks: { "name" => "masks" },
219
+ redirects: [ "{base}/_synapse/client/oidc/callback" ],
220
+ scopes: %w[openid profile email],
221
+ guide: "https://element-hq.github.io/synapse/latest/openid.html",
222
+ settings: [
223
+ [ "oidc_providers", "- idp_id: {name}\n idp_name: {tenant}\n issuer: {issuer}\n client_id: {client_id}\n" \
224
+ " client_secret: {client_secret}\n scopes: [openid, profile, email]\n" \
225
+ " user_mapping_provider:\n config:\n localpart_template: \"{{ user.preferred_username }}\"\n" \
226
+ " display_name_template: \"{{ user.name }}\"\n email_template: \"{{ user.email }}\"" ]
227
+ ]
228
+ },
229
+ {
230
+ key: "bookstack", name: "BookStack",
231
+ redirects: [ "{base}/oidc/callback" ],
232
+ scopes: %w[openid profile email groups],
233
+ guide: "https://www.bookstackapp.com/docs/admin/oidc-auth/",
234
+ settings: [
235
+ [ "AUTH_METHOD", "oidc" ], [ "OIDC_NAME", "{tenant}" ], [ "OIDC_ISSUER", "{issuer}" ], [ "OIDC_ISSUER_DISCOVER", "true" ],
236
+ [ "OIDC_CLIENT_ID", "{client_id}" ], [ "OIDC_CLIENT_SECRET", "{client_secret}" ],
237
+ [ "OIDC_DISPLAY_NAME_CLAIMS", "name" ], [ "OIDC_ADDITIONAL_SCOPES", "groups" ],
238
+ [ "OIDC_USER_TO_GROUPS", "true" ], [ "OIDC_GROUPS_CLAIM", "groups" ], [ "OIDC_REMOVE_FROM_GROUPS", "true" ]
239
+ ]
240
+ },
241
+ {
242
+ key: "wikijs", name: "Wiki.js", asks: { "name" => "masks" },
243
+ redirects: [ "{base}/login/{name}/callback" ],
244
+ scopes: %w[openid profile email groups],
245
+ guide: "https://docs.requarks.io/auth/oidc",
246
+ settings: [
247
+ [ "Client ID", "{client_id}" ], [ "Client Secret", "{client_secret}" ],
248
+ [ "Authorization Endpoint URL", "{authorize}" ], [ "Token Endpoint URL", "{token}" ],
249
+ [ "User Info Endpoint URL", "{userinfo}" ], [ "Issuer", "{issuer}" ], [ "Logout URL", "{logout}" ],
250
+ [ "Email Claim", "email" ], [ "Display Name Claim", "name" ], [ "Groups Claim", "groups" ],
251
+ [ "Callback URL", "{base}/login/{name}/callback" ]
252
+ ]
253
+ },
254
+ {
255
+ key: "minio", name: "MinIO",
256
+ redirects: [ "{base}/oauth_callback" ],
257
+ scopes: %w[openid profile email groups],
258
+ guide: "https://min.io/docs/minio/linux/operations/external-iam/configure-openid-external-identity-management.html",
259
+ settings: [
260
+ [ "MINIO_IDENTITY_OPENID_CONFIG_URL", "{discovery}" ], [ "MINIO_IDENTITY_OPENID_CLIENT_ID", "{client_id}" ],
261
+ [ "MINIO_IDENTITY_OPENID_CLIENT_SECRET", "{client_secret}" ], [ "MINIO_IDENTITY_OPENID_SCOPES", "openid,profile,email,groups" ],
262
+ [ "MINIO_IDENTITY_OPENID_CLAIM_NAME", "groups" ], [ "MINIO_IDENTITY_OPENID_REDIRECT_URI", "{base}/oauth_callback" ],
263
+ [ "MINIO_IDENTITY_OPENID_DISPLAY_NAME", "{tenant}" ]
264
+ ]
265
+ },
266
+ {
267
+ key: "argocd", name: "Argo CD",
268
+ redirects: [ "{base}/auth/callback", "{base}/api/dex/callback" ],
269
+ scopes: %w[openid profile email groups],
270
+ guide: "https://argo-cd.readthedocs.io/en/stable/operator-manual/user-management/#existing-oidc-provider",
271
+ settings: [
272
+ [ "argocd-cm oidc.config", "name: {tenant}\nissuer: {issuer}\nclientID: {client_id}\nclientSecret: {client_secret}\n" \
273
+ "requestedScopes: [openid, profile, email, groups]" ],
274
+ [ "argocd-rbac-cm policy.csv", "g, admins, role:admin" ], [ "argocd-rbac-cm scopes", "[groups]" ]
275
+ ]
276
+ },
277
+ {
278
+ key: "synology", name: "Synology DSM",
279
+ redirects: [ "{base}", "{base}/" ],
280
+ scopes: %w[openid profile email],
281
+ guide: "https://kb.synology.com/en-global/DSM/help/DSM/AdminCenter/connection_sso_client",
282
+ settings: [
283
+ [ "Profile", "OIDC" ], [ "Name", "{tenant}" ], [ "Well Known URL", "{discovery}" ],
284
+ [ "Client ID", "{client_id}" ], [ "Client secret", "{client_secret}" ],
285
+ [ "Scope", "openid profile email" ], [ "Username claim", "preferred_username" ], [ "Redirect URI", "{base}" ]
286
+ ]
185
287
  }
186
288
  ].map(&:freeze).freeze
187
289
 
@@ -0,0 +1,41 @@
1
+ require "socket"
2
+ require "zlib"
3
+
4
+ module Masks
5
+ module Server
6
+ class BootTask
7
+ WINDOW = 10.minutes
8
+
9
+ Outcome = Struct.new(:name, :host, :value, :done) do
10
+ def done? = done
11
+ end
12
+
13
+ class << self
14
+ def once(name, fingerprint: nil)
15
+ key = "masks:boot:#{name}:#{Digest::SHA256.hexdigest(fingerprint.to_s)[0, 16]}"
16
+ host = Socket.gethostname
17
+ outcome = nil
18
+
19
+ ActiveRecord::Base.transaction do
20
+ ActiveRecord::Base.connection.execute("SELECT pg_advisory_xact_lock(#{lock_id(name)})")
21
+ done_by = ::Rails.cache.read(key)
22
+
23
+ if done_by
24
+ outcome = Outcome.new(name, done_by, nil, false)
25
+ else
26
+ value = yield
27
+ ::Rails.cache.write(key, host, expires_in: WINDOW)
28
+ outcome = Outcome.new(name, host, value, true)
29
+ end
30
+ end
31
+
32
+ outcome
33
+ end
34
+
35
+ def lock_id(name)
36
+ Zlib.crc32("masks:boot:#{name}")
37
+ end
38
+ end
39
+ end
40
+ end
41
+ 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?