booth 0.0.10 → 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0280f442ad970037bc59f8ec9f200b78638e3b32fc5f3135623d7cd69a8b8afc'
4
- data.tar.gz: f0f98d84ebd59fac0ecd212d072cbe8d5960570af0ff99e1c723c4d1d74dc3f7
3
+ metadata.gz: 825454f7f3a05334b3fa0ae5dffa808debf032381a9eca3ecc33c328cd686d30
4
+ data.tar.gz: 961e43f55afaa1a41ac5234f56a8848016e89a0d3d5f2a4a3f4a992434894bad
5
5
  SHA512:
6
- metadata.gz: 5315f7ccaddb530588cbdfe5c0dd57919240e62f1378bfd54f66b96acf93c8e9cbdd31fed75bcecbca23a39fea33356dc486c4e55314a21e8248551eb819ea0a
7
- data.tar.gz: 5dbdb5999e95666b64acea7cb403d7c7dd93919929a9fb89ae9b7c42d5206a40c94e4f8226cb43395f835dde5561f37c82fc003dd551f8cef81d84bee974aa10
6
+ metadata.gz: dfd4cfc102db64fcc7d5c5654565928c095d0075ca743a8bc2ac1ade00e347b4342580a00d4e755ca082f9762f921de3f8405ed55dc8eaa5e86ff85214094207
7
+ data.tar.gz: ca078c52a93db8f77d6c46e48c81cee5ca34228606ed72bdad3b0156dc677df5979d367fb47b9f17796cb441449541c49494dff193f0217418861c25a982e4d3
data/CHANGELOG.md CHANGED
@@ -2,7 +2,22 @@
2
2
 
3
3
  # 0.0.10
4
4
 
5
- - Make Adminland.start_incognito work cross-domain
5
+ - Schema: add `booth_sessions.incognito_secret_key` (string, unique) and `booth_sessions.impersonator_session_id` (uuid, FK to self)
6
+
7
+ ### Upgrading: Add new columns via migration
8
+
9
+ ```ruby
10
+ class AddIncognitoColumnsToBoothSessions < ActiveRecord::Migration[8.1]
11
+ def change
12
+ add_column :booth_sessions, :incognito_secret_key, :string
13
+ add_index :booth_sessions, :incognito_secret_key, unique: true
14
+
15
+ add_reference :booth_sessions, :impersonator_session,
16
+ foreign_key: { to_table: :booth_sessions, on_delete: :nullify },
17
+ type: :uuid, index: true
18
+ end
19
+ end
20
+ ```
6
21
 
7
22
  # 0.0.9
8
23
 
@@ -22,6 +22,15 @@ de:
22
22
  email_too_long: The provided email is too long. It must be less at most %{maximum} characters long.
23
23
  email_too_short: The provided email is too short. It should be at least %{minimum} characters long.
24
24
  incognito_started: Sie sind jetzt im Namen eines anderen Kontos angemeldet.
25
+ incognito_token_issued: Der Inkognito-Schlüssel wurde ausgestellt.
26
+ incognito_token_expired: Der Inkognito-Schlüssel ist abgelaufen. Bitte stellen Sie einen neuen aus.
27
+ incognito_session_not_found: Der Inkognito-Schlüssel ist ungültig.
28
+ incognito_stopped: Sie sind nicht länger im Namen eines anderen Kontos angemeldet.
29
+ already_logged_in_on_scope: Sie sind bereits angemeldet. Bitte melden Sie sich zuerst ab.
30
+ scope_mismatch: Der angegebene Bereich stimmt nicht mit dem Zielkonto überein.
31
+ not_logged_in: Sie müssen angemeldet sein, um diese Aktion auszuführen.
32
+ session_not_found: Ihre Sitzung konnte nicht gefunden werden.
33
+ impersonator_session_not_found: Die ursprüngliche Sitzung konnte nicht wiederhergestellt werden.
25
34
  incompatible_webauth_device: Sorry, this device cannot be used. Please try another webauth device.
26
35
  invalid_domain: Die Domain "%{domain}" ist keine gültige Domain.
27
36
  invalid_email_characters: The provided email address contains invalid characters.
