masks-server 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/masks/server/account_controller.rb +3 -0
  3. data/app/controllers/masks/server/app_passwords_controller.rb +41 -0
  4. data/app/controllers/masks/server/authorize_controller.rb +33 -2
  5. data/app/controllers/masks/server/forward_auth_controller.rb +104 -12
  6. data/app/controllers/masks/server/logins_controller.rb +4 -3
  7. data/app/controllers/masks/server/organization_access_controller.rb +39 -0
  8. data/app/graphql/masks/server/manage/mutations/base_mutation.rb +7 -0
  9. data/app/graphql/masks/server/manage/mutations/create_group.rb +6 -4
  10. data/app/graphql/masks/server/manage/mutations/decline_access.rb +27 -0
  11. data/app/graphql/masks/server/manage/mutations/grant_access.rb +29 -0
  12. data/app/graphql/masks/server/manage/mutations/group_source.rb +34 -0
  13. data/app/graphql/masks/server/manage/mutations/issue_ldap_credential.rb +5 -2
  14. data/app/graphql/masks/server/manage/mutations/refresh_group.rb +21 -0
  15. data/app/graphql/masks/server/manage/mutations/remove_group_member.rb +2 -0
  16. data/app/graphql/masks/server/manage/mutations/run_health_checks.rb +19 -0
  17. data/app/graphql/masks/server/manage/mutations/update_client.rb +6 -0
  18. data/app/graphql/masks/server/manage/mutations/update_group.rb +7 -4
  19. data/app/graphql/masks/server/manage/mutations/update_tenant.rb +14 -1
  20. data/app/graphql/masks/server/manage/types/access_request_type.rb +21 -0
  21. data/app/graphql/masks/server/manage/types/actor_type.rb +6 -0
  22. data/app/graphql/masks/server/manage/types/client_type.rb +12 -0
  23. data/app/graphql/masks/server/manage/types/group_condition_input.rb +16 -0
  24. data/app/graphql/masks/server/manage/types/group_condition_type.rb +29 -0
  25. data/app/graphql/masks/server/manage/types/group_member_type.rb +16 -0
  26. data/app/graphql/masks/server/manage/types/group_type.rb +17 -1
  27. data/app/graphql/masks/server/manage/types/health_check_type.rb +18 -0
  28. data/app/graphql/masks/server/manage/types/ldap_credential_type.rb +1 -0
  29. data/app/graphql/masks/server/manage/types/mutation_type.rb +4 -0
  30. data/app/graphql/masks/server/manage/types/query_type.rb +24 -0
  31. data/app/graphql/masks/server/manage/types/release_type.rb +16 -0
  32. data/app/graphql/masks/server/manage/types/tenant_type.rb +6 -1
  33. data/app/helpers/masks/server/account_helper.rb +4 -0
  34. data/app/jobs/masks/server/cleanup_job.rb +1 -0
  35. data/app/jobs/masks/server/health_check_job.rb +21 -0
  36. data/app/jobs/masks/server/refresh_groups_job.rb +18 -0
  37. data/app/jobs/masks/server/release_check_job.rb +21 -0
  38. data/app/ldap/masks/server/ldap/connection.rb +3 -2
  39. data/app/ldap/masks/server/ldap/gate.rb +31 -4
  40. data/app/logins/masks/server/login_states/second_factor.rb +1 -1
  41. data/app/mailers/masks/server/actor_mailer.rb +31 -0
  42. data/app/models/masks/server/access_request.rb +103 -0
  43. data/app/models/masks/server/actor.rb +6 -0
  44. data/app/models/masks/server/app_password.rb +91 -0
  45. data/app/models/masks/server/app_passwords.rb +82 -0
  46. data/app/models/masks/server/client.rb +15 -0
  47. data/app/models/masks/server/connection.rb +7 -0
  48. data/app/models/masks/server/declared.rb +10 -2
  49. data/app/models/masks/server/email_verification.rb +2 -2
  50. data/app/models/masks/server/event.rb +11 -1
  51. data/app/models/masks/server/federation/protocol.rb +1 -1
  52. data/app/models/masks/server/federation/saml.rb +1 -1
  53. data/app/models/masks/server/forward_auth_rules.rb +162 -0
  54. data/app/models/masks/server/group.rb +207 -4
  55. data/app/models/masks/server/group_member.rb +7 -0
  56. data/app/models/masks/server/group_rule.rb +63 -0
  57. data/app/models/masks/server/health.rb +243 -0
  58. data/app/models/masks/server/health_check.rb +27 -0
  59. data/app/models/masks/server/invitation.rb +2 -2
  60. data/app/models/masks/server/ldap_credential.rb +2 -2
  61. data/app/models/masks/server/login.rb +28 -2
  62. data/app/models/masks/server/mail_previews.rb +5 -0
  63. data/app/models/masks/server/mail_template.rb +2 -0
  64. data/app/models/masks/server/membership.rb +10 -3
  65. data/app/models/masks/server/notifications.rb +3 -1
  66. data/app/models/masks/server/password_reset.rb +2 -2
  67. data/app/models/masks/server/provider.rb +1 -0
  68. data/app/models/masks/server/queue.rb +60 -0
  69. data/app/models/masks/server/release.rb +74 -0
  70. data/app/models/masks/server/session.rb +8 -0
  71. data/app/models/masks/server/tenant.rb +39 -2
  72. data/app/models/masks/server/token.rb +2 -1
  73. data/app/views/masks/server/account/_app_passwords.html.erb +83 -0
  74. data/app/views/masks/server/account/_organizations.html.erb +33 -0
  75. data/app/views/masks/server/account/index.html.erb +5 -0
  76. data/app/views/masks/server/actor_mailer/access_declined.html.erb +5 -0
  77. data/app/views/masks/server/actor_mailer/access_declined.text.erb +5 -0
  78. data/app/views/masks/server/actor_mailer/access_granted.html.erb +7 -0
  79. data/app/views/masks/server/actor_mailer/access_granted.text.erb +7 -0
  80. data/app/views/masks/server/authorize/error.html.erb +25 -0
  81. data/config/locales/de/access_requests.yml +43 -0
  82. data/config/locales/de/account/app_passwords.yml +42 -0
  83. data/config/locales/de/events.yml +5 -0
  84. data/config/locales/de/flashes.yml +1 -0
  85. data/config/locales/de/group_sources.yml +5 -0
  86. data/config/locales/de/manage/access_requests.yml +12 -0
  87. data/config/locales/de/manage/app_passwords.yml +16 -0
  88. data/config/locales/de/manage/forward_auth_rules.yml +29 -0
  89. data/config/locales/de/manage/group_sources.yml +44 -0
  90. data/config/locales/de/manage/health.yml +78 -0
  91. data/config/locales/de/manage/links.yml +20 -0
  92. data/config/locales/en/access_requests.yml +43 -0
  93. data/config/locales/en/account/app_passwords.yml +42 -0
  94. data/config/locales/en/events.yml +5 -0
  95. data/config/locales/en/flashes.yml +1 -0
  96. data/config/locales/en/group_sources.yml +5 -0
  97. data/config/locales/en/manage/access_requests.yml +12 -0
  98. data/config/locales/en/manage/app_passwords.yml +16 -0
  99. data/config/locales/en/manage/forward_auth_rules.yml +29 -0
  100. data/config/locales/en/manage/group_sources.yml +44 -0
  101. data/config/locales/en/manage/health.yml +78 -0
  102. data/config/locales/en/manage/links.yml +20 -0
  103. data/config/locales/es/access_requests.yml +43 -0
  104. data/config/locales/es/account/app_passwords.yml +42 -0
  105. data/config/locales/es/events.yml +5 -0
  106. data/config/locales/es/flashes.yml +1 -0
  107. data/config/locales/es/group_sources.yml +5 -0
  108. data/config/locales/es/manage/access_requests.yml +12 -0
  109. data/config/locales/es/manage/app_passwords.yml +16 -0
  110. data/config/locales/es/manage/forward_auth_rules.yml +29 -0
  111. data/config/locales/es/manage/group_sources.yml +44 -0
  112. data/config/locales/es/manage/health.yml +78 -0
  113. data/config/locales/es/manage/links.yml +20 -0
  114. data/config/locales/fr/access_requests.yml +43 -0
  115. data/config/locales/fr/account/app_passwords.yml +42 -0
  116. data/config/locales/fr/events.yml +5 -0
  117. data/config/locales/fr/flashes.yml +1 -0
  118. data/config/locales/fr/group_sources.yml +5 -0
  119. data/config/locales/fr/manage/access_requests.yml +12 -0
  120. data/config/locales/fr/manage/app_passwords.yml +16 -0
  121. data/config/locales/fr/manage/forward_auth_rules.yml +29 -0
  122. data/config/locales/fr/manage/group_sources.yml +44 -0
  123. data/config/locales/fr/manage/health.yml +78 -0
  124. data/config/locales/fr/manage/links.yml +20 -0
  125. data/config/routes.rb +5 -0
  126. data/db/migrate/20261020100000_create_access_requests.rb +28 -0
  127. data/db/migrate/20261020110000_add_app_passwords_to_clients.rb +5 -0
  128. data/db/migrate/20261020120000_add_forward_auth_rules_to_clients.rb +6 -0
  129. data/db/migrate/20261020130000_add_sources_to_groups.rb +15 -0
  130. data/db/migrate/20261020140000_create_health_checks.rb +25 -0
  131. data/db/migrate/20261020150000_add_link_lifetimes_to_tenants.rb +8 -0
  132. data/db/migrate/20261021100000_add_pinned_origin_to_tenants.rb +5 -0
  133. data/lib/generators/masks/server/install/install_generator.rb +3 -2
  134. data/lib/masks/server/configuration.rb +2 -11
  135. data/lib/masks/server/schema_reference.rb +12 -0
  136. data/lib/masks/server/tenancy/middleware.rb +17 -6
  137. data/public/masks-assets/.vite/manifest.json +13 -13
  138. data/public/masks-assets/assets/{account-D46qDQCo.js → account-h7I6VUPR.js} +2 -2
  139. data/public/masks-assets/assets/{account-D46qDQCo.js.map → account-h7I6VUPR.js.map} +1 -1
  140. data/public/masks-assets/assets/{app-D7W0gAyT.css → app-BXOScMjL.css} +1 -1
  141. data/public/masks-assets/assets/{handshake-DIFZPSAK.js → handshake-YQpRNMIs.js} +2 -2
  142. data/public/masks-assets/assets/{handshake-DIFZPSAK.js.map → handshake-YQpRNMIs.js.map} +1 -1
  143. data/public/masks-assets/assets/initial-Cn-UuAsy.js +3 -0
  144. data/public/masks-assets/assets/{initial-C1GpCiEh.js.map → initial-Cn-UuAsy.js.map} +1 -1
  145. data/public/masks-assets/assets/{login-C_6igRZ3.js → login-C21_jb6L.js} +3 -3
  146. data/public/masks-assets/assets/{login-C_6igRZ3.js.map → login-C21_jb6L.js.map} +1 -1
  147. data/public/masks-assets/assets/{manage-D4MzaDvM.js → manage-DpVxe5sv.js} +80 -51
  148. data/public/masks-assets/assets/manage-DpVxe5sv.js.map +1 -0
  149. data/public/masks-assets/assets/passkey-5UYPVD3c.js +2 -0
  150. data/public/masks-assets/assets/{passkey-DIj_5LCY.js.map → passkey-5UYPVD3c.js.map} +1 -1
  151. data/version.txt +1 -1
  152. metadata +87 -14
  153. data/public/masks-assets/assets/initial-C1GpCiEh.js +0 -3
  154. data/public/masks-assets/assets/manage-D4MzaDvM.js.map +0 -1
  155. data/public/masks-assets/assets/passkey-DIj_5LCY.js +0 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 608910b5ec386933423f04a3596c4b5d454b38e956449ca66d762d2cd79e3e82
