masks-server 0.7.0 → 0.8.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 (168) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/masks/server/enrollments_controller.rb +66 -0
  3. data/app/controllers/masks/server/introspections_controller.rb +2 -2
  4. data/app/controllers/masks/server/registrations_controller.rb +31 -1
  5. data/app/graphql/masks/server/manage/types/client_type.rb +8 -0
  6. data/app/graphql/masks/server/manage/types/query_type.rb +6 -1
  7. data/app/helpers/masks/server/application_helper.rb +0 -1
  8. data/app/helpers/masks/server/scopes_helper.rb +28 -0
  9. data/app/jobs/masks/server/cleanup_job.rb +1 -0
  10. data/app/models/masks/server/client.rb +81 -1
  11. data/app/models/masks/server/client_keys.rb +6 -1
  12. data/app/models/masks/server/enrollment_token.rb +54 -0
  13. data/app/models/masks/server/event.rb +3 -0
  14. data/app/models/masks/server/issuer.rb +1 -0
  15. data/app/models/masks/server/namespace.rb +6 -3
  16. data/app/models/masks/server/replay.rb +22 -4
  17. data/app/models/masks/server/token.rb +1 -0
  18. data/app/views/layouts/masks/server/manage.html.erb +2 -2
  19. data/app/views/masks/server/application/_person.html.erb +1 -1
  20. data/app/views/masks/server/handshakes/show.html.erb +129 -59
  21. data/config/locales/de/account.yml +1 -1
  22. data/config/locales/de/events.yml +5 -2
  23. data/config/locales/de/flashes.yml +2 -2
  24. data/config/locales/de/handshakes.yml +33 -14
  25. data/config/locales/de/mailers.yml +1 -1
  26. data/config/locales/de/manage/activity.yml +2 -2
  27. data/config/locales/de/manage/actor.yml +41 -23
  28. data/config/locales/de/manage/actors.yml +16 -15
  29. data/config/locales/de/manage/api.yml +1 -1
  30. data/config/locales/de/manage/app.yml +6 -6
  31. data/config/locales/de/manage/client.yml +45 -45
  32. data/config/locales/de/manage/clients.yml +28 -32
  33. data/config/locales/de/manage/connections.yml +2 -2
  34. data/config/locales/de/manage/consents.yml +2 -2
  35. data/config/locales/de/manage/device.yml +7 -7
  36. data/config/locales/de/manage/devices.yml +18 -18
  37. data/config/locales/de/manage/directory.yml +1 -1
  38. data/config/locales/de/manage/domains.yml +6 -6
  39. data/config/locales/de/manage/emails.yml +4 -4
  40. data/config/locales/de/manage/events.yml +3 -0
  41. data/config/locales/de/manage/groups.yml +6 -6
  42. data/config/locales/de/manage/ldap.yml +8 -8
  43. data/config/locales/de/manage/namespaces.yml +1 -1
  44. data/config/locales/de/manage/organization.yml +15 -8
  45. data/config/locales/de/manage/organizations.yml +3 -3
  46. data/config/locales/de/manage/policies.yml +6 -6
  47. data/config/locales/de/manage/policy_rules.yml +1 -1
  48. data/config/locales/de/manage/presence.yml +6 -6
  49. data/config/locales/de/manage/providers.yml +70 -48
  50. data/config/locales/de/manage/provisioning.yml +4 -4
  51. data/config/locales/de/manage/senders.yml +1 -1
  52. data/config/locales/de/manage/settings.yml +37 -26
  53. data/config/locales/de/manage/settings_shell.yml +2 -2
  54. data/config/locales/de/refusals.yml +5 -5
  55. data/config/locales/en/account.yml +3 -3
  56. data/config/locales/en/events.yml +4 -1
  57. data/config/locales/en/flashes.yml +3 -3
  58. data/config/locales/en/handshakes.yml +33 -14
  59. data/config/locales/en/logins.yml +2 -2
  60. data/config/locales/en/manage/actor.yml +32 -14
  61. data/config/locales/en/manage/actors.yml +16 -15
  62. data/config/locales/en/manage/app.yml +5 -5
  63. data/config/locales/en/manage/client.yml +9 -9
  64. data/config/locales/en/manage/clients.yml +8 -12
  65. data/config/locales/en/manage/connections.yml +1 -1
  66. data/config/locales/en/manage/consents.yml +1 -1
  67. data/config/locales/en/manage/device.yml +1 -1
  68. data/config/locales/en/manage/devices.yml +1 -1
  69. data/config/locales/en/manage/directory.yml +1 -1
  70. data/config/locales/en/manage/domains.yml +6 -6
  71. data/config/locales/en/manage/emails.yml +4 -4
  72. data/config/locales/en/manage/events.yml +3 -0
  73. data/config/locales/en/manage/groups.yml +6 -6
  74. data/config/locales/en/manage/ldap.yml +8 -8
  75. data/config/locales/en/manage/messages.yml +1 -1
  76. data/config/locales/en/manage/namespaces.yml +1 -1
  77. data/config/locales/en/manage/organization.yml +16 -9
  78. data/config/locales/en/manage/organizations.yml +3 -3
  79. data/config/locales/en/manage/policies.yml +6 -6
  80. data/config/locales/en/manage/policy_rules.yml +2 -2
  81. data/config/locales/en/manage/presence.yml +1 -1
  82. data/config/locales/en/manage/providers.yml +70 -48
  83. data/config/locales/en/manage/provisioning.yml +4 -4
  84. data/config/locales/en/manage/senders.yml +2 -2
  85. data/config/locales/en/manage/settings.yml +34 -23
  86. data/config/locales/es/account.yml +1 -1
  87. data/config/locales/es/application.yml +1 -1
  88. data/config/locales/es/events.yml +4 -1
  89. data/config/locales/es/flashes.yml +4 -4
  90. data/config/locales/es/handshakes.yml +33 -14
  91. data/config/locales/es/logins.yml +12 -12
  92. data/config/locales/es/mailers.yml +7 -7
  93. data/config/locales/es/manage/activity.yml +2 -2
  94. data/config/locales/es/manage/actor.yml +33 -15
  95. data/config/locales/es/manage/actors.yml +17 -16
  96. data/config/locales/es/manage/api.yml +1 -1
  97. data/config/locales/es/manage/app.yml +6 -6
  98. data/config/locales/es/manage/client.yml +30 -30
  99. data/config/locales/es/manage/clients.yml +22 -26
  100. data/config/locales/es/manage/connections.yml +1 -1
  101. data/config/locales/es/manage/consents.yml +1 -1
  102. data/config/locales/es/manage/device.yml +1 -1
  103. data/config/locales/es/manage/directory.yml +1 -1
  104. data/config/locales/es/manage/domains.yml +5 -5
  105. data/config/locales/es/manage/emails.yml +7 -7
  106. data/config/locales/es/manage/events.yml +3 -0
  107. data/config/locales/es/manage/groups.yml +6 -6
  108. data/config/locales/es/manage/ldap.yml +8 -8
  109. data/config/locales/es/manage/namespaces.yml +1 -1
  110. data/config/locales/es/manage/organization.yml +16 -9
  111. data/config/locales/es/manage/organizations.yml +3 -3
  112. data/config/locales/es/manage/policies.yml +8 -8
  113. data/config/locales/es/manage/policy_rules.yml +2 -2
  114. data/config/locales/es/manage/providers.yml +70 -48
  115. data/config/locales/es/manage/provisioning.yml +4 -4
  116. data/config/locales/es/manage/senders.yml +1 -1
  117. data/config/locales/es/manage/settings.yml +38 -27
  118. data/config/locales/es/manage/settings_shell.yml +2 -2
  119. data/config/locales/es/refusals.yml +3 -3
  120. data/config/locales/fr/events.yml +3 -0
  121. data/config/locales/fr/flashes.yml +1 -1
  122. data/config/locales/fr/handshakes.yml +33 -14
  123. data/config/locales/fr/manage/activity.yml +4 -4
  124. data/config/locales/fr/manage/actor.yml +43 -25
  125. data/config/locales/fr/manage/actors.yml +22 -21
  126. data/config/locales/fr/manage/api.yml +1 -1
  127. data/config/locales/fr/manage/app.yml +7 -7
  128. data/config/locales/fr/manage/client.yml +50 -50
  129. data/config/locales/fr/manage/clients.yml +28 -32
  130. data/config/locales/fr/manage/connections.yml +2 -2
  131. data/config/locales/fr/manage/consents.yml +1 -1
  132. data/config/locales/fr/manage/device.yml +1 -1
  133. data/config/locales/fr/manage/directory.yml +1 -1
  134. data/config/locales/fr/manage/domains.yml +6 -6
  135. data/config/locales/fr/manage/emails.yml +5 -5
  136. data/config/locales/fr/manage/events.yml +3 -0
  137. data/config/locales/fr/manage/groups.yml +6 -6
  138. data/config/locales/fr/manage/ldap.yml +8 -8
  139. data/config/locales/fr/manage/namespaces.yml +1 -1
  140. data/config/locales/fr/manage/organization.yml +15 -8
  141. data/config/locales/fr/manage/organizations.yml +3 -3
  142. data/config/locales/fr/manage/policies.yml +6 -6
  143. data/config/locales/fr/manage/policy_rules.yml +1 -1
  144. data/config/locales/fr/manage/providers.yml +70 -48
  145. data/config/locales/fr/manage/provisioning.yml +4 -4
  146. data/config/locales/fr/manage/senders.yml +1 -1
  147. data/config/locales/fr/manage/settings.yml +38 -27
  148. data/config/locales/fr/manage/settings_shell.yml +2 -2
  149. data/config/routes.rb +2 -0
  150. data/config/vite.json +1 -0
  151. data/db/migrate/20261011190000_add_enrolled_by_to_clients.rb +5 -0
  152. data/db/migrate/20261012100000_create_replays.rb +23 -0
  153. data/public/masks-assets/.vite/manifest-assets.json +1 -0
  154. data/public/masks-assets/.vite/manifest.json +62 -0
  155. data/public/masks-assets/assets/app-DoQZrM_J.css +2 -0
  156. data/public/masks-assets/assets/console-DoyNbIw3.css +1 -0
  157. data/public/masks-assets/assets/handshake-DIFZPSAK.js +2 -0
  158. data/public/masks-assets/assets/handshake-DIFZPSAK.js.map +1 -0
  159. data/public/masks-assets/assets/manage-isLCrWiU.js +567 -0
  160. data/public/masks-assets/assets/manage-isLCrWiU.js.map +1 -0
  161. data/version.txt +1 -1
  162. metadata +14 -7
  163. data/public/masks-assets/assets/app-olx-5PkA.css +0 -2
  164. data/public/masks-assets/assets/console-CiuQ3qj0.css +0 -1
  165. data/public/masks-assets/assets/handshake-BYhhQ_X3.js +0 -2
  166. data/public/masks-assets/assets/handshake-BYhhQ_X3.js.map +0 -1
  167. data/public/masks-assets/assets/manage-Z3GmPDNQ.js +0 -560
  168. data/public/masks-assets/assets/manage-Z3GmPDNQ.js.map +0 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 670bf543e143915a6be6c9ca44965b2a66315bf479d60ec190b2cbc53fe939bb