@@ -22,6 +22,15 @@ en:
22
22
  email_too_long: The provided email is too long. It must be less at most %{maximum} characters long.
23
23
  email_too_short: The provided email is too short. It should be at least %{minimum} characters long.
24
24
  incognito_started: You are now acting on behalf of another account.
25
+ incognito_token_issued: The incognito token has been issued.
26
+ incognito_token_expired: The incognito token has expired. Please issue a new one.
27
+ incognito_session_not_found: The incognito token is invalid.
28
+ incognito_stopped: You are no longer acting on behalf of another account.
29
+ already_logged_in_on_scope: You are already logged in. Please log out first.
30
+ scope_mismatch: The provided scope does not match the target account.
31
+ not_logged_in: You must be logged in to perform this action.
32
+ session_not_found: Your session could not be found.
33
+ impersonator_session_not_found: The original session could not be restored.
25
34
  incompatible_webauth_device: Sorry, this device cannot be used. Please try another webauth device.
26
35
  invalid_domain: The Domain "%{domain}" is not a valid domain name.
27
36
  invalid_email_characters: The provided email address contains invalid characters.
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Booth
4
+ module Adminland
5
+ module Incognitos
6
+ class ConsumeToken
7
+ include Calls
8
+ include ::Booth::Logging
9
+
10
+ option :scope, ::Booth::Coercers::Scope
11
+ option :request, ::Booth::Coercers::Request
12
+ option :secret_key
13
+
14
+ def call
15
+ do_find_session
16
+ .on_success { do_check_expiry }
17
+ .on_success { do_check_not_already_logged_in_as_someone_else }
18
+ .on_success { do_compare_scope }
19
+ .on_success { do_atomic_updates }
20
+ .on_success { do_audit }
21
+ .on_success { do_login }
22
+ end
23
+
24
+ private
25
+
26
+ attr_accessor :session
27
+
28
+ def do_find_session
29
+ self.session = ::Booth::Models::Session.find_by(incognito_secret_key: secret_key)
30
+
31
+ return Tron.failure(:incognito_session_not_found,
32
+ public_message: I18n.t('booth.incognito_session_not_found')) unless session
33
+
34
+ log { "Found session ##{session.id}" }
35
+ Tron.success :session_found
36
+ end
37
+
38
+ def do_check_expiry
39
+ return Tron.failure(:incognito_token_expired,
40
+ public_message: I18n.t('booth.incognito_token_expired')) unless session.created_at > 60.seconds.ago
41
+
42
+ Tron.success :session_within_expiry
43
+ end
44
+
45
+ def do_check_not_already_logged_in_as_someone_else
46
+ if request.authentication.logged_in? &&
47
+ request.authentication.credential_id != session.incognito_credential_id
48
+ log { 'Someone else is logged in on this scope' }
49
+ return Tron.failure :already_logged_in_on_scope,
50
+ public_message: I18n.t('booth.already_logged_in_on_scope')
51
+ end
52
+
53
+ Tron.success :not_logged_in_or_is_impersonator
54
+ end
55
+
56
+ def do_compare_scope
57
+ return Tron.failure(:scope_mismatch,
58
+ public_message: I18n.t('booth.scope_mismatch')) unless session.credential.scope == scope.to_s
59
+
60
+ Tron.success :scope_matches
61
+ end
62
+
63
+ def do_atomic_updates
64
+ ::Booth::Models::Session.transaction do
65
+ session.update!(incognito_secret_key: nil)
66
+ log { 'Cleared incognito_secret_key' }
67
+ revoke_impersonator_if_same_scope
68
+ session.update!(agent: request.agent, most_recent_ip: request.ip)
69
+ end
70
+
71
+ Tron.success :atomic_updates_done
72
+ end
73
+
74
+ def do_audit
75
+ ::Booth::Models::Audit.create!(
76
+ credential_id: session.incognito_credential_id,
77
+ ip: request.ip,
78
+ agent: request.agent,
79
+ event: :incognito_consumed,
80
+ )
81
+
82
+ Tron.success :audited
83
+ end
84
+
85
+ def do_login
86
+ request.authentication.login(session:)
87
+ Tron.success :incognito_started,
88
+ public_message: I18n.t('booth.incognito_started')
89
+ end
90
+
91
+ def revoke_impersonator_if_same_scope
92
+ return unless request.authentication.logged_in? &&
93
+ request.authentication.credential_id == session.incognito_credential_id
94
+
95
+ impersonator_session = ::Booth::Models::Session.find_by(
96
+ id: request.authentication.session_id,
97
+ )
98
+
99
+ return unless impersonator_session
100
+
101
+ log { "Revoking impersonator session ##{impersonator_session.id}" }
102
+ impersonator_session.update!(revoked_at: Time.current, revoke_reason: :impersonation)
103
+ session.update!(impersonator_session_id: impersonator_session.id)
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Booth
4
+ module Adminland
5
+ module Incognitos
6
+ class GenerateSecretKey
7
+ include Calls
8
+ include ::Booth::Logging
9
+
10
+ option :scope, ::Booth::Coercers::Scope
11
+ option :request, ::Booth::Coercers::Request
12
+ option :target_credential_id
13
+
14
+ def call
15
+ do_check_impersonator_logged_in
16
+ .on_success { do_find_target_credential }
17
+ .on_success { do_create_session }
18
+ .on_success { do_audit }
19
+ .on_success { do_return_key }
20
+ end
21
+
22
+ private
23
+
24
+ attr_accessor :impersonator_credential_id, :target_credential, :session, :secret_key
25
+
26
+ def do_check_impersonator_logged_in
27
+ return Tron.failure(:not_logged_in_as_impersonator,
28
+ public_message: I18n.t('booth.not_logged_in_as_impersonator')) unless request.authentication.logged_in?
29
+
30
+ self.impersonator_credential_id = request.authentication.credential_id
31
+ log { "Impersonator credential_id=#{impersonator_credential_id}" }
32
+ Tron.success :impersonator_captured
33
+ end
34
+
35
+ def do_find_target_credential
36
+ self.target_credential = ::Booth::Models::Credential.find_by(id: target_credential_id)
37
+
38
+ return Tron.failure(:target_credential_not_found,
39
+ public_message: I18n.t('booth.target_credential_not_found')) unless target_credential
40
+
41
+ log { "Target credential ##{target_credential.id} found" }
42
+ Tron.success :target_credential_found
43
+ end
44
+
45
+ def do_create_session
46
+ self.secret_key = SecureRandom.hex(32)
47
+
48
+ self.session = ::Booth::Models::Session.create!(
49
+ credential: target_credential,
50
+ incognito_credential_id: impersonator_credential_id,
51
+ incognito_secret_key: secret_key,
52
+ most_recent_ip: request.ip,
53
+ )
54
+
55
+ log { "Created incognito session ##{session.id}" }
56
+ Tron.success :session_created
57
+ end
58
+
59
+ def do_audit
60
+ ::Booth::Models::Audit.create!(
61
+ credential_id: impersonator_credential_id,
62
+ ip: request.ip,
63
+ agent: request.agent,
64
+ event: :incognito_token_issued,
65
+ )
66
+
67
+ Tron.success :audited
68
+ end
69
+
70
+ def do_return_key
71
+ Tron.success :incognito_token_issued,
72
+ secret_key:,
73
+ public_message: I18n.t('booth.incognito_token_issued')
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Booth
4
+ module Adminland
5
+ module Incognitos
6
+ class StopIncognito
7
+ include Calls
8
+ include ::Booth::Logging
9
+
10
+ option :scope, ::Booth::Coercers::Scope
11
+ option :request, ::Booth::Coercers::Request
12
+
13
+ def call
14
+ do_check_logged_in
15
+ .on_success { do_find_current_session }
16
+ .on_success { do_find_impersonator_session }
17
+ .on_success { do_revoke_incognito }
18
+ .on_success { do_audit }
19
+ .on_success { impersonator_session ? do_restore_impersonator : do_logout }
20
+ end
21
+
22
+ private
23
+
24
+ attr_accessor :current_session, :impersonator_session
25
+
26
+ def do_check_logged_in
27
+ return Tron.failure(:not_logged_in,
28
+ public_message: I18n.t('booth.not_logged_in')) unless request.authentication.logged_in?
29
+
30
+ Tron.success :logged_in
31
+ end
32
+
33
+ def do_find_current_session
34
+ self.current_session = ::Booth::Models::Session.find_by(
35
+ id: request.authentication.session_id,
36
+ )
37
+
38
+ return Tron.failure(:session_not_found,
39
+ public_message: I18n.t('booth.session_not_found')) unless current_session
40
+
41
+ Tron.success :current_session_found
42
+ end
43
+
44
+ def do_find_impersonator_session
45
+ return Tron.success(:no_impersonator_session) unless current_session.impersonator_session_id
46
+
47
+ self.impersonator_session = ::Booth::Models::Session.find_by(
48
+ id: current_session.impersonator_session_id,
49
+ )
50
+
51
+ return Tron.failure(:impersonator_session_not_found,
52
+ public_message: I18n.t('booth.impersonator_session_not_found')) unless impersonator_session
53
+
54
+ Tron.success :impersonator_session_found
55
+ end
56
+
57
+ def do_revoke_incognito
58
+ current_session.update!(revoked_at: Time.current, revoke_reason: :incognito_stopped)
59
+ Tron.success :incognito_revoked
60
+ end
61
+
62
+ def do_audit
63
+ ::Booth::Models::Audit.create!(
64
+ credential_id: current_session.incognito_credential_id,
65
+ ip: request.ip,
66
+ agent: request.agent,
67
+ event: :incognito_stopped,
68
+ )
69
+
70
+ Tron.success :audited
71
+ end
72
+
73
+ def do_restore_impersonator
74
+ impersonator_session.update!(revoked_at: nil, revoke_reason: nil)
75
+ request.authentication.login(session: impersonator_session)
76
+ Tron.success :incognito_stopped,
77
+ public_message: I18n.t('booth.incognito_stopped')
78
+ end
79
+
80
+ def do_logout
81
+ request.authentication.logout
82
+ Tron.success :incognito_stopped,
83
+ public_message: I18n.t('booth.incognito_stopped')
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -48,8 +48,16 @@ module Booth
48
48
  # Incognito Helpers
