machina-auth 0.1.1 → 0.1.2

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: 6d5387129c66f965ee5575eec314c27e3142884f5645e528e75c069d89989be1
4
- data.tar.gz: b9b4f8301d4e6050199331d0bb2a7afe884ab889828618ef2ac6c16dcc6fb537
3
+ metadata.gz: 72016a1f127a752307bd3351a7179bd514a07f01257141b5976c24512f9a58ab
4
+ data.tar.gz: 49e3d3a871defaf71cc93405268d6935afdbe97131b5f565fcf74fa7d336c8fa
5
5
  SHA512:
6
- metadata.gz: 67f43248481837cc6d930b8afd5649c802df1c3643bd79229ee607449a9f914e3d4c3b0f14dafde06f0128436f1379911552746920ef5b5503b0dd03800268cb
7
- data.tar.gz: bd763c044617cf3b33381bc548b53e4301265d9533264b74f38fc7b51d1355cd1935b6a969842c8ff95c6516352f8f777a1447bd055594c42f1593390a197bda
6
+ metadata.gz: 1b1ca73243c466e604fd6922c9f60ea716d264e7dac913093fdbcaca1fa4e18b5aea6318eae5d5f04fc5809644680c6b39147339f2fa95358da4e74238efee88
7
+ data.tar.gz: 78582e31a57898eacd0a5abfb4c4bb812eb6683420549590a88ffbe8ffa0ded22e091cdf093f76ef3db75675719d81b4dc7de05482a7babc5d364f03e56d4b2f
@@ -10,10 +10,12 @@ module Machina
10
10
  :product_slug,
11
11
  :cache_store,
12
12
  :cache_ttl,
13
- :manifest
13
+ :manifest,
14
+ :skip_paths
14
15
 
15
16
  def initialize
16
17
  @cache_ttl = 5.minutes
18
+ @skip_paths = []
17
19
  end
18
20
  end
19
21
  end
@@ -11,6 +11,9 @@ module Machina
11
11
 
12
12
  def call(env)
13
13
  request = ActionDispatch::Request.new(env)
14
+
15
+ return @app.call(env) if skip_path?(request)
16
+
14
17
  token = extract_token(request)
15
18
 
16
19
  return @app.call(env) if token.blank?
@@ -30,6 +33,25 @@ module Machina
30
33
 
31
34
  private
32
35
 
36
+ # Checks whether the request matches any configured skip path.
37
+ #
38
+ # Strings match as path prefixes. Regexes match against both the
39
+ # request path and the full URL, allowing pattern-based exclusions
40
+ # on host, path, or any combination.
41
+ def skip_path?(request)
42
+ return false if Machina.config.skip_paths.blank?
43
+
44
+ path = request.path
45
+ url = request.url
46
+
47
+ Machina.config.skip_paths.any? do |pattern|
48
+ case pattern
49
+ when Regexp then pattern.match?(path) || pattern.match?(url)
50
+ when String then path.start_with?(pattern)
51
+ end
52
+ end
53
+ end
54
+
33
55
  def extract_token(request)
34
56
  request.cookies['machina_session'] ||
35
57
  extract_bearer(request) ||
@@ -3,25 +3,54 @@
3
3
  module Machina
4
4
  # Reads the local permission manifest (machina.yml) and synchronises it
5
5
  # with the Machina Console so the Console knows which permissions exist.
6
+ #
7
+ # Supports flat manifests, environment-scoped manifests, and ERB
8
+ # interpolation.
6
9
  class PermissionSync
7
10
  def self.call!
8
11
  new.call!
9
12
  end
10
13
 
11
14
  def call!
12
- manifest = YAML.load_file(Machina.config.manifest)
15
+ manifest = load_manifest
13
16
 
14
- product_id = manifest['product_id'] || Machina.config.product_id
17
+ product_id = manifest[:product_id] || Machina.config.product_id
15
18
  if product_id.blank?
16
- raise Machina::ConfigurationError,
17
- '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)'
18
20
  end
19
21
 
