masks-server 0.11.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 (155) 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/forward_auth_controller.rb +104 -12
  6. data/app/controllers/masks/server/logins_controller.rb +4 -3
  7. data/app/controllers/masks/server/organization_access_controller.rb +39 -0
  8. data/app/graphql/masks/server/manage/mutations/base_mutation.rb +7 -0
  9. data/app/graphql/masks/server/manage/mutations/create_group.rb +6 -4
  10. data/app/graphql/masks/server/manage/mutations/decline_access.rb +27 -0
  11. data/app/graphql/masks/server/manage/mutations/grant_access.rb +29 -0
  12. data/app/graphql/masks/server/manage/mutations/group_source.rb +34 -0
  13. data/app/graphql/masks/server/manage/mutations/issue_ldap_credential.rb +5 -2
  14. data/app/graphql/masks/server/manage/mutations/refresh_group.rb +21 -0
  15. data/app/graphql/masks/server/manage/mutations/remove_group_member.rb +2 -0
  16. data/app/graphql/masks/server/manage/mutations/run_health_checks.rb +19 -0
  17. data/app/graphql/masks/server/manage/mutations/update_client.rb +6 -0
  18. data/app/graphql/masks/server/manage/mutations/update_group.rb +7 -4
  19. data/app/graphql/masks/server/manage/mutations/update_tenant.rb +14 -1
  20. data/app/graphql/masks/server/manage/types/access_request_type.rb +21 -0
  21. data/app/graphql/masks/server/manage/types/actor_type.rb +6 -0
  22. data/app/graphql/masks/server/manage/types/client_type.rb +12 -0
  23. data/app/graphql/masks/server/manage/types/group_condition_input.rb +16 -0
  24. data/app/graphql/masks/server/manage/types/group_condition_type.rb +29 -0
  25. data/app/graphql/masks/server/manage/types/group_member_type.rb +16 -0
  26. data/app/graphql/masks/server/manage/types/group_type.rb +17 -1
  27. data/app/graphql/masks/server/manage/types/health_check_type.rb +18 -0
  28. data/app/graphql/masks/server/manage/types/ldap_credential_type.rb +1 -0
  29. data/app/graphql/masks/server/manage/types/mutation_type.rb +4 -0
  30. data/app/graphql/masks/server/manage/types/query_type.rb +24 -0
  31. data/app/graphql/masks/server/manage/types/release_type.rb +16 -0
  32. data/app/graphql/masks/server/manage/types/tenant_type.rb +6 -1
  33. data/app/helpers/masks/server/account_helper.rb +4 -0
  34. data/app/jobs/masks/server/cleanup_job.rb +1 -0
  35. data/app/jobs/masks/server/health_check_job.rb +21 -0
  36. data/app/jobs/masks/server/refresh_groups_job.rb +18 -0
  37. data/app/jobs/masks/server/release_check_job.rb +21 -0
  38. data/app/ldap/masks/server/ldap/connection.rb +3 -2
  39. data/app/ldap/masks/server/ldap/gate.rb +31 -4
  40. data/app/logins/masks/server/login_states/second_factor.rb +1 -1
  41. data/app/mailers/masks/server/actor_mailer.rb +31 -0
  42. data/app/models/masks/server/access_request.rb +103 -0
  43. data/app/models/masks/server/actor.rb +6 -0
  44. data/app/models/masks/server/app_password.rb +91 -0
  45. data/app/models/masks/server/app_passwords.rb +82 -0
  46. data/app/models/masks/server/client.rb +15 -0
  47. data/app/models/masks/server/connection.rb +7 -0
  48. data/app/models/masks/server/declared.rb +10 -2
  49. data/app/models/masks/server/email_verification.rb +2 -2
  50. data/app/models/masks/server/event.rb +11 -1
  51. data/app/models/masks/server/federation/protocol.rb +1 -1
  52. data/app/models/masks/server/federation/saml.rb +1 -1
  53. data/app/models/masks/server/forward_auth_rules.rb +162 -0
  54. data/app/models/masks/server/group.rb +207 -4
  55. data/app/models/masks/server/group_member.rb +7 -0
  56. data/app/models/masks/server/group_rule.rb +63 -0
  57. data/app/models/masks/server/health.rb +243 -0
  58. data/app/models/masks/server/health_check.rb +27 -0
  59. data/app/models/masks/server/invitation.rb +2 -2
  60. data/app/models/masks/server/ldap_credential.rb +2 -2
  61. data/app/models/masks/server/login.rb +28 -2
  62. data/app/models/masks/server/mail_previews.rb +5 -0
  63. data/app/models/masks/server/mail_template.rb +2 -0
  64. data/app/models/masks/server/membership.rb +10 -3
  65. data/app/models/masks/server/notifications.rb +3 -1
  66. data/app/models/masks/server/password_reset.rb +2 -2
  67. data/app/models/masks/server/provider.rb +1 -0
  68. data/app/models/masks/server/queue.rb +60 -0
  69. data/app/models/masks/server/release.rb +74 -0
  70. data/app/models/masks/server/session.rb +8 -0
  71. data/app/models/masks/server/tenant.rb +39 -2
  72. data/app/models/masks/server/token.rb +2 -1
  73. data/app/views/masks/server/account/_app_passwords.html.erb +83 -0
  74. data/app/views/masks/server/account/_organizations.html.erb +33 -0
  75. data/app/views/masks/server/account/index.html.erb +5 -0
  76. data/app/views/masks/server/actor_mailer/access_declined.html.erb +5 -0
  77. data/app/views/masks/server/actor_mailer/access_declined.text.erb +5 -0
  78. data/app/views/masks/server/actor_mailer/access_granted.html.erb +7 -0
  79. data/app/views/masks/server/actor_mailer/access_granted.text.erb +7 -0
  80. data/app/views/masks/server/authorize/error.html.erb +25 -0
  81. data/config/locales/de/access_requests.yml +43 -0
  82. data/config/locales/de/account/app_passwords.yml +42 -0
  83. data/config/locales/de/events.yml +5 -0
  84. data/config/locales/de/flashes.yml +1 -0
  85. data/config/locales/de/group_sources.yml +5 -0
  86. data/config/locales/de/manage/access_requests.yml +12 -0
  87. data/config/locales/de/manage/app_passwords.yml +16 -0
  88. data/config/locales/de/manage/forward_auth_rules.yml +29 -0
  89. data/config/locales/de/manage/group_sources.yml +44 -0
  90. data/config/locales/de/manage/health.yml +78 -0
  91. data/config/locales/de/manage/links.yml +20 -0
  92. data/config/locales/en/access_requests.yml +43 -0
  93. data/config/locales/en/account/app_passwords.yml +42 -0
  94. data/config/locales/en/events.yml +5 -0
  95. data/config/locales/en/flashes.yml +1 -0
  96. data/config/locales/en/group_sources.yml +5 -0
  97. data/config/locales/en/manage/access_requests.yml +12 -0
  98. data/config/locales/en/manage/app_passwords.yml +16 -0
  99. data/config/locales/en/manage/forward_auth_rules.yml +29 -0
  100. data/config/locales/en/manage/group_sources.yml +44 -0
  101. data/config/locales/en/manage/health.yml +78 -0
  102. data/config/locales/en/manage/links.yml +20 -0
  103. data/config/locales/es/access_requests.yml +43 -0
  104. data/config/locales/es/account/app_passwords.yml +42 -0
  105. data/config/locales/es/events.yml +5 -0
  106. data/config/locales/es/flashes.yml +1 -0
  107. data/config/locales/es/group_sources.yml +5 -0
  108. data/config/locales/es/manage/access_requests.yml +12 -0
  109. data/config/locales/es/manage/app_passwords.yml +16 -0
  110. data/config/locales/es/manage/forward_auth_rules.yml +29 -0
  111. data/config/locales/es/manage/group_sources.yml +44 -0
  112. data/config/locales/es/manage/health.yml +78 -0
  113. data/config/locales/es/manage/links.yml +20 -0
  114. data/config/locales/fr/access_requests.yml +43 -0
  115. data/config/locales/fr/account/app_passwords.yml +42 -0
  116. data/config/locales/fr/events.yml +5 -0
  117. data/config/locales/fr/flashes.yml +1 -0
  118. data/config/locales/fr/group_sources.yml +5 -0
  119. data/config/locales/fr/manage/access_requests.yml +12 -0
  120. data/config/locales/fr/manage/app_passwords.yml +16 -0
  121. data/config/locales/fr/manage/forward_auth_rules.yml +29 -0
  122. data/config/locales/fr/manage/group_sources.yml +44 -0
  123. data/config/locales/fr/manage/health.yml +78 -0
  124. data/config/locales/fr/manage/links.yml +20 -0
  125. data/config/routes.rb +5 -0
  126. data/db/migrate/20261020100000_create_access_requests.rb +28 -0
  127. data/db/migrate/20261020110000_add_app_passwords_to_clients.rb +5 -0
  128. data/db/migrate/20261020120000_add_forward_auth_rules_to_clients.rb +6 -0
  129. data/db/migrate/20261020130000_add_sources_to_groups.rb +15 -0
  130. data/db/migrate/20261020140000_create_health_checks.rb +25 -0
  131. data/db/migrate/20261020150000_add_link_lifetimes_to_tenants.rb +8 -0
  132. data/db/migrate/20261021100000_add_pinned_origin_to_tenants.rb +5 -0
  133. data/lib/generators/masks/server/install/install_generator.rb +3 -2
  134. data/lib/masks/server/configuration.rb +2 -11
  135. data/lib/masks/server/schema_reference.rb +12 -0
  136. data/lib/masks/server/tenancy/middleware.rb +17 -6
  137. data/public/masks-assets/.vite/manifest.json +13 -13
  138. data/public/masks-assets/assets/{account-D46qDQCo.js → account-h7I6VUPR.js} +2 -2
  139. data/public/masks-assets/assets/{account-D46qDQCo.js.map → account-h7I6VUPR.js.map} +1 -1
  140. data/public/masks-assets/assets/{app-D7W0gAyT.css → app-BXOScMjL.css} +1 -1
  141. data/public/masks-assets/assets/{handshake-DIFZPSAK.js → handshake-YQpRNMIs.js} +2 -2
  142. data/public/masks-assets/assets/{handshake-DIFZPSAK.js.map → handshake-YQpRNMIs.js.map} +1 -1
  143. data/public/masks-assets/assets/initial-Cn-UuAsy.js +3 -0
  144. data/public/masks-assets/assets/{initial-C1GpCiEh.js.map → initial-Cn-UuAsy.js.map} +1 -1
  145. data/public/masks-assets/assets/{login-C_6igRZ3.js → login-C21_jb6L.js} +3 -3
  146. data/public/masks-assets/assets/{login-C_6igRZ3.js.map → login-C21_jb6L.js.map} +1 -1
  147. data/public/masks-assets/assets/{manage-D4MzaDvM.js → manage-DpVxe5sv.js} +80 -51
  148. data/public/masks-assets/assets/manage-DpVxe5sv.js.map +1 -0
  149. data/public/masks-assets/assets/passkey-5UYPVD3c.js +2 -0
  150. data/public/masks-assets/assets/{passkey-DIj_5LCY.js.map → passkey-5UYPVD3c.js.map} +1 -1
  151. data/version.txt +1 -1
  152. metadata +87 -14
  153. data/public/masks-assets/assets/initial-C1GpCiEh.js +0 -3
  154. data/public/masks-assets/assets/manage-D4MzaDvM.js.map +0 -1
  155. data/public/masks-assets/assets/passkey-DIj_5LCY.js +0 -2
@@ -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
@@ -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?
@@ -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
 
@@ -47,12 +48,19 @@ module Masks
47
48
  def apply_tenant(tenant, declared)
48
49
  known!(declared, TENANT_KEYS, tenant.subdomain)
49
50
 
50
- tenant.update!(declared.slice("name", "accent_color").compact) if declared.key?("name") || declared.key?("accent_color")
51
+ settings = declared.slice(*TENANT_SETTINGS).compact
52
+ apply_settings(tenant, settings) if settings.any?
51
53
 
52
54
  Array(declared["groups"]).each { |key, group| apply_group(key, group || {}) }
53
55
  Array(declared["apps"]).filter_map { |key, app| apply_app(tenant, key, app || {}) }
54
56
  end
55
57
 
58
+ def apply_settings(tenant, settings)
59
+ tenant.update!(settings)
60
+ rescue ActiveRecord::RecordInvalid => e
61
+ raise Invalid, "#{tenant.subdomain}: #{e.record.errors.full_messages.to_sentence}"
62
+ end
63
+
56
64
  def apply_group(key, declared)
57
65
  group = Group.find_or_initialize_by(key: key)
58
66
  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)
@@ -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
184
  LOGIN_REFUSED, LOGIN_THROTTLED, 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