4
- data.tar.gz: 8b1fabb6b7597f23802f81e07b966992a69cd9ba8bd03f070812729f246b66a6
3
+ metadata.gz: '0815510a0ee659d9ba2cf35bb8840d7a3a7b7abb7306e0e0cf3f64fd7890d7dd'
4
+ data.tar.gz: 188b4436824c6f113da49a7880ad15d6f4d0c915f5c5df9b94ba7affcb51fca8
5
5
  SHA512:
6
- metadata.gz: 96e59cfb63394b1fe6be72fcb2320c1ed401951ce6eb681600f405221eda392040f4d6f84a918641e9de5de236f563479e532c155c1fe015d5f0d53301d9f4d1
7
- data.tar.gz: a5733b84f8c14b7bc86e9b3ca2099b471fad489ac53486a71386f92e9fb10076d45c977387672236d1db039dc06218547fd3e214e65a533d322b3efc80f19bdb
6
+ metadata.gz: faeda2a8132f3e2e92ce8a1f41d235cae52f0640ad33ed3220d526619c5ecd113fece66af5923cb8e610564399ba3db0f22bdd16e9e0306d0155ba63f9e13f91
7
+ data.tar.gz: bef27fdefa6bc7d1f88a386ac23261cad8b487137ea8130c0bfa41467f7bbec11b59058386ceb0fabe0f97cca1a914a1a3a9aaef6c74a48478b266e3a9c62858
@@ -0,0 +1,66 @@
1
+ module Masks
2
+ module Server
3
+ class EnrollmentsController < ApplicationController
4
+ include TokenPresented
5
+
6
+ rate_limit to: 30, within: 1.minute,
7
+ by: -> { [ current_tenant.id, request.remote_ip ].join(":") },
8
+ with: -> {
9
+ render json: {
10
+ "error" => "too_many_requests",
11
+ "error_description" => "too many enrollment tokens asked for from this address"
12
+ }, status: :too_many_requests
13
+ }
14
+
15
+ before_action :no_store!
16
+
17
+ def create
18
+ client = authenticate_app!
19
+ token = EnrollmentToken.issue!(client, scope: params[:scope], name: params[:client_name],
20
+ expires_in: params[:expires_in])
21
+
22
+ Event.record!(Event::CLIENT_ENROLLMENT_ISSUED, client: client, scopes: token.scopes,
23
+ name: token.held("name"), expires_at: token.expires_at.iso8601)
24
+
25
+ render json: {
26
+ "initial_access_token" => token.secret,
27
+ "expires_in" => token.expires_in,
28
+ "scope" => token.scopes
29
+ }, status: :created
30
+ rescue Client::EnrollmentRefused => e
31
+ render json: { "error" => "invalid_request", "error_description" => e.message }, status: :bad_request
32
+ end
33
+
34
+ def destroy
35
+ app = authenticate_app!
36
+ machine = app.enrolled.find_by(client_id: params[:machine].to_s)
37
+
38
+ return not_enrolled if machine.nil?
39
+
40
+ revoked = machine.unenroll!
41
+
42
+ Event.record!(Event::CLIENT_UNENROLLED, client: machine, name: machine.name, enrolled_by: app.client_id,
43
+ revoked: revoked)
44
+
45
+ head :no_content
46
+ end
47
+
48
+ private
49
+
50
+ def authenticate_app!
51
+ client = authenticate_client!
52
+
53
+ client_refused!("a public client proves nothing about itself, so it enrolls and unenrolls nothing") if client.public?
54
+
55
+ client
56
+ end
57
+
58
+ def not_enrolled
59
+ render json: {
60
+ "error" => "invalid_request",
61
+ "error_description" => "that client was not enrolled by this app"
62
+ }, status: :not_found
63
+ end
64
+ end
65
+ end
66
+ end
@@ -13,7 +13,7 @@ module Masks
13
13
  client = authenticate_client!
