machina-auth 0.1.8 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a6d8d740d4e6998400a56db8fe506ce1155221bb7ff216fedda0c48ced30aa06
4
- data.tar.gz: bbaf611e06b06f3f8fdf767203ec2106504ba97d8cc4e548881ca5f6909df611
3
+ metadata.gz: 95a0c331d428907d79a618631f8b39aea1aa20e297090e44229aed0bc93c0324
4
+ data.tar.gz: 1489dd29fdf00291cb414b57f198d210bd9e1af818d373b4064b7cda6c0f1b66
5
5
  SHA512:
6
- metadata.gz: 3ca99f12ea236b7a17a84cadc439aa713344c37b2ba2d9a4f59c9d6938b3a7a92f9994f6e79ace9a52203ef64d8dbf3733978f53d70502e14b164efb2bd013f3
7
- data.tar.gz: 0c01bf88a2ae86779fcbb2e73ddf94e3269169cade8ced2c50a308e10bd2f54d099c00d1c07fadcabaa2fe4a2ec258683e1b4285e227ad7b7d869f7ba9b80b31
6
+ metadata.gz: '08a671be6f57a8ef5098444165e335e33cfb85e371f7d34d7842699854bab66050f1de191dc203839dc7c4b94dc72a556803699bc08333e453a5552fd0b94007'
7
+ data.tar.gz: 236485e85bfc0ef18afc0ddde3251b411f8579ff90b1fa870958b403e2175fbfbfce751d9c629d787c9848f709473e7f7f3779843ab6232b100aee27f1acfb83
@@ -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 = data.dig('session', 'type')&.to_sym
79
- @session_id = data.dig('session', 'id') || data['session_id']
80
- raw_expires = data.dig('session', 'expires_at') || data['expires_at']
81
- @expires_at = raw_expires ? Time.zone.parse(raw_expires) : nil
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
- def resolve_session(token)
23
- post('/internal/v1/sessions/resolve', { token: })
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
- session_data = resolve_session(token)
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
- # Checks whether the request matches any configured skip path.
38
- #
39
- # Strings match as path prefixes. Regexes match against both the
40
- # request path and the full URL, allowing pattern-based exclusions
41
- # on host, path, or any combination.
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 entry for the expired token and, when the token
57
- # originated from the session cookie, deletes that cookie so the
58
- # browser stops sending it on subsequent requests. This prevents a
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
@@ -84,25 +90,31 @@ module Machina
84
90
  match && match[1]
85
91
  end
86
92
 
87
- def resolve_session(token)
88
- cached = Machina.cache.read(cache_key(token))
89
- return cached if cached.present? && !(cached.is_a?(Hash) && cached[:stale])
90
-
91
- 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
92
104
  rescue StandardError
93
105
  nil
94
106
  end
95
107
 
96
- def fetch_from_identity_service(token)
97
- 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)
98
110
 
99
111
  unless response.success?
100
- Machina.cache.delete(cache_key(token))
112
+ Machina.cache.delete(cache_key(token, hints))
101
113
  return nil
102
114
  end
103
115
 
104
116
  data = unwrap_payload(response.parsed)
105
- 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)
106
118
  cache_workspace_ref(data)
107
119
  data
108
120
  end
@@ -112,25 +124,18 @@ module Machina
112
124
  end
113
125
 
114
126
  def cache_workspace_ref(data)
115
- workspace = data['workspace']
116
- organization = data['organization']
117
-
127
+ workspace, organization = data.values_at('workspace', 'organization')
118
128
  return unless workspace.is_a?(Hash) && organization.is_a?(Hash)
119
129
  return unless defined?(Machina::WorkspaceRef) && Machina::WorkspaceRef.table_exists?
120
130
 
121
131
  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!
132
+ ref.update!(name: workspace['name'], cached_at: Time.current, organization_id: organization['id'])
128
133
  rescue StandardError
129
134
  nil
130
135
  end
131
136
 
132
- def cache_key(token)
133
- "machina:session:#{token}"
137
+ def cache_key(token, hints)
138
+ "machina:session:#{token}#{hints.cache_suffix}"
134
139
  end
135
140
  end
136
141
  end
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Machina
4
- VERSION = '0.1.8'
4
+ VERSION = '0.3.0'
5
5
  end
@@ -63,7 +63,7 @@ module Machina
63
63
  entry = cache.read(key)
64
64
  next unless entry.is_a?(Hash)
65
65
 
66
- cache.write(key, entry.merge(stale: true))
66
+ cache.write(key, entry.merge('stale' => true))
67
67
  end
68
68
  end
69
69
 
@@ -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: true)
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
 
@@ -180,6 +280,41 @@ RSpec.describe Machina::Middleware::Authentication do
180
280
  expect(JSON.parse(body.first)['user_id']).to eq(mock['data']['user']['id'])
181
281
  end
182
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
+
183
318
  it 'uses the cache on subsequent requests' do
184
319
  response = Machina::IdentityClient::Response.new(
185
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: true)
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' => { 'id' => 'b2c3d4e5-f6a7-8901-bcde-f12345678901', 'name' => 'Primary' },
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' => { 'id' => 'b2c3d4e5-f6a7-8901-bcde-f12345678901', 'name' => 'Primary' },
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.1.8
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