49
49
  # -----------------
50
50
 
51
- def self.start_incognito(...)
52
- ::Booth::Adminland::Incognitos::Start.call(...)
51
+ def self.generate_incognito_secret_key(...)
52
+ ::Booth::Adminland::Incognitos::GenerateSecretKey.call(...)
53
+ end
54
+
55
+ def self.consume_incognito_token(...)
56
+ ::Booth::Adminland::Incognitos::ConsumeToken.call(...)
57
+ end
58
+
59
+ def self.stop_incognito(...)
60
+ ::Booth::Adminland::Incognitos::StopIncognito.call(...)
53
61
  end
54
62
 
55
63
  # ----------------
@@ -9,6 +9,9 @@ module Booth
9
9
  # changed_tenant (payload from: to:)
10
10
  enum :event, %i[
11
11
  completed_onboarding
12
+ incognito_consumed
13
+ incognito_stopped
14
+ incognito_token_issued
12
15
  logout
13
16
  registered
14
17
  ].index_with(&:to_s), prefix: true
data/lib/booth/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Booth
4
- VERSION = '0.0.10'
4
+ VERSION = '0.1.0'
5
5
  end
@@ -74,6 +74,8 @@ class CreateBoothTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.cu
74
74
  t.string :agent
75
75
  t.string :location