14
14
  token = presented_token
15
15
 
16
- return render json: INACTIVE unless token&.live? && entitled?(client, token)
16
+ return render json: INACTIVE unless token&.live? && !token.client&.archived? && entitled?(client, token)
17
17
 
18
18
  render json: described(token)
19
19
  end
@@ -22,7 +22,7 @@ module Masks
22
22
 
23
23
  def entitled?(client, token)
24
24
  return true if token.client_id == client.id
25
- return false unless client.approved?
25
+ return false if client.enrolled? || !client.approved?
26
26
 
27
27
  (Array(token.audience) & Array(client.resources)).any?
28
28
  end
@@ -14,6 +14,7 @@ module Masks
14
14
 
15
15
  before_action :no_store!
16
16
  before_action :require_registration_token, except: :create
17
+ before_action :refuse_enrolled, only: :update
17
18
 
18
19
  def create
19
20
  return redeem if bearer.present?
@@ -26,7 +27,7 @@ module Masks
26
27
  render json: issued(client), status: :created
27
28
  rescue ActiveRecord::RecordInvalid => e
28
29
  invalid_metadata(e.record.errors.full_messages.join("; "))
29
- rescue Client::ScopesUnavailable => e
30
+ rescue Client::ScopesUnavailable, Client::EnrollmentRefused => e
30
31
  invalid_metadata(e.message)
