masks-server 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/discovery_controller.rb +36 -0
- data/app/controllers/masks/server/enrollments_controller.rb +17 -0
- 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/controllers/masks/server/organization_members_controller.rb +3 -1
- 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/unenroll_client.rb +28 -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 +18 -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 +5 -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/application_job.rb +28 -0
- data/app/jobs/masks/server/check_domain_claims_job.rb +1 -3
- data/app/jobs/masks/server/cleanup_job.rb +5 -6
- data/app/jobs/masks/server/health_check_job.rb +21 -0
- data/app/jobs/masks/server/idle_accounts_job.rb +1 -3
- data/app/jobs/masks/server/prune_queue_job.rb +19 -0
- data/app/jobs/masks/server/refresh_groups_job.rb +18 -0
- data/app/jobs/masks/server/refresh_saml_metadata_job.rb +1 -3
- data/app/jobs/masks/server/release_check_job.rb +21 -0
- data/app/jobs/masks/server/rotate_signing_keys_job.rb +1 -3
- data/app/jobs/masks/server/text_job.rb +12 -1
- 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/app_preset.rb +102 -0
- data/app/models/masks/server/boot_task.rb +41 -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/enrollment_token.rb +1 -1
- data/app/models/masks/server/event.rb +15 -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/job_failures.rb +25 -0
- 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 +9 -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/client.yml +1 -1
- 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/de/manage/machines.yml +13 -0
- data/config/locales/de/manage/namespaces.yml +1 -1
- 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 +9 -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/en/manage/machines.yml +13 -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 +9 -0
- data/config/locales/es/flashes.yml +1 -0
- data/config/locales/es/group_sources.yml +5 -0
- data/config/locales/es/handshakes.yml +1 -1
- 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/es/manage/machines.yml +13 -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 +9 -0
- data/config/locales/fr/flashes.yml +1 -0
- data/config/locales/fr/group_sources.yml +5 -0
- data/config/locales/fr/handshakes.yml +1 -1
- 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/client.yml +1 -1
- data/config/locales/fr/manage/clients.yml +1 -1
- 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/locales/fr/manage/machines.yml +13 -0
- data/config/locales/fr/manage/providers.yml +1 -1
- data/config/routes.rb +6 -0
- data/db/migrate/20261019140000_add_indexes_for_cleanup_and_metrics.rb +10 -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 +6 -11
- data/lib/masks/server/schema_reference.rb +12 -0
- data/lib/masks/server/tenancy/middleware.rb +17 -6
- data/lib/tasks/masks.rake +31 -7
- 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-DR9qQiBU.js → manage-DpVxe5sv.js} +86 -50
- 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 +96 -14
- data/public/masks-assets/assets/initial-C1GpCiEh.js +0 -3
- data/public/masks-assets/assets/manage-DR9qQiBU.js.map +0 -1
- data/public/masks-assets/assets/passkey-DIj_5LCY.js +0 -2
|
@@ -20,6 +20,8 @@ module Masks
|
|
|
20
20
|
|
|
21
21
|
scope :live, -> { where(revoked_at: nil) }
|
|
22
22
|
|
|
23
|
+
after_commit :settle_sourced_groups, if: -> { saved_changes.keys.intersect?(%w[upstream_groups revoked_at actor_id]) }
|
|
24
|
+
|
|
23
25
|
class << self
|
|
24
26
|
def record!(provider:, actor:, identity:)
|
|
25
27
|
subject = identity["sub"].presence ||
|
|
@@ -40,6 +42,7 @@ module Masks
|
|
|
40
42
|
end
|
|
41
43
|
|
|
42
44
|
def absorb_identity(identity)
|
|
45
|
+
self.upstream_groups = Array(identity[Provider::GROUPS]).map(&:to_s).compact_blank.uniq
|
|
43
46
|
held = identity["email"].to_s.strip.downcase.presence
|
|
44
47
|
|
|
45
48
|
return self if held.nil?
|
|
@@ -125,6 +128,10 @@ module Masks
|
|
|
125
128
|
|
|
126
129
|
private
|
|
127
130
|
|
|
131
|
+
def settle_sourced_groups
|
|
132
|
+
Group.filled_by(provider).find_each { |group| group.settle!(actor) }
|
|
133
|
+
end
|
|
134
|
+
|
|
128
135
|
def store_tokens!(tokens, **attributes)
|
|
129
136
|
update!(access_token: tokens["access_token"], access_token_expires_at: expiry_from(tokens),
|
|
130
137
|
tokens_refreshed_at: Time.current, **attributes)
|
|
@@ -3,7 +3,8 @@ module Masks
|
|
|
3
3
|
class Declared
|
|
4
4
|
class Invalid < StandardError; end
|
|
5
5
|
|
|
6
|
-
|
|
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
|
|
@@ -146,6 +153,7 @@ module Masks
|
|
|
146
153
|
SIGNAL_STREAM_UPDATED = "signal_stream.updated".freeze
|
|
147
154
|
SIGNAL_STREAM_DELETED = "signal_stream.deleted".freeze
|
|
148
155
|
SIGNAL_UNDELIVERED = "signal.undelivered".freeze
|
|
156
|
+
TEXT_UNDELIVERED = "text.undelivered".freeze
|
|
149
157
|
SIGNAL_RECEIVED = "signal.received".freeze
|
|
150
158
|
|
|
151
159
|
ORGANIZATION_CREATED = "organization.created".freeze
|
|
@@ -167,10 +175,16 @@ module Masks
|
|
|
167
175
|
CUSTOM_DOMAIN_SERVED = "custom_domain.served".freeze
|
|
168
176
|
CUSTOM_DOMAIN_STOPPED = "custom_domain.stopped".freeze
|
|
169
177
|
|
|
178
|
+
JOB_FAILED = "job.failed".freeze
|
|
179
|
+
HEALTH_FAILING = "health.failing".freeze
|
|
180
|
+
HEALTH_RECOVERED = "health.recovered".freeze
|
|
181
|
+
RELEASE_AVAILABLE = "release.available".freeze
|
|
182
|
+
|
|
170
183
|
GRAVE = [
|
|
171
184
|
LOGIN_REFUSED, LOGIN_THROTTLED, LOGOUT_UNDELIVERED, REFRESH_REUSED, STREAM_FAILED,
|
|
172
185
|
DEVICE_BLOCKED, ACTOR_DELETED, AUTHENTICATOR_DISABLED, CONNECTION_REFUSED, DELEGATION_REFUSED,
|
|
173
|
-
EXCHANGE_REFUSED, SIGN_IN_RISKY, SIGNAL_UNDELIVERED, SIGNAL_RECEIVED, MEMBERSHIP_ROLE_KEPT,
|
|
186
|
+
EXCHANGE_REFUSED, SIGN_IN_RISKY, SIGNAL_UNDELIVERED, TEXT_UNDELIVERED, SIGNAL_RECEIVED, MEMBERSHIP_ROLE_KEPT,
|
|
187
|
+
ORGANIZATION_OWNERLESS, JOB_FAILED, HEALTH_FAILING
|
|
174
188
|
].freeze
|
|
175
189
|
|
|
176
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
|
|
@@ -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
|