4
- data.tar.gz: f70b574939800fdcb3407025402e39663e15f8a9c71e2dff473685147e7cf693
3
+ metadata.gz: 74cc1568b822c061657b6625a809deb750f5503e66fa8d0f390c7a596004953e
4
+ data.tar.gz: 8bdb0e298ee2c901c71f654215fd0b958d36cd6e69befa231313dd2f98a5d249
5
5
  SHA512:
6
- metadata.gz: a061d6d81ac514d825d3963c466e99272d224e0250e964e8a057a8e794a7cd1f26955cd8e1cb5753eb418f8c91e5dd2d9b31b98c8bba4eb97d11d7112ce0a40b
7
- data.tar.gz: c537d6afb5fc139d5e83fd92143f0eb28588f9ec1992e607209f89c5c9f59dfff3502dc0f1196a39c2a19fcec67910458494324a70e739ced310ea73f69168cb
6
+ metadata.gz: e0aa16a41f23221d2274bf66aac45ba8a1c20c901526cfd79905d14a229b3614921dba96c90a4ad296f69a210c8aee6d6aa11453d063d8a07cd9b1b724f13feb
7
+ data.tar.gz: 4c36c8378c7d459c4c4280227d36ec6f91234d3c2db47f857b8165312af340958789373497de7aa244f8c13ceb09a33415e2de40af99eed39331d1c3161b0838
@@ -9,6 +9,9 @@ module Masks
9
9
  return redirect_to login_path if @actor.nil?