31
32
  end
32
33
 
@@ -47,6 +48,8 @@ module Masks
47
48
  end
48
49
 
49
50
  def destroy
51
+ return unenroll if @client.enrolled?
52
+
50
53
  @client.update!(archived_at: Time.current)
51
54
 
52
55
  Event.record!(Event::CLIENT_ARCHIVED, client: @client, name: @client.name)
@@ -56,7 +59,27 @@ module Masks
56
59
 
57
60
  private
58
61
 
62
+ def unenroll
63
+ revoked = @client.unenroll!
64
+
65
+ Event.record!(Event::CLIENT_UNENROLLED, client: @client, name: @client.name,
66
+ enrolled_by: @client.enrolled_by&.client_id, revoked: revoked)
67
+
68
+ head :no_content
69
+ end
70
+
71
+ def refuse_enrolled
72
+ return unless @client.enrolled?
73
+
74
+ render json: {
75
+ "error" => "access_denied",
76
+ "error_description" => "an enrolled machine is changed only by the app that enrolled it"
77
+ }, status: :forbidden
78
+ end
79
+
59
80
  def redeem
81
+ return enrolled(@enrolled) if (@enrolled = EnrollmentToken.redeem!(bearer, body))
82
+
60
83
  token = InitialAccessToken.claim(bearer)
61
84
 
62
85
  return unauthorized("that initial access token is not valid or has expired") if token&.client.nil?
@@ -64,6 +87,13 @@ module Masks
64
87
  render json: issued(token.issue!), status: :created
65
88
  end
66
89
 
90
+ def enrolled(client)
91
+ Event.record!(Event::CLIENT_ENROLLED, client: client, name: client.name,
92
+ enrolled_by: client.enrolled_by.client_id)
93
+
94
+ render json: issued(client), status: :created
95
+ end
96
+
67
97
  def bearer
68
98
  request.authorization.to_s[/\ABearer (\S+)\z/, 1]
69
99
  end
@@ -57,6 +57,10 @@ module Masks
57
57
  field :dynamic, Boolean, null: false
58
58
  field :approved_at, GraphQL::Types::ISO8601DateTime
59
59
  field :approved_by, ActorType
60
+ field :enrolled_by, ClientType,
61
+ description: "The app that enrolled this client as one of its machines. Archiving that app archives this client."
62
+ field :enrolled, [ ClientType ], null: false,
63
+ description: "The machines this app enrolled that are not archived."
60
64
  field :archived_at, GraphQL::Types::ISO8601DateTime
61
65
  field :secret_expires_at, GraphQL::Types::ISO8601DateTime
62
66
  field :created_at, GraphQL::Types::ISO8601DateTime, null: false
@@ -75,6 +79,10 @@ module Masks
75
79
  argument :limit, Integer, required: false
76
80
  end
77
81
 
