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
@@ -20,6 +20,8 @@ module Masks
20
20
 
21
21
  scope :live, -> { where(revoked_at: nil) }
22
22
 
23
+ after_commit :settle_sourced_groups, if: -> { saved_changes.keys.intersect?(%w[upstream_groups revoked_at actor_id]) }
24
+
23
25
  class << self
24
26
  def record!(provider:, actor:, identity:)
25
27
  subject = identity["sub"].presence ||
@@ -40,6 +42,7 @@ module Masks
40
42
  end
41
43
 
42
44
  def absorb_identity(identity)
45
+ self.upstream_groups = Array(identity[Provider::GROUPS]).map(&:to_s).compact_blank.uniq
43
46
  held = identity["email"].to_s.strip.downcase.presence
44
47
 
45
48
  return self if held.nil?
@@ -125,6 +128,10 @@ module Masks
125
128
 
126
129
  private
127
130
 
131
+ def settle_sourced_groups
132
+ Group.filled_by(provider).find_each { |group| group.settle!(actor) }
133
+ end
134
+
128
135
  def store_tokens!(tokens, **attributes)
129
136
  update!(access_token: tokens["access_token"], access_token_expires_at: expiry_from(tokens),
130
137
  tokens_refreshed_at: Time.current, **attributes)
@@ -3,7 +3,8 @@ module Masks
3
3
  class Declared
4
4
  class Invalid < StandardError; end
5
5
 
6
- TENANT_KEYS = %w[name accent_color groups apps].freeze
6
+ TENANT_SETTINGS = (%w[name accent_color] + Tenant::LINK_LIFETIMES.keys.map(&:to_s)).freeze
7
+ TENANT_KEYS = (TENANT_SETTINGS + %w[groups apps]).freeze
7
8
  APP_KEYS = %w[preset url slug name client_id client_secret_env redirect_uris scopes allowed_groups
8
9
  access_token_lifetime refresh_token_lifetime refresh_token_absolute_lifetime].freeze
9
10
 
@@ -20,7 +21,7 @@ module Masks
20
21
  def initialize(document)
21
22
  raise Invalid, "the declared configuration must be a mapping" unless document.is_a?(Hash)
22
23
 
23
- @document = document
24
+ @document = document.except("limits")
24
25
  end
25
26
 
26
27
  def apply!
@@ -32,6 +33,8 @@ module Masks
32
33
  private
33
34
 
34
35
  def tenants
36
+ return [] if @document.empty?
37
+
35
38
  if @document.key?("tenants")
36
39
  @document.fetch("tenants").map do |subdomain, declared|
37
40
  [ Tenant.active.find_by(subdomain: subdomain) || raise(Invalid, "no tenant called #{subdomain}"), declared || {} ]
@@ -47,12 +50,19 @@ module Masks
47
50
  def apply_tenant(tenant, declared)
48
51
  known!(declared, TENANT_KEYS, tenant.subdomain)
49
52
 
50
- tenant.update!(declared.slice("name", "accent_color").compact) if declared.key?("name") || declared.key?("accent_color")
53
+ settings = declared.slice(*TENANT_SETTINGS).compact
54
+ apply_settings(tenant, settings) if settings.any?
51
55
 
52
56
  Array(declared["groups"]).each { |key, group| apply_group(key, group || {}) }
53
57
  Array(declared["apps"]).filter_map { |key, app| apply_app(tenant, key, app || {}) }
54
58
  end
55
59
 
60
+ def apply_settings(tenant, settings)
61
+ tenant.update!(settings)
62
+ rescue ActiveRecord::RecordInvalid => e
63
+ raise Invalid, "#{tenant.subdomain}: #{e.record.errors.full_messages.to_sentence}"
64
+ end
65
+
56
66
  def apply_group(key, declared)
57
67
  group = Group.find_or_initialize_by(key: key)
58
68
  group.update!(name: declared["name"] || group.name || key.to_s.humanize)
@@ -5,8 +5,8 @@ module Masks
5
5
 
6
6
  path "verify"
7
7
 
8
- def self.lifetime
9
- ::Rails.configuration.masks.email_verification_lifetime
8
+ def self.lifetime(tenant = Current.tenant)
9
+ Tenant.link_lifetime(:email_verification_lifetime, tenant)
10
10
  end