10
10
 
11
11
  @apps = Apps.held_by(@actor)
12
+ @app_passwords = AppPassword.live.where(actor: @actor).includes(:client).newest_first.to_a
13
+ @app_password_clients = AppPassword.offered(@actor)
14
+ @app_password = flash[:app_password]
12
15
  @launchable = Apps.launchable(@actor)
13
16
  @invitations = @actor.memberships.outstanding.joins(:organization).merge(Organization.active)
14
17
  .includes(:organization, :invited_by).order("organizations.name").to_a
@@ -0,0 +1,41 @@
1
+ module Masks
2
+ module Server
3
+ class AppPasswordsController < ApplicationController
4
+ ANCHOR = "app-passwords".freeze
5
+
6
+ before_action :require_actor
7
+
8
+ def create
9
+ return unless reauthenticated!(ANCHOR, t("app_passwords.again"))
10
+
11
+ client = Client.active.find_by(client_id: params[:client_id].to_s)
12
+ held = AppPassword.issue!(actor: current_actor, client: client, name: params[:name])
13
+
14
+ Event.record!(Event::APP_PASSWORD_CREATED, actor: current_actor, client: client, name: held.name)
15
+
16
+ flash[:app_password] = { "name" => held.name, "app" => client.name, "secret" => held.secret }
17
+ redirect_to root_path(anchor: ANCHOR)
18
+ rescue AppPassword::Refused => e
19
+ redirect_to root_path(anchor: ANCHOR), alert: t("app_passwords.refused.#{e.message}", most: AppPassword::MOST)
20
+ end
21
+
22
+ def destroy
23
+ held = AppPassword.live.find_by(id: params[:id].to_s[/\A\d+\z/], actor: current_actor)
24
+
25
+ return redirect_to root_path(anchor: ANCHOR), alert: t("app_passwords.unknown") if held.nil?
26
+
27
+ held.revoke!
28
+
29
+ Event.record!(Event::APP_PASSWORD_REVOKED, actor: current_actor, client: held.client, name: held.name)
30
+
31
+ redirect_to root_path(anchor: ANCHOR), notice: t("app_passwords.revoked", name: held.name)
32
+ end
33
+
34
+ private
35
+
36
+ def require_actor
37
+ redirect_to login_path unless current_actor
38
+ end
39
+ end
40
+ end
41
+ end
@@ -9,6 +9,10 @@ module Masks
9
9
  refuse(bad_request(:invalid_request_object, refusal.message))