20
22
  Machina.identity_client.sync_permissions(
21
23
  product_id:,
22
- permissions: manifest.fetch('permissions'),
23
- policies: manifest.fetch('policies', []),
24
+ permissions: manifest.fetch(:permissions),
25
+ policies: manifest.fetch(:policies, []),
24
26
  )
25
27
  end
28
+
29
+ private
30
+
31
+ # Loads the manifest YAML with ERB support and optional environment scoping.
32
+ #
33
+ # Tries +Rails.application.config_for+ first, which handles ERB evaluation
34
+ # and environment scoping (e.g. +test:+, +production:+, +shared:+ keys).
35
+ # Falls back to direct ERB + YAML parsing for flat manifests that have no
36
+ # environment keys.
37
+ def load_manifest
38
+ path = Pathname.new(Machina.config.manifest)
39
+
40
+ result = Rails.application.config_for(path)
41
+ return result if result.present?
42
+
43
+ parse_flat_manifest(path)
44
+ end
45
+
46
+ # Parses a flat (non-environment-scoped) manifest with ERB support.
47
+ #
48
+ # @param path [Pathname] absolute path to the YAML file
49
+ # @return [ActiveSupport::OrderedOptions]
50
+ def parse_flat_manifest(path)
51
+ raw = ERB.new(path.read).result
52
+ data = YAML.safe_load(raw, permitted_classes: [Symbol]).deep_symbolize_keys
53
+ ActiveSupport::OrderedOptions.new.update(data)
54
+ end
26
55
  end
27
56
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Machina
4
- VERSION = '0.1.1'
4
+ VERSION = '0.1.2'
5
5
  end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_helper'
