machina-auth 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/machina/authorized.rb +1 -1
- data/lib/machina/controller_helpers.rb +11 -11
- data/lib/machina/current.rb +13 -1
- data/lib/machina/identity_client.rb +2 -2
- data/lib/machina/middleware/authentication.rb +17 -12
- data/lib/machina/permission_sync.rb +2 -3
- data/lib/machina/test_helpers.rb +4 -4
- data/lib/machina/version.rb +1 -1
- data/lib/machina/webhook_receiver.rb +4 -2
- data/lib/machina/workspace_scoped.rb +1 -2
- data/lib/machina.rb +37 -17
- data/spec/machina/current_spec.rb +32 -0
- data/spec/machina/middleware/authentication_spec.rb +6 -6
- data/spec/machina/workspace_scoped_spec.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ca27cf7dd475d64e5bbf876084d6e7445a56ac65dbf9967d5b9b50a91f04f155
|
|
4
|
+
data.tar.gz: ad165e21c3628da603b016a7e40911f7efc3e60131cbad506ee1c5cb2dde48c8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3830bfe8f9b10c30bfedf347d3efa00ecc36feb09a028ed9372512c7c0a3f6817bee27a321c008394c6361b68c85e2aeb5ad0d71b41a72fd779ea8340d7e7fe6
|
|
7
|
+
data.tar.gz: f6c04f4aea2b6ad6f01c5632c3e49e92291a8341bb01c751f11eb22d6612aaad8e7f9b4c1124d77d9138c34094de00f41195cbabd60d6957293613111fa60bef
|
data/lib/machina/authorized.rb
CHANGED
|
@@ -75,8 +75,8 @@ module Machina
|
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
def assign_session_attrs(data)
|
|
78
|
-
@session_id = data.dig('session', 'id') || data['session_id']
|
|
79
78
|
@type = data.dig('session', 'type')&.to_sym
|
|
79
|
+
@session_id = data.dig('session', 'id') || data['session_id']
|
|
80
80
|
raw_expires = data.dig('session', 'expires_at') || data['expires_at']
|
|
81
81
|
@expires_at = raw_expires ? Time.zone.parse(raw_expires) : nil
|
|
82
82
|
end
|
|
@@ -8,20 +8,11 @@ module Machina
|
|
|
8
8
|
|
|
9
9
|
included do
|
|
10
10
|
helper_method :authorized, :logged_in? if respond_to?(:helper_method)
|
|
11
|
-
|
|
12
|
-
rescue_from Machina::Unauthorized do |error|
|
|
13
|
-
if request.format.json?
|
|
14
|
-
render json: { error: 'forbidden', permission: error.message }, status: :forbidden
|
|
15
|
-
else
|
|
16
|
-
redirect_to main_app.respond_to?(:root_path) ? main_app.root_path : '/',
|
|
17
|
-
alert: "You don't have permission to do that."
|
|
18
|
-
end
|
|
19
|
-
end
|
|
11
|
+
rescue_from Machina::Unauthorized, with: :respond_unauthorized
|
|
20
12
|
end
|
|
21
13
|
|
|
22
14
|
def authorized
|
|
23
|
-
|
|
24
|
-
current.authorized || Machina::Authorized::EMPTY
|
|
15
|
+
Machina::Current.authorized || Machina::Authorized::EMPTY
|
|
25
16
|
end
|
|
26
17
|
|
|
27
18
|
def logged_in?
|
|
@@ -58,6 +49,15 @@ module Machina
|
|
|
58
49
|
|
|
59
50
|
private
|
|
60
51
|
|
|
52
|
+
def respond_unauthorized(error)
|
|
53
|
+
if request.format.json?
|
|
54
|
+
render json: { error: 'forbidden', permission: error.message }, status: :forbidden
|
|
55
|
+
else
|
|
56
|
+
path = main_app.respond_to?(:root_path) ? main_app.root_path : '/'
|
|
57
|
+
redirect_to path, alert: "You don't have permission to do that."
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
61
|
def extract_bearer_token
|
|
62
62
|
header = request.headers['Authorization'].to_s
|
|
63
63
|
match = header.match(/\ABearer\s+(.+)\z/)
|
data/lib/machina/current.rb
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Machina
|
|
4
|
-
# Thread-safe per-request store for the currently authenticated Authorized object.
|
|
4
|
+
# Thread-safe per-request store for the currently authenticated {Authorized} object.
|
|
5
|
+
#
|
|
6
|
+
# The middleware writes exclusively to +Machina::Current.authorized+. Host
|
|
7
|
+
# apps that want +::Current.authorized+ should inherit from this class:
|
|
8
|
+
#
|
|
9
|
+
# class Current < Machina::Current
|
|
10
|
+
# # add app-specific attributes here
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Note: each +CurrentAttributes+ subclass has independent thread-local
|
|
14
|
+
# storage, so +::Current.authorized+ and +Machina::Current.authorized+ hold
|
|
15
|
+
# separate values even when +::Current < Machina::Current+. The gem always
|
|
16
|
+
# reads and writes through +Machina::Current+.
|
|
5
17
|
class Current < ActiveSupport::CurrentAttributes
|
|
6
18
|
attribute :authorized
|
|
7
19
|
end
|
|
@@ -42,9 +42,9 @@ module Machina
|
|
|
42
42
|
ensure_configured!
|
|
43
43
|
|
|
44
44
|
response = connection.post(path) do |request|
|
|
45
|
-
request.headers['Authorization'] = "Bearer #{config.service_token}"
|
|
46
|
-
request.headers['Content-Type'] = 'application/json'
|
|
47
45
|
request.headers['Accept'] = 'application/json'
|
|
46
|
+
request.headers['Content-Type'] = 'application/json'
|
|
47
|
+
request.headers['Authorization'] = "Bearer #{config.service_token}"
|
|
48
48
|
request.body = JSON.generate(payload)
|
|
49
49
|
end
|
|
50
50
|
|
|
@@ -19,16 +19,20 @@ module Machina
|
|
|
19
19
|
return @app.call(env) if token.blank?
|
|
20
20
|
|
|
21
21
|
session_data = resolve_session(token)
|
|
22
|
-
return unauthorized_response unless session_data
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
# Token present but invalid/expired: clear the stale cache entry and
|
|
24
|
+
# fall through with nil authorized. API callers get 401 from the
|
|
25
|
+
# controller; browser users get redirected to login by authenticate!.
|
|
26
|
+
unless session_data
|
|
27
|
+
Machina.cache.delete(cache_key(token))
|
|
28
|
+
return @app.call(env)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Machina::Current.authorized = Machina::Authorized.new(session_data)
|
|
27
32
|
|
|
28
33
|
@app.call(env)
|
|
29
34
|
ensure
|
|
30
35
|
Machina::Current.reset
|
|
31
|
-
::Current.reset if defined?(::Current) && ::Current != Machina::Current
|
|
32
36
|
end
|
|
33
37
|
|
|
34
38
|
private
|
|
@@ -53,7 +57,10 @@ module Machina
|
|
|
53
57
|
end
|
|
54
58
|
|
|
55
59
|
def extract_token(request)
|
|
56
|
-
request.cookies['machina_session']
|
|
60
|
+
request.cookies['machina_session']
|
|
61
|
+
|| extract_bearer(request)
|
|
62
|
+
|| request.headers['X-Api-Key']
|
|
63
|
+
|| request.params['token']
|
|
57
64
|
end
|
|
58
65
|
|
|
59
66
|
def extract_bearer(request)
|
|
@@ -90,16 +97,18 @@ module Machina
|
|
|
90
97
|
end
|
|
91
98
|
|
|
92
99
|
def cache_workspace_ref(data)
|
|
93
|
-
workspace
|
|
100
|
+
workspace = data['workspace']
|
|
94
101
|
organization = data['organization']
|
|
95
102
|
|
|
96
103
|
return unless workspace.is_a?(Hash) && organization.is_a?(Hash)
|
|
97
104
|
return unless defined?(Machina::WorkspaceRef) && Machina::WorkspaceRef.table_exists?
|
|
98
105
|
|
|
99
106
|
ref = Machina::WorkspaceRef.find_or_initialize_by(tenant_ref: workspace['id'])
|
|
100
|
-
|
|
107
|
+
|
|
101
108
|
ref.name = workspace['name']
|
|
102
109
|
ref.cached_at = Time.current
|
|
110
|
+
ref.organization_id = organization['id']
|
|
111
|
+
|
|
103
112
|
ref.save!
|
|
104
113
|
rescue StandardError
|
|
105
114
|
nil
|
|
@@ -109,10 +118,6 @@ module Machina
|
|
|
109
118
|
"machina:session:#{token}"
|
|
110
119
|
end
|
|
111
120
|
|
|
112
|
-
def unauthorized_response
|
|
113
|
-
body = JSON.generate(error: 'unauthorized')
|
|
114
|
-
[401, { 'Content-Type' => 'application/json', 'Content-Length' => body.bytesize.to_s }, [body]]
|
|
115
|
-
end
|
|
116
121
|
end
|
|
117
122
|
end
|
|
118
123
|
end
|
|
@@ -16,14 +16,13 @@ module Machina
|
|
|
16
16
|
|
|
17
17
|
product_id = manifest[:product_id] || Machina.config.product_id
|
|
18
18
|
if product_id.blank?
|
|
19
|
-
raise Machina::ConfigurationError,
|
|
20
|
-
'product_id is required for permission sync (set in machina.yml or Machina.config)'
|
|
19
|
+
raise Machina::ConfigurationError, 'product_id is required for permission sync (set in machina.yml or Machina.config)'
|
|
21
20
|
end
|
|
22
21
|
|
|
23
22
|
Machina.identity_client.sync_permissions(
|
|
24
23
|
product_id:,
|
|
25
|
-
permissions: manifest.fetch(:permissions),
|
|
26
24
|
policies: manifest.fetch(:policies, []),
|
|
25
|
+
permissions: manifest.fetch(:permissions),
|
|
27
26
|
)
|
|
28
27
|
end
|
|
29
28
|
|
data/lib/machina/test_helpers.rb
CHANGED
|
@@ -63,10 +63,10 @@ module Machina
|
|
|
63
63
|
data = default_session_data
|
|
64
64
|
|
|
65
65
|
data['user'].merge!(stringify(user))
|
|
66
|
-
data['
|
|
66
|
+
data['session'].merge!(stringify(session))
|
|
67
67
|
data['workspace'].merge!(stringify(workspace))
|
|
68
|
+
data['organization'].merge!(stringify(organization))
|
|
68
69
|
data['workspace']['id'] = tenant_ref.to_s if tenant_ref
|
|
69
|
-
data['session'].merge!(stringify(session))
|
|
70
70
|
data['permissions'] = permissions.map(&:to_s) if permissions
|
|
71
71
|
|
|
72
72
|
authorized = Machina::Authorized.new(data)
|
|
@@ -87,9 +87,9 @@ module Machina
|
|
|
87
87
|
def default_session_data
|
|
88
88
|
{
|
|
89
89
|
'user' => fake_user_data,
|
|
90
|
-
'organization' => fake_organization_data,
|
|
91
|
-
'workspace' => fake_workspace_data,
|
|
92
90
|
'session' => fake_session_data,
|
|
91
|
+
'workspace' => fake_workspace_data,
|
|
92
|
+
'organization' => fake_organization_data,
|
|
93
93
|
'permissions' => []
|
|
94
94
|
}
|
|
95
95
|
end
|
data/lib/machina/version.rb
CHANGED
|
@@ -5,13 +5,15 @@ module Machina
|
|
|
5
5
|
# invalidating or marking cached sessions as stale when permissions change.
|
|
6
6
|
class WebhookReceiver
|
|
7
7
|
def initialize(request, cache: Machina.cache)
|
|
8
|
-
@request = request
|
|
9
8
|
@cache = cache
|
|
9
|
+
@request = request
|
|
10
10
|
@raw_body = request.body.read
|
|
11
|
+
|
|
11
12
|
request.body.rewind if request.body.respond_to?(:rewind)
|
|
12
|
-
|
|
13
|
+
|
|
13
14
|
@event = request.headers['X-Machina-Event']
|
|
14
15
|
@signature = request.headers['X-Machina-Signature'].to_s
|
|
16
|
+
@payload = @raw_body.present? ? JSON.parse(@raw_body) : {}
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
def valid?
|
|
@@ -12,8 +12,7 @@ module Machina
|
|
|
12
12
|
validates :tenant_ref, presence: true
|
|
13
13
|
|
|
14
14
|
scope :in_current_workspace, lambda {
|
|
15
|
-
|
|
16
|
-
where(tenant_ref: current.authorized&.workspace_id)
|
|
15
|
+
where(tenant_ref: Machina::Current.authorized&.workspace_id)
|
|
17
16
|
}
|
|
18
17
|
end
|
|
19
18
|
end
|
data/lib/machina.rb
CHANGED
|
@@ -57,26 +57,28 @@ module Machina
|
|
|
57
57
|
|
|
58
58
|
# Builds the Console authorize URL.
|
|
59
59
|
#
|
|
60
|
-
#
|
|
60
|
+
# The Console's +/authorize+ endpoint requires a +redirect_to+ query param
|
|
61
|
+
# so it knows where to send the user after workspace selection. This method
|
|
62
|
+
# always produces that param — the two keyword arguments control how:
|
|
63
|
+
#
|
|
64
|
+
# 1. *Callback-based (preferred)* — uses the configured
|
|
65
|
+
# +identity_callback_uri+ as the redirect target. Pass +return_to+ to
|
|
66
|
+
# append the user's intended destination as a query param on the callback.
|
|
67
|
+
# 2. *Explicit* — pass +redirect_to+ directly to bypass callback resolution.
|
|
68
|
+
# Intended for backwards compatibility only.
|
|
69
|
+
#
|
|
70
|
+
# @param redirect_to [String, nil] explicit product redirect URL; when
|
|
71
|
+
# present the callback URI config is ignored
|
|
61
72
|
# @param return_to [String, nil] user's intended destination, appended to
|
|
62
|
-
#
|
|
63
|
-
# @return [String] the full authorize URL
|
|
64
|
-
# @raise [ConfigurationError] when
|
|
65
|
-
# +identity_callback_uri+ is
|
|
73
|
+
# +identity_callback_uri+ so the product app can restore it after auth
|
|
74
|
+
# @return [String] the full Console authorize URL
|
|
75
|
+
# @raise [ConfigurationError] when +redirect_to+ is omitted and
|
|
76
|
+
# +identity_callback_uri+ is not configured
|
|
66
77
|
def authorize_url(redirect_to: nil, return_to: nil)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return "#{base}/authorize?redirect_to=#{CGI.escape(redirect_to)}" if redirect_to.present?
|
|
70
|
-
|
|
71
|
-
callback = config.identity_callback_uri
|
|
72
|
-
if callback.blank?
|
|
73
|
-
raise ConfigurationError,
|
|
74
|
-
'identity_callback_uri must be configured to use authorize_url without an explicit redirect_to'
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
target = return_to.present? ? "#{callback}?return_to=#{CGI.escape(return_to)}" : callback
|
|
78
|
+
redirect_target = redirect_to.presence || callback_redirect_target(return_to)
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
base = config.identity_service_url.to_s.sub(%r{/\z}, '')
|
|
81
|
+
"#{base}/authorize?redirect_to=#{CGI.escape(redirect_target)}"
|
|
80
82
|
end
|
|
81
83
|
|
|
82
84
|
# Convenience wrapper that delegates to {authorize_url} with +return_to+.
|
|
@@ -86,5 +88,23 @@ module Machina
|
|
|
86
88
|
def login_url(return_to:)
|
|
87
89
|
authorize_url(return_to:)
|
|
88
90
|
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# Resolves the redirect target from +identity_callback_uri+ config,
|
|
95
|
+
# optionally appending a +return_to+ query param.
|
|
96
|
+
#
|
|
97
|
+
# @param return_to [String, nil] path or URL the user wants after auth
|
|
98
|
+
# @return [String] the callback URI (with optional +return_to+)
|
|
99
|
+
# @raise [ConfigurationError] when +identity_callback_uri+ is blank
|
|
100
|
+
def callback_redirect_target(return_to)
|
|
101
|
+
callback = config.identity_callback_uri
|
|
102
|
+
|
|
103
|
+
if callback.blank?
|
|
104
|
+
raise ConfigurationError, 'identity_callback_uri must be configured to use authorize_url without an explicit redirect_to'
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
return_to.present? ? "#{callback}?return_to=#{CGI.escape(return_to)}" : callback
|
|
108
|
+
end
|
|
89
109
|
end
|
|
90
110
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../rails_helper'
|
|
4
|
+
require_relative '../support/mock_responses'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Machina::Current do
|
|
7
|
+
let(:data) { MockResponses.session_resolution['data'] }
|
|
8
|
+
let(:authorized) { Machina::Authorized.new(data) }
|
|
9
|
+
|
|
10
|
+
describe '.authorized' do
|
|
11
|
+
it 'returns the value set on Machina::Current' do
|
|
12
|
+
described_class.authorized = authorized
|
|
13
|
+
|
|
14
|
+
expect(described_class.authorized).to eq(authorized)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'is independent from host ::Current even when it inherits' do
|
|
18
|
+
# The dummy app's ::Current inherits from Machina::Current,
|
|
19
|
+
# but each CurrentAttributes subclass has its own thread-local storage.
|
|
20
|
+
described_class.authorized = authorized
|
|
21
|
+
|
|
22
|
+
expect(::Current.authorized).to be_nil
|
|
23
|
+
expect(described_class.authorized).to eq(authorized)
|
|
24
|
+
ensure
|
|
25
|
+
::Current.reset
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'returns nil when nothing is set' do
|
|
29
|
+
expect(described_class.authorized).to be_nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -42,7 +42,7 @@ RSpec.describe Machina::Middleware::Authentication do
|
|
|
42
42
|
expect(workspace_ref).to have_attributes(name: 'Primary')
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
it '
|
|
45
|
+
it 'passes through with nil authorized when resolution fails' do
|
|
46
46
|
allow(identity_client).to receive(:resolve_session).and_return(
|
|
47
47
|
Machina::IdentityClient::Response.new(status: 404, body: '{}'),
|
|
48
48
|
)
|
|
@@ -50,11 +50,11 @@ RSpec.describe Machina::Middleware::Authentication do
|
|
|
50
50
|
env = Rack::MockRequest.env_for('/resource', 'HTTP_AUTHORIZATION' => 'Bearer ps_123')
|
|
51
51
|
status, _headers, body = middleware.call(env)
|
|
52
52
|
|
|
53
|
-
expect(status).to eq(
|
|
54
|
-
expect(JSON.parse(body.first)).to eq('
|
|
53
|
+
expect(status).to eq(200)
|
|
54
|
+
expect(JSON.parse(body.first)).to eq('user_id' => nil, 'permissions' => nil)
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
it 'evicts cached session when Console returns non-200 on stale re-fetch' do
|
|
57
|
+
it 'evicts cached session and passes through when Console returns non-200 on stale re-fetch' do
|
|
58
58
|
token = 'ps_stale_token'
|
|
59
59
|
cache_key = "machina:session:#{token}"
|
|
60
60
|
stale_data = MockResponses.session_resolution_minimal['data'].merge(stale: true)
|
|
@@ -68,7 +68,7 @@ RSpec.describe Machina::Middleware::Authentication do
|
|
|
68
68
|
env = Rack::MockRequest.env_for('/resource', 'HTTP_AUTHORIZATION' => "Bearer #{token}")
|
|
69
69
|
status, = middleware.call(env)
|
|
70
70
|
|
|
71
|
-
expect(status).to eq(
|
|
71
|
+
expect(status).to eq(200)
|
|
72
72
|
expect(Machina.cache.read(cache_key)).to be_nil
|
|
73
73
|
end
|
|
74
74
|
|
|
@@ -96,7 +96,7 @@ RSpec.describe Machina::Middleware::Authentication do
|
|
|
96
96
|
env = Rack::MockRequest.env_for('/resource', 'HTTP_AUTHORIZATION' => "Bearer #{token}")
|
|
97
97
|
status, = middleware.call(env)
|
|
98
98
|
|
|
99
|
-
expect(status).to eq(
|
|
99
|
+
expect(status).to eq(200)
|
|
100
100
|
expect(Machina.cache.read(cache_key)).to be_nil
|
|
101
101
|
end
|
|
102
102
|
|
|
@@ -19,7 +19,7 @@ RSpec.describe Machina::WorkspaceScoped do
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
it 'filters records to the current workspace' do
|
|
22
|
-
Current.authorized = Machina::Authorized.new(
|
|
22
|
+
Machina::Current.authorized = Machina::Authorized.new(
|
|
23
23
|
'user' => { 'id' => 'u1' },
|
|
24
24
|
'organization' => { 'id' => 'o1' },
|
|
25
25
|
'workspace' => { 'id' => 'ws-1', 'name' => 'Test' },
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: machina-auth
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ZAR
|
|
@@ -178,6 +178,7 @@ files:
|
|
|
178
178
|
- spec/machina/authorized_spec.rb
|
|
179
179
|
- spec/machina/configuration_spec.rb
|
|
180
180
|
- spec/machina/controller_helpers_spec.rb
|
|
181
|
+
- spec/machina/current_spec.rb
|
|
181
182
|
- spec/machina/identity_client_spec.rb
|
|
182
183
|
- spec/machina/middleware/authentication_spec.rb
|
|
183
184
|
- spec/machina/middleware/skip_paths_spec.rb
|