11
11
 
12
12
  def self.settle!(secret)
@@ -14,7 +14,7 @@ module Masks
14
14
  SESSION_EXPIRED = "session.expired".freeze
15
15
  LOGOUT_UNDELIVERED = "logout.undelivered".freeze
16
16
  LOGIN_REFUSED = "login.refused".freeze
17
- LOGIN_THROTTLED = "login.throttled".freeze
17
+ LIMIT_REACHED = "limit.reached".freeze
18
18
 
19
19
  ACCOUNT_CREATED = "account.created".freeze
20
20
  ACCOUNT_EXPORTED = "account.exported".freeze
@@ -54,6 +54,10 @@ module Masks
54
54
  GROUP_DELETED = "group.deleted".freeze
55
55
  GROUP_JOINED = "group.member_added".freeze
56
56
  GROUP_LEFT = "group.member_removed".freeze
57
+ ACCESS_REQUESTED = "access.requested".freeze
58
+ ACCESS_GRANTED = "access.granted".freeze
59
+ ACCESS_DECLINED = "access.declined".freeze
60
+ GROUP_REFRESHED = "group.refreshed".freeze
57
61
 
58
62
  DEVICE_CODE_ISSUED = "device_code.issued".freeze
59
63
  DEVICE_CODE_APPROVED = "device_code.approved".freeze
@@ -98,6 +102,9 @@ module Masks
98
102
  LDAP_CREDENTIAL_ISSUED = "ldap_credential.issued".freeze
99
103
  LDAP_CREDENTIAL_REVOKED = "ldap_credential.revoked".freeze
100
104
  LDAP_BOUND = "ldap.bound".freeze
105
+ APP_PASSWORD_CREATED = "app_password.created".freeze
106
+ APP_PASSWORD_USED = "app_password.used".freeze
107
+ APP_PASSWORD_REVOKED = "app_password.revoked".freeze
101
108
 
102
109
  CLIENT_CREATED = "client.created".freeze
103
110
  CLIENT_REGISTERED = "client.registered".freeze
@@ -169,12 +176,15 @@ module Masks
169
176
  CUSTOM_DOMAIN_STOPPED = "custom_domain.stopped".freeze
170
177
 
171
178
  JOB_FAILED = "job.failed".freeze
179
+ HEALTH_FAILING = "health.failing".freeze
180
+ HEALTH_RECOVERED = "health.recovered".freeze
181
+ RELEASE_AVAILABLE = "release.available".freeze
172
182
 
173
183
  GRAVE = [
174
- LOGIN_REFUSED, LOGIN_THROTTLED, LOGOUT_UNDELIVERED, REFRESH_REUSED, STREAM_FAILED,
184
+ LOGIN_REFUSED, LIMIT_REACHED, LOGOUT_UNDELIVERED, REFRESH_REUSED, STREAM_FAILED,
175
185
  DEVICE_BLOCKED, ACTOR_DELETED, AUTHENTICATOR_DISABLED, CONNECTION_REFUSED, DELEGATION_REFUSED,
176
186
  EXCHANGE_REFUSED, SIGN_IN_RISKY, SIGNAL_UNDELIVERED, TEXT_UNDELIVERED, SIGNAL_RECEIVED, MEMBERSHIP_ROLE_KEPT,
177
- ORGANIZATION_OWNERLESS, JOB_FAILED
187
+ ORGANIZATION_OWNERLESS, JOB_FAILED, HEALTH_FAILING
178
188
  ].freeze
179
189
 
180
190
  ACTIONS = constants(false).filter_map do |name|
@@ -86,7 +86,7 @@ module Masks
86
86
  end
87
87
 
88
88
  held["sub"] = dig(raw, map["sub"])&.to_s.presence
89
- held[Provider::GROUPS] = Array(groups(raw)).map(&:to_s).presence if provider.organization_id
89
+ held[Provider::GROUPS] = Array(groups(raw)).map(&:to_s).presence
90
90
  held.compact
91
91
  end
92
92
 
@@ -104,7 +104,7 @@ module Masks
104
104
 
105
105
  held["sub"] = map["sub"] == NAME_ID ? response.name_id.to_s.presence : attribute(attributes, map["sub"])&.to_s.presence