10
10
  end
11
11
 
12
+ rate_limit to: 1, within: 1.hour, only: :ask,
13
+ by: -> { [ current_tenant.id, current_actor&.id, refused_request(params[:rid])&.client_id ].join(":") },
14
+ with: -> { redirect_to authorize_refused_path(rid: params[:rid]), alert: t("authorize.error.slow_down") }
15
+
12
16
  def show
13
17
  authorization = Authorization.from_request(request)
14
18
 
@@ -45,7 +49,7 @@ module Masks
45
49
 
46
50
  login = advance(pending)
47
51
 
48
- return redirect_to(refuse_on_masks(pending, login.refusal)) if login.refused? && !answerable?(pending)
52
+ return redirect_to(refuse_on_masks(pending, login.refusal)) if login.refused? && (!answerable?(pending) || askable?(pending, login))
49
53
  return answer(deny(pending, login.refusal.error, login.refusal.description)) if login.refused?
50
54
  return interaction_required(pending) if login.prompted? && pending.silent?
51
55
  return redirect_to(login.redirect_to, allow_other_host: true) if login.redirect_to.present?
@@ -55,14 +59,31 @@ module Masks
55
59
  end
56
60
 
57
61
  def refused
58
- refusal = refused_request(params[:rid])&.refusal
62
+ pending = refused_request(params[:rid])
63
+ refusal = pending&.refusal
59
64
 
60
65
  return show_error("invalid_request", "that sign-in request has expired", :bad_request) if refusal.nil?
61
66
 
62
67
  @refused = true
68
+ @asking = asking_for(pending)
69
+ @waiting = @asking && AccessRequest.open.find_by(actor: current_actor, client: @asking)
70
+ @return_to = answerable?(pending) && pending.authorization.redirect_with(
71
+ issuer: issuer, error: refusal["error"], error_description: refusal["description"]
72
+ )
63
73
  show_error(refusal["error"], refusal["description"], :forbidden)
64
74
  end
65
75
 
76
+ def ask
77
+ pending = refused_request(params[:rid])
78
+ asking = pending && asking_for(pending)
79
+
80
+ return show_error("invalid_request", "that sign-in request has expired", :bad_request) if asking.nil?
81
+
82
+ AccessRequest.ask!(actor: current_actor, client: asking)
83
+
84
+ redirect_to authorize_refused_path(rid: params[:rid]), notice: t("authorize.error.asked")
85
+ end
86
+
66
87
  private
67
88
 
68
89
  def validate(authorization)
@@ -149,6 +170,16 @@ module Masks
149
170
  end
150
171
  end
151
172
 
173
+ def askable?(pending, login)
174
+ !pending.silent? && login.actor.present? && asking_for(pending, login.actor).present?
175
+ end
176
+
177
+ def asking_for(pending, actor = current_actor)
178
+ client = pending.client
179
+
180
+ client if actor && client && client.allowed_groups.any? && !client.admits?(actor)
181
+ end
182
+
152
183
  def admitted?(authorization)
153
184
  tracked(REQUESTS).key?(authorization.fingerprint)
154
185
  end
@@ -15,13 +15,21 @@ module Masks
15
15
 
16
16
  client = Client.forwarding_to(target)
17
17
  return head(:forbidden) if client.nil?
18
+ return basic(client, target) if ActionController::HttpAuthentication::Basic.has_basic_credentials?(request)
18
19
 
19
20
  held = ForwardSession.held(cookies[ForwardSession::COOKIE], client)
20
- return admit(held.actor) if held
21
-
22
- return head(:unauthorized) if params[:status] == "401"
23
-
24
- redirect_to forward_auth_start_url(rd: target.to_s), allow_other_host: true
21
+ decided = client.forward_rules.decide(path: target.path, method: forwarded_method, address: request.remote_ip,
22
+ actor: held&.actor)
23
+
24
+ case decided.outcome
25
+ when ForwardAuthRules::OPEN then admit(held&.actor)
26
+ when ForwardAuthRules::DENY then head(:forbidden)
27
+ when ForwardAuthRules::ONE_FACTOR then held ? admit(held.actor) : ask_to_sign_in(target)
28
+ when ForwardAuthRules::TWO_FACTOR then held&.session&.multi_factored? ? admit(held.actor) : ask_to_sign_in(target)
29
+ else
30
+ complete = held if held&.session&.complete?
31
+ decided.outcome == ForwardAuthRules::TOKEN ? admit_presented(client, complete) : answer(complete, target)
32
+ end
25
33
  end
26
34
 
27
35
  def start