4
+
5
+ RSpec.describe Machina::Middleware::Authentication, 'skip_paths' do
6
+ let(:app) do
7
+ lambda do |_env|
8
+ auth = Machina::Current.authorized
9
+ body = JSON.generate(user_id: auth&.user_id, permissions: auth&.permissions)
10
+ [200, { 'Content-Type' => 'application/json' }, [body]]
11
+ end
12
+ end
13
+ let(:middleware) { described_class.new(app) }
14
+ let(:identity_client) { instance_double(Machina::IdentityClient) }
15
+ let(:bearer_header) { { 'HTTP_AUTHORIZATION' => 'Bearer ps_123' } }
16
+
17
+ before do
18
+ allow(Machina).to receive(:identity_client).and_return(identity_client)
19
+ allow(identity_client).to receive(:resolve_session)
20
+ end
21
+
22
+ # -- Happy path: skipped routes pass through without resolution -----------
23
+
24
+ context 'with a string skip path' do
25
+ before { Machina.config.skip_paths = ['/webhooks'] }
26
+
27
+ it 'passes through without resolving the token' do
28
+ env = Rack::MockRequest.env_for('/webhooks', bearer_header)
29
+
30
+ status, _headers, body = middleware.call(env)
31
+
32
+ expect(status).to eq(200)
33
+ expect(JSON.parse(body.first)['user_id']).to be_nil
34
+ expect(identity_client).not_to have_received(:resolve_session)
35
+ end
36
+ end
37
+
38
+ context 'with a regex skip path' do
39
+ before { Machina.config.skip_paths = [%r{\A/callbacks/}] }
40
+
41
+ it 'passes through without resolving the token' do
42
+ env = Rack::MockRequest.env_for('/callbacks/stripe', bearer_header)
43
+
44
+ status, = middleware.call(env)
45
+
46
+ expect(status).to eq(200)
47
+ expect(identity_client).not_to have_received(:resolve_session)
48
+ end
49
+ end
50
+
51
+ # -- Prefix matching for strings -----------------------------------------
52
+
53
+ context 'when request path is a sub-path of a string skip path' do
54
+ before { Machina.config.skip_paths = ['/webhooks'] }
55
+
56
+ it 'skips nested paths' do
57
+ env = Rack::MockRequest.env_for('/webhooks/stripe/events', bearer_header)
58
+
59
+ status, = middleware.call(env)
60
+
61
+ expect(status).to eq(200)
62
+ expect(identity_client).not_to have_received(:resolve_session)
63
+ end
64
+ end
65
+
66
+ # -- Non-matching routes still resolve ------------------------------------
67
+
68
+ context 'when the path does not match any skip path' do
69
+ before do
70
+ Machina.config.skip_paths = ['/webhooks']
71
+ allow(identity_client).to receive(:resolve_session).with('ps_123').and_return(
72
+ Machina::IdentityClient::Response.new(status: 200, body: MockResponses.session_resolution),
73
+ )
74
+ end
75
+
76
+ it 'resolves the token normally' do
77
+ env = Rack::MockRequest.env_for('/api/resources', bearer_header)
78
+
79
+ status, _headers, body = middleware.call(env)
80
+
81
+ expect(status).to eq(200)
82
+ expect(JSON.parse(body.first)['user_id']).to eq(MockResponses.session_resolution['data']['user']['id'])
83
+ expect(identity_client).to have_received(:resolve_session).once
84
+ end
85
+ end
86
+
87
+ # -- Multiple skip paths -------------------------------------------------
88
+
89
+ context 'with multiple skip paths (mixed strings and regexes)' do
90
+ before { Machina.config.skip_paths = ['/webhooks', %r{\A/callbacks/\w+\z}] }
91
+
92
+ it 'skips a request matching the string entry' do
93
+ env = Rack::MockRequest.env_for('/webhooks', bearer_header)
94
+ middleware.call(env)
95
+ expect(identity_client).not_to have_received(:resolve_session)
96
+ end
97
+
98
+ it 'skips a request matching the regex entry' do
99
+ env = Rack::MockRequest.env_for('/callbacks/stripe', bearer_header)
100
+ middleware.call(env)
101
+ expect(identity_client).not_to have_received(:resolve_session)
102
+ end
103
+
104
+ it 'does not skip a request matching neither entry' do
105
+ env = Rack::MockRequest.env_for('/api/resources', bearer_header)
106
+ middleware.call(env)
107
+ expect(identity_client).to have_received(:resolve_session).once
108
+ end
109
+ end
110
+
111
+ # -- Edge cases: near-miss paths -----------------------------------------
112
+
113
+ context 'when the path is a near-miss of a string skip path' do
114
+ before { Machina.config.skip_paths = ['/webhooks'] }
115
+
116
+ it 'does not skip /webhook (no trailing s)' do
117
+ env = Rack::MockRequest.env_for('/webhook', bearer_header)
118
+ middleware.call(env)
119
+ expect(identity_client).to have_received(:resolve_session).once
120
+ end
121
+
122
+ it 'does not skip /other-webhooks (different prefix)' do
123
+ env = Rack::MockRequest.env_for('/other-webhooks', bearer_header)
124
+ middleware.call(env)
125
+ expect(identity_client).to have_received(:resolve_session).once
126
+ end
127
+ end
128
+
129
+ # -- Edge case: regex against full URL ------------------------------------
130
+
131
+ context 'with a regex that matches the full request URL' do
132
+ before { Machina.config.skip_paths = [/stripe\.com/] }
133
+
134
+ it 'matches against the full URL when present' do
135
+ env = Rack::MockRequest.env_for('https://api.stripe.com/webhooks', bearer_header)
136
+
137
+ status, = middleware.call(env)
138
+
139
+ expect(status).to eq(200)
140
+ expect(identity_client).not_to have_received(:resolve_session)
141
+ end
142
+ end
143
+
144
+ # -- Edge case: string against full URL -----------------------------------
145
+
146
+ context 'with a string skip path checked against a full URL' do
147
+ before { Machina.config.skip_paths = ['/webhooks'] }
148
+
149
+ it 'still matches the path portion of a full URL' do
150
+ env = Rack::MockRequest.env_for('https://myapp.com/webhooks/events', bearer_header)
151
+
152
+ status, = middleware.call(env)
153
+
154
+ expect(status).to eq(200)
155
+ expect(identity_client).not_to have_received(:resolve_session)
156
+ end
157
+ end
158
+
159
+ # -- Edge case: empty skip_paths (default) --------------------------------
160
+
161
+ context 'when skip_paths is empty (default)' do
162
+ it 'resolves tokens on all paths' do
163
+ env = Rack::MockRequest.env_for('/webhooks', bearer_header)
164
+ middleware.call(env)
165
+ expect(identity_client).to have_received(:resolve_session).once
166
+ end
167
+ end
168
+
169
+ # -- Edge case: no token on a skipped path --------------------------------
170
+
171
+ context 'when a skipped path has no token' do
172
+ before { Machina.config.skip_paths = ['/webhooks'] }
173
+
174
+ it 'passes through without attempting resolution' do
175
+ env = Rack::MockRequest.env_for('/webhooks')
176
+
177
+ status, = middleware.call(env)
178
+
179
+ expect(status).to eq(200)
180
+ expect(identity_client).not_to have_received(:resolve_session)
181
+ end
182
+ end
183
+
184
+ # -- Edge case: query string on a skipped path ----------------------------
185
+
186
+ context 'when a skipped path has query parameters' do
187
+ before { Machina.config.skip_paths = ['/webhooks'] }
188
+
189
+ it 'still skips (query string does not affect path matching)' do
190
+ env = Rack::MockRequest.env_for('/webhooks?verify=true', bearer_header)
191
+
192
+ status, = middleware.call(env)
193
+
194
+ expect(status).to eq(200)
195
+ expect(identity_client).not_to have_received(:resolve_session)
196
+ end
197
+ end
198
+ end
@@ -3,21 +3,21 @@
3
3
  require_relative '../rails_helper'