82
+ def enrolled
83
+ object.enrolled.active.order(:name)
84
+ end
85
+
78
86
  def tokens(limit: nil)
79
87
  Masks::Server::Token.where(kind: TokenType::GRANTS, client_id: object.id)
80
88
  .live
@@ -169,6 +169,8 @@ module Masks
169
169
  argument :action, String, required: false
170
170
  argument :grave, Boolean, required: false
171
171
  argument :organization, ID, required: false
172
+ argument :since, GraphQL::Types::ISO8601DateTime, required: false,
173
+ description: "Only events recorded at or after this time."
172
174
  argument :after_id, ID, required: false
173
175
  argument :limit, Integer, required: false
174
176
  end
@@ -180,6 +182,8 @@ module Masks
180
182
  argument :action, String, required: false
181
183
  argument :grave, Boolean, required: false
182
184
  argument :organization, ID, required: false
185
+ argument :since, GraphQL::Types::ISO8601DateTime, required: false,
186
+ description: "Only events recorded at or after this time."
183
187
  end
184
188
 
185
189
  field :event_actions, [ EventActionType ], null: false
@@ -484,7 +488,7 @@ module Masks
484
488
  scope.where("name ILIKE :term OR client_id = :exact", term: term, exact: search.strip)
485
489
  end
486
490
 
487
- def event_scope(actor: nil, client: nil, device: nil, action: nil, grave: false, organization: nil)
491
+ def event_scope(actor: nil, client: nil, device: nil, action: nil, grave: false, organization: nil, since: nil)
488
492
  subject = actor.present? ? Actor.find_by(uuid: actor) : nil
489
493
  group = organization.present? ? Masks::Server::Organization.find_by(key: organization) : nil
490
494
 
@@ -504,6 +508,7 @@ module Masks
504
508
  scope = scope.where(organization: group) if group
505
509
  scope = scope.where(action: action) if action.present?
506
510
  scope = scope.where(action: Masks::Server::Event::GRAVE) if grave
511
+ scope = scope.where(created_at: since..) if since
507
512
 
508
513
  scope
509
514
  end
@@ -15,7 +15,6 @@ module Masks
15
15
  .auth-page {
16
16
  --signal: #{accent};
17
17
  --signal-ink: #{tenant.accent_ink};
18
- --came: #{accent};
19
18
  --brass: #{accent};
20
19
  --ring: color-mix(in oklab, #{accent} 30%, transparent);
21
20
  }
@@ -3,13 +3,41 @@ module Masks
3
3
  module ScopesHelper
4
4
  CONSEQUENTIAL = /(:write|:command|:exec|:delete|:admin)\z|\Aoffline_access\z|\Amasks:manage(:[a-z]+)?\z|\Amasks:delegate:./
5
5
 
6
+ SEEN = [
7
+ Scopes::OPENID, Scopes::PROFILE, Scopes::EMAIL,
8
+ Scopes::IDENTITIES, Scopes::ORGANIZATION, Scopes::GROUPS
9
+ ].freeze
10
+
6
11
  def consequential_scope?(scope)
7
12
  scope.to_s.match?(CONSEQUENTIAL)
8
13
  end
9
14
 
15
+ def heeded_scope?(scope)
16
+ scope == Scopes::DELEGATE || consequential_scope?(scope)
17
+ end
18
+
10
19
  def ranked_scopes(described)
11
20
  described.to_a.partition { |scope, _| consequential_scope?(scope) }
12
21
  end
22
+
23
+ def plain_scopes(described, areas: [])
24
+ held = { sees: [], heeds: [], also: [] }
25
+
26
+ described.to_a.each do |scope, description|
27
+ next if areas.include?(scope)
28
+
29
+ group =
30
+ if heeded_scope?(scope) then :heeds
31
+ elsif SEEN.include?(scope) then :sees
32
+ else :also
33
+ end
34
+
35
+ held[group] << [ scope, description ]
36
+ end
37
+
38
+ held[:sees].sort_by! { |scope, _| SEEN.index(scope) }
39
+ held
40
+ end
13
41
  end
14
42
  end
15
43
  end
@@ -32,6 +32,7 @@ module Masks
32
32
  SigningKey.where.not(retired_at: nil).where(retired_at: ...cutoff).delete_all
33
33
  Event.where(created_at: ...tenant.event_retention.ago).in_batches(of: BATCH).delete_all
34
34
  Members.purge_lapsed!
35
+ Replay.where(expires_at: ...Time.current).in_batches(of: BATCH).delete_all
35
36
  end
36
37
 
37
38
  def sweep_tokens(cutoff)
@@ -26,6 +26,7 @@ module Masks
26
26
  CHALLENGE_METHODS = %w[S256].freeze
27
27
  LOOPBACK = %w[localhost 127.0.0.1 ::1].freeze
28
28
  DEFAULT_SCOPES = Scopes::STANDARD
29
+ MACHINE_ACCESS_LIFETIME = 10.minutes
29
30
  LINKS = %i[client_uri tos_uri policy_uri].freeze
30
31
  METADATA_URIS = (LINKS + [ :logo_uri ]).freeze
31
32
  URI_LIMIT = 2048
@@ -47,12 +48,15 @@ module Masks
47
48
  ].freeze