@@ -29,8 +37,20 @@ module Masks
29
37
  client = Client.forwarding_to(target)
30
38
 
31
39
  return render(plain: t("forward_auth.unknown"), status: :not_found) if client.nil?
32
- return redirect_to(login_path(return_to: request.fullpath)) if current_actor.nil?
33
- return render(plain: t("forward_auth.not_admitted", app: client.name), status: :forbidden) unless client.admits?(current_actor)
40
+
41
+ decided = client.forward_rules.decide(path: target.path, method: "GET", address: request.remote_ip, actor: current_actor)
42
+
43
+ return render(plain: t("forward_auth.denied", app: client.name), status: :forbidden) if decided.outcome == ForwardAuthRules::DENY
44
+ return send_to_login(decided) if current_actor.nil?
45
+
46
+ unless client.admits?(current_actor)
47
+ return redirect_to(target.to_s, allow_other_host: true) if decided.outcome == ForwardAuthRules::OPEN
48
+
49
+ return render(plain: t("forward_auth.not_admitted", app: client.name), status: :forbidden)
50
+ end
51
+
52
+ return step_up if decided.outcome == ForwardAuthRules::TWO_FACTOR && !current_session.multi_factored?
53
+ return step_up if [ ForwardAuthRules::ONE_FACTOR, ForwardAuthRules::OPEN ].exclude?(decided.outcome) && !current_session.complete?
34
54
 
35
55
  code = ForwardCode.mint!(actor: current_actor, client: client, session: current_session,
36
56
  payload: { "return_to" => target.to_s })
@@ -67,15 +87,87 @@ module Masks
67
87
  redirect_to code.return_to, allow_other_host: true
68
88
  end
69
89
 
70
- def admit(actor)
71
- response.headers["Remote-User"] = actor.nickname.presence || actor.identifier
72
- response.headers["Remote-Email"] = actor.email if actor.email_verified_at.present?
73
- response.headers["Remote-Name"] = actor.name if actor.name.present?
74
- response.headers["Remote-Groups"] = actor.group_labels.join(",")
90
+ def basic(client, target)
91
+ identifier, secret = ActionController::HttpAuthentication::Basic.user_name_and_password(request)
92
+ outcome = AppPasswords.authenticate(client: client, identifier: identifier, secret: secret, via: "forward_auth")
93
+
94
+ return head(:too_many_requests) if outcome.throttled?
95
+ return head(:unauthorized) if outcome.actor.nil?
96
+
97
+ decided = client.forward_rules.decide(path: target.path, method: forwarded_method, address: request.remote_ip,
98
+ actor: outcome.actor)
99
+ return head(:forbidden) if decided.outcome == ForwardAuthRules::DENY
100
+
101
+ admit(outcome.actor)
102
+ end
103
+
104
+ def answer(held, target)
105
+ held ? admit(held.actor) : ask_to_sign_in(target)
106
+ end
107
+
108
+ def ask_to_sign_in(target)
109
+ return head(:unauthorized) if params[:status] == "401"
110
+
111
+ redirect_to forward_auth_start_url(rd: target.to_s), allow_other_host: true
112
+ end
113
+
114
+ def send_to_login(decided)
115
+ Login.new(store: login_store).relax! if decided.outcome == ForwardAuthRules::ONE_FACTOR
116
+
117
+ redirect_to login_path(return_to: request.fullpath)
118
+ end
119
+
120
+ def step_up
121
+ Login.new(store: login_store).step_up!
122
+
123
+ redirect_to login_path(return_to: request.fullpath)
124
+ end
125
+
126
+ def admit_presented(client, held)
127
+ return admit(held.actor) if held
128
+
129
+ token = presented_token(client)
130
+ return admit(token.actor, machine: token.client) if token
131
+
132
+ response.headers["WWW-Authenticate"] = %(Bearer realm="#{client.forward_origin}")
133
+ head :unauthorized
134
+ end
135
+
136
+ def presented_token(client)
137
+ scheme, secret = request.authorization.to_s.split(" ", 2)
138
+ return nil unless scheme.to_s.casecmp?("Bearer") && secret.present?
139
+
140
+ claims = AccessToken.decode(secret.strip, issuer: issuer)
141
+ token = AccessToken.live.includes(:actor, :client).find_by(digest: claims["jti"])
142
+
143
+ return nil if token.nil? || token.bound? || token.client.nil?
144
+ return nil unless token.client_id == client.id || Array(token.audience).include?(client.forward_origin)
145
+ return nil if token.actor && (token.actor.suspended? || !client.admits?(token.actor))
146
+
147
+ token
148
+ rescue JWT::DecodeError
149
+ nil
150
+ end
151
+
152
+ def admit(actor, machine: nil)
153
+ if actor
154
+ response.headers["Remote-User"] = actor.nickname.presence || actor.identifier
155
+ response.headers["Remote-Email"] = actor.email if actor.email_verified_at.present?
156
+ response.headers["Remote-Name"] = actor.name if actor.name.present?
157
+ response.headers["Remote-Groups"] = actor.group_labels.join(",")
158
+ elsif machine
159
+ response.headers["Remote-User"] = machine.client_id
160
+ response.headers["Remote-Name"] = machine.name
161
+ response.headers["Remote-Groups"] = ""
162
+ end
75
163
 
