masks 0.2.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 (126) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +50 -0
  4. data/Rakefile +11 -0
  5. data/app/assets/builds/application.css +4764 -0
  6. data/app/assets/builds/application.js +8236 -0
  7. data/app/assets/builds/application.js.map +7 -0
  8. data/app/assets/builds/masks/application.css +1 -0
  9. data/app/assets/builds/masks/application.js +7122 -0
  10. data/app/assets/builds/masks/application.js.map +7 -0
  11. data/app/assets/images/masks.png +0 -0
  12. data/app/assets/javascripts/application.js +2 -0
  13. data/app/assets/javascripts/controllers/application.js +9 -0
  14. data/app/assets/javascripts/controllers/emails_controller.js +28 -0
  15. data/app/assets/javascripts/controllers/index.js +12 -0
  16. data/app/assets/javascripts/controllers/keys_controller.js +20 -0
  17. data/app/assets/javascripts/controllers/recover_controller.js +21 -0
  18. data/app/assets/javascripts/controllers/recover_password_controller.js +21 -0
  19. data/app/assets/javascripts/controllers/session_controller.js +94 -0
  20. data/app/assets/manifest.js +2 -0
  21. data/app/assets/masks_manifest.js +2 -0
  22. data/app/assets/stylesheets/application.css +26 -0
  23. data/app/controllers/concerns/masks/controller.rb +114 -0
  24. data/app/controllers/masks/actors_controller.rb +15 -0
  25. data/app/controllers/masks/application_controller.rb +35 -0
  26. data/app/controllers/masks/backup_codes_controller.rb +34 -0
  27. data/app/controllers/masks/debug_controller.rb +9 -0
  28. data/app/controllers/masks/devices_controller.rb +20 -0
  29. data/app/controllers/masks/emails_controller.rb +60 -0
  30. data/app/controllers/masks/error_controller.rb +14 -0
  31. data/app/controllers/masks/keys_controller.rb +45 -0
  32. data/app/controllers/masks/manage/actor_controller.rb +35 -0
  33. data/app/controllers/masks/manage/actors_controller.rb +12 -0
  34. data/app/controllers/masks/manage/base_controller.rb +12 -0
  35. data/app/controllers/masks/one_time_code_controller.rb +49 -0
  36. data/app/controllers/masks/passwords_controller.rb +33 -0
  37. data/app/controllers/masks/recoveries_controller.rb +43 -0
  38. data/app/controllers/masks/sessions_controller.rb +53 -0
  39. data/app/helpers/masks/application_helper.rb +49 -0
  40. data/app/jobs/masks/application_job.rb +7 -0
  41. data/app/jobs/masks/expire_actors_job.rb +15 -0
  42. data/app/jobs/masks/expire_recoveries_job.rb +15 -0
  43. data/app/mailers/masks/actor_mailer.rb +22 -0
  44. data/app/mailers/masks/application_mailer.rb +15 -0
  45. data/app/models/concerns/masks/access.rb +162 -0
  46. data/app/models/concerns/masks/actor.rb +132 -0
  47. data/app/models/concerns/masks/adapter.rb +68 -0
  48. data/app/models/concerns/masks/role.rb +9 -0
  49. data/app/models/concerns/masks/scoped.rb +54 -0
  50. data/app/models/masks/access/actor_password.rb +20 -0
  51. data/app/models/masks/access/actor_scopes.rb +18 -0
  52. data/app/models/masks/access/actor_signup.rb +22 -0
  53. data/app/models/masks/actors/anonymous.rb +40 -0
  54. data/app/models/masks/actors/system.rb +24 -0
  55. data/app/models/masks/adapters/active_record.rb +85 -0
  56. data/app/models/masks/application_model.rb +15 -0
  57. data/app/models/masks/application_record.rb +8 -0
  58. data/app/models/masks/check.rb +192 -0
  59. data/app/models/masks/credential.rb +166 -0
  60. data/app/models/masks/credentials/backup_code.rb +30 -0
  61. data/app/models/masks/credentials/device.rb +59 -0
  62. data/app/models/masks/credentials/email.rb +48 -0
  63. data/app/models/masks/credentials/factor2.rb +71 -0
  64. data/app/models/masks/credentials/key.rb +38 -0
  65. data/app/models/masks/credentials/last_login.rb +12 -0
  66. data/app/models/masks/credentials/masquerade.rb +32 -0
  67. data/app/models/masks/credentials/nickname.rb +63 -0
  68. data/app/models/masks/credentials/one_time_code.rb +34 -0
  69. data/app/models/masks/credentials/password.rb +28 -0
  70. data/app/models/masks/credentials/recovery.rb +71 -0
  71. data/app/models/masks/credentials/session.rb +67 -0
  72. data/app/models/masks/device.rb +30 -0
  73. data/app/models/masks/error.rb +51 -0
  74. data/app/models/masks/event.rb +14 -0
  75. data/app/models/masks/mask.rb +255 -0
  76. data/app/models/masks/rails/actor.rb +190 -0
  77. data/app/models/masks/rails/actor_role.rb +12 -0
  78. data/app/models/masks/rails/device.rb +47 -0
  79. data/app/models/masks/rails/email.rb +96 -0
  80. data/app/models/masks/rails/key.rb +61 -0
  81. data/app/models/masks/rails/recovery.rb +116 -0
  82. data/app/models/masks/rails/role.rb +20 -0
  83. data/app/models/masks/rails/scope.rb +15 -0
  84. data/app/models/masks/session.rb +447 -0
  85. data/app/models/masks/sessions/access.rb +26 -0
  86. data/app/models/masks/sessions/inline.rb +16 -0
  87. data/app/models/masks/sessions/request.rb +42 -0
  88. data/app/resources/masks/actor_resource.rb +9 -0
  89. data/app/resources/masks/session_resource.rb +15 -0
  90. data/app/views/layouts/masks/application.html.erb +17 -0
  91. data/app/views/layouts/masks/mailer.html.erb +17 -0
  92. data/app/views/layouts/masks/mailer.text.erb +1 -0
  93. data/app/views/layouts/masks/manage.html.erb +25 -0
  94. data/app/views/masks/actor_mailer/recover_credentials.html.erb +33 -0
  95. data/app/views/masks/actor_mailer/recover_credentials.text.erb +1 -0
  96. data/app/views/masks/actor_mailer/verify_email.html.erb +34 -0
  97. data/app/views/masks/actor_mailer/verify_email.text.erb +8 -0
  98. data/app/views/masks/actors/current.html.erb +152 -0
  99. data/app/views/masks/application/_header.html.erb +31 -0
  100. data/app/views/masks/backup_codes/new.html.erb +103 -0
  101. data/app/views/masks/emails/new.html.erb +103 -0
  102. data/app/views/masks/emails/verify.html.erb +51 -0
  103. data/app/views/masks/keys/new.html.erb +127 -0
  104. data/app/views/masks/manage/actor/show.html.erb +126 -0
  105. data/app/views/masks/manage/actors/index.html.erb +40 -0
  106. data/app/views/masks/one_time_code/new.html.erb +150 -0
  107. data/app/views/masks/passwords/edit.html.erb +58 -0
  108. data/app/views/masks/recoveries/new.html.erb +71 -0
  109. data/app/views/masks/recoveries/password.html.erb +64 -0
  110. data/app/views/masks/sessions/new.html.erb +153 -0
  111. data/config/brakeman.ignore +28 -0
  112. data/config/locales/en.yml +286 -0
  113. data/config/routes.rb +46 -0
  114. data/db/migrate/20231205173845_create_actors.rb +94 -0
  115. data/lib/generators/masks/install/USAGE +8 -0
  116. data/lib/generators/masks/install/install_generator.rb +33 -0
  117. data/lib/generators/masks/install/templates/initializer.rb +5 -0
  118. data/lib/generators/masks/install/templates/masks.json +6 -0
  119. data/lib/masks/configuration.rb +236 -0
  120. data/lib/masks/engine.rb +25 -0
  121. data/lib/masks/middleware.rb +70 -0
  122. data/lib/masks/version.rb +5 -0
  123. data/lib/masks.rb +183 -0
  124. data/lib/tasks/masks_tasks.rake +71 -0
  125. data/masks.json +274 -0
  126. metadata +416 -0
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Checks for a known, valid :device.
6
+ #
7
+ # If the device is not associated with the session's actor, it will be.
8
+ # Identification is based on the +user_agent+ and a few other facets.
9
+ class Device < Masks::Credential
10
+ checks :device
11
+
12
+ def lookup
13
+ return unless actor
14
+
15
+ device = config.find_device(session, actor:)
16
+
17
+ session.extras(device:)
18
+
19
+ nil
20
+ end
21
+
22
+ def maskup
23
+ device = session.extra(:device)
24
+
25
+ return deny! unless device
26
+
27
+ session_key = session.data[:device_key]
28
+
29
+ # ensure devices match across sessions, which would only happen if a
30
+ # session cookie happened is shared across machines. this destroys
31
+ # the entire session and cleans up everything involved.
32
+ if session_key && device.session_key != session_key
33
+ raise "invalid device"
34
+ end
35
+
36
+ # store devices that are found in a database of known devices
37
+ if device.known?
38
+ session.data[:device_key] = device.session_key
39
+ actor.devices << device
40
+ approve!
41
+ else
42
+ cleanup
43
+ deny!
44
+ end
45
+ end
46
+
47
+ def backup
48
+ session.extra(:device)&.touch(:accessed_at) if session&.passed?
49
+ end
50
+
51
+ def cleanup
52
+ device = session.extra(:device)
53
+ device&.reset_version
54
+
55
+ session.data[:device_key] = nil
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Checks for an :actor given a matching email.
6
+ class Email < Masks::Credential
7
+ checks :actor
8
+
9
+ def lookup
10
+ return if actor || !email
11
+
12
+ actor = config.find_actor(session, email:)
13
+ actor ||=
14
+ config.build_actor(
15
+ session,
16
+ email:,
17
+ nickname: generate_nickname(email)
18
+ )
19
+ actor.signup = true
20
+ actor
21
+ end
22
+
23
+ def maskup
24
+ approve! if actor && email && valid?
25
+ end
26
+
27
+ private
28
+
29
+ def email
30
+ @email ||=
31
+ [session_params[:email], session_params[:nickname]].find do |param|
32
+ ValidateEmail.valid?(param)
33
+ end
34
+ end
35
+
36
+ def generate_nickname(email)
37
+ return email if !nickname_format || nickname_format.match?(email)
38
+
39
+ parts = email.split("@")
40
+ name = parts[0].gsub(/[^\w\d]/, "")
41
+
42
+ prefix_nickname(
43
+ "#{name.downcase.slice(0, 16)}#{SecureRandom.hex.slice(0, 8)}"
44
+ )
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Base class for factor2 credentials.
6
+ module Factor2
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ validates :secret, presence: true, if: :enabled?
11
+ validates :code, presence: { allow_nil: true }
12
+
13
+ attribute :code
14
+
15
+ checks :factor2
16
+ end
17
+
18
+ def lookup
19
+ # nothing to do here
20
+ end
21
+
22
+ def maskup
23
+ check.optional = false if actor&.factor2?
24
+
25
+ return unless enabled?
26
+
27
+ code_param = session_params&.fetch(param, nil)&.presence
28
+
29
+ self.code = verify(code_param) if code_param
30
+
31
+ if code
32
+ approve!
33
+ elsif code_param
34
+ deny!
35
+ end
36
+ end
37
+
38
+ def verified?
39
+ code
40
+ end
41
+
42
+ def enabled?
43
+ secret.present?
44
+ end
45
+
46
+ def param
47
+ raise NotImplementedError
48
+ end
49
+
50
+ def secret
51
+ raise NotImplementedError
52
+ end
53
+
54
+ def enable(code, secret:)
55
+ raise NotImplementedError
56
+ end
57
+
58
+ def verify(code)
59
+ raise NotImplementedError
60
+ end
61
+
62
+ def verify_on_enable?
63
+ false
64
+ end
65
+
66
+ def generate_secret
67
+ nil
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Checks :key given a valid Authorization header.
6
+ class Key < Masks::Credential
7
+ checks :key
8
+
9
+ attribute :accessed
10
+
11
+ def lookup
12
+ secret = session.request.authorization&.split&.last
13
+ key = session.config.find_key(session, secret:)
14
+
15
+ return unless key
16
+
17
+ session.extras(key:)
18
+ session.scoped = key
19
+ self.accessed = true
20
+ key.actor
21
+ end
22
+
23
+ def maskup
24
+ key = session.extra(:key)
25
+
26
+ if key&.actor == session&.actor && session.scoped == key
27
+ approve!
28
+ else
29
+ deny!
30
+ end
31
+ end
32
+
33
+ def backup
34
+ session.scoped.touch(:accessed_at) if session&.passed? && accessed
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # As of now, simply keeps track of +last_login+ times on the actor.
6
+ class LastLogin < Masks::Credential
7
+ def backup
8
+ actor&.touch(:last_login_at) if session&.passed?
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Checks for an :actor to masquerade as.
6
+ class Masquerade < Masks::Credential
7
+ checks :actor
8
+
9
+ def lookup
10
+ return if session.actor
11
+
12
+ value = session.data[:as]
13
+
14
+ @loaded =
15
+ case value
16
+ when Masks::ANON
17
+ Actors::Anonymous.new(session:) if session.mask&.allow_anonymous?
18
+ when session.mask.actor_scope
19
+ value
20
+ when ValidateEmail.valid?(value)
21
+ config.find_actor(session, email: value)
22
+ when String
23
+ config.find_actor(session, nickname: prefix_nickname(value))
24
+ end
25
+ end
26
+
27
+ def maskup
28
+ approve! if @loaded
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Checks for an :actor given a matching nickname.
6
+ class Nickname < Masks::Credential
7
+ checks :actor
8
+
9
+ delegate :actor_id, :nickname, to: :actor, allow_nil: true
10
+
11
+ validates :nickname, :actor, presence: true
12
+ validate :validates_custom, if: :nickname
13
+
14
+ def lookup
15
+ return if actor
16
+
17
+ actor =
18
+ (
19
+ if nickname_param
20
+ config.find_actor(session, nickname: nickname_param)
21
+ end
22
+ )
23
+ actor ||=
24
+ if nickname_param
25
+ config
26
+ .build_actor(session, nickname: nickname_param)
27
+ .tap { |actor| actor.signup = true }
28
+ end
29
+
30
+ if actor&.new_record?
31
+ actor.nickname =
32
+ prefix_nickname(actor.nickname, default: actor.nickname)
33
+ end
34
+
35
+ actor
36
+ end
37
+
38
+ def maskup
39
+ approve! if valid?
40
+ end
41
+
42
+ private
43
+
44
+ def nickname_param
45
+ @nickname_param ||=
46
+ prefix_nickname(
47
+ session_params[:nickname],
48
+ default: session_params[:nickname]
49
+ )
50
+ end
51
+
52
+ def validates_custom
53
+ return unless nickname
54
+
55
+ validates_length :nickname, nickname_config&.length
56
+
57
+ return unless nickname_format
58
+
59
+ errors.add(:nickname, :format) unless nickname_format.match?(nickname)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Checks :factor2 for a valid one-time code.
6
+ class OneTimeCode < Masks::Credential
7
+ include Factor2
8
+
9
+ private
10
+
11
+ def param
12
+ :one_time_code
13
+ end
14
+
15
+ def secret
16
+ actor&.totp_secret
17
+ end
18
+
19
+ def verify(code)
20
+ valid_code?(code, secret)
21
+ end
22
+
23
+ def valid_code?(code, secret)
24
+ if code && secret
25
+ actor.totp.verify(code)
26
+ else
27
+ false
28
+ end
29
+ rescue OpenSSL::HMACError
30
+ false
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Checks :password for a match.
6
+ class Password < Masks::Credential
7
+ checks :password
8
+
9
+ def lookup
10
+ actor.password = password if actor&.new_record? && password
11
+
12
+ nil
13
+ end
14
+
15
+ def maskup
16
+ return unless password
17
+
18
+ actor&.authenticate(password) && actor&.valid? ? approve! : deny!
19
+ end
20
+
21
+ private
22
+
23
+ def password
24
+ session_params[:password]
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Checks for a :recovery on the session.
6
+ class Recovery < Masks::Credential
7
+ checks :recovery
8
+
9
+ def lookup
10
+ return if actor
11
+
12
+ @recovery =
13
+ if recovery_key
14
+ config.find_recovery(session, token: recovery_key)
15
+ else
16
+ config.build_recovery(
17
+ session,
18
+ nickname: nickname_param,
19
+ email: email_param,
20
+ phone: phone_param,
21
+ value: recovery_value
22
+ )
23
+ end
24
+
25
+ session.extras(recovery: @recovery)
26
+
27
+ @recovery&.actor
28
+ end
29
+
30
+ def maskup
31
+ return unless valid? && actor
32
+
33
+ if recovery_key
34
+ if recovery_password && @recovery&.valid?
35
+ @recovery.reset_password!(recovery_password)
36
+ approve!
37
+ end
38
+ elsif recovery_value && @recovery&.valid?
39
+ @recovery.notify!
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def recovery_value
46
+ params.dig(:recover, :value) if writable?
47
+ end
48
+
49
+ def recovery_key
50
+ params[:token]
51
+ end
52
+
53
+ def recovery_password
54
+ params.dig(:recover, :password) if writable?
55
+ end
56
+
57
+ def phone_param
58
+ @phone_param ||= Phonelib.valid?(recovery_value) ? recovery_value : nil
59
+ end
60
+
61
+ def email_param
62
+ @email_param ||=
63
+ ValidateEmail.valid?(recovery_value) ? recovery_value : nil
64
+ end
65
+
66
+ def nickname_param
67
+ @nickname_param ||= prefix_nickname(recovery_value, default: nil)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Credentials
5
+ # Checks for past :actor(s) on a session.
6
+ #
7
+ # This can be used in lieu of supplying identifying details like an
8
+ # email/nickname (usually provided they were supplied in the past).
9
+ class Session < Masks::Credential
10
+ checks :actor
11
+
12
+ def lookup
13
+ return if session_params[:actor_id]
14
+
15
+ actor_ids = session.data[:actors]&.keys || []
16
+ actor_id = session.data[:actor]
17
+ actors = (actor_ids.any? ? config.find_actors(session, actor_ids) : [])
18
+
19
+ # only lookup and return the current actor if
20
+ # it's not provided via a param (e.g. someone
21
+ # is trying to login)
22
+ actor =
23
+ if actor_id
24
+ actors.find do |a|
25
+ a.actor_id == actor_id &&
26
+ a.session_key == session.data[:actors][a.actor_id]
27
+ end
28
+ end
29
+
30
+ actor = Actors::Anonymous.new(session:) if optional? && !actors.present?
31
+
32
+ actor
33
+ end
34
+
35
+ def maskup
36
+ return approve! if optional? && actor&.anonymous?
37
+
38
+ actor_id = actor&.actor_id
39
+
40
+ return unless actor_id && session.data[:actors]&.fetch(actor_id, nil)
41
+ return unless session.data[:actor] == actor_id
42
+
43
+ if session.data.dig(:actors, actor_id) == actor.session_key
44
+ approve!
45
+ else
46
+ cleanup
47
+ end
48
+ end
49
+
50
+ def backup
51
+ return unless actor && passed?
52
+
53
+ session.data[:actors] ||= {}
54
+ session.data[:actors][actor.actor_id] = actor.session_key
55
+ session.data[:actor] = actor.actor_id
56
+ end
57
+
58
+ def cleanup
59
+ actor_id = actor&.actor_id || session.data[:actor]
60
+
61
+ session.data[:actor] = nil
62
+ session.data[:actors] ||= {}
63
+ session.data[:actors].delete(actor_id)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ # Represents a device, used for interacting with a session.
5
+ #
6
+ # Device detection is optionally added with the +Device+ credential,
7
+ # using the +device-detector+ gem.
8
+ #
9
+ # @see Masks::Credentials::Device Masks::Credentials::Device
10
+ class Device < ApplicationModel
11
+ attribute :session
12
+
13
+ def fingerprint
14
+ return unless detector.known?
15
+
16
+ input = [
17
+ detector.name,
18
+ detector.os_name,
19
+ detector.device_name,
20
+ detector.device_type
21
+ ].compact.join("-")
22
+
23
+ Digest::SHA512.hexdigest(input)
24
+ end
25
+
26
+ def detector
27
+ @detector ||= DeviceDetector.new(session.user_agent)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ module Error
5
+ # Base class for Masks errors
6
+ class Base < RuntimeError
7
+ end
8
+
9
+ # Thrown when a session is required but not provided
10
+ class InvalidSession < Base
11
+ def initialize
12
+ super("a session was expected, but none was provided")
13
+ end
14
+ end
15
+
16
+ # Thrown when invalid configuration is detected
17
+ class InvalidConfiguration < Base
18
+ def initialize(value)
19
+ super("cannot use '#{value}' for masks.json")
20
+ end
21
+ end
22
+
23
+ # Thrown when configuration is not found
24
+ class ConfigurationNotFound < Base
25
+ def initialize
26
+ super("cannot find masks.json")
27
+ end
28
+ end
29
+
30
+ # Thrown when Masks encounters an unauthorized session
31
+ class Unauthorized < Base
32
+ def initialize
33
+ super("unauthorized")
34
+ end
35
+ end
36
+
37
+ # Thrown when Masks cannot find a mask for a session
38
+ class UnknownMask < Base
39
+ def initialize(session)
40
+ super("could not determine mask for #{session}")
41
+ end
42
+ end
43
+
44
+ # Thrown when Masks cannot find an access class for the given name
45
+ class UnknownAccess < Base
46
+ def initialize(name)
47
+ super("could not determine access class for #{name}")
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Masks
4
+ # Publishes events using +ActiveSupport::Notifications+
5
+ class Event
6
+ class << self
7
+ def emit(name, **opts, &block)
8
+ ActiveSupport::Notifications.instrument("masks.#{name}", **opts) do
9
+ block.call
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end