48
49
 
49
50
  class ScopesUnavailable < StandardError; end
51
+ class EnrollmentRefused < StandardError; end
50
52
 
51
53
  has_many :tokens, dependent: :destroy
52
54
  has_many :consents, dependent: :destroy
53
55
  has_many :delegations, dependent: :destroy
54
56
  has_many :saml_participations, dependent: :delete_all
55
57
  has_many :namespaces, -> { order(:name) }, dependent: :nullify
58
+ has_many :enrolled, class_name: "Client", foreign_key: :enrolled_by_id, inverse_of: :enrolled_by,
59
+ dependent: :nullify
56
60
  has_one :logo, class_name: "ClientLogo", dependent: :delete
57
61
 
58
62
  validate :allowed_groups_are_named
@@ -86,8 +90,10 @@ module Masks
86
90
  normalizes(*METADATA_URIS, with: ->(value) { value.to_s.strip.presence })
87
91
 
88
92
  after_commit :refresh_logo!, on: %i[create update], if: :saved_change_to_logo_uri?
93
+ after_update :archived!, if: -> { saved_change_to_archived_at? && archived? }
89
94
 
90
95
  belongs_to :approved_by, class_name: "Actor", optional: true
96
+ belongs_to :enrolled_by, class_name: "Client", optional: true, inverse_of: :enrolled
91
97
  belongs_to :sign_in_policy, optional: true
92
98
 
93
99
  scope :approved, -> { where.not(approved_at: nil) }
@@ -132,7 +138,7 @@ module Masks
132
138
  def approved_for(resource)
133
139
  return nil if resource.blank?
134
140
 
135
- active.approved.where("resources @> ?", [ resource.to_s ].to_json).first
141
+ active.approved.where(enrolled_by_id: nil).where("resources @> ?", [ resource.to_s ].to_json).first
136
142
  end
137
143
 
138
144
  def register!(metadata)
@@ -169,6 +175,37 @@ module Masks
169
175
  )
170
176
  end
171
177
 
178
+ def enroll!(token, metadata)
179
+ app = token.client
180
+ app&.lock!
181
+ refusal = app&.enrollment_refusal(token.scope_list)
182
+
183
+ raise EnrollmentRefused, refusal || "the app that issued this enrollment token is gone" if app.nil? || refusal
184
+
185
+ held = metadata.to_h.stringify_keys
186
+
187
+ raise EnrollmentRefused, "jwks_uri is not accepted here; register the public keys as jwks" if held["jwks_uri"].present?
188
+ raise EnrollmentRefused, "jwks is required: the machine signs in with its own key" if held["jwks"].blank?
189
+
190
+ new(
191
+ client_id: SecureRandom.uuid,
192
+ name: held["client_name"].to_s.strip.presence || token.held("name") || "A machine",
193
+ jwks: held["jwks"],
194
+ token_endpoint_auth_method: PRIVATE_KEY_JWT,
195
+ grant_types: [ CLIENT_CREDENTIALS ],
196
+ response_types: [],
197
+ dpop_bound_access_tokens: true,
198
+ resources: app.resources,
199
+ allowed_scopes: Scopes.join(token.scope_list),
200
+ application_type: "native",
201
+ subject_type: Subjects::PUBLIC,
202
+ enrolled_by: app,
203
+ access_token_lifetime: MACHINE_ACCESS_LIFETIME.to_i,
204
+ approved_at: Time.current,
205
+ dynamic: false
206
+ ).issue_credentials!
207
+ end
208
+
172
209
  def bounded(requested)
173
210
  reserved = Scopes.reserved(requested) + Scopes.list(requested).select { |s| Scopes.prefix?(s) }
174
211
 
@@ -216,6 +253,38 @@ module Masks
216
253
  end
217
254
  end
218
255
 
256
+ def enrollment_refusal(scopes)
257
+ return "an archived client enrolls nothing" if archived?
258
+ return "a public client enrolls nothing" if public?
259
+ return "only an approved client enrolls machines" unless approved?
260
+ return "a client enrolls machines only after it claims a namespace" if namespaces.empty?
261
+ return "a scope is required" if Scopes.list(scopes).empty?
262
+
263
+ held = namespaces.map(&:name)
264
+ outside = Scopes.list(scopes).select do |scope|
265
+ Scopes.prefix?(scope) || scope.start_with?(Scopes::NAMESPACE) || !Scopes.covered?(held, scope)
266
+ end
267
+
268
+ "#{Scopes.join(outside)} is outside #{Scopes.join(held)}" if outside.any?
269
+ end
270
+
271
+ def enrolled?
272
+ enrolled_by_id.present?
273
+ end
274
+
275
+ def unenroll!
276
+ transaction do
277
+ revoked = end_tokens!
278
+ update!(archived_at: Time.current) unless archived?
279
+ revoked
280
+ end
281
+ end
282
+
283
+ def end_tokens!
284
+ now = Time.current
285
+ tokens.where(consumed_at: nil).update_all(consumed_at: now, updated_at: now)
286
+ end
287
+
219
288
  def public?