76
164
  head :ok
77
165
  end
78
166
 
167
+ def forwarded_method
168
+ request.headers["X-Forwarded-Method"].presence || request.headers["X-Original-Method"].presence || "GET"
169
+ end
170
+
79
171
  def return_to
80
172
  raw = request.query_string[/(?:\A|&)rd=(.*)\z/m, 1].to_s
81
173
 
@@ -35,9 +35,9 @@ module Masks
35
35
 
36
36
  def show
37
37
  hold_return_to
38
- return redirect_to after_login_path if current_actor && pending.nil?
39
-
40
38
  @login = run
39
+ return redirect_to after_login_path if current_actor && pending.nil? && !@login.stepping_up?
40
+
41
41
  @login.carry!(flash[:warnings]) if flash[:warnings].present?
42
42
 
43
43
  redirect_to after_login_path if @login.settled?
@@ -153,8 +153,9 @@ module Masks
153
153
  if login.touched?(:first_factor)
154
154
  sign_in(login.actor, amr: login.amr, policy: login.policy, first_factor: login.first_factored_by,
155
155
  authenticated_at: login.authenticated_at)
156
+ current_session.update!(second_factor_skipped: true) if login.skipped_second_factor?
156
157
  elsif login.signed_in? && login.touched?(:second_factor)
157
- current_session.update!(amr: Array(current_session.amr) | login.amr)
158
+ current_session.update!(amr: Array(current_session.amr) | login.amr, second_factor_skipped: false)
158
159
  end
159
160
 
160
161
  forget_login
@@ -0,0 +1,39 @@
1
+ module Masks
2
+ module Server
3
+ class OrganizationAccessController < OrganizationMembersController
4
+ def create
5
+ own!
6
+
7
+ asked = asked!
8
+ role = params[:role].to_s
9
+
10
+ asked.grant!(label: "#{@organization.key}:#{role}", by: current_actor, journey: Journey.account(current_actor))
11
+
12
+ back(notice: t("organization_access.granted", identifier: asked.actor.identifier, app: asked.client.name,
13
+ role: role, organization: @organization.name))
14
+ rescue AccessRequest::Refused => e
15
+ back(alert: e.message)
16
+ end
17
+
18
+ def destroy
19
+ own!
20
+
21
+ asked = asked!
22
+ asked.decline!(by: current_actor, journey: Journey.account(current_actor))
23
+
24
+ back(notice: t("organization_access.declined", identifier: asked.actor.identifier, app: asked.client.name))
25
+ rescue AccessRequest::Refused => e
26
+ back(alert: e.message)
27
+ end
28
+
29
+ private
30
+
31
+ def asked!
32
+ id = params[:id].to_s[/\A\d+\z/]
33
+
34
+ (id && AccessRequest.for_organization(@organization).includes(:actor, :client).find_by(id: id)) ||
35
+ raise(AccessRequest::Refused, t("organization_access.unknown", organization: @organization.name))
36
+ end
37
+ end
38
+ end
39
+ end
@@ -102,6 +102,13 @@ module Masks
102
102
  Masks::Server::Group.find_by(key: key) || refuse!("no group keyed #{key}")
103
103
  end
104
104
 
105
+ def access_request!(id)
106
+ held = id.to_s[/\A\d+\z/] && Masks::Server::AccessRequest.open.find_by(id: id)
107
+
108
+ managed!(held.actor) && held if held
109
+ held || refuse!("no access request with that id is waiting")
110
+ end
111
+
105
112
  def invited!(actor)
106
113
  actor_created!(actor, invited: true)
107
114
 
@@ -3,6 +3,8 @@ module Masks
3
3
  module Manage
4
4
  module Mutations
5
5
  class CreateGroup < BaseMutation
6
+ include GroupSource
7
+
6
8
  requires :security
7
9
 
8
10
  argument :key, String, description: "Lowercase letters, digits, dots, dashes, and underscores. Apps see it in the groups claim."
@@ -10,13 +12,13 @@ module Masks
10
12
 
11
13
  field :group, Types::GroupType, null: false
12
14
 
13
- def resolve(key:, name:)
14
- group = Masks::Server::Group.new(key: key, name: name)
15
+ def resolve(key:, name:, **source)
16
+ group = fill_source(Masks::Server::Group.new(key: key, name: name), **source)
15
17
  save!(group)
16
18
 
17
- audit!(Masks::Server::Event::GROUP_CREATED, group: group.key)
19
+ audit!(Masks::Server::Event::GROUP_CREATED, group: group.key, source: group.source)
18
20
 
19
- { group: group }
21
+ { group: group.reload }
20
22
  end
21
23
  end
22
24
  end