4
4
 
5
5
  RSpec.describe Machina::PermissionSync do
6
+ let(:client) { instance_double(Machina::IdentityClient) }
7
+
6
8
  before do
7
9
  Machina.config.product_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
8
- end
9
-
10
- it 'loads manifests and forwards them to the identity client' do
11
- client = instance_double(Machina::IdentityClient)
12
10
  allow(Machina).to receive(:identity_client).and_return(client)
13
11
  allow(client).to receive(:sync_permissions)
12
+ end
14
13
 
14
+ it 'loads manifests and forwards them to the identity client' do
15
15
  described_class.call!
16
16
 
17
17
  expect(client).to have_received(:sync_permissions).with(
18
18
  product_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
19
- permissions: [{ 'key' => 'sessions.view', 'description' => 'View sessions' }],
20
- policies: [{ 'name' => 'Viewer', 'api_name' => 'viewer', 'permissions' => ['sessions.view'] }],
19
+ permissions: [{ key: 'sessions.view', description: 'View sessions' }],
20
+ policies: [{ name: 'Viewer', api_name: 'viewer', permissions: ['sessions.view'] }],
21
21
  )
22
22
  end
23
23
 
@@ -26,4 +26,167 @@ RSpec.describe Machina::PermissionSync do
26
26
 
27
27
  expect { described_class.call! }.to raise_error(Machina::ConfigurationError, /product_id is required/)
28
28
  end