106
106
  held["email"] = held["email"].to_s.strip.downcase.presence
107
- held[Provider::GROUPS] = Array(attributes.multi(provider.group_claim)).map(&:to_s).presence if provider.organization_id
107
+ held[Provider::GROUPS] = Array(attributes.multi(provider.group_claim)).map(&:to_s).presence
108
108
 
109
109
  held.compact
110
110
  end
@@ -0,0 +1,162 @@
1
+ module Masks
2
+ module Server
3
+ class ForwardAuthRules
4
+ OPEN = "open".freeze
5
+ ONE_FACTOR = "one_factor".freeze
6
+ TWO_FACTOR = "two_factor".freeze
7
+ TOKEN = "token".freeze
8
+ DENY = "deny".freeze
9
+ OUTCOMES = [ OPEN, ONE_FACTOR, TWO_FACTOR, TOKEN, DENY ].freeze
10
+
11
+ SIGN_IN = "sign_in".freeze
12
+ DEFAULT = "default".freeze
13
+
14
+ METHODS = %w[GET HEAD POST PUT PATCH DELETE OPTIONS].freeze
15
+ LISTS = %w[paths methods networks groups].freeze
16
+ FIELDS = (LISTS + [ "outcome" ]).freeze
17
+ MOST_RULES = 50
18
+ MOST_ENTRIES = 20
19
+ PATH = %r{\A/[^\s?#]{0,511}\z}
20
+
21
+ Decision = Struct.new(:outcome, :rule, :index)
22
+
23
+ class Rule
24
+ attr_reader :paths, :methods, :networks, :groups, :outcome
25
+
26
+ def initialize(held)
27
+ @paths = Array(held["paths"])
28
+ @methods = Array(held["methods"])
29
+ @networks = Array(held["networks"]).filter_map { |range| ForwardAuthRules.network(range) }
30
+ @groups = Array(held["groups"])
31
+ @outcome = held["outcome"].to_s
32
+ end
33
+
34
+ def covers?(path, method, address)
35
+ covers_path?(path) && covers_method?(method) && covers_address?(address)
36
+ end
37
+
38
+ def gated?
39
+ groups.any?
40
+ end
41
+
42
+ def admits?(actor)
43
+ !gated? || actor.group_labels.intersect?(groups)
44
+ end
45
+
46
+ private
47
+
48
+ def covers_path?(path)
49
+ paths.empty? || patterns.any? { |pattern| pattern.match?(path) }
50
+ end
51
+
52
+ def covers_method?(method)
53
+ methods.empty? || methods.include?(method)
54
+ end
55
+
56
+ def covers_address?(address)
57
+ return true if networks.empty?
58
+
59
+ held = ForwardAuthRules.address(address)
60
+ held.present? && networks.any? { |network| network.include?(held) }
61
+ end
62
+
63
+ def patterns
64
+ @patterns ||= paths.map { |path| ForwardAuthRules.pattern(path) }
65
+ end
66
+ end
67
+
68
+ class << self
69
+ def normalize(value)
70
+ Array(value).map do |held|
71
+ next held unless held.is_a?(Hash)
72
+
73
+ held = held.to_h.transform_keys(&:to_s)
74
+ lists = LISTS.to_h { |list| [ list, Array(held[list]).map { |entry| entry.to_s.strip }.compact_blank.uniq ] }
75
+ lists["methods"] = lists["methods"].map(&:upcase)
76
+ lists["groups"] = lists["groups"].map(&:downcase)
77
+
78
+ held.except(*FIELDS).merge(lists, "outcome" => held["outcome"].to_s.strip.downcase)
79
+ end
80
+ end
81
+
82
+ def problems(rules)
83
+ return [ "forward auth rules must be a list of rules" ] unless rules.is_a?(Array)
84
+ return [ "forward auth rules hold more than #{MOST_RULES} rules" ] if rules.size > MOST_RULES
85
+
86
+ rules.flat_map.with_index(1) { |rule, position| problems_in(rule, position) }
87
+ end
88
+
89
+ def pattern(path)
90
+ source = path.split("**", -1).map { |piece| piece.split("*", -1).map { |held| Regexp.escape(held) }.join("[^/]*") }.join(".*")
91
+ source = "#{source.delete_suffix('/.*')}(/.*)?" if path.end_with?("/**")
92
+
93
+ /\A#{source}\z/
94
+ end
95
+
96
+ def network(range)
97
+ IPAddr.new(range.to_s)
98
+ rescue IPAddr::Error
99
+ nil
100
+ end
101
+
102
+ def address(value)
103
+ held = IPAddr.new(value.to_s.split("%").first.to_s)
104
+ held.ipv4_mapped? ? held.native : held
105
+ rescue IPAddr::Error
106
+ nil
107
+ end
108
+
109
+ def path(value)
110
+ decoded = URI::RFC2396_PARSER.unescape(value.to_s)
111
+ cleaned = Pathname.new(decoded.start_with?("/") ? decoded : "/#{decoded}").cleanpath.to_s
112
+
113
+ cleaned.start_with?("/") ? cleaned : "/"
114
+ end
115
+
116
+ private
117
+
118
+ def problems_in(rule, position)
119
+ named = "forward auth rule #{position}"
120
+ return [ "#{named} is not a set of fields" ] unless rule.is_a?(Hash)
121
+
122
+ unknown = rule.keys - FIELDS
123
+ return [ "#{named} names #{unknown.join(', ')}, which is not a field" ] if unknown.any?
124
+
125
+ [
126
+ ("#{named} has an outcome of #{rule['outcome'].inspect}, which is not one of #{OUTCOMES.join(', ')}" unless OUTCOMES.include?(rule["outcome"])),
127
+ *LISTS.map { |list| ("#{named} lists more than #{MOST_ENTRIES} #{list}" if rule[list].size > MOST_ENTRIES) },
128
+ *rule["paths"].grep_v(PATH).map { |path| "#{named} has a path of #{path.inspect}, which does not start with /" },
129
+ *(rule["methods"] - METHODS).map { |method| "#{named} names a method of #{method.inspect}, which is not one of #{METHODS.join(', ')}" },
130
+ *rule["networks"].reject { |range| network(range) }.map { |range| "#{named} has a network of #{range.inspect}, which is not an address or a range such as 192.168.0.0/16" },
131
+ *rule["groups"].grep_v(Client::ALLOWED_GROUP).map { |group| "#{named} names a group of #{group.inspect}, which is not a group key or key:role" }
132
+ ].compact
133
+ end
134
+ end
135
+
136
+ attr_reader :rules
137
+
138
+ def initialize(held)
139
+ @rules = Array(held).select { |rule| rule.is_a?(Hash) }.map { |rule| Rule.new(rule) }
140
+ end
141
+
142
+ def any?
143
+ rules.any?
144
+ end
145
+
146
+ def decide(path:, method:, address:, actor: nil)
147
+ cleaned = self.class.path(path)
148
+ verb = method.to_s.upcase.presence || "GET"
149
+
150
+ rules.each_with_index do |rule, index|
151
+ next unless rule.covers?(cleaned, verb, address)
152
+ return Decision.new(SIGN_IN, rule, index) if rule.gated? && actor.nil?
153
+ next unless rule.admits?(actor)
154
+
155
+ return Decision.new(rule.outcome, rule, index)
156
+ end
157
+
158
+ Decision.new(DEFAULT, nil, nil)
159
+ end
160
+ end
161
+ end
162
+ end
@@ -3,29 +3,232 @@ module Masks
3
3
  class Group < ApplicationRecord
4
4
  include TenantScoped
5
5
 
6
+ class Sourced < StandardError; end
7
+
6
8
  KEY = /\A[a-z0-9][a-z0-9._-]*\z/
9
+ MANUAL = "manual".freeze
10
+ RULE = "rule".freeze
11
+ PROVIDER = "provider".freeze
12
+ NESTED = "group".freeze
13
+ SOURCES = [ MANUAL, RULE, PROVIDER, NESTED ].freeze
14
+ SOURCED = [ RULE, PROVIDER, NESTED ].freeze
15
+ SETTINGS = %w[source rule provider_id upstream_group parent_labels].freeze
16
+ WATCHED = (%w[email email_verified_at] + GroupRule::FIELDS).freeze
17
+ PARENTS = 10
18
+ LONGEST = 200
7
19
 
20
+ belongs_to :provider, optional: true
8
21
  has_many :members, class_name: "GroupMember", dependent: :delete_all
9
22
  has_many :actors, through: :members
10
23
 
24
+ scope :sourced, -> { where(source: SOURCED) }
25
+ scope :ruled, -> { where(source: RULE) }
26
+ scope :filled_by, ->(provider) { where(source: PROVIDER, provider_id: provider.id) }
27
+ scope :nested_under, ->(label) { where(source: NESTED).where("groups.parent_labels @> ?", [ label ].to_json) }
28
+ scope :nested_under_organization, lambda { |key|
29
+ where(source: NESTED).where(
30
+ "EXISTS (SELECT 1 FROM jsonb_array_elements_text(groups.parent_labels) AS label WHERE label LIKE ?)",
31
+ "#{sanitize_sql_like(key)}:%"
32
+ )
33
+ }
34
+
11
35
  normalizes :key, with: ->(value) { value.to_s.strip.downcase }
12
36
  normalizes :name, with: ->(value) { value.to_s.strip }
37
+ normalizes :source, with: ->(value) { value.to_s.strip.downcase.presence || MANUAL }
38
+ normalizes :rule, with: ->(value) { GroupRule.normalize(value) }
39
+ normalizes :upstream_group, with: ->(value) { value.to_s.strip.presence }
40
+ normalizes :parent_labels, with: ->(value) { Array(value).map { |label| label.to_s.strip.downcase }.compact_blank.uniq.sort }
13
41
 
14
42
  validates :key, presence: true, format: { with: KEY }, length: { maximum: 64 },
15
43
  uniqueness: { scope: :tenant_id }
16
44
  validates :name, presence: true, length: { maximum: 100 }
45
+ validates :source, inclusion: { in: SOURCES }
46
+ validate :source_is_complete
47
+
48
+ after_commit :refresh_after_change, on: %i[create update], if: -> { saved_changes.keys.intersect?(SETTINGS) }
49
+ after_commit :refresh_children, on: :destroy
50
+
51
+ def sourced?
52
+ SOURCED.include?(source)
53
+ end
54
+
55
+ def children
56
+ Group.nested_under(key)
57
+ end
17
58
 
18
59
  def add!(actor, by: nil)
19
60
  member = members.find_or_create_by!(actor: actor)
20
- Event.record!(Event::GROUP_JOINED, actor: actor, by: by, group: key) if member.previously_new_record?
61
+
62
+ if member.previously_new_record?
63
+ Event.record!(Event::GROUP_JOINED, actor: actor, by: by, group: key)
64
+ settle_children!(actor, by: by)
65
+ elsif member.sourced?
66
+ member.update!(sourced_by: nil)
67
+ Event.record!(Event::GROUP_JOINED, actor: actor, by: by, group: key, kept: true)
68
+ end
69
+
21
70
  member
22
71
  end
23
72
 
24
73
  def remove!(actor, by: nil)
25
- removed = members.where(actor: actor).delete_all.positive?
26
- Event.record!(Event::GROUP_LEFT, actor: actor, by: by, group: key) if removed
27
- removed
74
+ member = members.find_by(actor: actor)
75
+
76
+ return false if member.nil?
77
+ raise Sourced, "#{actor.identifier} is in #{name} from its #{source}, so the #{source} decides" if member.sourced?
78
+
79
+ member.delete
80
+ Event.record!(Event::GROUP_LEFT, actor: actor, by: by, group: key)
81
+ settle_children!(actor, by: by)
82
+
83
+ true
28
84
  end
85
+
86
+ def refresh!(by: nil, seen: Set.new)
87
+ return self unless seen.add?(id)
88
+
89
+ wanted = sourced? ? wanted_actor_ids : []
90
+ held = members.where(sourced_by: source).pluck(:actor_id)
91
+ pinned = members.manual.pluck(:actor_id)
92
+ joining = wanted - held - pinned
93
+ leaving = (held - wanted) | members.sourced.where.not(sourced_by: source).pluck(:actor_id)
94
+
95
+ transaction do
96
+ members.sourced.where(actor_id: leaving).delete_all if leaving.any?
97
+ members.insert_all(joining.map { |actor_id| { actor_id: actor_id, tenant_id: tenant_id, sourced_by: source } }) if joining.any?
98
+ update_column(:refreshed_at, Time.current)
99
+ end
100
+
101
+ return self if joining.empty? && leaving.empty?
102
+
103
+ Event.record!(Event::GROUP_REFRESHED, by: by, group: key, source: source, added: joining.size, removed: leaving.size)
104
+ children.find_each { |child| child.refresh!(by: by, seen: seen) }
105
+
106
+ self
107
+ end
108
+
109
+ def settle!(actor, by: nil, seen: Set.new)
110
+ return unless seen.add?(id)
111
+
112
+ member = members.find_by(actor: actor)
113
+
114
+ return if member && !member.sourced?
115
+
116
+ if sourced? && wants?(actor)
117
+ return if member&.sourced_by == source
118
+
119
+ member ? member.update!(sourced_by: source) : members.create!(actor: actor, sourced_by: source)
120
+ Event.record!(Event::GROUP_JOINED, actor: actor, by: by, group: key, source: source) if member.nil?
121
+ elsif member
122
+ member.delete
123
+ Event.record!(Event::GROUP_LEFT, actor: actor, by: by, group: key, source: member.sourced_by)
124
+ else
125
+ return
126
+ end
127
+
128
+ children.find_each { |child| child.settle!(actor, by: by, seen: seen) }
129
+ end
130
+
131
+ def wants?(actor)
132
+ case source
133
+ when RULE then GroupRule.scope(rule).exists?(id: actor.id)
134
+ when PROVIDER then provider_connections.exists?(actor_id: actor.id)
135
+ when NESTED then parent_members.exists?(actor_id: actor.id) || parent_memberships.exists?(actor_id: actor.id)
136
+ else false
137
+ end
138
+ end
139
+
140
+ def parent_keys
141
+ parent_labels.grep_v(/:/)
142
+ end
143
+
144
+ private
145
+
146
+ def wanted_actor_ids
147
+ case source
148
+ when RULE then GroupRule.scope(rule).pluck(:id)
149
+ when PROVIDER then provider_connections.distinct.pluck(:actor_id)
150
+ when NESTED then parent_members.distinct.pluck(:actor_id) | parent_memberships.distinct.pluck(:actor_id)
151
+ else []
152
+ end
153
+ end
154
+
155
+ def provider_connections
156
+ return Connection.none if provider_id.nil?
157
+
158
+ Connection.live.where(provider_id: provider_id).where("connections.upstream_groups @> ?", [ upstream_group ].to_json)
159
+ end
160
+
161
+ def parent_members
162
+ GroupMember.joins(:group).where(groups: { key: parent_keys })
163
+ end
164
+
165
+ def parent_memberships
166
+ roles = parent_labels.grep(/:/).map { |label| label.split(":", 2) }
167
+
168
+ return Membership.none if roles.empty?
169
+
170
+ held = roles.map do |key, role|
171
+ Membership.sanitize_sql_array([ "(organizations.key = ? AND memberships.role = ?)", key, role ])
172
+ end
173
+
174
+ Membership.accepted.joins(:organization).merge(Organization.active).where(held.join(" OR "))
175
+ end
176
+
177
+ def settle_children!(actor, by:)
178
+ children.find_each { |child| child.settle!(actor, by: by) }
179
+ end
180
+
181
+ def refresh_after_change
182
+ refresh!
183
+ end
184
+
185
+ def refresh_children
186
+ children.find_each(&:refresh!)
187
+ end
188
+
189
+ def source_is_complete
190
+ case source
191
+ when RULE then GroupRule.problems(rule).each { |problem| errors.add(:rule, problem) }
192
+ when PROVIDER then provider_is_complete
193
+ when NESTED then parents_are_complete
194
+ end
195
+ end
196
+
197
+ def provider_is_complete
198
+ errors.add(:provider, "is needed to fill a group from one") if provider.nil?
199
+ errors.add(:upstream_group, "names the group at the provider") if upstream_group.blank? || upstream_group.length > LONGEST
200
+ end
201
+
202
+ def parents_are_complete
203
+ return errors.add(:parent_labels, "name at least one group or organization role") if parent_labels.empty?
204
+ return errors.add(:parent_labels, "name at most #{PARENTS}") if parent_labels.size > PARENTS
205
+
206
+ parent_labels.grep_v(Client::ALLOWED_GROUP).each do |label|
207
+ errors.add(:parent_labels, "has #{label}, which is not a group key or key:role")
208
+ end
209
+
210
+ (parent_keys - Group.where(key: parent_keys).pluck(:key)).each do |missing|
211
+ errors.add(:parent_labels, "name #{missing}, which is not a group")
212
+ end
213
+
214
+ errors.add(:parent_labels, "would put #{name} inside itself") if cycles?
215
+ end
216
+
217
+ def cycles?
218
+ pending = parent_keys.dup
219
+ seen = Set.new
220
+
221
+ until pending.empty?
222
+ held = pending.shift
223
+
224
+ return true if held == key
225
+ next unless seen.add?(held)
226
+
227
+ pending.concat(Array(Group.where(key: held, source: NESTED).pick(:parent_labels)).grep_v(/:/))
228
+ end
229
+
230
+ false
231
+ end
29
232
  end
30
233
  end
31
234
  end
@@ -5,6 +5,13 @@ module Masks
5
5
 
6
6
  belongs_to :group
7
7
  belongs_to :actor
8
+
9
+ scope :manual, -> { where(sourced_by: nil) }
10
+ scope :sourced, -> { where.not(sourced_by: nil) }
11
+
12
+ def sourced?
13
+ sourced_by.present?
14
+ end
8
15
  end
9
16
  end
10
17
  end
@@ -0,0 +1,63 @@
1
+ module Masks
2
+ module Server
3
+ module GroupRule
4
+ EMAIL_DOMAIN = "email_domain".freeze
5
+ PROFILE = "profile".freeze
6
+ KINDS = [ EMAIL_DOMAIN, PROFILE ].freeze
7
+ FIELDS = %w[name given_name family_name middle_name nickname gender zoneinfo locale].freeze
8
+ LIMIT = 5
9
+ DOMAIN = /\A[a-z0-9][a-z0-9.-]*\.[a-z]{2,}\z/
10
+ LONGEST = 200
11
+
12
+ class << self
13
+ def normalize(value)
14
+ Array(value).filter_map do |condition|
15
+ next unless condition.respond_to?(:to_h)
16
+
17
+ held = condition.to_h.to_h { |key, item| [ key.to_s, item.to_s.strip ] }
18
+ kind = held["kind"]
19
+ value = kind == EMAIL_DOMAIN ? held["value"].to_s.downcase.delete_prefix("@") : held["value"].to_s
20
+
21
+ { "kind" => kind, "field" => (held["field"] if kind == PROFILE), "value" => value }.compact
22
+ end
23
+ end
24
+
25
+ def problems(conditions)
26
+ return [ "a rule has at most #{LIMIT} conditions" ] if conditions.size > LIMIT
27
+ return [ "a rule needs at least one condition" ] if conditions.empty?
28
+
29
+ conditions.filter_map { |condition| problem(condition) }
30
+ end
31
+
32
+ def scope(conditions)
33
+ conditions.reduce(Actor.all) do |scope, condition|
34
+ case condition["kind"]
35
+ when EMAIL_DOMAIN
36
+ scope.where.not(email_verified_at: nil)
37
+ .where("LOWER(actors.email) LIKE ?", "%@#{Actor.sanitize_sql_like(condition['value'])}")
38
+ when PROFILE
39
+ scope.merge(Actor.directed).where(condition["field"] => condition["value"])
40
+ else
41
+ scope.none
42
+ end
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def problem(condition)
49
+ case condition["kind"]
50
+ when EMAIL_DOMAIN
51
+ "#{condition['value'].inspect} is not a domain" unless condition["value"].match?(DOMAIN)
52
+ when PROFILE
53
+ return "#{condition['field'].inspect} is not a profile field a rule can read" unless FIELDS.include?(condition["field"])
54
+
55
+ "a profile condition needs a value" if condition["value"].blank? || condition["value"].length > LONGEST
56
+ else
57
+ "#{condition['kind'].inspect} is not a kind of condition"
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end