@@ -0,0 +1,27 @@
1
+ module Masks
2
+ module Server
3
+ module Manage
4
+ module Mutations
5
+ class DeclineAccess < BaseMutation
6
+ requires :security
7
+
8
+ description "Declines a waiting access request. The actor is told by email and can ask again in an hour."
9
+
10
+ argument :id, ID
11
+
12
+ field :access_request, Types::AccessRequestType, null: false
13
+
14
+ def resolve(id:)
15
+ asked = access_request!(id)
16
+
17
+ asked.decline!(by: viewer, journey: Masks::Server::Journey.manage(viewer))
18
+
19
+ { access_request: asked }
20
+ rescue Masks::Server::AccessRequest::Refused => e
21
+ refuse!(e.message)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,29 @@
1
+ module Masks
2
+ module Server
3
+ module Manage
4
+ module Mutations
5
+ class GrantAccess < BaseMutation
6
+ requires :security
7
+
8
+ description "Grants a waiting access request by putting the actor in one of the groups the client allows. " \
9
+ "Name the group with label, or leave it out for the first one the client allows."
10
+
11
+ argument :id, ID
12
+ argument :label, String, required: false
13
+
14
+ field :access_request, Types::AccessRequestType, null: false
15
+
16
+ def resolve(id:, label: nil)
17
+ asked = access_request!(id)
18
+
19
+ asked.grant!(label: label, by: viewer, journey: Masks::Server::Journey.manage(viewer))
20
+
21
+ { access_request: asked }
22
+ rescue Masks::Server::AccessRequest::Refused => e
23
+ refuse!(e.message)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,34 @@
1
+ module Masks
2
+ module Server
3
+ module Manage
4
+ module Mutations
5
+ module GroupSource
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ argument :source, String, required: false,
10
+ description: "manual, rule, provider, or group. A sourced group fills itself and keeps anyone added by hand."
11
+ argument :rule, [ Types::GroupConditionInput ], required: false,
12
+ description: "For rule: up to five conditions, all of which must hold."
13
+ argument :provider_key, String, required: false, description: "For provider: the provider whose sign-ins list groups."
14
+ argument :upstream_group, String, required: false, description: "For provider: the group name the provider lists."
15
+ argument :parent_labels, [ String ], required: false,
16
+ description: "For group: group keys and organization roles, as key:role, the group holds everyone from."
17
+ end
18
+
19
+ private
20
+
21
+ def fill_source(group, source: nil, rule: nil, provider_key: nil, upstream_group: nil, parent_labels: nil)
22
+ group.source = source unless source.nil?
23
+ group.rule = rule.map(&:to_h) unless rule.nil?
24
+ group.provider = provider_key.blank? ? nil : provider!(provider_key) unless provider_key.nil?
25
+ group.upstream_group = upstream_group unless upstream_group.nil?
26
+ group.parent_labels = parent_labels unless parent_labels.nil?
27
+
28
+ group
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -8,12 +8,15 @@ module Masks
8
8
  description "Issues a bind DN and secret an app uses to search this tenant's LDAP directory. The secret is returned once."
9
9
 
10
10
  argument :label, String, description: "Lowercase letters, digits, dots, dashes, and underscores. It becomes the cn of the bind DN."
11
+ argument :client_id, ID, required: false,
12
+ description: "The client this app is registered as. People who make an app password for that client can bind with it on a connection that bound with this credential first."
11
13
 
12
14
  field :ldap_credential, Types::LdapCredentialType, null: false
13
15
  field :secret, String, null: false
14
16
 
15
- def resolve(label:)
16
- credential = Masks::Server::LdapCredential.issue!(label: label, by: viewer)
17
+ def resolve(label:, client_id: nil)
18
+ client = client_id.present? ? client!(client_id) : nil
19
+ credential = Masks::Server::LdapCredential.issue!(label: label, by: viewer, client: client)
17
20
 
18
21
  audit!(Masks::Server::Event::LDAP_CREDENTIAL_ISSUED, label: credential.label)
19
22
 
@@ -0,0 +1,21 @@
1
+ module Masks
2
+ module Server
3
+ module Manage
4
+ module Mutations
5
+ class RefreshGroup < BaseMutation
6
+ requires :security
7
+
8
+ description "Fills a sourced group now instead of waiting for the hourly run."
9
+
10
+ argument :key, String
11
+
12
+ field :group, Types::GroupType, null: false
13
+
14
+ def resolve(key:)
15
+ { group: group!(key).refresh!(by: viewer) }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -17,6 +17,8 @@ module Masks
17
17
  group.remove!(actor, by: viewer)
18
18
 
19
19
  { group: group }
20
+ rescue Masks::Server::Group::Sourced => e
21
+ refuse!(e.message)
20
22
  end
21
23
  end
22
24
  end
@@ -0,0 +1,19 @@
1
+ module Masks
2
+ module Server
3
+ module Manage
4
+ module Mutations
5
+ class RunHealthChecks < BaseMutation
6
+ requires :support
7
+
8
+ description "Runs every health check now, instead of waiting for the next scheduled run."
9
+
10
+ field :health, [ Types::HealthCheckType ], null: false
11
+
12
+ def resolve
13
+ { health: Masks::Server::Health.check!.sort_by(&:name) }
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -32,6 +32,8 @@ module Masks
32
32
  argument :saml_slo_url, String, required: false,
33
33
  description: "The application's single logout URL, which receives masks' logout messages over HTTP-POST. Empty stops single logout."
34
34
  argument :consent_required, Boolean, required: false