220
289
  token_endpoint_auth_method == "none"
221
290
  end
@@ -412,6 +481,17 @@ module Masks
412
481
 
413
482
  private
414
483
 
484
+ def archived!
485
+ end_tokens!
486
+
487
+ machines = enrolled.active.pluck(:id)
488
+ return if machines.empty?
489
+
490
+ now = Time.current
491
+ Client.where(id: machines).update_all(archived_at: archived_at, updated_at: now)
492
+ Token.where(client_id: machines, consumed_at: nil).update_all(consumed_at: now, updated_at: now)
493
+ end
494
+
415
495
  def allowed_groups_are_named
416
496
  unknown = allowed_groups.grep_v(ALLOWED_GROUP)
417
497
 
@@ -9,6 +9,8 @@ module Masks
9
9
  SECRET = %w[d p q dp dq qi k].freeze
10
10
  ALGORITHMS = %w[RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 ES512].freeze
11
11
  KINDS = %w[RSA EC].freeze
12
+ CURVES = %w[P-256 P-384 P-521].freeze
13
+ SHORTEST_RSA = 2048
12
14
  LEEWAY = 30
13
15
  LONGEST = 1.hour
14
16
 
@@ -23,8 +25,11 @@ module Masks
23
25
  raise Refused, "every key must be a JSON object" unless key.is_a?(Hash)
24
26
  raise Refused, "#{key['kty'].presence || 'that'} is not a kind of key a client signs with" unless KINDS.include?(key["kty"])
25
27
  raise Refused, "a client's keys must be public" if key.keys.any? { |field| SECRET.include?(field.to_s) }
28
+ raise Refused, "#{key['crv'].presence || 'that'} is not a curve a client signs with" if key["kty"] == "EC" && !CURVES.include?(key["crv"])
26
29
 
27
- JWT::JWK.new(key).verify_key
30
+ verified = JWT::JWK.new(key).verify_key
31
+
32
+ raise Refused, "an RSA key must be at least #{SHORTEST_RSA} bits" if verified.is_a?(OpenSSL::PKey::RSA) && verified.n.num_bits < SHORTEST_RSA
28
33
  end
29
34
 
30
35
  held
@@ -0,0 +1,54 @@
1
+ module Masks
2
+ module Server
3
+ class EnrollmentToken < Token
4
+ SHORTEST = 1.minute
5
+ LONGEST = 1.hour
6
+
7
+ class << self
8
+ def lifetime
9
+ 15.minutes
10
+ end
11
+
12
+ def issue!(client, scope:, name: nil, expires_in: nil)
13
+ scopes = Scopes.list(scope)
14
+ refusal = client.enrollment_refusal(scopes)
15
+
16
+ raise Client::EnrollmentRefused, refusal if refusal
17
+
18
+ mint!(
19
+ client: client,
20
+ scopes: Scopes.join(scopes),
21
+ audience: client.resources,
22
+ payload: { "name" => name.to_s.strip.presence }.compact,
23
+ expires_at: lasting(expires_in).from_now
24
+ )
25
+ end
26
+
27
+ def redeem!(secret, metadata)
28
+ transaction(requires_new: true) do
29
+ token = claim(secret)
30
+
31
+ next nil if token.nil?
32
+
33
+ Client.enroll!(token, metadata)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def lasting(expires_in)
40
+ return lifetime if expires_in.blank?
41
+
42
+ seconds = Integer(expires_in.to_s, 10)
43
+
44
+ raise ArgumentError unless seconds.between?(SHORTEST.to_i, LONGEST.to_i)
45
+
46
+ seconds.seconds
47
+ rescue ArgumentError
48
+ raise Client::EnrollmentRefused,
49
+ "expires_in must be between #{SHORTEST.to_i} and #{LONGEST.to_i} seconds"
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -106,6 +106,9 @@ module Masks
106
106
  CLIENT_RESTORED = "client.restored".freeze
107
107
  CLIENT_SECRET_ROTATED = "client.secret_rotated".freeze
108
108
  CLIENT_LOGO_REFUSED = "client.logo_refused".freeze
109
+ CLIENT_ENROLLMENT_ISSUED = "client.enrollment_issued".freeze
110
+ CLIENT_ENROLLED = "client.enrolled".freeze
111
+ CLIENT_UNENROLLED = "client.unenrolled".freeze
109
112
 
