fastlane-plugin-infisical 0.1.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.
@@ -0,0 +1,385 @@
1
+ require "fastlane_core/command_executor"
2
+ require "fastlane_core/configuration/configuration"
3
+ require "match"
4
+ require "fileutils"
5
+ require "net/http"
6
+ require "json"
7
+ require "uri"
8
+ require "base64"
9
+
10
+ module Fastlane
11
+ module InfisicalStorage
12
+ class Storage < ::Match::Storage::Interface
13
+ attr_reader :project_id
14
+ attr_reader :environment
15
+ attr_reader :secret_path
16
+ attr_reader :infisical_url
17
+ attr_reader :username
18
+ attr_reader :readonly
19
+ attr_reader :team_id
20
+ attr_reader :team_name
21
+ attr_reader :api_key_path
22
+ attr_reader :api_key
23
+
24
+ def self.configure(params)
25
+ if params[:git_url].to_s.length > 0
26
+ UI.important("Looks like you still define a `git_url` somewhere, even though")
27
+ UI.important("you use Infisical. You can remove the `git_url`")
28
+ UI.important("from your Matchfile and Fastfile")
29
+ UI.message("The above is just a warning, fastlane will continue as usual now...")
30
+ end
31
+
32
+ return(
33
+ self.new(
34
+ project_id: params[:infisical_project_id],
35
+ environment: params[:infisical_environment],
36
+ secret_path: params[:infisical_secret_path],
37
+ infisical_url: params[:infisical_url],
38
+ username: params[:username],
39
+ readonly: params[:readonly],
40
+ team_id: params[:team_id],
41
+ team_name: params[:team_name],
42
+ api_key_path: params[:api_key_path],
43
+ api_key: params[:api_key],
44
+ )
45
+ )
46
+ end
47
+
48
+ def initialize(
49
+ project_id:,
50
+ environment: "dev",
51
+ secret_path: "/",
52
+ infisical_url: nil,
53
+ username: nil,
54
+ readonly: nil,
55
+ team_id: nil,
56
+ team_name: nil,
57
+ api_key_path: nil,
58
+ api_key: nil
59
+ )
60
+ @project_id = project_id || ENV["INFISICAL_PROJECT_ID"]
61
+ @environment = environment || ENV["INFISICAL_ENVIRONMENT"] || "dev"
62
+ @secret_path = secret_path || ENV["INFISICAL_SECRET_PATH"] || "/"
63
+ @infisical_url = infisical_url || ENV["INFISICAL_URL"] || "https://app.infisical.com"
64
+
65
+ UI.important("🌐 URL Resolution Debug:")
66
+ UI.important(" infisical_url param: #{infisical_url.inspect}")
67
+ UI.important(" ENV['INFISICAL_URL']: #{ENV['INFISICAL_URL'].inspect}")
68
+ UI.important(" Final @infisical_url: #{@infisical_url}")
69
+
70
+ @username = username
71
+ @readonly = readonly
72
+ @team_id = team_id
73
+ @team_name = team_name
74
+ @api_key_path = api_key_path
75
+ @api_key = api_key
76
+
77
+ # Get authentication token
78
+ @auth_token = get_auth_token
79
+
80
+ UI.message("Initializing match for Infisical at project #{@project_id} in environment #{@environment}")
81
+ end
82
+
83
+ def prefixed_working_directory
84
+ @_folder_prefix ||= currently_used_team_id
85
+ if @_folder_prefix.nil?
86
+ UI.important(
87
+ "Looks like you run `match` in `readonly` mode, and didn't provide a `team_id`. This will still work, however it is recommended to provide a `team_id` in your Appfile or Matchfile",
88
+ )
89
+ @_folder_prefix = "*"
90
+ end
91
+ return File.join(working_directory, @_folder_prefix)
92
+ end
93
+
94
+ def download
95
+ return if @working_directory
96
+
97
+ self.working_directory = Dir.mktmpdir
98
+
99
+ with_infisical_error_handling do
100
+ secrets = list_all_secrets
101
+
102
+ secrets.each do |secret|
103
+ # Extract the relative path from the secret key
104
+ relative_path = secret["secretKey"]
105
+ next unless relative_path.start_with?("fastlane/")
106
+
107
+ # Remove the fastlane/ prefix and decode the file content
108
+ filename = File.join(self.working_directory, relative_path.delete_prefix("fastlane/"))
109
+ FileUtils.mkdir_p(File.dirname(filename))
110
+
111
+ # Decode base64 content if it's encoded
112
+ content = secret["secretValue"]
113
+ if is_base64?(content)
114
+ content = Base64.decode64(content)
115
+ end
116
+
117
+ IO.binwrite(filename, content)
118
+ end
119
+ end
120
+
121
+ UI.verbose(
122
+ "Successfully downloaded all secrets from Infisical to #{self.working_directory}",
123
+ )
124
+ end
125
+
126
+ def currently_used_team_id
127
+ if self.readonly
128
+ return self.team_id
129
+ else
130
+ if self.team_id.to_s.empty?
131
+ UI.user_error!(
132
+ "The `team_id` option is required. fastlane cannot automatically determine portal team id via the App Store Connect API (yet)",
133
+ )
134
+ end
135
+
136
+ spaceship =
137
+ ::Match::SpaceshipEnsure.new(self.username, self.team_id, self.team_name, api_token)
138
+ return spaceship.team_id
139
+ end
140
+ end
141
+
142
+ def api_token
143
+ api_token =
144
+ Spaceship::ConnectAPI::Token.from(hash: self.api_key, filepath: self.api_key_path)
145
+ api_token ||= Spaceship::ConnectAPI.token
146
+ return api_token
147
+ end
148
+
149
+ def human_readable_description
150
+ "Infisical Storage [Project: #{self.project_id}, Environment: #{self.environment}]"
151
+ end
152
+
153
+ def upload_files(files_to_upload: [], custom_message: nil)
154
+ files_to_upload.each do |current_file|
155
+ # Convert file path to secret key
156
+ secret_key = "fastlane/" + current_file.delete_prefix(self.working_directory + "/")
157
+ UI.verbose("Uploading '#{secret_key}' to Infisical...")
158
+
159
+ # Read file content and encode as base64 for binary files
160
+ content = IO.binread(current_file)
161
+ encoded_content = Base64.encode64(content)
162
+
163
+ create_or_update_secret(secret_key, encoded_content)
164
+ end
165
+ end
166
+
167
+ def delete_files(files_to_delete: [], custom_message: nil)
168
+ files_to_delete.each do |current_file|
169
+ secret_key = "fastlane/" + current_file.delete_prefix(self.working_directory + "/")
170
+ delete_secret(secret_key)
171
+ end
172
+ end
173
+
174
+ def skip_docs
175
+ true
176
+ end
177
+
178
+ def list_files(file_name: "", file_ext: "")
179
+ Dir[File.join(working_directory, self.team_id, "**", file_name, "*.#{file_ext}")]
180
+ end
181
+
182
+ def generate_matchfile_content(template: nil)
183
+ raise "Not Implemented"
184
+ end
185
+
186
+ private
187
+
188
+ def get_auth_token
189
+ # Try different authentication methods
190
+
191
+ # Method 1: Universal Auth (Client ID + Client Secret)
192
+ client_id = ENV["INFISICAL_CLIENT_ID"]
193
+ client_secret = ENV["INFISICAL_CLIENT_SECRET"]
194
+
195
+ if client_id && client_secret
196
+ return authenticate_universal_auth(client_id, client_secret)
197
+ end
198
+
199
+ # Method 2: Service Token (legacy)
200
+ service_token = ENV["INFISICAL_TOKEN"]
201
+ if service_token
202
+ return service_token
203
+ end
204
+
205
+ UI.user_error!("No Infisical authentication method found. Please set either INFISICAL_CLIENT_ID + INFISICAL_CLIENT_SECRET or INFISICAL_TOKEN environment variables.")
206
+ end
207
+
208
+ def authenticate_universal_auth(client_id, client_secret)
209
+ UI.important("🔐 Attempting Universal Auth with URL: #{@infisical_url}")
210
+ UI.important("🔑 Client ID: #{client_id}")
211
+
212
+ uri = URI("#{@infisical_url}/api/v1/auth/universal-auth/login")
213
+ http = Net::HTTP.new(uri.host, uri.port)
214
+ http.use_ssl = true if uri.scheme == 'https'
215
+
216
+ request = Net::HTTP::Post.new(uri)
217
+ request['Content-Type'] = 'application/json'
218
+ request.body = {
219
+ clientId: client_id,
220
+ clientSecret: client_secret
221
+ }.to_json
222
+
223
+ UI.important("📤 Request URL: #{uri}")
224
+ UI.important("📤 Request payload: #{request.body}")
225
+
226
+ response = http.request(request)
227
+
228
+ UI.important("📥 Response: #{response.code}")
229
+ UI.important("📥 Response body: #{response.body}")
230
+
231
+ if response.code == '200'
232
+ result = JSON.parse(response.body)
233
+ UI.success("✅ Universal Auth successful!")
234
+ return result['accessToken']
235
+ else
236
+ UI.user_error!("Failed to authenticate with Infisical: #{response.code} #{response.body}")
237
+ end
238
+ end
239
+
240
+ def list_all_secrets
241
+ uri = URI("#{@infisical_url}/api/v3/secrets")
242
+ uri.query = URI.encode_www_form({
243
+ workspaceId: @project_id,
244
+ environment: @environment,
245
+ secretPath: @secret_path,
246
+ recursive: true
247
+ })
248
+
249
+ http = Net::HTTP.new(uri.host, uri.port)
250
+ http.use_ssl = true if uri.scheme == 'https'
251
+
252
+ request = Net::HTTP::Get.new(uri)
253
+ request['Authorization'] = "Bearer #{@auth_token}"
254
+
255
+ response = http.request(request)
256
+
257
+ if response.code == '200'
258
+ result = JSON.parse(response.body)
259
+ return result['secrets'] || []
260
+ else
261
+ UI.error("Failed to list secrets: #{response.code} #{response.body}")
262
+ return []
263
+ end
264
+ end
265
+
266
+ def create_or_update_secret(secret_key, value)
267
+ # Check if secret exists
268
+ existing_secret = get_secret(secret_key)
269
+
270
+ if existing_secret
271
+ update_secret(secret_key, value)
272
+ else
273
+ create_secret(secret_key, value)
274
+ end
275
+ end
276
+
277
+ def get_secret(secret_key)
278
+ uri = URI("#{@infisical_url}/api/v3/secrets/#{URI.encode_www_form_component(secret_key)}")
279
+ uri.query = URI.encode_www_form({
280
+ workspaceId: @project_id,
281
+ environment: @environment,
282
+ secretPath: @secret_path
283
+ })
284
+
285
+ http = Net::HTTP.new(uri.host, uri.port)
286
+ http.use_ssl = true if uri.scheme == 'https'
287
+
288
+ request = Net::HTTP::Get.new(uri)
289
+ request['Authorization'] = "Bearer #{@auth_token}"
290
+
291
+ response = http.request(request)
292
+
293
+ if response.code == '200'
294
+ result = JSON.parse(response.body)
295
+ return result['secret']
296
+ else
297
+ return nil
298
+ end
299
+ end
300
+
301
+ def create_secret(secret_key, value)
302
+ uri = URI("#{@infisical_url}/api/v3/secrets/#{URI.encode_www_form_component(secret_key)}")
303
+
304
+ http = Net::HTTP.new(uri.host, uri.port)
305
+ http.use_ssl = true if uri.scheme == 'https'
306
+
307
+ request = Net::HTTP::Post.new(uri)
308
+ request['Authorization'] = "Bearer #{@auth_token}"
309
+ request['Content-Type'] = 'application/json'
310
+ request.body = {
311
+ workspaceId: @project_id,
312
+ environment: @environment,
313
+ secretPath: @secret_path,
314
+ secretValue: value,
315
+ secretComment: "Fastlane Match certificate/profile",
316
+ type: "shared"
317
+ }.to_json
318
+
319
+ response = http.request(request)
320
+
321
+ unless response.code == '200'
322
+ UI.error("Failed to create secret #{secret_key}: #{response.code} #{response.body}")
323
+ end
324
+ end
325
+
326
+ def update_secret(secret_key, value)
327
+ uri = URI("#{@infisical_url}/api/v3/secrets/#{URI.encode_www_form_component(secret_key)}")
328
+
329
+ http = Net::HTTP.new(uri.host, uri.port)
330
+ http.use_ssl = true if uri.scheme == 'https'
331
+
332
+ request = Net::HTTP::Patch.new(uri)
333
+ request['Authorization'] = "Bearer #{@auth_token}"
334
+ request['Content-Type'] = 'application/json'
335
+ request.body = {
336
+ workspaceId: @project_id,
337
+ environment: @environment,
338
+ secretPath: @secret_path,
339
+ secretValue: value
340
+ }.to_json
341
+
342
+ response = http.request(request)
343
+
344
+ unless response.code == '200'
345
+ UI.error("Failed to update secret #{secret_key}: #{response.code} #{response.body}")
346
+ end
347
+ end
348
+
349
+ def delete_secret(secret_key)
350
+ uri = URI("#{@infisical_url}/api/v3/secrets/#{URI.encode_www_form_component(secret_key)}")
351
+
352
+ http = Net::HTTP.new(uri.host, uri.port)
353
+ http.use_ssl = true if uri.scheme == 'https'
354
+
355
+ request = Net::HTTP::Delete.new(uri)
356
+ request['Authorization'] = "Bearer #{@auth_token}"
357
+ request['Content-Type'] = 'application/json'
358
+ request.body = {
359
+ workspaceId: @project_id,
360
+ environment: @environment,
361
+ secretPath: @secret_path
362
+ }.to_json
363
+
364
+ response = http.request(request)
365
+
366
+ unless response.code == '200'
367
+ UI.verbose("Failed to delete secret #{secret_key}: #{response.code} #{response.body}")
368
+ end
369
+ end
370
+
371
+ def is_base64?(string)
372
+ # Simple check to see if a string is base64 encoded
373
+ string.is_a?(String) && string.match(/\A[A-Za-z0-9+\/]*={0,2}\z/) && (string.length % 4 == 0)
374
+ end
375
+
376
+ def with_infisical_error_handling
377
+ explainer = "Note: Infisical credentials should be set via environment variables. Set either INFISICAL_CLIENT_ID + INFISICAL_CLIENT_SECRET or INFISICAL_TOKEN."
378
+ yield
379
+ rescue StandardError => e
380
+ UI.error("Infisical authentication error: #{e}.\n\n#{explainer}")
381
+ raise e
382
+ end
383
+ end
384
+ end
385
+ end
@@ -0,0 +1,259 @@
1
+ require "fastlane/plugin/infisical/version"
2
+ require "fastlane_core/ui/ui"
3
+ require "infisical-sdk"
4
+ require "base64"
5
+ require "tempfile"
6
+
7
+ UI = FastlaneCore::UI unless defined?(UI)
8
+
9
+ module Fastlane
10
+ module InfisicalStorage
11
+ class Storage < Match::Storage::Interface
12
+ attr_reader :project_id
13
+ attr_reader :environment
14
+ attr_reader :secret_path
15
+ attr_reader :infisical_url
16
+
17
+ def self.configure(params)
18
+ return(
19
+ Storage.new(
20
+ project_id: params[:project_id],
21
+ environment: params[:environment],
22
+ secret_path: params[:secret_path],
23
+ infisical_url: params[:infisical_url],
24
+ username: params[:username],
25
+ readonly: params[:readonly],
26
+ team_id: params[:team_id],
27
+ team_name: params[:team_name],
28
+ api_key_path: params[:api_key_path],
29
+ api_key: params[:api_key]
30
+ )
31
+ )
32
+ end
33
+
34
+ def initialize(
35
+ project_id:,
36
+ environment: "dev",
37
+ secret_path: "/",
38
+ infisical_url: nil,
39
+ username: nil,
40
+ readonly: nil,
41
+ team_id: nil,
42
+ team_name: nil,
43
+ api_key_path: nil,
44
+ api_key: nil
45
+ )
46
+ @project_id = project_id || ENV["INFISICAL_PROJECT_ID"]
47
+ @environment = environment || ENV["INFISICAL_ENVIRONMENT"] || "dev"
48
+ @secret_path = secret_path || ENV["INFISICAL_SECRET_PATH"] || "/"
49
+ @infisical_url =
50
+ infisical_url || ENV["INFISICAL_URL"] || "https://app.infisical.com"
51
+ @username = username
52
+ @readonly = readonly
53
+ @team_id = team_id
54
+ @team_name = team_name
55
+ @api_key_path = api_key_path
56
+ @api_key = api_key
57
+
58
+ # Initialize Infisical SDK client
59
+ @client = InfisicalSDK::InfisicalClient.new(@infisical_url)
60
+
61
+ # Authenticate using the best available method
62
+ authenticate_client
63
+
64
+ UI.message(
65
+ "Initializing match for Infisical at project #{@project_id} in environment #{@environment}"
66
+ )
67
+ end
68
+
69
+ def prefixed_working_directory
70
+ # the directory depends on the team_id
71
+ if @team_id
72
+ @_folder_prefix = "team_id/#{@team_id}"
73
+ elsif @team_name
74
+ @_folder_prefix = "team_name/#{@team_name}"
75
+ else
76
+ @_folder_prefix = nil
77
+ end
78
+
79
+ # If team_id/team_name is not set, use the one from the Appfile/Matchfile
80
+ if @_folder_prefix.nil?
81
+ UI.important(
82
+ "Looks like you run `match` in `readonly` mode, and didn't provide a `team_id`. This will still work, however it is recommended to provide a `team_id` in your Appfile or Matchfile"
83
+ )
84
+ @_folder_prefix = "*"
85
+ end
86
+ return File.join(working_directory, @_folder_prefix)
87
+ end
88
+
89
+ def download
90
+ return if @working_directory
91
+
92
+ self.working_directory = Dir.mktmpdir
93
+
94
+ with_infisical_error_handling do
95
+ # List all secrets with fastlane/ prefix
96
+ secrets = list_fastlane_secrets
97
+
98
+ secrets.each do |secret|
99
+ # Extract the relative path from the secret key
100
+ relative_path = secret["secretKey"]
101
+ next unless relative_path.start_with?("fastlane/")
102
+
103
+ # Remove fastlane/ prefix for local file path
104
+ local_path = relative_path.sub(%r{^fastlane/}, "")
105
+ full_path = File.join(working_directory, local_path)
106
+
107
+ # Create directory if needed
108
+ FileUtils.mkdir_p(File.dirname(full_path))
109
+
110
+ # Decode and write the file
111
+ content = Base64.decode64(secret["secretValue"])
112
+ File.write(full_path, content)
113
+
114
+ UI.verbose("Downloaded: #{relative_path} -> #{local_path}")
115
+ end
116
+
117
+ UI.success("Downloaded #{secrets.length} files from Infisical")
118
+ end
119
+ end
120
+
121
+ def upload_files(files_to_upload)
122
+ with_infisical_error_handling do
123
+ files_to_upload.each do |file_path|
124
+ # Calculate relative path and secret key
125
+ relative_path = file_path.gsub(working_directory + "/", "")
126
+ secret_key = "fastlane/#{relative_path}"
127
+
128
+ # Read and encode file content
129
+ content = File.read(file_path)
130
+ encoded_content = Base64.encode64(content)
131
+
132
+ # Create or update the secret
133
+ create_or_update_secret(secret_key, encoded_content)
134
+
135
+ UI.verbose("Uploaded: #{relative_path}")
136
+ end
137
+
138
+ UI.success("Uploaded #{files_to_upload.length} files to Infisical")
139
+ end
140
+ end
141
+
142
+ def delete_files(files_to_delete)
143
+ with_infisical_error_handling do
144
+ files_to_delete.each do |file_path|
145
+ relative_path = file_path.gsub(working_directory + "/", "")
146
+ secret_key = "fastlane/#{relative_path}"
147
+
148
+ delete_secret(secret_key)
149
+
150
+ UI.verbose("Deleted: #{relative_path}")
151
+ end
152
+ end
153
+ end
154
+
155
+ def skip_docs
156
+ false
157
+ end
158
+
159
+ def generate_matchfile_content(template: nil)
160
+ return ""
161
+ end
162
+
163
+ private
164
+
165
+ def authenticate_client
166
+ # Try Universal Auth first (preferred method)
167
+ client_id =
168
+ ENV["INFISICAL_CLIENT_ID"] ||
169
+ ENV["INFISICAL_UNIVERSAL_AUTH_CLIENT_ID"]
170
+ client_secret =
171
+ ENV["INFISICAL_CLIENT_SECRET"] ||
172
+ ENV["INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET"]
173
+
174
+ if client_id && client_secret
175
+ UI.verbose("🔐 Authenticating with Universal Auth...")
176
+ @client.auth.universal_auth(
177
+ client_id: client_id,
178
+ client_secret: client_secret
179
+ )
180
+ UI.success("✅ Universal Auth successful!")
181
+ return
182
+ end
183
+
184
+ # Fallback to service token (legacy)
185
+ service_token = ENV["INFISICAL_TOKEN"]
186
+ if service_token
187
+ UI.verbose("🔐 Using Service Token authentication...")
188
+ # Service tokens are used differently - they're passed as bearer tokens
189
+ # The SDK might handle this automatically, but we may need to set it manually
190
+ UI.important(
191
+ "⚠️ Service Token authentication with SDK - this may not work with E2EE enabled"
192
+ )
193
+ # Note: The SDK may not directly support service tokens in the same way
194
+ # This would need to be tested
195
+ return
196
+ end
197
+
198
+ UI.user_error!(
199
+ "No Infisical authentication method found. Please set either INFISICAL_CLIENT_ID + INFISICAL_CLIENT_SECRET or INFISICAL_TOKEN environment variables."
200
+ )
201
+ end
202
+
203
+ def list_fastlane_secrets
204
+ secrets =
205
+ @client.secrets.list(
206
+ project_id: @project_id,
207
+ environment: @environment,
208
+ path: @secret_path
209
+ )
210
+
211
+ # Filter for fastlane secrets only
212
+ secrets.select { |secret| secret["secretKey"].start_with?("fastlane/") }
213
+ end
214
+
215
+ def create_or_update_secret(secret_key, value)
216
+ begin
217
+ # Try to update first (in case it exists)
218
+ @client.secrets.update(
219
+ secret_name: secret_key,
220
+ secret_value: value,
221
+ project_id: @project_id,
222
+ environment: @environment
223
+ )
224
+ UI.verbose("Updated secret: #{secret_key}")
225
+ rescue => e
226
+ # If update fails, try to create
227
+ if e.message.include?("not found") || e.message.include?("404")
228
+ @client.secrets.create(
229
+ secret_name: secret_key,
230
+ secret_value: value,
231
+ project_id: @project_id,
232
+ environment: @environment
233
+ )
234
+ UI.verbose("Created secret: #{secret_key}")
235
+ else
236
+ raise e
237
+ end
238
+ end
239
+ end
240
+
241
+ def delete_secret(secret_key)
242
+ @client.secrets.delete(
243
+ secret_name: secret_key,
244
+ project_id: @project_id,
245
+ environment: @environment
246
+ )
247
+ end
248
+
249
+ def with_infisical_error_handling
250
+ explainer =
251
+ "Note: Infisical credentials should be set via environment variables. Set either INFISICAL_CLIENT_ID + INFISICAL_CLIENT_SECRET or INFISICAL_TOKEN."
252
+ yield
253
+ rescue StandardError => e
254
+ UI.error("Infisical error: #{e}.\n\n#{explainer}")
255
+ raise e
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,5 @@
1
+ module Fastlane
2
+ module InfisicalStorage
3
+ VERSION = "0.1.0"
4
+ end
5
+ end