76
76
  t.hstore :historical_locations, null: false, default: {}
77
+ t.string :incognito_secret_key, index: { unique: true }
78
+ t.references :impersonator_session, foreign_key: { to_table: :booth_sessions, on_delete: :nullify }, type: :uuid, index: true
77
79
  t.timestamps
78
80
  end
79
81
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: booth
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.10
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - halo
@@ -230,7 +230,9 @@ files:
230
230
  - lib/booth/adminland/authenticators/exist.rb
231
231
  - lib/booth/adminland/credentials/create.rb
232
232
  - lib/booth/adminland/credentials/index.rb
233
- - lib/booth/adminland/incognitos/start.rb
233
+ - lib/booth/adminland/incognitos/consume_token.rb
234
+ - lib/booth/adminland/incognitos/generate_secret_key.rb
235
+ - lib/booth/adminland/incognitos/stop_incognito.rb
234
236
  - lib/booth/adminland/onboardings/create.rb
235
237
  - lib/booth/adminland/onboardings/destroy.rb
236
238
  - lib/booth/adminland/onboardings/find.rb
@@ -1,106 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Booth
4
- module Adminland
5
- module Incognitos
6
- # The admin (impersonator) specifies their own +scope+ and a +target_scope+.
7
- # +request+ is coerced with the impersonator's +scope+ (+request.authentication+ checks the
8
- # impersonator's scope). We create a new session for the target credential and store the
9
- # impersonator's credential_id as +incognito_credential_id+. If +target_scope+ matches the
10
- # impersonator's scope, we revoke the impersonator's session before logging in as the target.
11
- class Start
12
- include Calls
13
- include ::Booth::Logging
14
-
15
- option :scope, ::Booth::Coercers::Scope
16
- option :request, ::Booth::Coercers::Request
17
- option :target_credential_id
18
- option :target_scope
19
-
20
- def call
21
- do_check_impersonator_logged_in
22
- .on_success { do_find_target_credential }
23
- .on_success { do_compare_scope }
24
- .on_success { do_create_session }
25
- .on_success { do_revoke_impersonator_if_same_scope }
26
- .on_success { do_login }
27
- end
28
-
29
- private
30
-
31
- attr_accessor :impersonator_credential_id, :target_credential, :session
32
-
33
- def do_check_impersonator_logged_in
34
- unless request.authentication.logged_in?
35
- log { 'No impersonator logged in' }
36
- return Tron.failure :not_logged_in_as_impersonator,
37
- public_message: I18n.t('booth.not_logged_in_as_impersonator')
38
- end
39
-
40
- self.impersonator_credential_id = request.authentication.credential_id
41
- log { "Impersonator credential_id=#{impersonator_credential_id}" }
42
- Tron.success :impersonator_captured
43
- end
44
-
45
- def do_find_target_credential
46
- self.target_credential = ::Booth::Models::Credential.find_by(id: target_credential_id)
47
-
48
- if target_credential
49
- log { "Target credential ##{target_credential.id} found" }
50
- Tron.success :target_credential_found
51
- else
52
- Tron.failure :target_credential_not_found,
53
- public_message: I18n.t('booth.target_credential_not_found')
54
- end
55
- end
56
-
57
- def do_compare_scope
58
- ::Booth::Comparisons::Scope.call(
59
- expected: target_credential.scope,
60
- actual: target_scope,
61
- )
62
- end
63
-
64
- def do_create_session
65
- self.session = ::Booth::Models::Session.create!(
66
- credential: target_credential,
67
- incognito_credential_id: impersonator_credential_id,
68
- agent: request.agent,
69
- most_recent_ip: request.ip,
70
- )
71
- log { "Created incognito session ##{session.id}" }
72
- Tron.success :session_created
73
- end
74
-
75
- def do_revoke_impersonator_if_same_scope
76
- return Tron.success(:no_revoke_needed) unless same_scope?
77
-
78
- impersonator_session = ::Booth::Models::Session.find_by(
79
- id: request.authentication.session_id,
80
- )
81
- return Tron.failure(:impersonator_session_not_found) unless impersonator_session
82
-
83
- log { "Revoking impersonator session ##{impersonator_session.id}" }
84
- impersonator_session.update!(revoked_at: Time.current, revoke_reason: :impersonation)
85
-
86
- Tron.success :impersonator_session_revoked
87
- end
88
-
89
- def do_login
90
- target_auth.login(session:)
91
- log { "Incognito started: #{target_credential.username}" }
92
- Tron.success :incognito_started,
93
- public_message: I18n.t('booth.incognito_started')
94
- end
95
-
96
- def same_scope?
97
- target_scope == request.scope
98
- end
99
-
100
- def target_auth
101
- @target_auth ||= request.authentication(scope: target_scope)
102
- end
103
- end
104
- end
105
- end
106
- end