110
113
  TOKEN_REVOKED = "token.revoked".freeze
111
114
  SAML_ASSERTED = "saml.asserted".freeze
@@ -163,6 +163,7 @@ module Masks
163
163
  "pushed_authorization_request_endpoint" => "#{url}/par",
164
164
  "device_authorization_endpoint" => "#{url}/device_authorization",
165
165
  "handshake_endpoint" => "#{url}/handshake",
166
+ "enrollment_endpoint" => "#{url}/enrollments",
166
167
  "masks_protocol_version" => Masks::PROTOCOL_VERSION,
167
168
  "revocation_endpoint" => "#{url}/revoke",
168
169
  "introspection_endpoint" => "#{url}/introspect",
@@ -17,12 +17,15 @@ module Masks
17
17
  Scopes.list(scopes).select { |scope| Scopes.prefix?(scope) && !scope.start_with?(Scopes::NAMESPACE) }
18
18
  end
19
19
 
20
- def named(scopes)
21
- where(name: prefixes(scopes))
20
+ def overlapping(scopes)
21
+ names = prefixes(scopes)
22
+ return none if names.empty?
23
+
24
+ where(names.map { "(starts_with(name, ?) OR starts_with(?, name))" }.join(" OR "), *names.flat_map { |name| [ name, name ] })
22
25
  end
23
26
 
24
27
  def taken(handshake)
25
- named(handshake.scopes).where.not(resource: handshake.resource.to_s)
28
+ overlapping(handshake.scopes).where.not(resource: handshake.resource.to_s)
26
29
  end
27
30
 
28
31
  def refusal(handshake)
@@ -1,10 +1,28 @@
1
1
  module Masks
2
2
  module Server
3
- module Replay
4
- def self.first?(kind, id, expires_in:, within: nil)
5
- key = [ kind, Current.tenant&.id, within, Digest::SHA256.hexdigest(id.to_s) ].compact.join(":")
3
+ class Replay < ApplicationRecord
4
+ include TenantScoped
6
5
 
7
- ::Rails.cache.write(key, true, expires_in: expires_in, unless_exist: true)
6
+ class << self
7
+ def first?(kind, id, expires_in:, within: nil)
8
+ now = Time.current
9
+ claimed = connection.exec_query(sanitize_sql_array([ <<~SQL, Current.tenant.id, kind.to_s, digest(within, id), now + expires_in, now, now ]), "Replay")
10
+ INSERT INTO replays (tenant_id, kind, digest, expires_at, created_at)
11
+ VALUES (?, ?, ?, ?, ?)
12
+ ON CONFLICT (tenant_id, kind, digest) DO UPDATE
13
+ SET expires_at = EXCLUDED.expires_at, created_at = EXCLUDED.created_at
14
+ WHERE replays.expires_at <= ?
15
+ RETURNING id
16
+ SQL
17
+
18
+ claimed.rows.any?
19
+ end
20
+
21
+ private
22
+
23
+ def digest(within, id)
24
+ Digest::SHA256.hexdigest([ within&.to_s, id.to_s ].to_json)
25
+ end
8
26
  end
9
27
  end
10
28
  end
@@ -16,6 +16,7 @@ module Masks
16
16
  "password_reset" => "PasswordReset",
17
17
  "confirmation" => "ConfirmationCode",
18
18
  "initial_access" => "InitialAccessToken",
19
+ "enrollment" => "EnrollmentToken",
19
20
  "request" => "PendingRequest",
20
21
  "login" => "PendingLogin",
21
22
  "pushed" => "PushedRequest",
@@ -1,11 +1,11 @@
1
1
  <!DOCTYPE html>
2
- <html lang="<%= Masks::Server::Locales.tag %>" data-theme="rose">
2
+ <html lang="<%= Masks::Server::Locales.tag %>">
3
3
  <head>
4
4
  <title><%= content_for(:title) || "Manage #{Masks::Server::Current.tenant&.name}" %></title>
5
5
  <meta charset="utf-8">
6
6
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
7
7
  <meta name="masks-root" content="<%= request.script_name %>">
8
- <meta name="color-scheme" content="dark">
8
+ <meta name="color-scheme" content="light dark">
9
9
  <link rel="icon" href="/masks-public/favicon.svg" type="image/svg+xml">
10
10
  <link rel="apple-touch-icon" href="/masks-public/icon.png">
11
11
  <%= csrf_meta_tags %>
@@ -11,7 +11,7 @@
11
11
  <span class="person-sub">
12
12
  <%= details.join(" · ") %>
13
13
  <% if actor.email_unconfirmed? %>
14
- <span class="person-note"><%= t("application.person.unconfirmed") %></span>
14
+ <span class="person-note"><%= local_assigns[:unconfirmed] || t("application.person.unconfirmed") %></span>
15
15
  <% end %>
16
16
  </span>
17
17
  <% end %>