machina-auth 0.1.8 → 0.4.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/configuration.rb +2 -0
- data/lib/machina/identity_client.rb +11 -2
- data/lib/machina/middleware/authentication/hints.rb +53 -0
- data/lib/machina/middleware/authentication.rb +52 -43
- 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 +158 -5
- 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: 60692b69e1db477292fba2a947650c2ac15099a616a05318071860a02a550694
|
|
4
|
+
data.tar.gz: 3625e766f4645037333365f8771e6363be979de0c484ff086b61ced3b335b84b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 10cb1b5e52daf7e76f344bfb8445e2ecca186d5cda9b1693dd376cd7ad4e503306229eabe510879ab72cd10c0a275b5c51ba2d84275758539b5fe1d8ca12b482
|
|
7
|
+
data.tar.gz: a168a7dd638f5663394dda922ba91582bfef041922325c635deff9d2ce79b7828916f21553ab5357d12a8b88276e6f8c560584898c212a9daaea0a4b560a7b98
|
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
|
|
@@ -10,12 +10,14 @@ module Machina
|
|
|
10
10
|
:product_slug,
|
|
11
11
|
:cache_store,
|
|
12
12
|
:cache_ttl,
|
|
13
|
+
:negative_cache_ttl,
|
|
13
14
|
:manifest,
|
|
14
15
|
:skip_paths,
|
|
15
16
|
:identity_callback_uri
|
|
16
17
|
|
|
17
18
|
def initialize
|
|
18
19
|
@cache_ttl = 5.minutes
|
|
20
|
+
@negative_cache_ttl = 30.seconds
|
|
19
21
|
@skip_paths = []
|
|
20
22
|
end
|
|
21
23
|
end
|
|
@@ -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,57 @@
|
|
|
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
|
+
|
|
12
|
+
# Negative-cache marker for a rejected token. A Hash, so it round-trips through any cache coder.
|
|
13
|
+
INVALID_SESSION = { '__machina_invalid__' => true }.freeze
|
|
14
|
+
|
|
15
|
+
private_constant :TRANSIENT_FAILURE, :INVALID_SESSION
|
|
16
|
+
|
|
8
17
|
def initialize(app)
|
|
9
18
|
@app = app
|
|
10
19
|
end
|
|
11
20
|
|
|
12
21
|
def call(env)
|
|
13
22
|
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
23
|
return @app.call(env) if skip_path?(request)
|
|
17
24
|
|
|
18
25
|
token = extract_token(request)
|
|
19
26
|
return @app.call(env) if token.blank?
|
|
20
27
|
|
|
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)
|
|
28
|
+
dispatch_session(env, request, token, Hints.from(env, request))
|
|
31
29
|
ensure
|
|
32
30
|
Machina::Current.reset
|
|
33
31
|
end
|
|
34
32
|
|
|
35
33
|
private
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
35
|
+
def dispatch_session(env, request, token, hints)
|
|
36
|
+
session_data = resolve_session(token, hints)
|
|
37
|
+
|
|
38
|
+
if session_data.is_a?(Hash)
|
|
39
|
+
Machina::Current.authorized = Machina::Authorized.new(session_data)
|
|
40
|
+
return @app.call(env)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if transient_failure?(session_data)
|
|
44
|
+
@app.call(env)
|
|
45
|
+
else
|
|
46
|
+
handle_expired_token(env, request, token)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def transient_failure?(result)
|
|
51
|
+
result == TRANSIENT_FAILURE
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Strings match as path prefixes; regexes match path or full URL.
|
|
42
55
|
def skip_path?(request)
|
|
43
56
|
return false if Machina.config.skip_paths.blank?
|
|
44
57
|
|
|
@@ -53,13 +66,9 @@ module Machina
|
|
|
53
66
|
end
|
|
54
67
|
end
|
|
55
68
|
|
|
56
|
-
# Clears the
|
|
57
|
-
#
|
|
58
|
-
# browser stops sending it on subsequent requests. This prevents a
|
|
59
|
-
# redirect loop where an expired cookie shadows fresh callback tokens.
|
|
69
|
+
# Clears the cookie for a rejected token. The cache holds the INVALID_SESSION
|
|
70
|
+
# sentinel (written in fetch_from_identity_service) and must not be cleared here.
|
|
60
71
|
def handle_expired_token(env, request, token)
|
|
61
|
-
Machina.cache.delete(cache_key(token))
|
|
62
|
-
|
|
63
72
|
status, headers, body = @app.call(env)
|
|
64
73
|
Rack::Utils.delete_cookie_header!(headers, 'machina_session') if request.cookies['machina_session'] == token
|
|
65
74
|
[status, headers, body]
|
|
@@ -84,25 +93,32 @@ module Machina
|
|
|
84
93
|
match && match[1]
|
|
85
94
|
end
|
|
86
95
|
|
|
87
|
-
def resolve_session(token)
|
|
88
|
-
cached = Machina.cache.read(cache_key(token))
|
|
89
|
-
return
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
def resolve_session(token, hints)
|
|
97
|
+
cached = Machina.cache.read(cache_key(token, hints))
|
|
98
|
+
return nil if cached == INVALID_SESSION
|
|
99
|
+
return cached if cached.present? && !(cached.is_a?(Hash) && cached['stale'])
|
|
100
|
+
|
|
101
|
+
fetch_from_identity_service(token, hints)
|
|
102
|
+
rescue Faraday::Error => e
|
|
103
|
+
# Network-level failures (connection refused, timeout, DNS) are
|
|
104
|
+
# transient — return stale cached data when available, otherwise
|
|
105
|
+
# signal transient failure so the caller preserves the cookie.
|
|
106
|
+
Rails.logger.warn("[machina] Identity service unreachable: #{e.class} — #{e.message}")
|
|
107
|
+
cached.presence || TRANSIENT_FAILURE
|
|
92
108
|
rescue StandardError
|
|
93
109
|
nil
|
|
94
110
|
end
|
|
95
111
|
|
|
96
|
-
def fetch_from_identity_service(token)
|
|
97
|
-
response = Machina.identity_client.resolve_session(token)
|
|
112
|
+
def fetch_from_identity_service(token, hints)
|
|
113
|
+
response = Machina.identity_client.resolve_session(token, **hints.to_resolve_kwargs)
|
|
98
114
|
|
|
99
115
|
unless response.success?
|
|
100
|
-
Machina.cache.
|
|
116
|
+
Machina.cache.write(cache_key(token, hints), INVALID_SESSION, expires_in: Machina.config.negative_cache_ttl)
|
|
101
117
|
return nil
|
|
102
118
|
end
|
|
103
119
|
|
|
104
120
|
data = unwrap_payload(response.parsed)
|
|
105
|
-
Machina.cache.write(cache_key(token), data, expires_in: Machina.config.cache_ttl)
|
|
121
|
+
Machina.cache.write(cache_key(token, hints), data, expires_in: Machina.config.cache_ttl)
|
|
106
122
|
cache_workspace_ref(data)
|
|
107
123
|
data
|
|
108
124
|
end
|
|
@@ -112,25 +128,18 @@ module Machina
|
|
|
112
128
|
end
|
|
113
129
|
|
|
114
130
|
def cache_workspace_ref(data)
|
|
115
|
-
workspace
|
|
116
|
-
organization = data['organization']
|
|
117
|
-
|
|
131
|
+
workspace, organization = data.values_at('workspace', 'organization')
|
|
118
132
|
return unless workspace.is_a?(Hash) && organization.is_a?(Hash)
|
|
119
133
|
return unless defined?(Machina::WorkspaceRef) && Machina::WorkspaceRef.table_exists?
|
|
120
134
|
|
|
121
135
|
ref = Machina::WorkspaceRef.find_or_initialize_by(tenant_ref: workspace['id'])
|
|
122
|
-
|
|
123
|
-
ref.name = workspace['name']
|
|
124
|
-
ref.cached_at = Time.current
|
|
125
|
-
ref.organization_id = organization['id']
|
|
126
|
-
|
|
127
|
-
ref.save!
|
|
136
|
+
ref.update!(name: workspace['name'], cached_at: Time.current, organization_id: organization['id'])
|
|
128
137
|
rescue StandardError
|
|
129
138
|
nil
|
|
130
139
|
end
|
|
131
140
|
|
|
132
|
-
def cache_key(token)
|
|
133
|
-
"machina:session:#{token}"
|
|
141
|
+
def cache_key(token, hints)
|
|
142
|
+
"machina:session:#{token}#{hints.cache_suffix}"
|
|
134
143
|
end
|
|
135
144
|
end
|
|
136
145
|
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: '{}'),
|
|
@@ -54,10 +154,10 @@ RSpec.describe Machina::Middleware::Authentication do
|
|
|
54
154
|
expect(JSON.parse(body.first)).to eq('user_id' => nil, 'permissions' => nil)
|
|
55
155
|
end
|
|
56
156
|
|
|
57
|
-
it '
|
|
157
|
+
it 'negative-caches the rejection 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
|
|
|
@@ -69,10 +169,11 @@ RSpec.describe Machina::Middleware::Authentication do
|
|
|
69
169
|
status, = middleware.call(env)
|
|
70
170
|
|
|
71
171
|
expect(status).to eq(200)
|
|
72
|
-
|
|
172
|
+
# The stale session is replaced by the short-lived negative sentinel, not deleted.
|
|
173
|
+
expect(Machina.cache.read(cache_key)).to eq('__machina_invalid__' => true)
|
|
73
174
|
end
|
|
74
175
|
|
|
75
|
-
it '
|
|
176
|
+
it 'negative-caches the rejection when Console returns non-200 after cache expires' do
|
|
76
177
|
token = 'ps_expired_cache'
|
|
77
178
|
cache_key = "machina:session:#{token}"
|
|
78
179
|
|
|
@@ -97,7 +198,24 @@ RSpec.describe Machina::Middleware::Authentication do
|
|
|
97
198
|
status, = middleware.call(env)
|
|
98
199
|
|
|
99
200
|
expect(status).to eq(200)
|
|
100
|
-
expect(Machina.cache.read(cache_key)).to
|
|
201
|
+
expect(Machina.cache.read(cache_key)).to eq('__machina_invalid__' => true)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
it 'does not re-hit Console for a rejected token while the negative cache is warm' do
|
|
205
|
+
token = 'ps_dead_token'
|
|
206
|
+
|
|
207
|
+
allow(identity_client).to receive(:resolve_session).with(token).and_return(
|
|
208
|
+
Machina::IdentityClient::Response.new(status: 404, body: '{}'),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
3.times do
|
|
212
|
+
env = Rack::MockRequest.env_for('/resource', 'HTTP_AUTHORIZATION' => "Bearer #{token}")
|
|
213
|
+
status, = middleware.call(env)
|
|
214
|
+
expect(status).to eq(200)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Without negative caching this would be 3 calls; the sentinel absorbs the retries.
|
|
218
|
+
expect(identity_client).to have_received(:resolve_session).with(token).once
|
|
101
219
|
end
|
|
102
220
|
|
|
103
221
|
context 'when a stale cookie coexists with a fresh callback token' do
|
|
@@ -180,6 +298,41 @@ RSpec.describe Machina::Middleware::Authentication do
|
|
|
180
298
|
expect(JSON.parse(body.first)['user_id']).to eq(mock['data']['user']['id'])
|
|
181
299
|
end
|
|
182
300
|
|
|
301
|
+
context 'when the identity service is unreachable' do
|
|
302
|
+
it 'returns stale cached data instead of destroying the session' do
|
|
303
|
+
token = 'ps_network_blip'
|
|
304
|
+
cache_key = "machina:session:#{token}"
|
|
305
|
+
cached_data = MockResponses.session_resolution_minimal['data'].merge('stale' => true)
|
|
306
|
+
|
|
307
|
+
Machina.cache.write(cache_key, cached_data, expires_in: 5.minutes)
|
|
308
|
+
|
|
309
|
+
allow(identity_client).to receive(:resolve_session).with(token).and_raise(
|
|
310
|
+
Faraday::ConnectionFailed.new('Connection refused'),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
env = Rack::MockRequest.env_for('/resource', 'HTTP_AUTHORIZATION' => "Bearer #{token}")
|
|
314
|
+
status, _headers, body = middleware.call(env)
|
|
315
|
+
|
|
316
|
+
expect(status).to eq(200)
|
|
317
|
+
expect(JSON.parse(body.first)['user_id']).to eq(cached_data['user']['id'])
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
it 'does not delete the cookie on transient network failure' do
|
|
321
|
+
allow(identity_client).to receive(:resolve_session).with('ps_network_fail').and_raise(
|
|
322
|
+
Faraday::ConnectionFailed.new('Connection refused'),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
env = Rack::MockRequest.env_for(
|
|
326
|
+
'/resource',
|
|
327
|
+
'HTTP_COOKIE' => 'machina_session=ps_network_fail',
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
_status, headers, _body = middleware.call(env)
|
|
331
|
+
|
|
332
|
+
expect(headers['set-cookie']).to be_nil
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
183
336
|
it 'uses the cache on subsequent requests' do
|
|
184
337
|
response = Machina::IdentityClient::Response.new(
|
|
185
338
|
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.4.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
|