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
|
@@ -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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
@@ -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
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
require "net/smtp"
|
|
2
|
+
|
|
3
|
+
module Masks
|
|
4
|
+
module Server
|
|
5
|
+
module Health
|
|
6
|
+
Result = Struct.new(:name, :status, :summary, :details) do
|
|
7
|
+
def failing?
|
|
8
|
+
status == HealthCheck::FAILING
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
SERVER = %w[jobs worker queue ldap metrics].freeze
|
|
13
|
+
TENANT = %w[mail origin domains keys].freeze
|
|
14
|
+
NAMES = (SERVER + TENANT).freeze
|
|
15
|
+
|
|
16
|
+
FAILING_KEY = "masks:health:failing".freeze
|
|
17
|
+
ORIGIN_PREFIX = "masks:health:origin:".freeze
|
|
18
|
+
KEEP = 1.day
|
|
19
|
+
NOTE_EVERY = 10.minutes
|
|
20
|
+
SMTP_TIMEOUT = 5
|
|
21
|
+
TOKEN_LENGTH = 24
|
|
22
|
+
|
|
23
|
+
SMTP_ERRORS = [
|
|
24
|
+
Net::SMTPError, IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def check!(tenant = Current.tenant)
|
|
30
|
+
rows = store!(server_results + tenant_results)
|
|
31
|
+
remember!(tenant, rows)
|
|
32
|
+
rows
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def server_results
|
|
36
|
+
[ jobs_result, worker_result, queue_result, ldap_result, metrics_result ]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def tenant_results
|
|
40
|
+
[ mail_result, origin_result, domains_result, keys_result ]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def store!(results)
|
|
44
|
+
now = Time.current
|
|
45
|
+
|
|
46
|
+
results.map do |result|
|
|
47
|
+
row = HealthCheck.find_or_initialize_by(name: result.name)
|
|
48
|
+
was = row.status
|
|
49
|
+
|
|
50
|
+
row.assign_attributes(status: result.status, summary: result.summary, details: result.details, checked_at: now)
|
|
51
|
+
row.changed_at = now if was != result.status
|
|
52
|
+
row.save!
|
|
53
|
+
|
|
54
|
+
announce(row, was)
|
|
55
|
+
|
|
56
|
+
row
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def announce(row, was)
|
|
61
|
+
if row.failing? && was != HealthCheck::FAILING
|
|
62
|
+
Event.record!(Event::HEALTH_FAILING, by: nil, check: row.name, summary: row.summary)
|
|
63
|
+
elsif row.ok? && was == HealthCheck::FAILING
|
|
64
|
+
Event.record!(Event::HEALTH_RECOVERED, by: nil, check: row.name, summary: row.summary)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def remember!(tenant, rows)
|
|
69
|
+
held = (::Rails.cache.read(FAILING_KEY) || {}).transform_values { |subdomains| subdomains - [ tenant.subdomain ] }
|
|
70
|
+
rows.select(&:failing?).each { |row| (held[row.name] ||= []) << tenant.subdomain }
|
|
71
|
+
|
|
72
|
+
remember_all!(held)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def remember_all!(failing)
|
|
76
|
+
::Rails.cache.write(FAILING_KEY, failing.reject { |_, subdomains| subdomains.empty? }, expires_in: KEEP)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def failing_named
|
|
80
|
+
(::Rails.cache.read(FAILING_KEY) || {}).sort.map do |name, subdomains|
|
|
81
|
+
SERVER.include?(name) ? name : "#{name} (#{subdomains.sort.join(', ')})"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def jobs_result
|
|
86
|
+
return ok("jobs", "elsewhere") unless Queue.run?
|
|
87
|
+
|
|
88
|
+
failed = Queue.failing_jobs
|
|
89
|
+
|
|
90
|
+
return ok("jobs", "none") if failed.empty?
|
|
91
|
+
|
|
92
|
+
failing("jobs", "failed", count: failed.size, jobs: failed.map(&:demodulize))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def worker_result
|
|
96
|
+
return ok("worker", "elsewhere") unless Queue.run?
|
|
97
|
+
return ok("worker", "unchecked") unless Queue.silence.positive?
|
|
98
|
+
return failing("worker", "silent", minutes: Queue.silence.to_i / 60) if Queue.worker_silent?
|
|
99
|
+
|
|
100
|
+
ok("worker", "reporting")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def queue_result
|
|
104
|
+
return ok("queue", "elsewhere") unless Queue.run?
|
|
105
|
+
return ok("queue", "unchecked") unless Queue.wait.positive?
|
|
106
|
+
|
|
107
|
+
stuck = Queue.stuck_since
|
|
108
|
+
|
|
109
|
+
return failing("queue", "stuck", since: stuck.utc.iso8601, minutes: Queue.wait.to_i / 60) if stuck
|
|
110
|
+
|
|
111
|
+
ok("queue", "moving")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def ldap_result
|
|
115
|
+
settings = ::Rails.configuration.masks
|
|
116
|
+
|
|
117
|
+
return ok("ldap", "tls") if settings.ldap_cert.present?
|
|
118
|
+
return ok("ldap", "local") unless settings.ldap_plaintext
|
|
119
|
+
|
|
120
|
+
warning("ldap", "plain", port: settings.ldap_port)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def metrics_result
|
|
124
|
+
token = ENV["MASKS_METRICS_TOKEN"]
|
|
125
|
+
|
|
126
|
+
return ok("metrics", "off") if token.blank?
|
|
127
|
+
return warning("metrics", "short", length: token.length, needed: TOKEN_LENGTH) if token.length < TOKEN_LENGTH
|
|
128
|
+
|
|
129
|
+
ok("metrics", "on")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def mail_result
|
|
133
|
+
settings = smtp_settings
|
|
134
|
+
|
|
135
|
+
return ok("mail", "none") if settings.nil?
|
|
136
|
+
|
|
137
|
+
return failing("mail", "silent", error: Senders::Smtp::UNREACHABLE) if settings.delete(:vetted) && !public_smtp?(settings)
|
|
138
|
+
|
|
139
|
+
error = smtp_problem(settings)
|
|
140
|
+
|
|
141
|
+
error ? failing("mail", "silent", error: error) : ok("mail", "answers")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def smtp_settings
|
|
145
|
+
sender = Sender.primary(Sender::MAIL)
|
|
146
|
+
|
|
147
|
+
if sender.is_a?(Senders::Smtp)
|
|
148
|
+
{ address: sender[:address], port: sender[:port].to_i, tls: sender[:tls] == true, domain: sender[:domain], vetted: true }
|
|
149
|
+
elsif (held = ::Rails.configuration.masks.smtp_settings)
|
|
150
|
+
{ address: held[:address], port: held[:port].to_i, tls: held[:tls] == true, domain: held[:domain] }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def public_smtp?(settings)
|
|
155
|
+
::Rails.env.local? || Outbound.public_address(settings[:address], settings[:port]).present?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def smtp_problem(settings)
|
|
159
|
+
smtp = Net::SMTP.new(settings[:address], settings[:port])
|
|
160
|
+
smtp.open_timeout = SMTP_TIMEOUT
|
|
161
|
+
smtp.read_timeout = SMTP_TIMEOUT
|
|
162
|
+
settings[:tls] ? smtp.enable_tls : smtp.enable_starttls_auto
|
|
163
|
+
|
|
164
|
+
smtp.start(settings[:domain].presence || "localhost") { nil }
|
|
165
|
+
|
|
166
|
+
nil
|
|
167
|
+
rescue *SMTP_ERRORS => error
|
|
168
|
+
error.class.name
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def origin_result
|
|
172
|
+
return ok("origin", "unset") if Current.tenant&.public_origin.nil?
|
|
173
|
+
|
|
174
|
+
noted = ::Rails.cache.read(origin_key(Current.tenant))
|
|
175
|
+
|
|
176
|
+
return ok("origin", "matches") if noted.nil?
|
|
177
|
+
|
|
178
|
+
warning("origin", "mismatch", seen: noted["seen"], origin: noted["origin"])
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def note_origin!(tenant, seen, origin)
|
|
182
|
+
return if seen.nil? || origin.nil? || seen.casecmp?(origin)
|
|
183
|
+
|
|
184
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
185
|
+
last = noted[tenant.id]
|
|
186
|
+
|
|
187
|
+
return if last && now - last < NOTE_EVERY
|
|
188
|
+
|
|
189
|
+
noted[tenant.id] = now
|
|
190
|
+
::Rails.cache.write(origin_key(tenant), { "seen" => seen, "origin" => origin }, expires_in: KEEP)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def forget_origin!(tenant)
|
|
194
|
+
noted.delete(tenant.id)
|
|
195
|
+
::Rails.cache.delete(origin_key(tenant))
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def noted
|
|
199
|
+
@noted ||= Concurrent::Map.new
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def origin_key(tenant)
|
|
203
|
+
"#{ORIGIN_PREFIX}#{tenant.id}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def domains_result
|
|
207
|
+
return ok("domains", "none") unless DomainClaim.exists?
|
|
208
|
+
|
|
209
|
+
missing = DomainClaim.where.not(missing_since: nil).order(:domain).pluck(:domain)
|
|
210
|
+
|
|
211
|
+
return ok("domains", "proven") if missing.empty?
|
|
212
|
+
|
|
213
|
+
warning("domains", "missing", domains: missing)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def keys_result
|
|
217
|
+
active = SigningKey.active.first
|
|
218
|
+
|
|
219
|
+
return failing("keys", "missing") if active.nil?
|
|
220
|
+
|
|
221
|
+
days = ((Time.current - active.activated_at) / 1.day).floor
|
|
222
|
+
period = Current.tenant.key_rotation_days
|
|
223
|
+
|
|
224
|
+
return ok("keys", "manual", days: days) if period.nil?
|
|
225
|
+
return warning("keys", "stale", days: days, period: period) if days > period
|
|
226
|
+
|
|
227
|
+
ok("keys", "fresh", days: days, period: period)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def ok(name, summary, **details)
|
|
231
|
+
Result.new(name, HealthCheck::OK, summary, details.stringify_keys)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def warning(name, summary, **details)
|
|
235
|
+
Result.new(name, HealthCheck::WARNING, summary, details.stringify_keys)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def failing(name, summary, **details)
|
|
239
|
+
Result.new(name, HealthCheck::FAILING, summary, details.stringify_keys)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Server
|
|
3
|
+
class HealthCheck < ApplicationRecord
|
|
4
|
+
include TenantScoped
|
|
5
|
+
|
|
6
|
+
OK = "ok".freeze
|
|
7
|
+
WARNING = "warning".freeze
|
|
8
|
+
FAILING = "failing".freeze
|
|
9
|
+
STATUSES = [ OK, WARNING, FAILING ].freeze
|
|
10
|
+
|
|
11
|
+
validates :name, presence: true, uniqueness: { scope: :tenant_id }
|
|
12
|
+
validates :status, inclusion: { in: STATUSES }
|
|
13
|
+
validates :summary, presence: true
|
|
14
|
+
|
|
15
|
+
scope :failing, -> { where(status: FAILING) }
|
|
16
|
+
scope :by_name, -> { order(:name) }
|
|
17
|
+
|
|
18
|
+
def ok?
|
|
19
|
+
status == OK
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def failing?
|
|
23
|
+
status == FAILING
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -7,12 +7,12 @@ module Masks
|
|
|
7
7
|
class Refused < StandardError; end
|
|
8
8
|
|
|
9
9
|
class << self
|
|
10
|
-
def issue!(label:, by:)
|
|
10
|
+
def issue!(label:, by:, client: nil)
|
|
11
11
|
name = label.to_s.strip.downcase
|
|
12
12
|
raise Refused, "a label is lowercase letters, digits, dots, dashes, and underscores" unless name.match?(LABEL)
|
|
13
13
|
raise Refused, "a live LDAP credential is already labeled #{name}" if named(name)
|
|
14
14
|
|
|
15
|
-
mint!(payload: { "label" => name, "issued_by" => by&.uuid }.compact, expires_at: LIFETIME.from_now)
|
|
15
|
+
mint!(client: client, payload: { "label" => name, "issued_by" => by&.uuid }.compact, expires_at: LIFETIME.from_now)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def named(label)
|
|
@@ -30,6 +30,9 @@ module Masks
|
|
|
30
30
|
|
|
31
31
|
SETTLED = "settled".freeze
|
|
32
32
|
FIRST_FACTOR_BY = "first_factor_by".freeze
|
|
33
|
+
RELAXED = "relaxed".freeze
|
|
34
|
+
STEP_UP = "step_up".freeze
|
|
35
|
+
ASKED_FOR = 10.minutes
|
|
33
36
|
|
|
34
37
|
def self.permitted_updates
|
|
35
38
|
STATES.flat_map { |state| state.declared_updates }.uniq
|
|
@@ -256,7 +259,30 @@ module Masks
|
|
|
256
259
|
|
|
257
260
|
wanted = request.respond_to?(:multi_factor?) && request.multi_factor?
|
|
258
261
|
|
|
259
|
-
wanted || (request.present? && policy.apps_require_second_factor) || state("risk-check").stepping_up?
|
|
262
|
+
wanted || (request.present? && policy.apps_require_second_factor) || state("risk-check").stepping_up? ||
|
|
263
|
+
asked?(STEP_UP)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def relaxed?
|
|
267
|
+
asked?(RELAXED) && !stepping_up? && !policy.second_factor_required
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def skipped_second_factor?
|
|
271
|
+
relaxed? && actor.present? && actor.second_factor? && !touched?(:second_factor) && !amr.include?(Issuer::MULTI_FACTOR)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def relax!(now = Time.current)
|
|
275
|
+
store[RELAXED] = { "until" => (now + ASKED_FOR).to_i }
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def step_up!(now = Time.current)
|
|
279
|
+
store[STEP_UP] = { "until" => (now + ASKED_FOR).to_i }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def asked?(key)
|
|
283
|
+
held = store[key]
|
|
284
|
+
|
|
285
|
+
held.is_a?(Hash) && held["until"].to_i > Time.current.to_i
|
|
260
286
|
end
|
|
261
287
|
|
|
262
288
|
def multi_factored?
|
|
@@ -268,7 +294,7 @@ module Masks
|
|
|
268
294
|
def second_factored?
|
|
269
295
|
return false if reauthenticating? || stale? || actor.nil? || stepping_up?
|
|
270
296
|
|
|
271
|
-
touched?(:second_factor) || signed_in? || remembered?(:second_factor)
|
|
297
|
+
touched?(:second_factor) || (signed_in? && !session.second_factor_skipped?) || remembered?(:second_factor)
|
|
272
298
|
end
|
|
273
299
|
|
|
274
300
|
def second_factor_pending?
|
|
@@ -29,6 +29,7 @@ module Masks
|
|
|
29
29
|
acme = Organization.new(key: "acme", name: "Acme", roles: [ "billing" ])
|
|
30
30
|
joining = Membership.new(organization: acme, actor: person, role: "billing", invited_by: manager,
|
|
31
31
|
invited_as: person.email, pending: true)
|
|
32
|
+
asked = AccessRequest.new(actor: person, client: client || Client.new(name: "Grafana"), label: "admins")
|
|
32
33
|
promoted = Event.new(action: Event::MEMBERSHIP_ROLE_CHANGED, actor: person, by: manager, organization: acme,
|
|
33
34
|
created_at: Time.current, details: { "was" => "member", "now" => "owner" })
|
|
34
35
|
|
|
@@ -51,6 +52,10 @@ module Masks
|
|
|
51
52
|
ActorMailer.organization_invitation(joining, journey: managing) ],
|
|
52
53
|
[ "approved", "Account approved", managing,
|
|
53
54
|
ActorMailer.approved(person, journey: managing) ],
|
|
55
|
+
[ "access_granted", "Access granted", managing,
|
|
56
|
+
ActorMailer.access_granted(asked, journey: managing) ],
|
|
57
|
+
[ "access_declined", "Access declined", managing,
|
|
58
|
+
ActorMailer.access_declined(asked, journey: managing) ],
|
|
54
59
|
[ "notification", "Security notice", system,
|
|
55
60
|
ActorMailer.notification(person, changed, journey: system) ],
|
|
56
61
|
[ "notification_organization", "Organization role changed", system,
|
|
@@ -13,6 +13,8 @@ module Masks
|
|
|
13
13
|
"approval_requested" => %w[tenant nickname],
|
|
14
14
|
"recovery_requested" => %w[tenant nickname],
|
|
15
15
|
"approved" => %w[tenant name nickname],
|
|
16
|
+
"access_granted" => %w[tenant name nickname app],
|
|
17
|
+
"access_declined" => %w[tenant name nickname app],
|
|
16
18
|
SIGNATURE => %w[tenant]
|
|
17
19
|
}.freeze
|
|
18
20
|
SUBJECT_LIMIT = 150
|