29
+
30
+ context 'with a flat (non-environment-scoped) manifest' do
31
+ let(:tmpfile) do
32
+ file = Tempfile.new(['machina', '.yml'])
33
+ file.write(<<~YAML)
34
+ product_id: flat-product-id
35
+
36
+ permissions:
37
+ - key: items.view
38
+ description: View items
39
+
40
+ policies:
41
+ - name: Reader
42
+ api_name: reader
43
+ permissions:
44
+ - items.view
45
+ YAML
46
+ file.close
47
+ file
48
+ end
49
+
50
+ before { Machina.config.manifest = tmpfile.path }
51
+ after { tmpfile.unlink }
52
+
53
+ it 'loads the manifest as top-level keys' do
54
+ described_class.call!
55
+
56
+ expect(client).to have_received(:sync_permissions).with(
57
+ product_id: 'flat-product-id',
58
+ permissions: [{ key: 'items.view', description: 'View items' }],
59
+ policies: [{ name: 'Reader', api_name: 'reader', permissions: ['items.view'] }],
60
+ )
61
+ end
62
+ end
63
+
64
+ context 'with an environment-scoped manifest' do
65
+ let(:tmpfile) do
66
+ file = Tempfile.new(['machina', '.yml'])
67
+ file.write(<<~YAML)
68
+ test:
69
+ product_id: test-product-id
70
+ permissions:
71
+ - key: reports.view
72
+ description: View reports
73
+ policies: []
74
+
75
+ production:
76
+ product_id: prod-product-id
77
+ permissions:
78
+ - key: reports.view
79
+ description: View reports
80
+ - key: reports.edit
81
+ description: Edit reports
82
+ policies: []
83
+ YAML
84
+ file.close
85
+ file
86
+ end
87
+
88
+ before do
89
+ Machina.config.manifest = tmpfile.path
90
+ Machina.config.product_id = nil
91
+ end
92
+
93
+ after { tmpfile.unlink }
94
+
95
+ it 'loads the manifest scoped to the current Rails environment' do
96
+ described_class.call!
97
+
98
+ expect(client).to have_received(:sync_permissions).with(
99
+ product_id: 'test-product-id',
100
+ permissions: [{ key: 'reports.view', description: 'View reports' }],
101
+ policies: [],
102
+ )
103
+ end
104
+ end
105
+
106
+ context 'with an ERB manifest' do
107
+ let(:tmpfile) do
108
+ file = Tempfile.new(['machina', '.yml'])
109
+ file.write(<<~'YAML')
110
+ product_id: <%= "erb-product-id" %>
111
+
112
+ permissions:
113
+ - key: tasks.view
114
+ description: View tasks
115
+
116
+ policies: []
117
+ YAML
118
+ file.close
119
+ file
120
+ end
121
+
122
+ before do
123
+ Machina.config.manifest = tmpfile.path
124
+ Machina.config.product_id = nil
125
+ end
126
+
127
+ after { tmpfile.unlink }
128
+
129
+ it 'evaluates ERB before parsing YAML' do
130
+ described_class.call!
131
+
132
+ expect(client).to have_received(:sync_permissions).with(
133
+ product_id: 'erb-product-id',
134
+ permissions: [{ key: 'tasks.view', description: 'View tasks' }],
135
+ policies: [],
136
+ )
137
+ end
138
+ end
139
+
140
+ context 'when manifest product_id is absent but config product_id is set' do
141
+ let(:tmpfile) do
142
+ file = Tempfile.new(['machina', '.yml'])
143
+ file.write(<<~YAML)
144
+ permissions:
145
+ - key: items.view
146
+ description: View items
147
+ policies: []
148
+ YAML
149
+ file.close
150
+ file
151
+ end
152
+
153
+ before { Machina.config.manifest = tmpfile.path }
154
+ after { tmpfile.unlink }
155
+
156
+ it 'falls back to Machina.config.product_id' do
157
+ described_class.call!
158
+
159
+ expect(client).to have_received(:sync_permissions).with(
160
+ product_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
161
+ permissions: [{ key: 'items.view', description: 'View items' }],
162
+ policies: [],
163
+ )
164
+ end
165
+ end
166
+
167
+ context 'when policies key is omitted from manifest' do
168
+ let(:tmpfile) do
169
+ file = Tempfile.new(['machina', '.yml'])
170
+ file.write(<<~YAML)
171
+ permissions:
172
+ - key: items.view
173
+ description: View items
174
+ YAML
175
+ file.close
176
+ file
177
+ end
178
+
179
+ before { Machina.config.manifest = tmpfile.path }
180
+ after { tmpfile.unlink }
181
+
182
+ it 'defaults policies to an empty array' do
183
+ described_class.call!
184
+
185
+ expect(client).to have_received(:sync_permissions).with(
186
+ product_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
187
+ permissions: [{ key: 'items.view', description: 'View items' }],
188
+ policies: [],
189
+ )
190
+ end
191
+ end
29
192
  end
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.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - ZAR
@@ -179,6 +179,7 @@ files:
179
179
  - spec/machina/controller_helpers_spec.rb
180
180
  - spec/machina/identity_client_spec.rb
181
181
  - spec/machina/middleware/authentication_spec.rb
182
+ - spec/machina/middleware/skip_paths_spec.rb
182
183
  - spec/machina/permission_sync_spec.rb
183
184
  - spec/machina/test_helpers_spec.rb
184
185
  - spec/machina/webhook_receiver_spec.rb