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.
- checksums.yaml +4 -4
- data/app/controllers/masks/server/account_controller.rb +3 -0
- data/app/controllers/masks/server/app_passwords_controller.rb +41 -0
- data/app/controllers/masks/server/authorize_controller.rb +33 -2
- data/app/controllers/masks/server/forward_auth_controller.rb +104 -12
- data/app/controllers/masks/server/logins_controller.rb +4 -3
- data/app/controllers/masks/server/organization_access_controller.rb +39 -0
- data/app/graphql/masks/server/manage/mutations/base_mutation.rb +7 -0
- data/app/graphql/masks/server/manage/mutations/create_group.rb +6 -4
- data/app/graphql/masks/server/manage/mutations/decline_access.rb +27 -0
- data/app/graphql/masks/server/manage/mutations/grant_access.rb +29 -0
- data/app/graphql/masks/server/manage/mutations/group_source.rb +34 -0
- data/app/graphql/masks/server/manage/mutations/issue_ldap_credential.rb +5 -2
- data/app/graphql/masks/server/manage/mutations/refresh_group.rb +21 -0
- data/app/graphql/masks/server/manage/mutations/remove_group_member.rb +2 -0
- data/app/graphql/masks/server/manage/mutations/run_health_checks.rb +19 -0
- data/app/graphql/masks/server/manage/mutations/update_client.rb +6 -0
- data/app/graphql/masks/server/manage/mutations/update_group.rb +7 -4
- data/app/graphql/masks/server/manage/mutations/update_tenant.rb +14 -1
- data/app/graphql/masks/server/manage/types/access_request_type.rb +21 -0
- data/app/graphql/masks/server/manage/types/actor_type.rb +6 -0
- data/app/graphql/masks/server/manage/types/client_type.rb +12 -0
- data/app/graphql/masks/server/manage/types/group_condition_input.rb +16 -0
- data/app/graphql/masks/server/manage/types/group_condition_type.rb +29 -0
- data/app/graphql/masks/server/manage/types/group_member_type.rb +16 -0
- data/app/graphql/masks/server/manage/types/group_type.rb +17 -1
- data/app/graphql/masks/server/manage/types/health_check_type.rb +18 -0
- data/app/graphql/masks/server/manage/types/ldap_credential_type.rb +1 -0
- data/app/graphql/masks/server/manage/types/mutation_type.rb +4 -0
- data/app/graphql/masks/server/manage/types/query_type.rb +24 -0
- data/app/graphql/masks/server/manage/types/release_type.rb +16 -0
- data/app/graphql/masks/server/manage/types/tenant_type.rb +6 -1
- data/app/helpers/masks/server/account_helper.rb +4 -0
- data/app/jobs/masks/server/cleanup_job.rb +1 -0
- data/app/jobs/masks/server/health_check_job.rb +21 -0
- data/app/jobs/masks/server/refresh_groups_job.rb +18 -0
- data/app/jobs/masks/server/release_check_job.rb +21 -0
- data/app/ldap/masks/server/ldap/connection.rb +3 -2
- data/app/ldap/masks/server/ldap/gate.rb +31 -4
- data/app/logins/masks/server/login_states/second_factor.rb +1 -1
- data/app/mailers/masks/server/actor_mailer.rb +31 -0
- data/app/models/masks/server/access_request.rb +103 -0
- data/app/models/masks/server/actor.rb +6 -0
- data/app/models/masks/server/app_password.rb +91 -0
- data/app/models/masks/server/app_passwords.rb +82 -0
- data/app/models/masks/server/client.rb +15 -0
- data/app/models/masks/server/connection.rb +7 -0
- data/app/models/masks/server/declared.rb +10 -2
- data/app/models/masks/server/email_verification.rb +2 -2
- data/app/models/masks/server/event.rb +11 -1
- data/app/models/masks/server/federation/protocol.rb +1 -1
- data/app/models/masks/server/federation/saml.rb +1 -1
- data/app/models/masks/server/forward_auth_rules.rb +162 -0
- data/app/models/masks/server/group.rb +207 -4
- data/app/models/masks/server/group_member.rb +7 -0
- data/app/models/masks/server/group_rule.rb +63 -0
- data/app/models/masks/server/health.rb +243 -0
- data/app/models/masks/server/health_check.rb +27 -0
- data/app/models/masks/server/invitation.rb +2 -2
- data/app/models/masks/server/ldap_credential.rb +2 -2
- data/app/models/masks/server/login.rb +28 -2
- data/app/models/masks/server/mail_previews.rb +5 -0
- data/app/models/masks/server/mail_template.rb +2 -0
- data/app/models/masks/server/membership.rb +10 -3
- data/app/models/masks/server/notifications.rb +3 -1
- data/app/models/masks/server/password_reset.rb +2 -2
- data/app/models/masks/server/provider.rb +1 -0
- data/app/models/masks/server/queue.rb +60 -0
- data/app/models/masks/server/release.rb +74 -0
- data/app/models/masks/server/session.rb +8 -0
- data/app/models/masks/server/tenant.rb +39 -2
- data/app/models/masks/server/token.rb +2 -1
- data/app/views/masks/server/account/_app_passwords.html.erb +83 -0
- data/app/views/masks/server/account/_organizations.html.erb +33 -0
- data/app/views/masks/server/account/index.html.erb +5 -0
- data/app/views/masks/server/actor_mailer/access_declined.html.erb +5 -0
- data/app/views/masks/server/actor_mailer/access_declined.text.erb +5 -0
- data/app/views/masks/server/actor_mailer/access_granted.html.erb +7 -0
- data/app/views/masks/server/actor_mailer/access_granted.text.erb +7 -0
- data/app/views/masks/server/authorize/error.html.erb +25 -0
- data/config/locales/de/access_requests.yml +43 -0
- data/config/locales/de/account/app_passwords.yml +42 -0
- data/config/locales/de/events.yml +5 -0
- data/config/locales/de/flashes.yml +1 -0
- data/config/locales/de/group_sources.yml +5 -0
- data/config/locales/de/manage/access_requests.yml +12 -0
- data/config/locales/de/manage/app_passwords.yml +16 -0
- data/config/locales/de/manage/forward_auth_rules.yml +29 -0
- data/config/locales/de/manage/group_sources.yml +44 -0
- data/config/locales/de/manage/health.yml +78 -0
- data/config/locales/de/manage/links.yml +20 -0
- data/config/locales/en/access_requests.yml +43 -0
- data/config/locales/en/account/app_passwords.yml +42 -0
- data/config/locales/en/events.yml +5 -0
- data/config/locales/en/flashes.yml +1 -0
- data/config/locales/en/group_sources.yml +5 -0
- data/config/locales/en/manage/access_requests.yml +12 -0
- data/config/locales/en/manage/app_passwords.yml +16 -0
- data/config/locales/en/manage/forward_auth_rules.yml +29 -0
- data/config/locales/en/manage/group_sources.yml +44 -0
- data/config/locales/en/manage/health.yml +78 -0
- data/config/locales/en/manage/links.yml +20 -0
- data/config/locales/es/access_requests.yml +43 -0
- data/config/locales/es/account/app_passwords.yml +42 -0
- data/config/locales/es/events.yml +5 -0
- data/config/locales/es/flashes.yml +1 -0
- data/config/locales/es/group_sources.yml +5 -0
- data/config/locales/es/manage/access_requests.yml +12 -0
- data/config/locales/es/manage/app_passwords.yml +16 -0
- data/config/locales/es/manage/forward_auth_rules.yml +29 -0
- data/config/locales/es/manage/group_sources.yml +44 -0
- data/config/locales/es/manage/health.yml +78 -0
- data/config/locales/es/manage/links.yml +20 -0
- data/config/locales/fr/access_requests.yml +43 -0
- data/config/locales/fr/account/app_passwords.yml +42 -0
- data/config/locales/fr/events.yml +5 -0
- data/config/locales/fr/flashes.yml +1 -0
- data/config/locales/fr/group_sources.yml +5 -0
- data/config/locales/fr/manage/access_requests.yml +12 -0
- data/config/locales/fr/manage/app_passwords.yml +16 -0
- data/config/locales/fr/manage/forward_auth_rules.yml +29 -0
- data/config/locales/fr/manage/group_sources.yml +44 -0
- data/config/locales/fr/manage/health.yml +78 -0
- data/config/locales/fr/manage/links.yml +20 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20261020100000_create_access_requests.rb +28 -0
- data/db/migrate/20261020110000_add_app_passwords_to_clients.rb +5 -0
- data/db/migrate/20261020120000_add_forward_auth_rules_to_clients.rb +6 -0
- data/db/migrate/20261020130000_add_sources_to_groups.rb +15 -0
- data/db/migrate/20261020140000_create_health_checks.rb +25 -0
- data/db/migrate/20261020150000_add_link_lifetimes_to_tenants.rb +8 -0
- data/db/migrate/20261021100000_add_pinned_origin_to_tenants.rb +5 -0
- data/lib/generators/masks/server/install/install_generator.rb +3 -2
- data/lib/masks/server/configuration.rb +2 -11
- data/lib/masks/server/schema_reference.rb +12 -0
- data/lib/masks/server/tenancy/middleware.rb +17 -6
- data/public/masks-assets/.vite/manifest.json +13 -13
- data/public/masks-assets/assets/{account-D46qDQCo.js → account-h7I6VUPR.js} +2 -2
- data/public/masks-assets/assets/{account-D46qDQCo.js.map → account-h7I6VUPR.js.map} +1 -1
- data/public/masks-assets/assets/{app-D7W0gAyT.css → app-BXOScMjL.css} +1 -1
- data/public/masks-assets/assets/{handshake-DIFZPSAK.js → handshake-YQpRNMIs.js} +2 -2
- data/public/masks-assets/assets/{handshake-DIFZPSAK.js.map → handshake-YQpRNMIs.js.map} +1 -1
- data/public/masks-assets/assets/initial-Cn-UuAsy.js +3 -0
- data/public/masks-assets/assets/{initial-C1GpCiEh.js.map → initial-Cn-UuAsy.js.map} +1 -1
- data/public/masks-assets/assets/{login-C_6igRZ3.js → login-C21_jb6L.js} +3 -3
- data/public/masks-assets/assets/{login-C_6igRZ3.js.map → login-C21_jb6L.js.map} +1 -1
- data/public/masks-assets/assets/{manage-D4MzaDvM.js → manage-DpVxe5sv.js} +80 -51
- data/public/masks-assets/assets/manage-DpVxe5sv.js.map +1 -0
- data/public/masks-assets/assets/passkey-5UYPVD3c.js +2 -0
- data/public/masks-assets/assets/{passkey-DIj_5LCY.js.map → passkey-5UYPVD3c.js.map} +1 -1
- data/version.txt +1 -1
- metadata +87 -14
- data/public/masks-assets/assets/initial-C1GpCiEh.js +0 -3
- data/public/masks-assets/assets/manage-D4MzaDvM.js.map +0 -1
- 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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
|
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
|
|
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
|