machina-auth 0.1.7 → 0.3.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 +4 -4
- data/lib/machina/authorized.rb +10 -7
- data/lib/machina/identity_client.rb +11 -2
- data/lib/machina/middleware/authentication/hints.rb +53 -0
- data/lib/machina/middleware/authentication.rb +55 -44
- data/lib/machina/test_helpers.rb +2 -1
- data/lib/machina/version.rb +1 -1
- data/lib/machina/webhook_receiver.rb +1 -1
- data/spec/contracts/session_resolution_contract_spec.rb +20 -0
- data/spec/machina/authorized_spec.rb +22 -0
- data/spec/machina/identity_client_spec.rb +23 -0
- data/spec/machina/middleware/authentication_spec.rb +156 -1
- data/spec/machina/test_helpers_spec.rb +6 -0
- data/spec/machina/webhook_receiver_spec.rb +1 -1
- data/spec/support/mock_responses.rb +12 -4
- 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: 95a0c331d428907d79a618631f8b39aea1aa20e297090e44229aed0bc93c0324
|
|
4
|
+
data.tar.gz: 1489dd29fdf00291cb414b57f198d210bd9e1af818d373b4064b7cda6c0f1b66
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '08a671be6f57a8ef5098444165e335e33cfb85e371f7d34d7842699854bab66050f1de191dc203839dc7c4b94dc72a556803699bc08333e453a5552fd0b94007'
|
|
7
|
+
data.tar.gz: 236485e85bfc0ef18afc0ddde3251b411f8579ff90b1fa870958b403e2175fbfbfce751d9c629d787c9848f709473e7f7f3779843ab6232b100aee27f1acfb83
|
data/lib/machina/authorized.rb
CHANGED
|
@@ -5,9 +5,9 @@ module Machina
|
|
|
5
5
|
# their organization/workspace context, and granted permissions.
|
|
6
6
|
class Authorized
|
|
7
7
|
attr_reader :user_id, :user_email, :user_name, :avatar_url,
|
|
8
|
-
:organization_id, :org_name, :org_personal, :org_role,
|
|
9
|
-
:workspace_id, :workspace_name,
|
|
10
|
-
:session_id, :expires_at, :type
|
|
8
|
+
:organization_id, :org_name, :org_slug, :org_personal, :org_role,
|
|
9
|
+
:workspace_id, :workspace_name, :workspace_slug,
|
|
10
|
+
:session_id, :expires_at, :type, :integration_name
|
|
11
11
|
|
|
12
12
|
def initialize(data = {})
|
|
13
13
|
assign_user_attrs(data)
|
|
@@ -65,6 +65,7 @@ module Machina
|
|
|
65
65
|
def assign_org_attrs(data)
|
|
66
66
|
@organization_id = data.dig('organization', 'id')
|
|
67
67
|
@org_name = data.dig('organization', 'name')
|
|
68
|
+
@org_slug = data.dig('organization', 'slug')
|
|
68
69
|
@org_personal = data.dig('organization', 'personal')
|
|
69
70
|
@org_role = data.dig('membership', 'policy_name') || data.dig('organization', 'role')
|
|
70
71
|
end
|
|
@@ -72,13 +73,15 @@ module Machina
|
|
|
72
73
|
def assign_workspace_attrs(data)
|
|
73
74
|
@workspace_id = data.dig('workspace', 'id')
|
|
74
75
|
@workspace_name = data.dig('workspace', 'name')
|
|
76
|
+
@workspace_slug = data.dig('workspace', 'slug')
|
|
75
77
|
end
|
|
76
78
|
|
|
77
79
|
def assign_session_attrs(data)
|
|
78
|
-
@type
|
|
79
|
-
@session_id
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
@type = data.dig('session', 'type')&.to_sym
|
|
81
|
+
@session_id = data.dig('session', 'id') || data['session_id']
|
|
82
|
+
@integration_name = data.dig('session', 'integration_name')
|
|
83
|
+
raw_expires = data.dig('session', 'expires_at') || data['expires_at']
|
|
84
|
+
@expires_at = raw_expires ? Time.zone.parse(raw_expires) : nil
|
|
82
85
|
end
|
|
83
86
|
|
|
84
87
|
EMPTY = new.freeze # rubocop:disable Lint/UselessConstantScoping
|
|
@@ -19,8 +19,17 @@ module Machina
|
|
|
19
19
|
@connection = connection
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
# @param token [String] raw session token (ps_) or API key (mk_)
|
|
23
|
+
# @param workspace_id [String, nil] optional workspace UUID hint for org-scoped API keys
|
|
24
|
+
# @param workspace_slug [String, nil] optional workspace slug hint (alternative to id)
|
|
25
|
+
# @param organization_slug [String, nil] optional organization slug; identity service
|
|
26
|
+
# rejects with 403 if the value does not match the token's bound organization
|
|
27
|
+
def resolve_session(token, workspace_id: nil, workspace_slug: nil, organization_slug: nil)
|
|
28
|
+
payload = { token: }
|
|
29
|
+
payload[:workspace_id] = workspace_id if workspace_id
|
|
30
|
+
payload[:workspace_slug] = workspace_slug if workspace_slug
|
|
31
|
+
payload[:organization_slug] = organization_slug if organization_slug
|
|
32
|
+
post('/internal/v1/sessions/resolve', payload)
|
|
24
33
|
end
|
|
25
34
|
|
|
26
35
|
def revoke_session(token)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Machina
|
|
4
|
+
module Middleware
|
|
5
|
+
class Authentication
|
|
6
|
+
# Value object representing the workspace + organization hints supplied
|
|
7
|
+
# with a resolve request. Read from Rack env or request headers, then
|
|
8
|
+
# forwarded to the identity service as kwargs.
|
|
9
|
+
#
|
|
10
|
+
# Sources, in order:
|
|
11
|
+
#
|
|
12
|
+
# 1. `env['machina.workspace_hint']` / `env['machina.organization_hint']`
|
|
13
|
+
# — set by the product's routes or a Rack middleware when the URL
|
|
14
|
+
# itself scopes to an org/workspace (e.g. `/mcp/:org/:workspace`).
|
|
15
|
+
# 2. `X-Machina-Workspace-Hint` / `X-Machina-Organization-Hint` request
|
|
16
|
+
# headers — fallback for clients that can't influence the URL.
|
|
17
|
+
#
|
|
18
|
+
# The values are passed through verbatim to the identity service. The
|
|
19
|
+
# organization hint is enforced as a match against the token's bound
|
|
20
|
+
# organization (403 on mismatch).
|
|
21
|
+
# Slug/UUID disambiguation is cheap: UUIDs match a narrow pattern.
|
|
22
|
+
# Anything else is treated as a slug.
|
|
23
|
+
HINT_UUID_RE = /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/
|
|
24
|
+
private_constant :HINT_UUID_RE
|
|
25
|
+
|
|
26
|
+
Hints = Data.define(:workspace, :organization) do
|
|
27
|
+
def self.from(env, request)
|
|
28
|
+
new(
|
|
29
|
+
workspace: env['machina.workspace_hint'].presence ||
|
|
30
|
+
request.headers['X-Machina-Workspace-Hint'].presence,
|
|
31
|
+
organization: env['machina.organization_hint'].presence ||
|
|
32
|
+
request.headers['X-Machina-Organization-Hint'].presence,
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def empty?
|
|
37
|
+
workspace.nil? && organization.nil?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_resolve_kwargs
|
|
41
|
+
kwargs = {}
|
|
42
|
+
kwargs[HINT_UUID_RE.match?(workspace) ? :workspace_id : :workspace_slug] = workspace if workspace
|
|
43
|
+
kwargs[:organization_slug] = organization if organization
|
|
44
|
+
kwargs
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def cache_suffix
|
|
48
|
+
empty? ? '' : ":#{organization}:#{workspace}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -1,44 +1,53 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'authentication/hints'
|
|
4
|
+
|
|
3
5
|
module Machina
|
|
4
6
|
module Middleware
|
|
5
7
|
# Rack middleware that extracts authentication tokens from incoming requests,
|
|
6
8
|
# resolves them against the identity service, and sets Current.authorized.
|
|
7
9
|
class Authentication
|
|
10
|
+
TRANSIENT_FAILURE = :transient_failure
|
|
11
|
+
private_constant :TRANSIENT_FAILURE
|
|
12
|
+
|
|
8
13
|
def initialize(app)
|
|
9
14
|
@app = app
|
|
10
15
|
end
|
|
11
16
|
|
|
12
17
|
def call(env)
|
|
13
18
|
request = ActionDispatch::Request.new(env)
|
|
14
|
-
# Skip paths allow the developer to disable the middleware from validating
|
|
15
|
-
# any token or headers on any matched URL or Path.
|
|
16
19
|
return @app.call(env) if skip_path?(request)
|
|
17
20
|
|
|
18
21
|
token = extract_token(request)
|
|
19
22
|
return @app.call(env) if token.blank?
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
return handle_expired_token(env, request, token) unless session_data
|
|
27
|
-
|
|
28
|
-
Machina::Current.authorized = Machina::Authorized.new(session_data)
|
|
29
|
-
|
|
30
|
-
@app.call(env)
|
|
24
|
+
dispatch_session(env, request, token, Hints.from(env, request))
|
|
31
25
|
ensure
|
|
32
26
|
Machina::Current.reset
|
|
33
27
|
end
|
|
34
28
|
|
|
35
29
|
private
|
|
36
30
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
31
|
+
def dispatch_session(env, request, token, hints)
|
|
32
|
+
session_data = resolve_session(token, hints)
|
|
33
|
+
|
|
34
|
+
if session_data.is_a?(Hash)
|
|
35
|
+
Machina::Current.authorized = Machina::Authorized.new(session_data)
|
|
36
|
+
return @app.call(env)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if transient_failure?(session_data)
|
|
40
|
+
@app.call(env)
|
|
41
|
+
else
|
|
42
|
+
handle_expired_token(env, request, token, hints)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def transient_failure?(result)
|
|
47
|
+
result == TRANSIENT_FAILURE
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Strings match as path prefixes; regexes match path or full URL.
|
|
42
51
|
def skip_path?(request)
|
|
43
52
|
return false if Machina.config.skip_paths.blank?
|
|
44
53
|
|
|
@@ -53,12 +62,9 @@ module Machina
|
|
|
53
62
|
end
|
|
54
63
|
end
|
|
55
64
|
|
|
56
|
-
# Clears the cache
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
# redirect loop where an expired cookie shadows fresh callback tokens.
|
|
60
|
-
def handle_expired_token(env, request, token)
|
|
61
|
-
Machina.cache.delete(cache_key(token))
|
|
65
|
+
# Clears the cache and cookie for a token that Console explicitly rejected.
|
|
66
|
+
def handle_expired_token(env, request, token, hints)
|
|
67
|
+
Machina.cache.delete(cache_key(token, hints))
|
|
62
68
|
|
|
63
69
|
status, headers, body = @app.call(env)
|
|
64
70
|
Rack::Utils.delete_cookie_header!(headers, 'machina_session') if request.cookies['machina_session'] == token
|
|
@@ -66,37 +72,49 @@ module Machina
|
|
|
66
72
|
end
|
|
67
73
|
|
|
68
74
|
def extract_token(request)
|
|
69
|
-
request
|
|
75
|
+
extract_param_token(request)
|
|
70
76
|
|| request.cookies['machina_session']
|
|
71
77
|
|| extract_bearer(request)
|
|
72
78
|
|| request.headers['X-Api-Key']
|
|
73
79
|
end
|
|
74
80
|
|
|
81
|
+
def extract_param_token(request)
|
|
82
|
+
request.params['token']
|
|
83
|
+
rescue ActionDispatch::Http::Parameters::ParseError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
75
87
|
def extract_bearer(request)
|
|
76
88
|
auth_header = request.headers['Authorization'].to_s
|
|
77
89
|
match = auth_header.match(/\ABearer\s+(.+)\z/)
|
|
78
90
|
match && match[1]
|
|
79
91
|
end
|
|
80
92
|
|
|
81
|
-
def resolve_session(token)
|
|
82
|
-
cached = Machina.cache.read(cache_key(token))
|
|
83
|
-
return cached if cached.present? && !(cached.is_a?(Hash) && cached[
|
|
84
|
-
|
|
85
|
-
fetch_from_identity_service(token)
|
|
93
|
+
def resolve_session(token, hints)
|
|
94
|
+
cached = Machina.cache.read(cache_key(token, hints))
|
|
95
|
+
return cached if cached.present? && !(cached.is_a?(Hash) && cached['stale'])
|
|
96
|
+
|
|
97
|
+
fetch_from_identity_service(token, hints)
|
|
98
|
+
rescue Faraday::Error => e
|
|
99
|
+
# Network-level failures (connection refused, timeout, DNS) are
|
|
100
|
+
# transient — return stale cached data when available, otherwise
|
|
101
|
+
# signal transient failure so the caller preserves the cookie.
|
|
102
|
+
Rails.logger.warn("[machina] Identity service unreachable: #{e.class} — #{e.message}")
|
|
103
|
+
cached.presence || TRANSIENT_FAILURE
|
|
86
104
|
rescue StandardError
|
|
87
105
|
nil
|
|
88
106
|
end
|
|
89
107
|
|
|
90
|
-
def fetch_from_identity_service(token)
|
|
91
|
-
response = Machina.identity_client.resolve_session(token)
|
|
108
|
+
def fetch_from_identity_service(token, hints)
|
|
109
|
+
response = Machina.identity_client.resolve_session(token, **hints.to_resolve_kwargs)
|
|
92
110
|
|
|
93
111
|
unless response.success?
|
|
94
|
-
Machina.cache.delete(cache_key(token))
|
|
112
|
+
Machina.cache.delete(cache_key(token, hints))
|
|
95
113
|
return nil
|
|
96
114
|
end
|
|
97
115
|
|
|
98
116
|
data = unwrap_payload(response.parsed)
|
|
99
|
-
Machina.cache.write(cache_key(token), data, expires_in: Machina.config.cache_ttl)
|
|
117
|
+
Machina.cache.write(cache_key(token, hints), data, expires_in: Machina.config.cache_ttl)
|
|
100
118
|
cache_workspace_ref(data)
|
|
101
119
|
data
|
|
102
120
|
end
|
|
@@ -106,25 +124,18 @@ module Machina
|
|
|
106
124
|
end
|
|
107
125
|
|
|
108
126
|
def cache_workspace_ref(data)
|
|
109
|
-
workspace
|
|
110
|
-
organization = data['organization']
|
|
111
|
-
|
|
127
|
+
workspace, organization = data.values_at('workspace', 'organization')
|
|
112
128
|
return unless workspace.is_a?(Hash) && organization.is_a?(Hash)
|
|
113
129
|
return unless defined?(Machina::WorkspaceRef) && Machina::WorkspaceRef.table_exists?
|
|
114
130
|
|
|
115
131
|
ref = Machina::WorkspaceRef.find_or_initialize_by(tenant_ref: workspace['id'])
|
|
116
|
-
|
|
117
|
-
ref.name = workspace['name']
|
|
118
|
-
ref.cached_at = Time.current
|
|
119
|
-
ref.organization_id = organization['id']
|
|
120
|
-
|
|
121
|
-
ref.save!
|
|
132
|
+
ref.update!(name: workspace['name'], cached_at: Time.current, organization_id: organization['id'])
|
|
122
133
|
rescue StandardError
|
|
123
134
|
nil
|
|
124
135
|
end
|
|
125
136
|
|
|
126
|
-
def cache_key(token)
|
|
127
|
-
"machina:session:#{token}"
|
|
137
|
+
def cache_key(token, hints)
|
|
138
|
+
"machina:session:#{token}#{hints.cache_suffix}"
|
|
128
139
|
end
|
|
129
140
|
end
|
|
130
141
|
end
|
data/lib/machina/test_helpers.rb
CHANGED
|
@@ -127,7 +127,8 @@ module Machina
|
|
|
127
127
|
{
|
|
128
128
|
'id' => '00000000-0000-4000-a000-000000001000',
|
|
129
129
|
'type' => 'platform_session',
|
|
130
|
-
'expires_at' => (Time.now.utc + 86_400).iso8601
|
|
130
|
+
'expires_at' => (Time.now.utc + 86_400).iso8601,
|
|
131
|
+
'integration_name' => nil
|
|
131
132
|
}
|
|
132
133
|
end
|
|
133
134
|
end
|
data/lib/machina/version.rb
CHANGED
|
@@ -68,6 +68,26 @@ RSpec.describe 'Session Resolution Contract' do
|
|
|
68
68
|
end.not_to raise_error
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
it 'accepts an optional workspace_id hint' do
|
|
72
|
+
expect do
|
|
73
|
+
ConsoleSchema.validate!(
|
|
74
|
+
{ 'token' => 'mk_abc', 'workspace_id' => 'b2c3d4e5-f6a7-8901-bcde-f12345678901' },
|
|
75
|
+
schema_key,
|
|
76
|
+
'request.body',
|
|
77
|
+
)
|
|
78
|
+
end.not_to raise_error
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'accepts an optional workspace_slug hint' do
|
|
82
|
+
expect do
|
|
83
|
+
ConsoleSchema.validate!(
|
|
84
|
+
{ 'token' => 'mk_abc', 'workspace_slug' => 'zar' },
|
|
85
|
+
schema_key,
|
|
86
|
+
'request.body',
|
|
87
|
+
)
|
|
88
|
+
end.not_to raise_error
|
|
89
|
+
end
|
|
90
|
+
|
|
71
91
|
it 'fails when token is missing' do
|
|
72
92
|
expect { ConsoleSchema.validate!({}, schema_key, 'request.body') }.to raise_error(/Schema validation failed/)
|
|
73
93
|
end
|
|
@@ -18,6 +18,7 @@ RSpec.describe Machina::Authorized do
|
|
|
18
18
|
it 'exposes organization attributes' do
|
|
19
19
|
expect(authorized.organization_id).to eq('a1b2c3d4-e5f6-7890-abcd-ef1234567890')
|
|
20
20
|
expect(authorized.org_name).to eq('ZAR')
|
|
21
|
+
expect(authorized.org_slug).to eq('zar')
|
|
21
22
|
expect(authorized.org_personal).to be(false)
|
|
22
23
|
expect(authorized.org_role).to eq('admin')
|
|
23
24
|
end
|
|
@@ -25,6 +26,15 @@ RSpec.describe Machina::Authorized do
|
|
|
25
26
|
it 'exposes workspace attributes' do
|
|
26
27
|
expect(authorized.workspace_id).to eq('b2c3d4e5-f6a7-8901-bcde-f12345678901')
|
|
27
28
|
expect(authorized.workspace_name).to eq('Primary')
|
|
29
|
+
expect(authorized.workspace_slug).to eq('primary')
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'returns nil for slugs when missing from payload (older Console)' do
|
|
33
|
+
data['organization'].delete('slug')
|
|
34
|
+
data['workspace'].delete('slug')
|
|
35
|
+
auth = described_class.new(data)
|
|
36
|
+
expect(auth.org_slug).to be_nil
|
|
37
|
+
expect(auth.workspace_slug).to be_nil
|
|
28
38
|
end
|
|
29
39
|
|
|
30
40
|
it 'aliases tenant_ref to workspace_id' do
|
|
@@ -36,6 +46,18 @@ RSpec.describe Machina::Authorized do
|
|
|
36
46
|
end
|
|
37
47
|
end
|
|
38
48
|
|
|
49
|
+
describe '#integration_name' do
|
|
50
|
+
it 'returns nil when not set' do
|
|
51
|
+
expect(authorized.integration_name).to be_nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'returns the value from session data' do
|
|
55
|
+
data['session']['integration_name'] = 'GitHub Webhooks'
|
|
56
|
+
auth = described_class.new(data)
|
|
57
|
+
expect(auth.integration_name).to eq('GitHub Webhooks')
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
39
61
|
describe '#type' do
|
|
40
62
|
it 'returns a symbol' do
|
|
41
63
|
expect(authorized.type).to eq(:platform_session)
|
|
@@ -25,6 +25,29 @@ RSpec.describe Machina::IdentityClient do
|
|
|
25
25
|
expect(response.parsed).to eq('data' => { 'user' => { 'id' => 'd7e60485-114e-4142-8dc2-c3612f920cc9' } })
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
it 'forwards a workspace_id hint when provided' do
|
|
29
|
+
stubs.post('/internal/v1/sessions/resolve') do |env|
|
|
30
|
+
expect(JSON.parse(env.body)).to eq(
|
|
31
|
+
'token' => 'mk_123',
|
|
32
|
+
'workspace_id' => 'b2c3d4e5-f6a7-8901-bcde-f12345678901',
|
|
33
|
+
)
|
|
34
|
+
[200, { 'Content-Type' => 'application/json' }, JSON.generate(data: {})]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
response = client.resolve_session('mk_123', workspace_id: 'b2c3d4e5-f6a7-8901-bcde-f12345678901')
|
|
38
|
+
expect(response).to be_success
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'forwards a workspace_slug hint when provided' do
|
|
42
|
+
stubs.post('/internal/v1/sessions/resolve') do |env|
|
|
43
|
+
expect(JSON.parse(env.body)).to eq('token' => 'mk_123', 'workspace_slug' => 'zar')
|
|
44
|
+
[200, { 'Content-Type' => 'application/json' }, JSON.generate(data: {})]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
response = client.resolve_session('mk_123', workspace_slug: 'zar')
|
|
48
|
+
expect(response).to be_success
|
|
49
|
+
end
|
|
50
|
+
|
|
28
51
|
it 'syncs permissions for a product' do
|
|
29
52
|
product_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
|
30
53
|
|
|
@@ -42,6 +42,106 @@ RSpec.describe Machina::Middleware::Authentication do
|
|
|
42
42
|
expect(workspace_ref).to have_attributes(name: 'Primary')
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
describe 'workspace hint forwarding' do
|
|
46
|
+
let(:mock) { MockResponses.session_resolution }
|
|
47
|
+
|
|
48
|
+
it 'forwards a slug hint from env to the identity client and keys the cache by it' do
|
|
49
|
+
expect(identity_client).to receive(:resolve_session)
|
|
50
|
+
.with('mk_abc', workspace_slug: 'my-org')
|
|
51
|
+
.and_return(Machina::IdentityClient::Response.new(status: 200, body: mock))
|
|
52
|
+
|
|
53
|
+
env = Rack::MockRequest.env_for('/mcp', 'HTTP_AUTHORIZATION' => 'Bearer mk_abc')
|
|
54
|
+
env['machina.workspace_hint'] = 'my-org'
|
|
55
|
+
|
|
56
|
+
status, = middleware.call(env)
|
|
57
|
+
expect(status).to eq(200)
|
|
58
|
+
expect(Machina.cache.read('machina:session:mk_abc::my-org')).to be_a(Hash)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'forwards a uuid hint as workspace_id rather than slug' do
|
|
62
|
+
uuid = 'b2c3d4e5-f6a7-8901-bcde-f12345678901'
|
|
63
|
+
expect(identity_client).to receive(:resolve_session)
|
|
64
|
+
.with('mk_abc', workspace_id: uuid)
|
|
65
|
+
.and_return(Machina::IdentityClient::Response.new(status: 200, body: mock))
|
|
66
|
+
|
|
67
|
+
env = Rack::MockRequest.env_for('/mcp', 'HTTP_AUTHORIZATION' => 'Bearer mk_abc')
|
|
68
|
+
env['machina.workspace_hint'] = uuid
|
|
69
|
+
|
|
70
|
+
status, = middleware.call(env)
|
|
71
|
+
expect(status).to eq(200)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'reads the X-Machina-Workspace-Hint header when env is unset' do
|
|
75
|
+
expect(identity_client).to receive(:resolve_session)
|
|
76
|
+
.with('mk_abc', workspace_slug: 'zar')
|
|
77
|
+
.and_return(Machina::IdentityClient::Response.new(status: 200, body: mock))
|
|
78
|
+
|
|
79
|
+
env = Rack::MockRequest.env_for('/mcp',
|
|
80
|
+
'HTTP_AUTHORIZATION' => 'Bearer mk_abc',
|
|
81
|
+
'HTTP_X_MACHINA_WORKSPACE_HINT' => 'zar')
|
|
82
|
+
|
|
83
|
+
status, = middleware.call(env)
|
|
84
|
+
expect(status).to eq(200)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'does not forward a hint when none is set' do
|
|
88
|
+
expect(identity_client).to receive(:resolve_session)
|
|
89
|
+
.with('mk_abc')
|
|
90
|
+
.and_return(Machina::IdentityClient::Response.new(status: 200, body: mock))
|
|
91
|
+
|
|
92
|
+
env = Rack::MockRequest.env_for('/mcp', 'HTTP_AUTHORIZATION' => 'Bearer mk_abc')
|
|
93
|
+
|
|
94
|
+
status, = middleware.call(env)
|
|
95
|
+
expect(status).to eq(200)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'forwards organization_slug from env to the identity client' do
|
|
99
|
+
expect(identity_client).to receive(:resolve_session)
|
|
100
|
+
.with('mk_abc', organization_slug: 'zar', workspace_slug: 'default')
|
|
101
|
+
.and_return(Machina::IdentityClient::Response.new(status: 200, body: mock))
|
|
102
|
+
|
|
103
|
+
env = Rack::MockRequest.env_for('/mcp', 'HTTP_AUTHORIZATION' => 'Bearer mk_abc')
|
|
104
|
+
env['machina.organization_hint'] = 'zar'
|
|
105
|
+
env['machina.workspace_hint'] = 'default'
|
|
106
|
+
|
|
107
|
+
status, = middleware.call(env)
|
|
108
|
+
expect(status).to eq(200)
|
|
109
|
+
expect(Machina.cache.read('machina:session:mk_abc:zar:default')).to be_a(Hash)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it 'reads the X-Machina-Organization-Hint header when env is unset' do
|
|
113
|
+
expect(identity_client).to receive(:resolve_session)
|
|
114
|
+
.with('mk_abc', organization_slug: 'zar')
|
|
115
|
+
.and_return(Machina::IdentityClient::Response.new(status: 200, body: mock))
|
|
116
|
+
|
|
117
|
+
env = Rack::MockRequest.env_for('/mcp',
|
|
118
|
+
'HTTP_AUTHORIZATION' => 'Bearer mk_abc',
|
|
119
|
+
'HTTP_X_MACHINA_ORGANIZATION_HINT' => 'zar')
|
|
120
|
+
|
|
121
|
+
status, = middleware.call(env)
|
|
122
|
+
expect(status).to eq(200)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'caches hinted and unhinted responses under separate keys' do
|
|
126
|
+
allow(identity_client).to receive(:resolve_session)
|
|
127
|
+
.with('mk_abc')
|
|
128
|
+
.and_return(Machina::IdentityClient::Response.new(status: 200, body: mock))
|
|
129
|
+
allow(identity_client).to receive(:resolve_session)
|
|
130
|
+
.with('mk_abc', workspace_slug: 'zar')
|
|
131
|
+
.and_return(Machina::IdentityClient::Response.new(status: 200, body: mock))
|
|
132
|
+
|
|
133
|
+
plain_env = Rack::MockRequest.env_for('/mcp', 'HTTP_AUTHORIZATION' => 'Bearer mk_abc')
|
|
134
|
+
middleware.call(plain_env)
|
|
135
|
+
|
|
136
|
+
hinted_env = Rack::MockRequest.env_for('/mcp', 'HTTP_AUTHORIZATION' => 'Bearer mk_abc')
|
|
137
|
+
hinted_env['machina.workspace_hint'] = 'zar'
|
|
138
|
+
middleware.call(hinted_env)
|
|
139
|
+
|
|
140
|
+
expect(Machina.cache.read('machina:session:mk_abc')).to be_a(Hash)
|
|
141
|
+
expect(Machina.cache.read('machina:session:mk_abc::zar')).to be_a(Hash)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
45
145
|
it 'passes through with nil authorized when resolution fails' do
|
|
46
146
|
allow(identity_client).to receive(:resolve_session).and_return(
|
|
47
147
|
Machina::IdentityClient::Response.new(status: 404, body: '{}'),
|
|
@@ -57,7 +157,7 @@ RSpec.describe Machina::Middleware::Authentication do
|
|
|
57
157
|
it 'evicts cached session and passes through when Console returns non-200 on stale re-fetch' do
|
|
58
158
|
token = 'ps_stale_token'
|
|
59
159
|
cache_key = "machina:session:#{token}"
|
|
60
|
-
stale_data = MockResponses.session_resolution_minimal['data'].merge(stale
|
|
160
|
+
stale_data = MockResponses.session_resolution_minimal['data'].merge('stale' => true)
|
|
61
161
|
|
|
62
162
|
Machina.cache.write(cache_key, stale_data, expires_in: 5.minutes)
|
|
63
163
|
|
|
@@ -160,6 +260,61 @@ RSpec.describe Machina::Middleware::Authentication do
|
|
|
160
260
|
expect(headers['set-cookie']).to be_nil
|
|
161
261
|
end
|
|
162
262
|
|
|
263
|
+
it 'falls through to bearer token when request body is malformed JSON' do
|
|
264
|
+
mock = MockResponses.session_resolution
|
|
265
|
+
allow(identity_client).to receive(:resolve_session).with('ps_bearer').and_return(
|
|
266
|
+
Machina::IdentityClient::Response.new(status: 200, body: mock),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
env = Rack::MockRequest.env_for(
|
|
270
|
+
'/resource',
|
|
271
|
+
method: 'POST',
|
|
272
|
+
input: 'not valid json',
|
|
273
|
+
'CONTENT_TYPE' => 'application/json',
|
|
274
|
+
'HTTP_AUTHORIZATION' => 'Bearer ps_bearer',
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
status, _headers, body = middleware.call(env)
|
|
278
|
+
|
|
279
|
+
expect(status).to eq(200)
|
|
280
|
+
expect(JSON.parse(body.first)['user_id']).to eq(mock['data']['user']['id'])
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
context 'when the identity service is unreachable' do
|
|
284
|
+
it 'returns stale cached data instead of destroying the session' do
|
|
285
|
+
token = 'ps_network_blip'
|
|
286
|
+
cache_key = "machina:session:#{token}"
|
|
287
|
+
cached_data = MockResponses.session_resolution_minimal['data'].merge('stale' => true)
|
|
288
|
+
|
|
289
|
+
Machina.cache.write(cache_key, cached_data, expires_in: 5.minutes)
|
|
290
|
+
|
|
291
|
+
allow(identity_client).to receive(:resolve_session).with(token).and_raise(
|
|
292
|
+
Faraday::ConnectionFailed.new('Connection refused'),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
env = Rack::MockRequest.env_for('/resource', 'HTTP_AUTHORIZATION' => "Bearer #{token}")
|
|
296
|
+
status, _headers, body = middleware.call(env)
|
|
297
|
+
|
|
298
|
+
expect(status).to eq(200)
|
|
299
|
+
expect(JSON.parse(body.first)['user_id']).to eq(cached_data['user']['id'])
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
it 'does not delete the cookie on transient network failure' do
|
|
303
|
+
allow(identity_client).to receive(:resolve_session).with('ps_network_fail').and_raise(
|
|
304
|
+
Faraday::ConnectionFailed.new('Connection refused'),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
env = Rack::MockRequest.env_for(
|
|
308
|
+
'/resource',
|
|
309
|
+
'HTTP_COOKIE' => 'machina_session=ps_network_fail',
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
_status, headers, _body = middleware.call(env)
|
|
313
|
+
|
|
314
|
+
expect(headers['set-cookie']).to be_nil
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
163
318
|
it 'uses the cache on subsequent requests' do
|
|
164
319
|
response = Machina::IdentityClient::Response.new(
|
|
165
320
|
status: 200,
|
|
@@ -21,6 +21,7 @@ RSpec.describe Machina::TestHelpers do
|
|
|
21
21
|
expect(authorized.workspace_name).to eq('Test Workspace')
|
|
22
22
|
expect(authorized.session_id).to eq('00000000-0000-4000-a000-000000001000')
|
|
23
23
|
expect(authorized.type).to eq(:platform_session)
|
|
24
|
+
expect(authorized.integration_name).to be_nil
|
|
24
25
|
expect(authorized.expires_at).to be_a(Time)
|
|
25
26
|
expect(authorized.permissions).to eq([])
|
|
26
27
|
end
|
|
@@ -60,6 +61,11 @@ RSpec.describe Machina::TestHelpers do
|
|
|
60
61
|
expect(authorized.workspace_name).to eq('Staging')
|
|
61
62
|
end
|
|
62
63
|
|
|
64
|
+
it 'overrides integration_name via session hash' do
|
|
65
|
+
authorized = sign_in_as_machina(session: { integration_name: 'Sentry Alerts' })
|
|
66
|
+
expect(authorized.integration_name).to eq('Sentry Alerts')
|
|
67
|
+
end
|
|
68
|
+
|
|
63
69
|
it 'sets permissions' do
|
|
64
70
|
authorized = sign_in_as_machina(permissions: ['reports.view', 'reports.create'])
|
|
65
71
|
expect(authorized.can?('reports.view')).to be(true)
|
|
@@ -36,7 +36,7 @@ RSpec.describe Machina::WebhookReceiver do
|
|
|
36
36
|
receiver = described_class.new(request, cache:)
|
|
37
37
|
|
|
38
38
|
expect(receiver.process!).to be(true)
|
|
39
|
-
expect(cache.read('machina:session:ps_123')).to include(stale
|
|
39
|
+
expect(cache.read('machina:session:ps_123')).to include('stale' => true)
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
@@ -13,7 +13,11 @@ module MockResponses
|
|
|
13
13
|
'data' => {
|
|
14
14
|
'user' => session_user,
|
|
15
15
|
'organization' => session_organization,
|
|
16
|
-
'workspace' => {
|
|
16
|
+
'workspace' => {
|
|
17
|
+
'id' => 'b2c3d4e5-f6a7-8901-bcde-f12345678901',
|
|
18
|
+
'name' => 'Primary',
|
|
19
|
+
'slug' => 'primary'
|
|
20
|
+
},
|
|
17
21
|
'membership' => { 'id' => 'c3d4e5f6-a7b8-9012-cdef-123456789012', 'policy_name' => 'admin' },
|
|
18
22
|
'permissions' => ['sessions.view'],
|
|
19
23
|
'session' => session_meta
|
|
@@ -27,12 +31,12 @@ module MockResponses
|
|
|
27
31
|
end
|
|
28
32
|
|
|
29
33
|
def session_organization
|
|
30
|
-
{ 'id' => 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'name' => 'ZAR', 'personal' => false }
|
|
34
|
+
{ 'id' => 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'name' => 'ZAR', 'slug' => 'zar', 'personal' => false }
|
|
31
35
|
end
|
|
32
36
|
|
|
33
37
|
def session_meta
|
|
34
38
|
{ 'id' => 'd4e5f6a7-b8c9-0123-def0-1234567890ab', 'type' => 'platform_session',
|
|
35
|
-
'expires_at' => '2026-03-20T12:00:00Z' }
|
|
39
|
+
'expires_at' => '2026-03-20T12:00:00Z', 'integration_name' => nil }
|
|
36
40
|
end
|
|
37
41
|
|
|
38
42
|
# A minimal resolution response for cache tests (fewer fields populated).
|
|
@@ -42,7 +46,11 @@ module MockResponses
|
|
|
42
46
|
'data' => {
|
|
43
47
|
'user' => { 'id' => 'd7e60485-114e-4142-8dc2-c3612f920cc9' },
|
|
44
48
|
'organization' => { 'id' => 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' },
|
|
45
|
-
'workspace' => {
|
|
49
|
+
'workspace' => {
|
|
50
|
+
'id' => 'b2c3d4e5-f6a7-8901-bcde-f12345678901',
|
|
51
|
+
'name' => 'Primary',
|
|
52
|
+
'slug' => 'primary'
|
|
53
|
+
},
|
|
46
54
|
'permissions' => ['sessions.view'],
|
|
47
55
|
'session' => { 'id' => 'd4e5f6a7-b8c9-0123-def0-1234567890ab', 'type' => 'platform_session',
|
|
48
56
|
'expires_at' => nil }
|
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.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ZAR
|
|
@@ -152,6 +152,7 @@ files:
|
|
|
152
152
|
- lib/machina/errors.rb
|
|
153
153
|
- lib/machina/identity_client.rb
|
|
154
154
|
- lib/machina/middleware/authentication.rb
|
|
155
|
+
- lib/machina/middleware/authentication/hints.rb
|
|
155
156
|
- lib/machina/permission_sync.rb
|
|
156
157
|
- lib/machina/tasks/sync.rake
|
|
157
158
|
- lib/machina/test_helpers.rb
|