35
+ argument :app_passwords, Boolean, required: false,
36
+ description: "Whether people may make an app password for this client. An app password signs in over LDAP and forward auth without a second factor, for this client alone."
35
37
  argument :access_token_lifetime, Integer, required: false,
36
38
  description: "Seconds an access token lasts, from 60 to a day. Zero goes back to an hour."
37
39
  argument :refresh_token_lifetime, Integer, required: false,
@@ -41,6 +43,10 @@ module Masks
41
43
  "from 300 to 400 days. Zero goes back to 90 days."
42
44
  argument :allowed_groups, [ String ], required: false,
43
45
  description: "Group keys, or organization roles as key:role, whose people may sign in to this client. Empty lets everyone in."
46
+ argument :forward_auth_rules, GraphQL::Types::JSON, required: false,
47
+ description: "The ordered rules forward auth follows, for an app behind a proxy only. Each rule has `paths` " \
48
+ "(absolute, with * for one segment and ** for any depth), `methods`, `networks` (addresses or ranges), " \
49
+ "and `groups` it covers, and an `outcome` of open, one_factor, two_factor, token, or deny. Empty removes them all."
44
50
  argument :sign_in_policy, ID, required: false
45
51
  argument :client_uri, String, required: false
46
52
  argument :logo_uri, String, required: false
@@ -3,19 +3,22 @@ module Masks
3
3
  module Manage
4
4
  module Mutations
5
5
  class UpdateGroup < BaseMutation
6
+ include GroupSource
7
+
6
8
  requires :security
7
9
 
8
10
  argument :key, String
9
- argument :name, String
11
+ argument :name, String, required: false
10
12
 
11
13
  field :group, Types::GroupType, null: false
12
14
 
13
- def resolve(key:, name:)
15
+ def resolve(key:, name: nil, **source)
14
16
  group = group!(key)
15
- group.name = name
17
+ group.name = name unless name.nil?
18
+ fill_source(group, **source)
16
19
  save!(group)
17
20
 
18
- { group: group }
21
+ { group: group.reload }
19
22
  end
20
23
  end
21
24
  end
@@ -30,14 +30,27 @@ module Masks
30
30
  argument :key_rotation_days, Integer, required: false,
31
31
  description: "Days between scheduled signing key rotations, from 7 to 365. Zero leaves rotation to a manager."
32
32
 
33
+ argument :invitation_lifetime, Integer, required: false,
34
+ description: "Seconds an invitation to an account stays valid, from an hour to 30 days. Zero goes back to 7 days."
35
+ argument :organization_invitation_lifetime, Integer, required: false,
36
+ description: "Seconds an invitation to an organization stays open after it was last sent, from an hour to 30 days. Zero goes back to 14 days."
37
+ argument :password_reset_lifetime, Integer, required: false,
38
+ description: "Seconds a password reset link stays valid, from 5 minutes to a day. Zero goes back to 30 minutes."
39
+ argument :email_verification_lifetime, Integer, required: false,
40
+ description: "Seconds an email confirmation link stays valid, from an hour to 14 days. Zero goes back to 2 days."
41
+
33
42
  field :tenant, Types::TenantType, null: false
34
43
 
35
44
  def resolve(name: nil, dynamic_client_scopes: nil, dynamic_registration: nil,
36
45
  named_by: nil, browsers_only: nil, blocked_agents: nil, sign_in_policy: nil,
37
46
  suspend_after: nil, delete_after: nil, event_retention_days: nil, risky_networks: nil,
38
- accent_color: nil, key_rotation_days: nil, ldap_enabled: nil, ldap_password_only: nil)
47
+ accent_color: nil, key_rotation_days: nil, ldap_enabled: nil, ldap_password_only: nil, **link_lifetimes)
39
48
  tenant = Current.tenant
40
49
 
50
+ link_lifetimes.compact.each do |name, seconds|
51
+ tenant[name] = seconds.zero? ? Masks::Server::Tenant.default_link_lifetime(name) : seconds
52
+ end
53
+
41
54
  tenant.name = name unless name.nil?
42
55
  tenant.accent_color = accent_color unless accent_color.nil?
43
56
  tenant.key_rotation_days = key_rotation_days.zero? ? nil : key_rotation_days unless key_rotation_days.nil?
@@ -0,0 +1,21 @@
1
+ module Masks
2
+ module Server
3
+ module Manage
4
+ module Types
5
+ class AccessRequestType < BaseObject
6
+ description "An actor turned away from a client who asked for access. Granting it puts them in one of the groups the client allows."
7
+
8
+ field :id, ID, null: false
9
+ field :actor, ActorType, null: false
10
+ field :client, ClientType, null: false
11
+ field :label, String, description: "The group key, or organization role as key:role, the actor was put in. Null until granted."
12
+ field :decided_by, ActorType
13
+ field :granted_at, GraphQL::Types::ISO8601DateTime
14
+ field :declined_at, GraphQL::Types::ISO8601DateTime
15
+ field :open, Boolean, null: false, method: :open?, description: "True while nobody has granted or declined it."
16
+ field :created_at, GraphQL::Types::ISO8601DateTime, null: false
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end